重载运算符

发布时间 2023-11-29 20:56:25作者: ZTer
  • 运算符重载的意义是使得程序员可以重新定义一个运算符的行为。

基本规则

可以被重载的运算符

程序员几乎可以重载 C++ 的所有运算符,包括以下这些:

+ - * / % ^ & | ~
= < > += -= *= /= %=
^= &= |= << >> <<= >>= ==
!= <= >= ! && || ++ --
, ->* -> () []
new delete
new[] delete[]

但是仍有一些不能重载

. .* :: ?:
sizeof typeid
static_cast dynamic_cast const_cast
reinterpret_cast

注意:

  1. 只有已经存在的运算符可以被重载,无法自创一个运算符表达某种运算。
  2. 不能重载基本类(比如整数、浮点数等等)的运算符。
  3. 重载运算符之后参数的数量和重载之前保持一致(比如乘法要有两个参数 a * b,那么重载之后也必须有两个参数而不能使用默认参数)
  4. 重载运算符之后运算优先级和之前保持一致。

重载运算符的写法

  • 重载运算符可以是一个成员函数,也可以是一个全局函数。

    当重载运算符是一个成员函数的话,默认其中一个参数是 *this,可以少给一个参数。

    当重载运算符是一个全局函数的话,需要给出全部参数。

如下:

class A
{
public:
  const A A::operator + (const A& that) const;
  //函数后的 const 保证这个操作不会改变参数的值
};

const A operator + (const A& r, const A& l);

另外有一些细节,我们实现重载运算的时候,要尽量与未重载之前的运算性质一致,如下:

class Integer
{
public:
  Integer(int n = 0) : i(n) {}
  const Integer Integer::operator + (const Integer& that) const
  {
    return Integer(i + that.i);
  }
private:
  int i;
};
  1. 加法原来的返回值是一个常量类型,函数的返回值是一个 const Integer 类型。这保证了重载之后与重载之前的加法的返回值均不可以做左值。
  2. 加法不会改变两个参数的值,因此我们在参数表和函数后加上 const,表示*this, that 两个参数均不可修改。

使用重载运算符

Integer x(1), y(5), z;
x + y; ====> x.operator + (y);

当我们执行 x + y 的时候,编译器发现运算符左边的变量是 Integer 类型,因此它会调用 Integer 类里的重载加法。

编译器对每一个运算符都会做类似检查,根据检查到的对象的数据类型选择对应的(重载)运算规则操作。

我们把这个受到检查的对象成为此次运算的 receiver,它决定了此次运算采用何种运算规则。

现在有三次运算操作:

z = x + y;
z = x + 3;
z = 3 + y;
  1. 第一次运算中,毫无疑问,调用了重载 + 运算符,将新对象赋值给 z 。
  2. 第二次运算中,receiver 是对象 x,它要求调用重载 + 运算符,但是传进去的第二个参数是一个 int 类型的数。这时候编译器发现 Integer 包含一个需求一个 int 类型参数的构造函数,它就调用这个构造函数把 int 转化为 Integer 类的一个对象,再执行 1 中的操作。
  3. 第三次运算中,receiver 是一个 int 类型的数,它要求调用 int 类型 + 运算符,但是传进去的参数不存在一种方式(类型转换函数)转化为 int 类型,于是无法执行。

上面是重载运算符是成员函数的情况,假设重载运算符是一个全局函数,情况又有所不同;

/* 在 Integer 类内要声明友元函数,否则无法访问私有成员 i */

friend const Integer operator + (const Integer& a, const Integer& b);

/* 在 Integer 类外 */
const Integer operator + (const Integer& a, const Integer& b)
{
  return Integer(a.i + b.i);
} 

这时我们发现可以执行 z = 3 + x; 了。因为这次的重载运算符不是成员函数,(成员函数默认第一个参数是自己,如果没有类型转换函数,就无法自动转换)所以第一个参数也可以发生自动类型转换。

当编译器尝试调用 int 类型的加法无果后,它会尝试寻找别的可能性。这时发现可以把 3 通过构造函数转换为 Integer 类从而调用重载运算符,于是它就去做了这件事,程序顺利编译。

总结一下

根据上面的概述,我们做如下约定:

  • 对于一元运算符,建议把它们写成成员函数。
  • 重载的 = () [] -> ->* 运算符,必须是成员函数。
  • 所有其他二元运算符,建议把它们写成类的友元全局函数。(否则会出现一些错误,比如上面的 z = 3 + x; 那样的错误)

函数原型

