基本概念

1. 定义

重载的运算符是具有特殊名字的函数,它们的名字由关键字operator和其后要定义的运算符号共同组成。

我们也可以直接调用一个重载的运算符函数:

  1. // 非成员运算符函数的等价调用
  2. data1 + data2;
  3. operator+(data1, data2);
  4. // 成员运算符函数的等价调用
  5. data1 += data2;
  6. data1.operator+=(data2);

2. 参数数量

重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符之外,其他重载运算符不能含有默认实参。operator()

Tips:当一个重载符是成员函数时,this绑定到左侧运算对象,成员运算符的(显式)参数数量比运算对象的数量少一个。

3. 参数类型

对于一个运算符而言,它要么是类的成员函数,要么至少含有一个类类型的参数。这意味当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。

4. 不应该重载的运算符

Tips:我们可以重载大部分的运算符,但是::.*.?:这四个运算符是不能被重载的

  • 不建议重载逻辑与运算符&&、逻辑或运算符||和逗号运算符,:使用重载的运算符本质上是一次函数调用,像逻辑与运算符&&、逻辑或运算符||和逗号运算符,的运算对象求值顺序规则无法保留下来,逻辑与运算符&&和逻辑或运算符||的短路求值规则也无法保留下来
  • 不建议重载逗号运算符,和取地址运算符&:C++语言定义了这两种运算符用于类类型对象时的特殊含义,重载它们容易导致类的用户无法适应
  • 我们只能重载已有的运算符,而不能发明新的运算符

5. 成员运算符函数vs非成员运算符函数

我们定义重载的运算符时,必须首先决定它是声明为类的成员函数还是声明为一个普通的非成员函数:

  • 赋值=、下标[]、调用()和成员访问箭头->运算符必须是成员函数
  • 复合赋值运算符一般来说应该是成员函数,但并非必须
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员函数
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数

输入和输出运算符

IO库分别使用>><<执行输入和输出操作,并定义了其读写内置类型的版本,而类需要自定义适合其对象的新版本以支持IO操作。

1. 重载输出运算符<<

通常情况下,输出运算符的第一个形参是非常量ostream对象的引用。因为向流写入内容会改变其状态所以不能是常量,另外一个形参是引用是因为我们无法直接复制一个ostream对象。第二个形参一般是一个常量引用。引用的原因是我们希望避免复制形参,常量是因为打印对象不会改变对象的内容。并且为了和其他输出运算符保持一致,operator<<一般要返回它的ostream形参。

  1. #include<iostream>
  2. #include<string>
  3. class Cat {
  4. // 输出运算符访问类的非公有成员数据成员时需要定义成友元函数
  5. friend std::ostream &operator<<(std::ostream &os, const Cat &cat);
  6. public:
  7. Cat(const std::string &name, int age) : name_(name), age_(age) { }
  8. private:
  9. std::string name_;
  10. int age_;
  11. };
  12. std::ostream &operator<<(std::ostream &os, const Cat &cat) {
  13. os << cat.age_ << " " << cat.name_;
  14. return os;
  15. }
  16. int main() {
  17. Cat cat1("tomo", 10), cat2("cola", 8);
  18. std::cout << "cat1: " << cat1 << std::endl;
  19. std::cout << "cat2: " << cat2 << std::endl;
  20. }
  21. // 输出:
  22. cat1: 10 tomo
  23. cat2: 8 cola

需要注意以下几点:

  • 输出运算符尽量避免格式化操作,尤其不要打印换行符
  • 输入输出运算符必须是非成员函数(否则它的左侧运算对象必须是我们类的一个对象),但是IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元

2. 重载输入运算符>>

通常情况下,输入运算符的一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。和输出运算符<<不一样的是,输入运算符必须处理输入可能失败的情况,确保对象处于正确的状态。

  1. std::istream &operator>>(std::istream &is, Cat &cat) {
  2. is >> cat.age_ >> cat.name_;
  3. // 发生IO错误时将给定的对象重置为空Cat, 确保对象处于正确的状态
  4. if (!is) {
  5. cat = Cat("", 0);
  6. }
  7. return is;
  8. }

在执行输入运算符时可能发生下列错误:

  • 当流含有错误类型的数据时读取操作可能失败,例如输入运算符假定接下来读入的是两个数字数据,但是输入的不是数字数据,则读取数据及后续对流的其他使用都将失败
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败

算术和关系运算符

我们一般把算术和关系运算符定义成非成员函数以允许对左侧或者右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量引用。

  1. Cat operator+(const Cat &lhs, const Cat &rhs) {
  2. Cat sum = lhs;
  3. sum += rhs; // 使用对应的复合赋值运算符来实现算数运算符
  4. return sum;
  5. }

