C++学习笔记十四-运算符重载
概述:C++ 允许我们重定义操作符用于类类型对象时的含义。如果需要,可以像内置转换那样使用类类型转换,将一个类型的对象隐式转换到另一类型。标准库为容器类定义了几个重载操作符。这些容器类定义了下标操作符以访问数据元素,定义了 * 和 -> 对容器迭代器解引用。这些标准库的类型具有相同的操作符,使用它们就像使用内置数组和指针一样。
一、重载操作符的定义
1.重载操作符是具有特殊名称的函数:保留字 operator 后接需定义的操作符号。像任意其他函数一样,重载操作符具有返回类型和形参表,如下语句:
Sales_item operator+(const Sales_item&, const Sales_item&);
声明了加号操作符,可用于将两个 Sales_item 对象“相加”并获得一个 Sales_item 对象的副本。
2.除了函数调用操作符之外,重载操作符的形参数目(包括成员函数的隐式 this 指针)与操作符的操作数数目相同。函数调用操作符可以接受任意数目的操作数。
3.可重载的运算符:
+ |
- |
* |
/ |
% |
^ |
& |
| |
~ |
! |
, |
= |
< |
> |
<= |
>= |
++ |
-- |
<< |
>> |
== |
!= |
&& |
|| |
+= |
-= |
/= |
%= |
^= |
&= |
|= |
*= |
<<= |
>>= |
[] |
() |
-> |
->* |
new |
new [] |
delete |
delete [] |
4.不可重载的运算符:
:: |
.* |
. |
?: |
5.重载操作符必须具有一个类类型操作数:重载操作符必须具有至少一个类类型或枚举类型的操作数。这条规则强制重载操作符不能重新定义用于内置类型对象的操作符的含义。
用于内置类型的操作符,其含义不能改变。例如,内置的整型加号操作符不能重定义:
// error: cannot redefine built-in operator for ints int operator+(int, int);
6.操作符的优先级、结合性或操作数目不能改变。不管操作数的类型和操作符的功能定义如何.有四个符号(+, -, * 和 &)既可作一元操作符又可作二元操作符,这些操作符有的在其中一种情况下可以重载,有的两种都可以,定义的是哪个操作符由操作数数目控制。除了函数调用操作符 operator() 之外,重载操作符时使用默认实参是非法的。
7.不再具备短路求值特性:重载操作符并不保证操作数的求值顺序,尤其是,不会保证内置逻辑 AND、逻辑 OR和逗号操作符的操作数求值。在 && 和 || 的重载版本中,两个操作数都要进行求值,而且对操作数的求值顺序不做规定。因此,重载 &&、|| 或逗号操作符不是一种好的做法。
8.类成员与非成员:大多数重载操作符可以定义为普通非成员函数或类的成员函数。
A.作为类成员的重载函数,其形参看起来比操作数数目少 1。作为成员函数的操作符有一个隐含的 this 形参,限定为第一个操作数。
B.重载一元操作符如果作为成员函数就没有(显式)形参,如果作为非成员函数就有一个形参。类似地,重载二元操作符定义为成员时有一个形参,定义为非成员函数时有两个形参。
C.复合赋值返回一个引用而加操作符返回一个 Sales_item 对象,这也没什么。当应用于算术类型时,这一区别与操作符的返回类型相匹配:加返回一个右值,而复合赋值返回对左操作数的引用。
9.操作符重载和友元关系:操作符定义为非成员函数时,通常必须将它们设置为所操作类的友元。在这种情况下,操作符通常需要访问类的私有部分。
10.使用重载操作符:
A.使用重载操作符的方式,与内置类型操作数上使用操作符的方式一样。假定 item1 和 item2 是 Sales_item 对象,可以打印它们的和,就像打印两个 int 的和一样:
cout << item1 + item2 << endl; B.也可以像调用普通函数一样调用重载操作符函数,指定函数并传递适当类型适当数目的形参:
// equivalent direct call to nonmember operator function cout << operator+(item1, item2) << endl; C.调用成员操作符函数与调用任意其他函数是一样的:指定运行函数的对象,然后使用点或箭头操作符获取希望调用的函数,同时传递所需数目和类型的实参。对于二元成员操作符函数的情况,我们必须传递一个操作数:
item1 += item2; // expression based "call" item1.operator+=(item2); // equivalent call to member operator function
二、重载操作符的设计
1.不要重载具有内置含义的操作符:
A.赋值操作符、取地址操作符和逗号操作符对类类型操作数有默认含义。如果没有特定重载版本,编译器就自己定义以下这些操作符。
合成赋值操作符(第 13.2 节)进行逐个成员赋值:使用成员自己的赋值:使用成员自己的赋值操作依次对每个成员进行赋值。
B.默认情况下,取地址操作符(&)和逗号操作符(,)在类类型对象上的执行,与在内置类型对象上的执行一样。取地址操作符返回对象的内存地址,逗号操作符从左至右计算每个表达式的值,并返回最右边操作数的值。
C.内置逻辑与(&&)和逻辑或(||)操作符使用短路求值(第 5.2 节)。如果重新定义该操作符,将失去操作符的短路求值特征。
通过为给定类类型的操作数重定义操作符,可以改变这些操作符的含义。 重载逗号、取地址、逻辑与、逻辑或等等操作符通常不是好做法。这些操作符具有有用的内置含义,如果我们定义了自己的版本,就不能再使用这些内置含义。
2.大多数操作符对类对象没有意义(对这个关点有疑问):除非提供了重载定义,赋值、取地址和逗号操作符对于类类型操作数没有意义。设计类的时候,应该确定要支持哪些操作符。
3.复合赋值操作符:如果一个类有算术操作符或位操作符,那么,提供相应的复合赋值操作符一般是个好的做法。例如,Sales_item 类定义了 + 操作符,逻辑上,它也应该定义 +=。
4.相等和关系操作符:
A.将要用作关联容器键类型的类应定义 < 操作符。关联容器默认使用键类型的 < 操作符。即使该类型将只存储在顺序容器中,类通常也应该定义相等(==)和小于(<)操作符,理由是许多算法假定这个操作符存在。例如 sort 算法使用 < 操作符,而 find 算法使用 == 操作符。
B.如果类定义了相等操作符,它也应该定义不等操作符 !=。类用户会假设如果可以进行相等比较,则也可以进行不等比较。同样的规则也应用于其他关系操作符。如果类定义了 <,则它可能应该定义全部的四个关系操作符(>,>=,<,<=)。
5.审慎使用操作符重载:
A.当内置操作符和类型上的操作存在逻辑对应关系时,操作符重载最有用。使用重载操作符而不是创造命名操作,可以令程序更自然、更直观,而滥用操作符重载使得我们的类难以理解。
B.在实践中很少发生明显的操作符重载滥用。例如,不负责任的程序员可能会定义 operator+ 来执行减法。更常见但仍不可取的是,改变操作符的“正常”含义以强行适应给定类型。操作符应该只用于对用户而言无二义的操作。在这里所谓有二义的操作符,就是指具有多个不同解释的操作符。
C.当一个重载操作符的含义不明显时,给操作取一个名字更好。对于很少用的操作,使用命名函数通常也比用操作符更好。如果不是普通操作,没有必要为简洁而使用操作符。
6.选择成员或非成员实现:为类设计重载操作符的时候,必须选择是将操作符设置为类成员还是普通非成员函数。在某些情况下,程序员没有选择,操作符必须是成员;在另一些情况下,有些经验原则可指导我们做出决定。下面是一些指导原则,有助于决定将操作符设置为类成员还是普通非成员函数.
A.赋值(=)、下标([])、调用(())和成员访问箭头(->)等操作符必须定义为成员,将这些操作符定义为非成员函数将在编译时标记为错误。像赋值一样,复合赋值操作符通常应定义为类的成员,与赋值不同的是,不一定非得这样做,如果定义非成员复合赋值操作符,不会出现编译错误。
B.改变对象状态或与给定类型紧密联系的其他一些操作符,如自增、自减和解引用,通常就定义为类成员。
C.对称的操作符,如算术操作符、相等操作符、关系操作符和位操作符,最好定义为普通非成员函数。
三、输入和输出操作符
1.输出操作符 << 的重载:为了与 IO 标准库一致,操作符应接受 ostream& 作为第一个形参,对类类型 const 对象的引用作为第二个形参,并返回对 ostream 形参的引用。
重载输出操作符一般的简单定义如下:
// general skeleton of the overloaded output operator ostream& operator <<(ostream& os, const ClassType &object) { // any special logic to prepare object // actual output of members os << // ... // return ostream object return os; }
2.输出操作符通常所做格式化尽量少:用于内置类型的输出操作符所做格式化很少,并且不输出换行符。由于内置类型的这种既定处理,用户预期类输出操作符也有类似行为。通过限制输出操作符只输出对象的内容,如果需要执行任意额外的格式化,我们让用户决定该如何处理。尤其是,输出操作符不应该输出换行符,如果该操作符输出换行符,则用户就不能将说明文字与对象输出在同一行上。尽量减少操作符所做格式化,让用户自己控制输出细节。
3.IO 操作符必须为非成员函数:当定义符合标准库 iostream 规范的输入或输出操作符的时候,必须使它成为非成员操作符,为什么需要这样做呢?
A.我们不能将该操作符定义为类的成员,否则,左操作数将只能是该类类型的对象:
// if operator<< is a member of Sales_item Sales_item item; item << cout; B.相反,如果想要使用重载操作符为该类型提供 IO 操作,就必须将它们定义为非成员函数。IO 操作符通常对非公用数据成员进行读写,因此,类通常将 IO 操作符设为友元。
4.输入操作符 >> 的重载:与输出操作符类似,输入操作符的第一个形参是一个引用,指向它要读的流,并且返回的也是对同一个流的引用。它的第二个形参是对要读入的对象的非 const 引用,该形参必须为非 const,因为输入操作符的目的是将数据读到这个对象中。
A.输入和输出操作符有如下区别:输入操作符必须处理错误和文件结束的可能性。
B.Sales_item 的输入操作符如下:
istream& operator>>(istream& in, Sales_item& s) { double price; in >> s.isbn >> s.units_sold >> price; // check that the inputs succeeded if (in) //我们无需检查每次读入,只在使用读入数据之前检查一次即可:
s.revenue = s.units_sold * price; else s = Sales_item(); // input failed: reset object to default state return in; }
在这个操作符中,如果发生了错误,就将形参恢复为空 Sales_item 对象,以避免给它一个无效状态。用户如果需要输入是否成功,可以测试流。即使用户忽略了输入可能错误,对象仍处于可用状态——它的成员都已经定义。类似地,对象将不会产生令人误解的结果——它的数据是内在一致的。
C.处理输入错误:有些输入操作符的确需要进行附加检查。例如,我们的输入操作符可以检查读到的 isbn 格式是否恰当。也许我们已成功读取了数据,但这些数据不能恰当解释为 ISBN,在这种情况下,尽管从技术上说实际的 IO 是成功的,但输入操作符仍可能需要设置条件状态以指出失败。通常输入操作符仅需设置 failbit。设置 eofbit 意思是文件耗尽,设置 badbit 可以指出流被破坏,这些错误最好留给 IO 标准库自己来指出。
四、算术操作符和关系操作符
1.算术操作符
A。一般而言,将算术和关系操作符定义为非成员函数,既定义了算术操作符又定义了相关复合赋值操作符的类,一般应使用复合赋值实现算术操作符。
B.根据复合赋值操作符(如 +=)来实现算术操作符(如 +),比其他方式更简单且更有效。例如,我们的 Sales_item 操作符。如果我们调用 += 来实现 +,则可以不必创建和撤销一个临时量来保存 + 的结果。
2.相等操作符
A.通常,C++ 中的类使用相等操作符表示对象是等价的。即,它们通常比较每个数据成员,如果所有对应成员都相同,则认为两个对象相等。
B.设计原则:
a.如果类定义了 == 操作符,该操作符的含义是两个对象包含同样的数据。
b.如果类具有一个操作,能确定该类型的两个对象是否相等,通常将该函数定义为 operator== 而不是创造命名函数。用户将习惯于用 == 来比较对象,而且这样做比记住新名字更容易。
c.如果类定义了 operator==,它也应该定义 operator!=。用户会期待如果可以用某个操作符,则另一个也存在。
d.相等和不操作符一般应该相互联系起来定义,让一个操作符完成比较对象的实际工作,而另一个操作符只是调用前者。
C.定义了 operator== 的类更容易与标准库一起使用。有些算法,如 find,默认使用 == 操作符,如果类定义了 ==,则这些算法可以无须任何特殊处理而用于该类类型。
3.关系操作符
A.定义了相等操作符的类一般也具有关系操作符。尤其是,因为关联容器和某些算法使用小于操作符,所以定义 operator< 可能相当有用。
B.一般说来,如果有两个对象,其中任意一个都不小于另一个,则认为它们相等。
C.因为 < 的逻辑定义与 == 的逻辑定义不一致,所以根本不定义 < 会更好。
D.关联容器以及某些算法,使用默认 < 操作符。一般而言,关系操作符,诸如相等操作符,应定义为非成员函数。
五、赋值操作符
1.类赋值操作符接受类类型形参,通常,该形参是对类类型的 const 引用,但也可以是类类型或对类类型的非 const 引用。如果没有定义这个操作符,则编译器将合成它。类赋值操作符必须是类的成员,以便编译器可以知道是否需要合成一个。
2.赋值操作符可以重载。无论形参为何种类型,赋值操作符必须定义为成员函数,这一点与复合赋值操作符有所不同。
3.赋值必须返回对 *this 的引用:一般而言,赋值操作符与复合赋值操作符应返回操作符的引用。
六、下标操作符
1.下标操作符必须定义为类成员函数。
2.定义下标操作符比较复杂的地方在于,它在用作赋值的左右操作符数时都应该能表现正常。下标操作符出现在左边,必须生成左值,可以指定引用作为返回类型而得到左值。只要下标操作符返回引用,就可用作赋值的任意一方。
3.可以对 const 和非 const 对象使用下标也是个好主意。应用于 const 对象时,返回值应为 const 引用,因此不能用作赋值的目标。
七、成员访问操作符
1.箭头操作符必须定义为类成员函数。解引用操作不要求定义为成员,但将它作为成员一般也是正确的。
2.重载解引用操作符:
A.解引用操作符是个一元操作符。在这个类中,解引用操作符定义为成员,因此没有显式形参.
B.像下标操作符一样,我们需要解引用操作符的 const 和非 const 版本。它们的区别在于返回类型:const 成员返回 const 引用以防止用户改变基础对象。
3.重载箭头操作符:
A.箭头操作符与众不同。它可能表现得像二元操作符一样:接受一个对象和一个成员名。对对象解引用以获取成员。不管外表如何,箭头操作符不接受显式形参。
B.这里没有第二个形参,因为 -> 的右操作数不是表达式,相反,是对应着类成员的一个标识符。没有明显可行的途径将一个标识符作为形参传递给函数,相反,由编译器处理获取成员的工作。
C.当这样编写时: point->action();
a.如果 point 是一个指针,指向具有名为 action 的成员的类对象,则编译器将代码编译为调用该对象的 action 成员。
b.否则,如果 action 是定义了 operator-> 操作符的类的一个对象,则 point->action 与 point.operator->()->action 相同。即,执行 point 的 operator->(),然后使用该结果重复这三步。
c.否则,代码出错。
D.对重载箭头的返回值的约束:重载箭头操作符必须返回指向类类型的指针,或者返回定义了自己的箭头操作符的类类型对象。
八、自增操作符和自减操作符
1.C++ 语言不要求自增操作符或自减操作符一定作为类的成员,但是,因为这些操作符改变操作对象的状态,所以更倾向于将它们作为成员。
2.前缀式操作符:为了与内置类型一致,前缀式操作符应返回被增量或减量对象的引用。
3.后缀式操作符:后缀式操作符函数接受一个额外的(即,无用的)int 型形参。使用后缀式操作符进,编译器提供 0 作为这个形参的实参。尽管我们的前缀式操作符函数可以使用这个额外的形参,但通常不应该这样做。那个形参不是后缀式操作符的正常工作所需要的,它的唯一目的是使后缀函数与前缀函数区别开来。
4.定义后缀式操作符:为了与内置操作符一致,后缀式操作符应返回旧值(即,尚未自增或自减的值),并且,应作为值返回,而不是返回引用。
现在将后缀式操作符加到 CheckedPtr:
class CheckedPtr { public: // increment and decrement CheckedPtr operator++(int); // postfix operators CheckedPtr operator--(int); // other members as before };
CheckedPtr CheckedPtr::operator--(int) //因为不使用 int 形参,所以没有对其命名。
{
// no check needed here, the call to prefix decrement will do the check
CheckedPtr ret(*this); // save current value
--*this; // move backward one element and check
return ret; // return saved state
}
5.显式调用前缀式操作符:可以显式调用重载操作符而不是将它作为操作符用在表达式中。如果想要使用函数调用来调用后缀式操作符,必须给出一个整型实参值,所传递的值通常被忽略,但该值是必要的,用于通知编译器需要的是后缀式版本。
CheckedPtr parr(ia, ia + size); // iapoints to an array of ints
parr.operator++(0); // call postfix operator++
parr.operator++(); // call prefix operator++
6.一般而言,最好前缀式和后缀式都定义。只定义前缀式或只定义后缀式的类,将会让习惯于使用两种形式的用户感到奇怪。
九、调用操作符和函数对象
1.函数调用操作符必须声明为成员函数。一个类可以定义函数调用操作符的多个版本,由形参的数目或类型加以区别。
定义了调用操作符的类,其对象常称为函数对象,即它们是行为类似函数的对象。
2.可以为类类型的对象重载函数调用操作符。一般为表示操作的类重载调用操作符。例如,可以定义名为 absInt 的结构,该结构封装将 int 类型的值转换为绝对值的操作:
struct absInt { int operator() (int val) { return val < 0 ? -val : val; } };
//通过为类类型的对象提供一个实参表而使用调用操作符,所用的方式看起来像一个函数调用:
int i = -42; absInt absObj; // object that defines function call operator unsigned int ui = absObj(i); // calls absInt::operator(int)
3.函数对象可以比函数更灵活
4.标准库定义的函数对象:标准库还定义了一组函数适配器,使我们能够特化或者扩展标准库所定义的以及自定义的函数对象类。这些标准库函数对象类型是在 functional 头文件中定义的。
A.每个标准库函数对象类表示一个操作符,即,每个类都定义了应用命名操作的调用操作符。例如,plus 是表示加法操作符的模板类型。plus 模板中的调用操作符对一对操作数应用 + 运算。
B.有两个一元函数对象类:一元减(negate<Type>))和逻辑非(logical_not<Type>))。其余的标准库函数对象都是表示二元操作符的二元函数对象类。为二元操作符定义的调用操作符需要两个给定类型的形参,而一元函数对象类型定义了接受一个实参的调用操作符。
5.函数对象的函数适配器
A.绑定器,是一种函数适配器,它通过将一个操作数绑定到给定值而将二元函数对象转换为一元函数对象
标准库定义了两个绑定器适配器:bind1st 和 bind2nd。每个绑定器接受一个函数对象和一个值。正如你可能想到的,bind1st 将给定值绑定到二元函数对象的第一个实参,bind2nd 将给定值绑定到二元函数对象的第二个实参。例如,为了计算一个容器中所有小于或等于 10 的元素的个数,可以这样给 count_if 传递值:
count_if(vec.begin(), vec.end(), bind2nd(less_equal<int>(), 10));
B.标准库还定义了两个求反器:not1 和 not2。你可能已经想到的,not1 将一元函数对象的真值求反,not2 将二元函数对象的真值求反。
为了对 less_equal 函数对象的绑定求反,可以编写这样的代码:
count_if(vec.begin(), vec.end(), not1(bind2nd(less_equal<int>(), 10)));
十、转换与类类型
1.除了定义到类类型的转换之外,我们还可以定义从类类型的转换。即,我们可以定义转换操作符,给定类类型的对象,该操作符将产生其他类型的对象。像其他转换一样,编译器将自动应用这个转换。
2.转换为什么有用:
A.转换减少所需操作符的数目
B.支持混合类型表达式
3.转换操作符:
A.转换操作符是一种特殊的类成员函数。转换函数必须是成员函数,不能指定返回类型,并且形参表必须为空。
它定义将类类型值转变为其他类型值的转换。转换操作符在类定义体内声明,在保留字 operator 之后跟着转换的目标类型:
class SmallInt { public: SmallInt(int i = 0): val(i) { if (i < 0 || i > 255) throw std::out_of_range("Bad SmallInt initializer"); } operator int() const { return val; } private: std::size_t val; };
转换函数采用如下通用形式:
operator type();
这里,type 表示内置类型名、类类型名或由类型别名定义的名字。对任何可作为函数返回类型的类型(除了 void 之外)都可以定义转换函数。一般而言,不允许转换为数组或函数类型,转换为指针类型(数据和函数指针)以及引用类型是可以的。
B.转换函数一般不应该改变被转换的对象。因此,转换操作符通常应定义为 const 成员。
4.使用类类型转换(只要存在转换,编译器将在可以使用内置转换的地方自动调用它)
A.在表达式中:
SmallInt si;
double dval;
si >= dval // si converted to int and then convert to double
B.在条件中:
if (si) // si converted to int and then convert to bool
C.将实参传给函数或从函数返回值:
int calc(int);
SmallInt si;
int i = calc(si); // convert si to int and call calc
D.作为重载操作符的操作数:
// convert si to int then call opeator<< on the int value
cout << si << endl;
E.在显式类型转换中:
int ival;
SmallInt si = 3.541; //
instruct compiler to cast si to int
ival = static_cast<int>(si) + 3;
5.类类型转换和标准转换:使用转换函数时,被转换的类型不必与所需要的类型完全匹配。必要时可在类类型转换之后跟上标准转换以获得想要的类型。例如,在一个 SmallInt 对象与一个 double 值的比较中:
SmallInt si; double dval; si >= dval // si converted to int and then convert to double
首先将 si 从 SmallInt 对象转换为 int 值,然后将该 int 值转换为 double 值。
6.只能应用一个类类型转换:类类型转换之后不能再跟另一个类类型转换。如果需要多个类类型转换,则代码将出错。
7.标准转换可放在类类型转换之前:使用构造函数执行隐式转换的时候,构造函数的形参类型不必与所提供的类型完全匹配。例如,下面的代码调用 SmallInt(int) 类中定义的构造函数(SmallInt(int))将 sobj 转换为 SmallInt 类型:
void calc(SmallInt); short sobj; // sobj promoted from short to int // that int converted to SmallInt through the SmallInt(int) constructor calc(sobj);
8.实参匹配和多个转换操作符:
A.如果两个转换操作符都可用在一个调用中,而且在转换函数之后存在标准转换,则根据该标准转换的类别选择最佳匹配。
B.当两个构造函数定义的转换都可以使用时,如果存在构造函数实参所需的标准转换,就用该标准转换的类型选择最佳匹配。
C.避免二义性最好的方法是避免编写互相提供隐式转换的成对的类。
9.警告:避免转换函数的过度使用
A.避免二义性最好的方法是,保证最多只有一种途径将一个类型转换为另一类型。做到这点,最好的办法是限制转换操作符的数目,尤其是,到一种内置类型应该只有一个转换。
B.当转换操作符用于没有明显映射关系的类类型和转换类型之间时,容易引起误解,在这种情况下,提供转换函数可能会令类的使用者迷惑不解。
10.转换操作符之后的标准转换:
A.如果重载集中的两个函数可以用同一转换函数匹配,则使用在转换之后或之前的标准转换序列的等级来确定哪个函数具有最佳匹配。
B.只有两个转换序列使用同一转换操作时,才用类类型转换之后的标准转换序列作为选择标准。
11.多个转换和重载确定:
A.编译器将不会试图区别两个不同的类类型转换。具体而言,即使一个调用需要在类类型转换之后跟一个标准转换,而另一个是完全匹配,编译器仍会将该调用标记为错误。
B.显式强制转换消除二义性
12.标准转换和构造函数:显式构造函数调用消除二义性
在调用重载函数时,需要使用构造函数或强制类型转换来转换实参,这是设计拙劣的表现。