Part III: Tools for Class Authors
Chapter 14. Overloaded Operations and Conversions
当运算符应用于类类型的对象时,运算符重载 (operator overloading) 允许我们定义它的含义。
14.1 基本概念
重载的运算符是具有特殊名字的函数:关键字 operator
后面跟着要定义的运算符符号。
重载运算符有返回类型、形参列表和函数体。
重载运算符函数中形参的个数与运算符的运算对象的个数一样。
在二元运算符中,左侧运算对象传递给第一个形参,右侧运算对象传递给第二个。
除了重载的函数调用运算符 operator()
以外,重载的运算符没有默认实参。
如果一个运算符函数是成员函数,第一个(左侧)运算对象绑定到隐式 this 指针。因此,成员运算符函数的(显式)形参的个数比运算符的运算对象的个数要少一个。
运算符函数要么是类的成员,要么至少有一个类类型的形参。
// error: cannot redefine the built-in operator for ints
int operator+(int, int);
此限制意味着,当将运算符符应用于内置类型的运算对象时,我们无法更改其含义。
可以重载大多数,但不是所有,运算符。
可以重载的运算符:
+ - * / % ^ & | ~ ! , = < > <= >= ++ -- << >>
== != && || += -= /= %= ^= &= |= *= <<= >>=
[] () -> ->* new new[] delete delete[]
不能重载的运算符:
:: .* . ?:
只能重载已有的运算符,不能发明新的运算符符号。
重载运算符的优先级和结合律与其对应的内置运算符相同。
直接调用一个重载运算符函数
通常情况下,通过在正确类型的实参上使用运算符,可以直接“调用”重载运算符函数。然而,也可以像调用普通函数那样直接调用重载运算符函数。
// equivalent calls to a nonmember operator function
data1 + data2;
// normal expression
operator+(data1, data2); // equivalent function call
显式调用成员运算符函数的方式与其他成员函数相同。
data1 += data2; // expression-based "call"
data1.operator+=(data2); // equivalent call to a member operator function
一些运算符不应该被重载
一些运算符可以保证运算对象的求值顺序。因为使用重载运算符实际上是一个函数调用,所以这些保证不能应用到重载运算符。
特别是,逻辑与、逻辑或、逗号运算符的运算对象求值顺序的保证不能保留。
而且,&&
或 ||
运算符的重载版本不保留内置运算符的短路求值属性。
因为这些运算符的重载版本不能保留求值顺序和/或短路求值,因此通常不建议重载它们。
不重载逗号的另一个原因,也是不重载取地址运算符的原因:与大多数运算符不同,C++语言定义了逗号和取地址运算符在应用与类类型时的含义。因为这些运算符有内置含义,它们通常不应该被重载。
使用的定义与内置含义一致
当设计一个类时,应该总是先考虑该类将提供哪些操作。只有在知道需要什么操作之后,才考虑将每个操作定义为普通函数还是重载运算符。如果操作在逻辑上可以映射到运算符上,那么将其定义为重载运算符是不错的选择:
- 如果类执行IO操作,则将移位运算符定义成与内置类型的IO操作方式一致。
- 如果类具有测试相等性的操作,定义
operator==
。如果类具有operator==
,则通常也应该具有operator!=
。 - 如果类具有单一的自然排序操作,定义
operator<
。如果类具有operator<
,则它可能应该具有所有关系运算符。 - 重载运算符的返回类型通常应与该运算符的内置版本的返回类型兼容:逻辑和关系运算符应返回 bool,算术运算符应返回类类型的值,赋值和复合赋值应该返回对左侧操作数的引用。
赋值与复合赋值运算符
赋值运算符的行为应类似于合成运算符:赋值后,左侧和右侧运算对象应具有相同的值,并且运算符应返回对其左侧运算对象的引用。
如果一个类具有算术运算符或位运算符,那么通常也应提供相应的复合赋值运算符。+=
运算符应定义为具有与内置运算符相同的行为:先执行 +
,后执行 =
。
选择作为成员或非成员实现
下面的准则有助于决定使运算符作为成员或普通非成员函数:
- 赋值
=
、下标[]
、调用()
、成员访问箭头->
运算符必须定义为成员。 - 复合赋值运算符一般应该是成员。但与赋值不同,它们不必需是成员。
- 改变对象的状态或与给定类型紧密相关的运算符,比如递增、递减、解引用,通常应该是成员。
- 具有对称性的运算符,即可以转换任何一侧运算对象的运算符,比如算术、相等、关系、位运算符,通常应定义为普通的非成员函数。
程序员希望能够在混合类型的表达式中使用对称性运算符。例如,可以将一个 int 和一个 double 相加。这种加法是对称的,因为可以将任一类型用作左侧或右侧运算对象。如果想提供涉及类对象的相似混合类型表达式,则必须将运算符定义为非成员函数。
如果将运算符定义为成员函数,则左侧运算对象必须是该运算符所属类的对象。
string s = "world";
string t = s + "!"; // ok: we can add a const char* to a string
string u = "hi" + s; // would be an error if + were a member of string
因为 string 将 +
定义为普通的非成员函数,所以 "hi" + s
等同于 operator+("hi", s)
。与任何函数调用一样,任何一个实参都可以转换为形参类型。唯一的要求是至少一个运算对象具有类类型,并且两个运算对象都可以(明确地)转换为字符串。
建议:仅当运算符对用户来说无二义性时,才使用它。若运算符有多个合理的解释,则该运算符具有二义性。
14.2 输入和输出运算符
重载输出运算符 <<
通常情况下,输出运算符的第一个形参是一个指向非const ostream 对象的引用。
第二个形参一般应该是对将要打印的类类型的 const 引用。
为了与其他输出运算符一致,operator<<
一般返回其 ostream
形参。
Sales_data 输出运算符
ostream &operator<<(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
return os;
}
通常,输出运算符打印对象的内容时应该尽量减少格式化操作。它们不应该打印换行符。
IO运算符必须是非成员函数
Sales_data data;
data << cout; // if operator<< is a member of Sales_data
IO运算符通常需要读写非public 数据成员,所以IO运算符通常应该声明为友元。
重载输入运算符 >>
通常,输入运算符的第一个形参是对将要读取的流的引用,第二个形参是对读入到的非const对象的引用。运算符通常返回对其给定流的引用。
Sales_data 输入运算符
istream &operator>>(istream &is, Sales_data &item) {
double price; // no need to initialize; we'll read into price before we use it
is >> item.bookNo >> item.units_sold >> price;
if (is) // check that the inputs succeeded
item.revenue = item.units_sold * price;
else
item = Sales_data(); // input failed: give the object the default state
return is;
}
注意:输入运算符必须处理输入失败的可能性;输出运算符一般不需要。
输入时的错误
输入运算符可能发生的错误:
- 当流中包含错误的数据类型时,读取操作失败。
- 读取操作失败也可能是,遇到文件结尾或输入流上的其他错误。
实践:如果有错误,输入运算符应该决定如何进行错误恢复。
标示错误
一些输入运算符需要做额外的数据验证。输入运算符可能需要设置流的条件状态来标明错误,即使从技术来将实际的IO是正确的。通常输入运算符只设置 failbit。而 eofbit、badbit 等错误最好留给IO库自己标示。
14.3 算术和关系运算符
通常情况下,为了允许对左侧或右侧运算对象进行转换,将算术与关系运算符定义为非成员函数。
这些运算符应该不需要改变运算对象的状态,所以形参通常是指向const的引用。
算术运算符通常生成一个新值,它是两个运算对象计算的结果。
定义算术运算符的类一般也会定义对应的复合赋值运算符。
实践:同时定义了算术运算符和相关复合赋值的类,通常应使用复合赋值来实现算术运算符。
// assumes that both objects refer to the same book
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs; // copy data members from lhs into sum
sum += rhs; // add rhs into sum
return sum;
}
相等运算符
bool operator==(const Sales_data &lhs, const Sales_data &rhs) {
return lhs.isbn() == rhs.isbn() && lhs.units_sold == rhs.units_sold && lhs.revenue == rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs) {
return !(lhs == rhs);
}
设计准则:
- 如果类有一个操作来确定两个对象是否相等,它应该将这个函数定义成
operator==
而不是命名函数:用户无需学习记忆一个新操作名称;使用库容器和算法更方便。 - 如果类定义
operator==
,此运算符一般应该确定给定的对象是否包含相等的数据。 - 如果类定义
operator==
,它应该也定义operator!=
。 - 相等或不相等运算符中的一个应该将工作委托给另一个。即,其中一个应该负责比较对象的实际工作,另一个应该调用实际工作的运算符。
关系运算符
定义了相等运算符的类通常(但不总是)定义关系运算符。特别地,因为关联容器和一些算法使用小于运算符,定义 operator<
会有用。
一般情况下关系运算符应该:
- 定义顺序关系,要与用作关联容器的关键字的要求一致;
- 如果类具有
==
,定义关系要与其一致。特别是,如果两个对象!=
,那么一个对象应该<
另一个。
如果 <
存在唯一的逻辑定义,则类通常应定义 <
运算符。但是,如果类也具有 ==
,则仅当 <
和 ==
的定义产生一致的结果时才定义 <
。
14.4 赋值运算符
除了复制赋值和移动赋值运算符(将类类型的一个对象赋值给相同类型的另一个对象)之外,类还可以定义允许其他类型作为右侧运算对象的其他赋值运算符。
例如,除了复制赋值和移动赋值运算符之外,库 vector 类还定义了第三个赋值运算符,该运算符接受一个使用花括号括起来的元素列表。可以如下使用该运算符:
vector<string> v;
v = {"a", "an", "the"};
可以把这个运算符加到 StrVec 类中:
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
// other members as in § 13.5
};
StrVec &StrVec::operator=(initializer_list<string> il) {
// alloc_n_copy allocates space and copies elements from the given range
auto data = alloc_n_copy(il.begin(), il.end());
free(); // destroy the elements in this object and free the space
elements = data.first; // update data members to point to the new space
first_free = cap = data.second;
return *this;
}
复合赋值运算符
复合赋值运算符不需要是成员。但是,我们倾向于在类内定义所有的赋值,包括复合赋值。
复合赋值运算符应该返回指向其左侧运算对象的引用。
// member binary operator: left-hand operand is bound to the implicit this pointer
// assumes that both objects refer to the same book
Sales_data& Sales_data::operator+=(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
14.5 下标运算符
可以通过位置提取元素的表示容器的类,通常定义下标运算符 operator[]
。
下标运算符必须是成员函数。
为了与下标的普通含义兼容,下标运算符通常返回对所获取元素的引用。通过返回引用,下标可以在赋值的任何一侧使用。
如果类有下标运算符,通常应该定义两种版本:一种返回普通引用,另一种是 const 成员,返回 const 引用。当下标应用于 const 对象时,下标应返回对 const 的引用,这样就不能将赋值给返回的对象。
class StrVec {
public:
std::string& operator[](std::size_t n) { return elements[n]; }
const std::string& operator[](std::size_t n) const { return elements[n]; }
// other members as in § 13.5
private:
std::string *elements; // pointer to the first element in the array
};
// assume svec is a StrVec
const StrVec cvec = svec; // copy elements from svec into cvec
// if svec has any elements, run the string empty function on the first one
if (svec.size() && svec[0].empty()) {
svec[0] = "zero"; // ok: subscript returns a reference to a string
cvec[0] = "Zip"; // error: subscripting cvec returns a reference to const
}
14.6 递增和递减运算符
定义了递增或递减运算符的类应该同时定义前置和后置版本。这些运算符通常应该定义为成员。
定义前置递增/递减运算符
class StrBlobPtr {
public:
// increment and decrement
StrBlobPtr& operator++(); // prefix operators
StrBlobPtr& operator--();
// other members as before
};
为了与内置运算符一致,前置运算符应该返回指向递增或递减后对象的引用。
// prefix: return a reference to the incremented/decremented object
StrBlobPtr& StrBlobPtr::operator++() {
// if curr already points past the end of the container, can't increment it
check(curr, "increment past end of StrBlobPtr");
++curr; // advance the current state
return *this;
}
StrBlobPtr& StrBlobPtr::operator--() {
// if curr is zero, decrementing it will yield an invalid subscript
--curr; // move the current state back one element
check(-1, "decrement past begin of StrBlobPtr");
return *this;
}
区分前置与后置运算符
为了与前置版本区分,后置版本接受一个额外的 int 类型的形参。当使用后置运算符时,编译器为这个形参提供 0 作为实参。正常情况下,后置运算符执行的工作不需要该参数。其唯一目的是将后置函数与前置版本区分开。
class StrBlobPtr {
public:
// increment and decrement
StrBlobPtr operator++(int); // postfix operators
StrBlobPtr operator--(int);
// other members as before
};
为了与内置运算符一致,后置运算符应该返回原值(递增或递减之前的值),返回形式是值,而不是引用。
// postfix: increment/decrement the object but return the unchanged value
StrBlobPtr StrBlobPtr::operator++(int) {
// no check needed here; the call to prefix increment will do the check
StrBlobPtr ret = *this; // save the current value
++*this; // advance one element; prefix ++ checks the increment
return ret; // return the saved state
}
StrBlobPtr StrBlobPtr::operator--(int) {
// no check needed here; the call to prefix decrement will do the check
StrBlobPtr ret = *this; // save the current value
--*this; // move backward one element; prefix -- checks the decrement
return ret; // return the saved state
}
因为 int 形参不会被使用,所以不需要为它命名。
显式调用后置运算符
StrBlobPtr p(a1); // p points to the vector inside a1
p.operator++(0); // call postfix operator++
p.operator++(); // call prefix operator++
14.7 成员访问运算符
解引用 *
和箭头 ->
运算符通常用于表示迭代器的类和智能指针类中。
箭头运算符必须是成员。解引用运算符不需要是成员,但通常应该也是成员。
class StrBlobPtr {
public:
std::string& operator*() const
{
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p) is the vector to which this object points
}
std::string* operator->() const
{
// delegate the real work to the dereference operator
return & this->operator*(); // 返回解引用运算符返回的元素的地址
}
// other members as before
};
注意:这两个运算符被定义成 const 成员。获取一个元素不需要改变 StrBlobPtr 的状态。
StrBlob a1 = {"hi", "bye", "now"};
StrBlobPtr p(a1); // p points to the vector inside a1
*p = "okay"; // assigns to the first element in a1
cout << p->size() << endl; // prints 4, the size of the first element in a1
cout << (*p).size() << endl; // equivalent to p->size()
对箭头运算符返回值的限定
与大多数其他运算符一样,可以定义 operator*
来执行我们指定的任何操作,尽管这样做不太好。比如,可以定义 operator*
来返回固定值,或打印对象的内容,或者其他。
对于重载箭头,情况并非如此。箭头运算符永远不会失去其成员访问的基本含义。当重载箭头时,可以改变的是箭头从那个对象中获取指定成员。而箭头获取成员的事实不能改变。
当编写 point->mem
时,point 必须是指向类对象的指针,或者是带有重载的 operator->
的类的对象。根据 point 的类型,point->mem
等效于:
(*point).mem; // point is a built-in pointer type
point.operator()->mem; // point is an object of class type
否则代码是错误的。
注意:重载的箭头运算符必须返回一个指向类类型的指针,或者一个定义了自己的箭头运算符的类类型的对象。