前面我们说过,重载的运算符要与重载之前的参数特性和返回值一致,所以我们需要关心这些运算符的原型是怎样的。

除此之外,如果一个重载运算符函数不是太复杂,那么它就应该是内联的,并且建议在函数的声明处就加上 inline 关键字来表示这个函数将要被内联以方便阅读代码。

  • + - * / % ^ & | ~
    const T operator X (const T& l, const T& r);(全局友元函数)
    const T operator X (const T& l, const T& r) const;(成员函数)

这些运算符不会修改参数的值,所以参数是 const T& 的类型,运算结果是一个新的不能做左值的对象,所以返回值是 const T

  • ! && || < <= == >= >
    bool operator X (const T& l, const T& r);(全局友元函数)
    bool operator X (const T& l, const T& r) const;(成员函数)

这些运算符不会修改参数的值,所以参数是 const T& 的类型,运算结果是一个 bool 类型的变量(真或假)。

  • []
    T& operator [] (unsigned int index);

中括号运算符需要一个 unsigned int 类型的参数,返回的结果是可以做左值的对象,所以返回值是 T&

  • 自增/自减运算符
    const T& operator ++ ();(前置自增)
    const T operator ++ (int);(后置自增)
    const T& operator -- (); (前置自减)
    const T operator -- (int)(后置自减)

因为要区分自增/自减运算符在变量前面还是后面,需要在后面填充一个 (int) 来让编译器区分它们,这个 (int) 并无其他特殊意义。

前置的自增/自减符号返回了一个引用,因为要先自增/自减再返回,返回值就是自增/自减后的变量。

后置的自增/自减符号返回了一个新对象,因为要返回变量自增/自减以前的值,这就只能把它以前的值当作一个新对象返回,然后让变量自增/自减。

例:实现重载 ++ 运算符

主体还是上面的那个 Integer 类,为了方便阅读,这里再放一下全部的源代码。

class Integer
{
public:
  Integer(int i = 0){ this -> i = i; }
  friend inline const Integer operator + (const Integer& a, const Integer& b);
  inline const Integer& operator ++ ();
  inline const Integer operator ++ (int);
private:
  int i;
};

inline const Integer operator + (const Integer& a, const Integer& b)
{
  return Integer(a.i + b.i);
}

//重载前置 ++ 运算符
inline const Integer& Integer::operator ++ ()
{
  this -> i += 1;
  return *this;
}

// 重载后置 ++ 运算符

inline const Integer Integer::operator ++ (int)
{
  Integer old(*this);
  //先创建旧版本的对象,拷贝构造发生
  ++ (*this);
  //调用前置 ++
  return old;
  //返回旧版本的对象,不能返回引用,因为 old 是一个局部变量
}

可以看到,后置 ++ 是基于前置 ++ 来实现的,这样以后如果有需要修改 ++ 运算符的含义,可以只修改前置的而不用修改后置的,会方便一些。

例:重载逻辑运算符

类似上面的依赖做法,我们只需要重载逻辑运算的 == < 运算符,就可以基于它们派生出所有的逻辑运算了。这样只用修改 == < 的逻辑运算函数就可以实现对所有逻辑运算函数的修改。

我们假定下面的函数已经在类内做过如友元声明。

//友元声明
friend inline bool operator X (const Integer& lhs, const Integer& rhs);
//因为返回的是原始类型,它们本身的类型就是 const T&,所以返回类型不用加 const 了。

inline bool operator == (const Integer& lhs, const Integer& rhs)
{
  return lhs.i == rhs.i;
}

inline bool operator != (const Integer& lhs, const Integer& rhs)
{
  return !(lhs == rhs);
}

inline bool operator < (const Integer& lhs, const Integer& rhs)
{
  return lhs.i < rhs.i;
}

inline bool operator > (const Integer& lhs, const Integer& rhs)
{
  return rhs < lhs;
}

inline bool operator <= (const Integer& lhs, const Integer& rhs)
{
  return !(lhs > rhs);
}

inline bool operator >= (const Integer& lhs, const Integer& rhs)
{
  return !(lhs < rhs);
}

例:重载 [ ] 运算符

当一个类代表了某种容器,而我们需要提供下标访问的功能时,就需要重载 [] 运算符。

/* In Vector.h */
#ifndef _VECTOR_H_
#define _VECTOR_H_

class Vector
{
public:
  Vector(int);
  ~Vector();
  inline int& operator [] (int index)
  {
    return *(v_array + index);//返回 v_array 后的第 index 个元素
  }
private:
  int v_size;
  int* v_array;
};

