最近在尝试实现C++ STL库,遇到的一个很重要问题就是在容器、迭代器中的运算符重载。运算符重载看上去比较简单,但如果希望重载结果“符合常理”,即符合C++的运算习惯,也不是非常容易。这些问题通常出现在函数定义中:
- 重载函数的返回值,是原类型、引用类型,还是常引用类型?
- 重载函数的参数,是原类型、引用类型、常引用类型,还是右值引用类型?
- 重载函数是否需要const标记?
这篇博客主要讨论这些问题。
1 返回值
函数返回值有3种选择:
- 原类型T
- 引用类型T&
- 常引用类型const T&
首先应该区分原类型和引用类型。一个判决标准是:如果返回对象是在函数内部定义的,就一定要返回原类型,否则(返回值是this指向的对象,或返回值为某个函数参数),返回引用类型即可。
一个常见(且非常危险)的错误是:
class T{
public:
T& operator+(const T& t){
T tReturn;
//TODO:计算tReturn
return tReturn;
}
};
T a,b,c;
c = a + b;
这段代码的问题在于,当返回tReturn的时候,只返回了其引用,而tReturn是函数内部定义的,在函数结束的时候即被销毁。因此,c指向的是一个无意义的空间(在堆栈中)。
而正确的代码为:
T operator+(const T& t){...}
使用原类型,函数返回时会调用复制构造函数,将返回值存储在安全的位置。
如果是返回本对象,例如return *this
,则需要使用引用类型,此时需要区分常引用和非常引用类型。一般来说,如果你不希望返回值的接收者修改返回对象,则应使用常引用类型(const T&
),否则应该使用普通引用类型(T&
)。
例如,赋值运算符的重载应该返回什么类型呢?我们可以考虑如下运算的结果:
int a = 1, b = 2;
(a = b) = 3;
cout << a << " " << b;
执行完毕后,会得到3 2
。注意到a = b
运算后,应有a == 2, b == 2
。但接下来得到a == 3
的结果,说明a = b
运算返回了a
,而且是a
的普通引用。这样,系统会继续执行a = 3
语句,将a
赋值给3。
由此可知,赋值运算符重载函数的返回值应为普通引用T&
。
再举一个例子:考虑自加运算符(++),其返回值应该如何设置?
如果是前置自加(++a
),应该是先自加再返回,因此可以先把对象本身自加,再返回对象的引用。这时需要注意,一般约定++运算符的返回值不可被修改(即所谓的“右值”),我们重载运算符时也应遵循这一习惯。所以,前置自加的返回值为const T&
:
class T{
public:
const T& operator++(){
//TODO: 对象的自加操作
return *this;
}
};
而对于后置自加(a++),则应该返回原值,然后再自加。此时,一般的做法是先复制对象的一个副本,自加对象后,返回副本对象。由于副本对象是在函数内部定义的,所以返回值为原类型T
:
class T{
public:
T operator++(int){//C++中约定int参数表示后置自加
T tmp = *this;
//TODO: this对象的自加
return tmp;
}
};
2 参数
所有双目运算符都需要再传入一个参数(一般与重载对象的类型相同),该参数的类型有如下几种选择:
- 原类型T
- 引用类型T&
- 常引用类型const T&
- 右值引用类型T&&
首先,原类型T
一般不会使用,因为会调用复制构造函数,造成额外的成本。只要在函数中不会修改参数的值,均可用常引用类型const T&
。
如果在函数中会修改参数的值,则应该使用引用类型T&
,但这种情况很少见,因为一般的双目运算符都不会修改其右侧运算数的值。唯一的特例是括号运算符operator()
,这是因为括号运算符是用于函数对象,换句话说,它直接代表了一个函数,所以参数可以是任意类型。例如:
class T{
public:
void operator()(int a, string b, T& c){...}
};
T t;
t(1, 2, t);//这就类似于调用一个名为t的函数
最后,值得注意的是右值引用T&&
。参数为右值引用说明了该参数为临时对象,即将被销毁,因此其中的一些动态开辟的变量可以被重载函数回收利用。右值引用通常用于含复制操作的函数中,最典型的例子就是赋值运算符。
class T{
public:
//operator=需要两个重载函数
T& operator=(const T& t);//参数为左值,说明将来还会用到,因此只能复制里面的数据
T& operator=(T&& t);//参数为右值,很快就会被销毁,因此可以直接把里面的数据“拿来”(该函数也可以不写,但效率相应会有影响)
}
T t1, t2, t3;
t1 = t2; //t2作为左值传入,运算结束后,t2的值不会改变。
t3 = T(); //这个新生成的T作为右值传入。
t3 = std::move(t2);//t2被强制转换成右值输入,在运算结束后,t2的值可能会改变。
3 const标记
const标记相对比较简单:如果函数内部不会修改本对象的值,则(建议)加上const标记。最简单的情况是:关系运算符重载函数都应该是const类型的,四则运算、位运算等不修改被运算数的运算符也是const类型的。
值得注意的是const函数的2个性质:
1. 即使两个函数的区别仅为一个有const标记,一个没有const标记,这两个函数也是不同的函数;
2. const函数只能调用const函数,const对象也只有const函数可以被访问。
因此,某些返回引用类型的函数不应该设置为const类型,否则,const对象也可以调用该函数得到引用类型,并修改该引用类型,造成不符合预期的结果。
举例来说,类似迭代器这样的类会重载operator*
来模拟指针的“获取相应地址的对象”的行为,该函数应该返回所指向对象的引用。问题是,对于一般的对象,应该返回非const引用类型,对于const对象,则应该返回const引用。因此,可以定义如下的重载函数:
class T1{
public:
T2& operator*(){
return t;
}
const T2& operator*() const{
return t;
}
private:
T2 t;
};
T t1;//(*t1)返回t1.t的引用
const T t2;//(*t2)返回t2.t的常引用
4 常见运算符的重载规则
综上,可以得出常见运算符的重载规则(并非全部),注意这些规则并不是绝对的(理论上你可以任意定义),只是所谓“符合常理”的。例外仍有很多,例如ostream
的operator<<
定义的就不是按位左移操作,也不符合下面的规则。定义运算符的时候,应该注意思考,用户在使用该运算符时,所期待的结果是什么?
运算符 | 返回值 | 参数 | 是否const |
---|---|---|---|
双目+, -, *, /, %, ^, |, &, <<, >> | T | const T& | 是 |
>, <, >=, <=, ==, != | bool | const T& | 是 |
~ | T | 无 | 是 |
! | bool | 无 | 是 |
&&, ||, ! | bool | const T& | 是 |
+=, -=, *=, /=, %=, ^=, |=, &=, <<=, >>= | T& | const T& | 否 |
前置++, - - | const T& | 无 | 否 |
后置++, - - | T | int | 否 |
赋值运算符= | T& | const T&或T&& | 否 |