注意:

  • 一般将累加的值放到一个局部变量,操作完成后返回该局部变量的副本作为结果
  • 如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符

1. 相等运算符

  • 如果有一个类含有判断两个对象是否相等的操作,那么它应该把函数定义成operator==而非一个普通的命名函数,这样用户无须再费时费力去学习并记忆一个全新的函数名字
  • 如果类定义了operator==,那么该运算符也应该能判断一组给定的对象中是否含有重复数据
  • 相等运算应该具有传递性,比如a==bb==c,那么我们能推出a==c
  • 如果类定义了operator==,那么也应该定义operator!=
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,另一个只是调用真正工作的运算符

2. 关系运算符

通常情况下,关系运算符应该:

  • 定义顺序关系,令其与关联容器中对关键字的要求一致
  • 如果类同时也有==运算符,则定义一种关系令其与==保持一致,特别是如果两个对象是!=的,那么一个对象应该<另一个

赋值运算符

1. 普通赋值运算符

我们之前定义过拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给该类的另一个对象。类还可以定义其他赋值运算符使用别的类作为右侧运算对象。

Tips:我们可以重载赋值运算符,但是无论形参是什么,赋值运算符都必须被定义为成员函数。(Why?)

  1. Cat &Cat::operator=(const std::string &s) {
  2. name_ = s;
  3. return *this;
  4. }

2. 复合赋值运算符

Tips:赋值运算符都必须定义成类的成员,复合赋值运算符通常情况下也应该这么做,这两类运算符都应该返回左侧运算对象的引用。

复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的符合赋值运算符也要返回其左侧运算对象的引用:

  1. // 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
  2. Cat &Cat::operator+=(const Cat &rhs) {
  3. // 假设两个Cat对象的name_相同
  4. age_ += rhs.age_;
  5. return *this;
  6. }

下标运算符

表示容器的类可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]

Tips:下标运算符必须是成员函数。如果一个类包含下标运算符,那么它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并返回常量引用。

与下标的原始定义兼容,我们需要确保:

  • 下标运算符通常以所访问元素的引用作为返回值,这样下标可以出现在赋值运算符的任意一端
  • 最好定义下标运算符的常量和非常量版本,当作用于一个常量对象时下标运算符返回常量引用以确保我们不会给返回的对象赋值
  1. class StrVec {
  2. public:
  3. std::string& operator[](std::size_t n)
  4. { return elements[n]; }
  5. const std::string& operator[](std::size_t n) const
  6. { return elements[n]; }
  7. private:
  8. std::string *elements;
  9. };

递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定义为类的成员(因为它们改变的正好是所操作对象的状态)。同时为了保持与内置版本一致,前置运算符应该返回递增或者递减后对象的引用。

1. 前置版本

  1. // 前置版本:返回递增/递减对象的引用
  2. Cat& Cat::operator++() {
  3. ++age_;
  4. return *this;
  5. }
  6. Cat& Cat::operator--() {
  7. --age_;
  8. return *this;
  9. }

后置版本接收一个额外的(不被使用的)int类型的形参,当我们使用后置运算符时,编译器为这个形参提供一个值为0的形参。

  1. // 后置版本:递增/递减对象的值但是返回原值
  2. Cat Cat::operator++(int) {
  3. Cat ret = *this;
  4. ++*this;
  5. return ret;
  6. }
  7. Cat Cat::operator--(int) {
  8. Cat ret = *this;
  9. --*this;
  10. return ret;
  11. }

成员访问运算符

在迭代器和智能指针类中常常用到解引用运算符*和箭头运算符->,其中箭头运算符必须是类的成员呢,解引用运算符往往也是类的成员。

对于形如point->mem的表达式来说,point必须是指向类的对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem分别等价于:

  • (*point).mempoint是一个内置的指针类型
  • point.operator()->mempoint是类的一个对象

函数调用运算符

如果类重载了函数调用运算符,那么我们可以像使用函数一样使用该类的对象,因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。

  1. struct absInt {
  2. int operator()(int val) const {
  3. return val < 0 ? -val : val;
  4. }
  5. };
  6. // 调用
  7. absInt absObj;
  8. int ui = absObj(-42);

函数调用运算符必须是成员函数,一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或者类型上有所区别。

函数对象常常作为泛型算法的实参,比如可以使用for_each算法和我们的PrintString类来打印容器的内容:

  1. #include<iostream>
  2. #include<string>
  3. #include<vector>
  4. class PrintString {
  5. public:
  6. explicit PrintString(std::ostream &o = std::cout, char c = ' '): os(o), sep(c) { }
  7. void operator()(const std::string &s) const { os << s << sep; }
  8. private:
  9. std::ostream &os; // 用于写入的目的流
  10. char sep; // 用于将不同输出隔开的字符
  11. };
  12. int main() {
  13. // 在cout中打印字符串, 后面跟一个空格
  14. PrintString printer;
  15. printer("tomocat");
  16. // 在cerr中打印字符串, 后面跟一个换行符
  17. PrintString errors(std::cerr, '\n');
  18. errors("error");
  19. }