#endif

/* In Vector.cpp */
#include "vector.h"

Vector::Vector(int size) : v_size(size)
{
  v_array = new int[size];//动态申请内存
}

Vector::~Vector()
{
  delete [] v_array;// 内存回收
}

/* In main.cpp */
#include "vector.h"
#include <iostream>

using namespace std;

int main()
{
  Vector v(100);
  v[45] = 6;//直接给对象赋值,被重载为给 v_array[index] 赋值
  cout << v[45];//直接调用 v_array[45] 也可以
  return 0;
}

重载赋值运算符

在「拷贝构造」一节中,我们强调「初始化」和「赋值」是不同的操作。

用对象初始化对象将会调用拷贝构造函数,用对象给对象赋值将会调用赋值运算。

目前只考虑本类的对象给本类的对象赋值的情况,至于别的类的对象赋值给本类或者本类对象赋值给别的类,则涉及到重载类型转换,我们将在下一节中介绍。

先来设计一个简单的类 Person,它具有记录一个人的姓名,年龄以及输出它们的功能。

我们在这些函数上加上一些输出,以便整个程序的运行过程清晰可见:

class Person
{
public:
  Person(char* name = "David", int age = 18) : Age(age)
  {
    this -> name = new char [strlen(name)];
    strcpy(this -> name, name);
    cout << "Constructor called" << endl;
  }//构造函数
  ~Person()
  {
    cout << "Distructor called, object " << name << " has been deleted." << endl;
    delete [] name;
  }//析构函数
  Person (Person& that) : Age(that.Age)
  {
    this -> name = new char [strlen(that.name)];
    strcpy(this -> name, that.name);
    cout << "Copy constructor called" << endl;
  }//拷贝构造
  void ShowID()
  {
    cout << "Name: " << name << "\nAge: " << Age << '\n';
  }//打印信息
private:
  char* name;
  int Age;
};

不妨先来试试编译器为我们补充的默认赋值运算符,它执行的是成员到成员的赋值

int main()
{
  Person a("Jack", 18);
  Person b = a, c;
  a.ShowID();
  b.ShowID();
  c.ShowID();
  c = a;
  c.ShowID();
  return 0;
}

结果不负众望的炸了:

这个报错我们似曾相识,实际上就是有一块内存被重复释放了。

我们的拷贝构造函数一定是安全的,这一点在之前「拷贝构造」一文中已经证明过了,这说明变量 a, b 都是没问题的。

那就只可能是系统自带的赋值运算出了问题——它赋值的时候直接把 c 的 char* name 指向 b 的 char* name 所指向的位置,而没有重新开辟一片内存来存放 c 的 name。

而当 b 被析构掉,c 的 char* name 指向的内存实际上已经被析构了,当 c 执行析构时,就会出现重复释放的错误。

现在我们来重写一下赋值运算符来修正这个错误:

inline Person& operator = (const Person& that)
// 因为是修改自己,所以参数可以加 const,括号后不能加 const
{
  strcpy(name, that.name);
  Age = that.Age;
  return *this;
  //其实上面已经完成修改了,用不上返回值,这里加上 return *this; 是方便连续赋值
  //这样就支持 a = b = c; 这种赋值方法了
  //返回值也可以加上 const,加上 const 之后就不可以做左值了
  //譬如不支持 (a = b) = c;(尽管这看上去是脱裤子放屁的一件事)
  //因为函数原型支持这种写法,所以重载函数也要支持,返回值就不加 const 了
}

但是这个程序仍然是不完美的

假设有对象 a ,它里面的 char* name 指向了 10000 个字符的字符串,另一个对象 b 里面的 char* name 指向了一个 10 个字符的字符串。

当我们执行 a = b; 根据上面的代码,程序实际上会用 10000 个 char 的内存存 10 个字符,这无疑是大炮打苍蝇的行为。

为了避免这种浪费,应该这么写:

inline Person& operator = (const Person& that)
{
  delete [] name;
  name = new char [strlen(that.name)];
  strcpy(name, that.name);
  Age = that.Age;
  return *this;
}

总体意思上就是把原来的内存释放掉,然后新建一块大小合适的内存再做字符串的拷贝。

但是这样写还有问题,假如执行 a = a; 呢?

我们会发现进来第一步我们先把 a 的字符串 delete 掉了,然后不管是调用 strlen(that.name) 或者 strcpy(name, that.name) 都不行了,因为这里 that.name 就是被 delete 掉的 name。

所以我们应该再做一次特判:

