参考资料:
- 《C++ Primer》第5版
- 《C++ Primer 习题集》第5版
14.1 基本概念(P490)
重载的运算符是具有特殊名字的函数,其名字有 operator
和要定义的运算符组合而成。和其他函数一样,重载运算符也具有返回类型、参数列表、函数体。
重载运算符函数的参数数量和该运算符的运算对象数量一样多,对于二元运算符来说,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数。除了重载调用运算符 operator()
外,其他重载运算符不能有默认实参。
如果一个重载运算符函数是成员函数,则它的第一个运算对象隐式绑定到 this
指针上。
对于一个重载运算符函数来说,它至少含有一个类类型的参数。
![8ec4dd2082e6596f5f4ef932fa95612](https://i-blog.csdnimg.cn/blog_migrate/5bc6f8a0d6ff197bc567ee14f159d2ca.jpeg)
有四个符号( +, -, *, &
)既是一元运算符也是二元运算符,具体定义哪种运算符由参数数量决定。
重载运算符不改变优先级和结合律。
直接调用一个重载运算符
我们可以像调用普通函数一样直接调用运算符函数:
data1 + data2; // 间接调用
operator+(data1, data2); // 直接调用
data1.operator+=(data2); // 直接调用(成员函数)
某些运算符不应该被重载
前面提到过,某些运算符规定了求值顺序(如逻辑与、逻辑或、逗号),由于使用重载运算符本质上是一次函数调用,所以这些求值顺序将无法应用到重载运算符上。另外,逻辑与和逻辑或的短路属性在重载运算符中也不能实现。此外,C++ 语言已经定义了逗号和取地址运算符作用于类类型时的含义,所以一般情况下我们也不应该重载它们。
总结:通常情况下,不应该重载逗号、取地址、逻辑与、逻辑或运算符。
使用与内置类型一致的含义
如果某些类操作在逻辑上与运算符相关,则它们适合被定义成重载运算符:
- 如果类执行 IO 操作,则定义移位运算符使其与内置类型的 IO 保持一致。
- 如果某个类的操作检查相等性,则定义
operator==
和operator!=
。 - 如果类包含一个单序比较操作,则定义
operator<
和其他比较操作。 - 重载运算符的返回类型应与内置版本的返回类型兼容:逻辑运算符和关系运算符应返回
bool
;算术运算符返回一个类类型的值;赋值和复合赋值运算符返回左侧运算对象的引用。
选择作为成员或非成员
当我们定义重载运算符时,需要确定将其声明为类的成员函数还是普通函数。下面的准则有助于我们做出选择:
- 赋值、下标、调用、成员访问箭头运算符必须是成员
- 复合赋值运算符一般是成员
- 改变对象状态或与给定类型密切相关的运算符,如递增、递减、解引用,通常应该是成员
- 具有对称性的运算符通常应该是非成员函数。
关于最后一点,这里着重解释一下。当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。而如 +
这种具有对称性的运算符,常常会遇到下面这种情况:
string s = "hello";
string t = s + "!"; // 正确
t = "!" + s;
如果 string
的重载 +
是成员函数,那么最后一条语句就是错误的,我们显然不希望这种情况发生。
14.2 输入和输出运算符(P494)
14.2.1 重载输出运算符<<
(P494)
通常情况下,输出运算符的第一个形参是一个非常量 ostream
对象的引用,第二个形参是一个常量的引用,返回值为 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 运算符一般被声明成类的友元。
14.2.2 重载输入运算符>>
Sales_data
的输入运算符
istream &operator>>(istream &is, Sales_data &item){
double price;
is >> item.bookNo >> item.units_sold >> price;
if(is) // 检测输入是否成功
item.revenue = item.units_sold * price;
else // 输入失败,对象被赋予默认状态
item = Sale_data();
return is;
}
输入运算符必须处理输入失败的情况
输入时的错误
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
14.3 算术和关系运算符(P497)
通常情况下,我们把算术运算符和关系运算符定义成非成员函数,以允许左侧或右侧运算对象进行转换。
算术运算符通常会计算它的两个运算对象并得到一个新值,这个新值常常位于一个局部变量之内,最后返回该局部变量的副本。如果一个类定义了算术运算符,一般也会定义对应的复合赋值运算符,此时最有效的方式是用复合赋值来定义算术运算符:
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs;
sum += rhs;
return sum;
}
14.3.1 相等运算符(P497)
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 !(lsh == rhs);
}
14.3.2 关系运算符(P498)
前面提到过,关联容器和一些算法需要用到小于运算符,所以定义 operator<
会比较有用。此时需要注意,如果类同时也含有 ==
运算符,应保证:如果两个对象 ==
,则不应有一个对象 <
令一个对象成立;如果两个对象 !=
,则必有一个对象 <
另外一个对象。
14.4 赋值运算符(P499)
标准库 vector
支持用花括号内的元素列表赋值:
vector<string> v;
v = {"a", "an", "the"};
同样地,我们也为 StrVec
添加这种赋值方法:
class StrVec{
public:
StrVec &operator=(initializer_list<string>);
};
StrVec &StrVec::operator=(initializer_list<string> il) {
auto data = alloc_n_copy(il.begin(), il.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
复合赋值运算符
复合赋值运算符不必须是类的成员,但一般还是将其设计成成员函数:
Sales_data &Sales_data::operator+=(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
14.5 下标运算符(P501)
下标运算符必须是成员函数。为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值。同时,我们最好同时定义下标运算符的常量版本和非常量版本。
class StrVec{
public:
string &operator[](size_t n)
{ return elements[n]; }
const string &operator[](size_t n) const
{ return elements[n];}
}
14.6 递增和递减运算符(P502)
C++ 并不要求递增和递减运算符必须是类的成员,但因为它们改变所操作对象的状态,所以建议将其设定为成员函数/
定义前置递增/递减运算符
class StrBlobPtr {
public:
StrBlobPtr &operator++();
StrBlobPtr &operator--();
};
StrBlobPtr &StrBlobPtr::operator++() {
check(curr, "increment past end of StrBlobPtr");
++curr;
return *this;
}
StrBlobPtr &StrBlobPtr::operator--() {
// curr为无符号类型,如果curr为0,--后将得到一个很大的正数
--curr;
check(curr, "increment past end of StrBlobPtr");
return *this;
}
区分前置和后置运算符
前置和后置版本使用的是同一个符号,并且运算对象的数量和类型也相同。为了区分前置版本和后置版本,后置版本接受一个额外的、不被使用的 int
类型形参,当我们使用后置运算符时,编译器为这个形参提供值为 0 的实参:
class StrBlobPtr {
public:
StrBlobPtr &operator++(int);
StrBlobPtr &operator--(int);
};
// 无需为int形参命名
StrBlobPtr &StrBlobPtr::operator++(int) {
StrBlobPtr ret = *this; // 记录当前的值
++*this;
return ret;
}
StrBlobPtr &StrBlobPtr::operator--(int) {
StrBlobPtr ret = *this; // 记录当前的值
--*this;
return ret;
}
显式地调用后置运算符
StrBlobPtr p(a1);
p.operator++(0); // 显式调用后置版本
p.operator++(); // 显式调用前置版本
14.7 成员访问运算符(P504)
迭代器类和智能指针类常常用到解引用运算符和箭头运算符:
class StrBlobPtr {
public:
string &operator*() const {
auto p = check(curr, "dereference past end");
return (*p)[curr];
}
string *operator->() const {
return &(this->operator*());
}
// ...
};
StrBlob a1 = {"hi", "bye", "now"};
StrBlobPtr p(a1);
*p = "okay";
对箭头运算符返回值的限定
对于形如 point->mem
的表达式来说,point
必须是指向类对象的指针或者是一个重载了 operator->
的类对象,根据 point
类型的不同,point->mem
分别等价于:
(*point).mem;
point.operator()->mem;
point->mem
的执行过程:
- 如果
point
是指针,则我们应用内置箭头运算符,表达式等价于(*point).mem
。 - 如果
point
是定义了operator->
的类的一个对象,则我们使用point.operator->()
的结果来获取mem
。如果结果是一个指针,则执行第 1 步;否则重复调用当前步骤。