使用标准库for_each算法和我们的PrintString类来打印容器的内容:

  1. for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

类型转换运算符

1. 简介

类型转换运算符conversion operator是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:

  1. operator type() const;

其中type表示某种类型,类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针以及函数指针)或者引用类型。类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,所以一般被定义为const成员。

2. 例子

我们定义一个表示0~255之间一个整数的一个类:

  1. // 类型转换运算符支持将SmallInt对象转化成int
  2. class SmallInt {
  3. public:
  4. SmallInt(int i = 0) : val_(i) {
  5. if (i < 0 || i > 255)
  6. throw std::out_of_range("Bad SmallInt value");
  7. }
  8. operator int() const { return val_; }
  9. private:
  10. std::size_t val_;
  11. };
  12. // 首先将4隐式地转换成SmallInt,然后调用SmallInt::operator=
  13. si = 4;
  14. // 首先将si隐式地转换成int,然后执行整数的加法
  15. si + 3;

3. 显式的类型转换运算符

Tips:实践中类很少提供类型转换运算符,所以类型转换运算符常被explicit修饰以阻止隐式转换。

在实践中类很少提供类型转换运算符,在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说,定义向bool的类型转换还是比较普遍的现象。但是这种类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍然编译通过:

  1. // 如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的
  2. // 因为istream本身并没有定义<<运算符,所以本来这段代码应该产生错误
  3. int i = 42;
  4. cin << i;
  5. // 执行过程如下:
  6. // 1) istream的bool类型转换符将cin转换为bool
  7. // 2) bool被提升为int并作为左移运算符的左侧运算对象
  8. // 3) 提升后的bool值(1或0)会被左移42个位置

为了防止这样的异常发生,C++新标准引入了显式的类型转换运算符:

  1. class SmallInt {
  2. public:
  3. // 转换构造函数: 编译支持int到SmallInt的隐式转换
  4. SmallInt(int i = 0) : val_(i) {
  5. if (i < 0 || i > 255)
  6. throw std::out_of_range("Bad SmallInt value");
  7. }
  8. // 类类型转换运算符: 编译器不支持SmallInt到int的隐式转换
  9. explicit operator int() const { return val_; }
  10. // ...其他成员
  11. private:
  12. int val_;
  13. };
  14. // 正确:SmallInt的构造函数不是显式的
  15. SmallInt si = 3;
  16. // 错误: 此处需要隐式的类型转换,但类的运算符是显式的
  17. si + 3;
  18. // 正确: 显式地请求类型转换
  19. static_cast<int>(si) + 3;

4. 避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则我们编写的代码将很可能会具有二义性。

两种情况下可能存在多重转换路径:

  • 第一种情况是两个类提供相同的类型转换:例如A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换
  • 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身就可以通过其他类型转换联系在一起。最典型的例子就是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型相关的转换规则

Tips:通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换

  1. struct B;
  2. struct A {
  3. A() = default;
  4. A(const B&);
  5. };
  6. struct B {
  7. operator A() const;
  8. };
  9. A f(const A&);
  10. B b;
  11. // 二义性错误: 含义可能是f(B::operator A())或f(A::A(const B&))
  12. A a = f(b);
  13. // 正确: 显式地使用B的类型转换运算符
  14. A a1 = f(b.operator A());
  15. // 正确: 显式地调用A的转换构造函数
  16. A a2 = f(A(b));

函数匹配与重载运算符

Tips:如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

如果a是一种类类型,那么表达式a sym b可能是如下两种。这意味着表达式中运算符的候选函数集既应该包含成员函数,也应该包含非成员函数。

  1. a.operatorsym(b); // a有一个operatorsym成员函数
  2. operatorsym(a, b); // operatorsym是一个普通函数

举个例子,我们为SmallInt类定义一个加法运算符:

  1. class SmallInt {
  2. friend SmallInt operator+(const SmallInt&, const SmallInt&);
  3. public:
  4. // 转换源为int的类型转换
  5. SmallInt(int = 0);
  6. // 转换目标为int的类型转换
  7. operator int() const { return val_; }
  8. private:
  9. std::size_t val_;
  10. };

我们可以将两个SmallInt对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:

  1. SmallInt s1, s2;
  2. // 使用重载的operator
  3. SmallInt s3 = s1 + s2;
  4. // 二义性错误: 既可以把0转换成SmallInt, 然后使用SmallInt的+; 也可以将s3转换成int, 对int执行内置的加法运算
  5. int i = s3 + 0;