inline Person& operator = (const Person& that)
{
  if(this != &that)//不是自己给自己赋值,再执行下面的
  //注意要用指针判断,如果写成 if(*this != that) 则需要重载 != 运算符
  {
    delete [] name;
    name = new char [strlen(that.name)];
    strcpy(name, that.name);
    Age = that.Age;
  }
  return *this;
}

这样就很优雅且完美了。

重载类型转换函数

基本类型的自动类型转换

C++ 会做一些简单的自动类型转换(如需要),对于基本类型来说,通常是窄的 -> 宽的的转换,比如下面的这些转换。

(图源自翁恺教授网课的 PPT)

这里说的窄 -> 宽是指安全的转换,即不损失精度,不改变原数据的值的情况下可以做出的类型转换。

我们把这种自动的类型转换称为“隐式类型转换”。编译器会拒绝宽 -> 窄的隐式类型转换,因为这是不安全的。

对于上图:

  • Primitive 指基本类型中可以进行的隐式自动转换。

  • Implicit 指任何类型都可以进行的隐式自动转换。

当然,如果一定要完成宽 -> 窄的转换,则需要用“显式类型转换”,比如 int a; f((double)a); 就是一个显式的类型转换。显式类型转换是程序员通过代码指明要求编译器去做的。这表示程序员知道此处不安全,但仍选择这样做。这样一来,可能产生的错误结果就由程序员自己负责。

构造函数的自动类型转换

除了基本类型的自动类型转换,构造函数也可以完成类型转换的工作:

class A
{
  A(){}
};

class B
{
  B(const A&){}
};

f(B){}

int main()
{
  A a;
  f(a);// 通过 B 的构造函数使用 a 构造了一个 B 类的对象作为参数
}

具体的,当一个类具有只有一个参数的构造函数的时候,可以通过构造函数隐式地把参数转化为该类型的一个对象(即完成参数类型到类类型的类型转换)。

如果你不希望让编译器用构造函数做上面那件事(因为它是隐式的,程序员可能不知道这个地方发生了非程序员本意的类型转换(实际上就是不小心写错了)),可以在构造函数之前加上 explicit 关键字,这表示“构造函数就是构造函数,不要用它做类型转换”。

具体写法是这样的:

explicit B(const A& a){/* do sth. */}
B b = a;//OK!
b = a;//Error!

重载的类型转换

重载函数形式如下,可以把一个 A 类型的对象转换为 B 类型的对象,该函数必须是成员函数,并且没有返回类型有返回值

class A
{
public: 
  operator B() const;
  //把 *this 转化为 B 类的一个对象,不改变 *this 的值,加 const 限定
  //没有返回类型 
};

A::operator B() const
{
  /* do something */
  return b;//b 是一个 B 类型的对象
}

这样说可能不太清楚,我们来实现一个分数类:

class Frac
{
public:
  Frac(int nume, int deno) : numerator(nume), denominator(deno) {}
  ~Frac(){}
  inline void Print(){ cout << numerator << '/' << denominator << '\n'; }
  operator double() const;
private:
  int numerator, denominator;
  //分子,分母
};

Frac::operator double() const
{
  return (double)numerator/(double)denominator;
}//在这个类型转换中,分数分子除以分母转换为 double 类型的小数

void f(double x)// f需求一个 double
{
  cout << x << endl;
}

int main()
{
  Frac x(1, 3);
  f(x);//给了一个 Frac 类型的对象
  return 0;
}
// 输出结果 0.333333 可知发生了类型转换

另外,一个类 A 中不能存在多种从 A 到 B 的转换,否则编译器会不知道使用哪种转换方式,譬如这段代码:

class A
{
public: 
  operator B() const;
};

class B
{
public:
  B(A);
};

void f(B){}

int main()
{
  A a;
  f(a);
  return 0;
}

存在一种从 A 到 B 的类型转换函数,而 B 的构造函数也可以将 A 转换为 B,此时程序无法编译。同样的,也不能写出多个从 A 到 B 的类型转换函数。

其中一种解决方案就是给 B 的构造函数加上 explicit 关键字,B 的构造函数就不能完成类型转换了。

总结

  • 总的来说,不建议使用类型转换函数。因为自动的东西可能会导致一些没有意识到的类型转换,这时候编译器不会提醒我们发生了类型转换。
  • 建议另写一个函数专门来做类型转换这件事,而不是重载类型转换函数。这样一来,所有的类型转换都必须我们亲自去调用那个自定义的函数,我们也就会知道——这里发生了类型转换,而且是我们亲自批准的。