第十四章 重载运算与类型转换

315 阅读14分钟

C++ 允许为类类型自定义运算符和转换规则。通过运算符重载可以重定义运算符的含义,从而让程序更易于编写和阅读。

基本概念

重载运算符是具有特殊名字的函数,它的名字由关键字 operator 后接运算符符号构成,重载运算符也有返回类型、参数类型、函数体,参数数目和运算对象一样多;对于成员运算符函数,第一个运算对象绑定到隐式 this 上,参数数目比运算对象少一个。

除了函数调用运算符 operator() 之外,其它重载运算符不能含有默认实参。

  • 运算符函数要么是类成员,要么至少有一个类类型参数,不能为内置类型重载运算符。
  • 大多数运算符都可以被重载,但无法定义新的运算符。
  • +-*& 既是一元运算符,也是二元运算符,两者对应的参数数目不同。
  • 重载运算符保持原有的优先级和结合律。

operator.png

以下准则有助于确定运算符应该定义为成员还是非成员:

  • 赋值 =、下标 []、调用 ()、箭头 -> 运算符必须是成员。
  • 复合赋值运算符一般是成员,但并非必须。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,比如递增、递减、解引用等,通常应该是成员。
  • 具有对称性的运算符常用于含有混合类型的表达式中,这些运算符可能转换运算对象,比如算术、相等性、关系、位运算等,因此通常应是非成员函数。

重载运算符函数可以通过运算符隐式调用,也可以通过成员/非成员运算符函数显式调用。

data1 + data2;
operator+(data1, data2);

data1 += data2;
data1.operator+=(data2);

逗号 ,、取地址 &、逻辑与 &&、逻辑或 || 这些运算符不适合重载,原因在于:

  • 重载运算符本质上就是函数调用,需要先计算出所有运算对象,因此无法保持原有的求值顺序,包括短路求值。
  • C++ 已经定义了 ,& 作用于类类型对象时的特殊含义。

逻辑上与运算符相关的类操作适合定义成重载运算符,而且应该保持内置版本的含义:

  • 类的 IO 操作定义为移位运算符。
  • 类的相等性检查定义 operator==operator!=
  • 类的单序比较操作定义 operator< 和其它关系操作。
  • 重载运算符的返回类型通常应与内置版本兼容:
    • 逻辑、关系运算符返回 bool
    • 算术运算符返回类类型。
    • 赋值、复合赋值运算符返回左侧运算对象的引用。
  • 如果类含有算术运算符或位运算符,最好也定义相应的复合赋值运算符。

内置运算符和自定义操作间存在逻辑映射关系时,运算符重载的效果最好。过分滥用运算符重载会使类难以理解,只有在操作的含义对用户来说清晰明了时才使用运算符。

输入和输出运算符

IO 库定义了读写内置类型的输入输出运算符 >><<,而类则需自定义相应版本以支持 IO 操作。

重载输出运算符 <<

通常,重载输出运算符 operator<< 应满足:

  • 第一个形参是 ostream 对象的非常量引用,第二个形参是自定义类类型的常量引用。
  • 返回值是输入的 ostream 对象引用。

输出运算符主要负责打印对象内容而非控制格式,应尽量减少格式化操作让用户有权控制输出细节。与 IO 标准库兼容的输入输出运算符必须是非成员函数,且一般声明为友元。

重载输入运算符 >>

通常,重载输入运算符 operator>> 应满足:

  • 第一个形参是 istream 对象的非常量引用,第二个形参是自定义类类型的非常量引用。
  • 返回值是输入的 istream 对象引用。

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

  • 流读取的数据类型错误时,读写操作可能失败。
  • 读取操作到达文件末尾或遇到输入流的其它错误时,读写操作失败。

输入运算符必须处理输入可能失败的情况,确保对象处于正确的状态。如果发生错误前对象已经有一部分被改变,则适时地将对象置为合法状态格外重要。

一些输入运算符需要进行数据验证,应该设置流的条件状态以标示失败信息,通常输入运算符只设置 failbit;此外,eofbit 表示文件耗尽,badbit 表示流被破坏。最好的方式是由 IO 标准库自己来标示这些错误。

算术和关系运算符

通常,算术和关系运算符定义为非成员函数以允许转换运算对象类型,函数形参均为常量引用。

如果定义了算术运算符,一般也会定义相应的复合赋值运算符,此时最有效的方式是通过复合赋值运算符来实现算术运算符。

通常,C++ 中的类通过定义相等运算符来检验对象是否相等,即比较每个数据成员,只有所有成员都相等时才认为对象相等。基本设计准则是:

  • 判断自定义类对象是否相等的操作应该定义为操作符函数 operator==,而非普通的命名函数,降低函数名记忆负担,而且易于使用标准库容器和算法。
  • 通常,相等运算符具有传递性。
  • 定义了 operator== 的类也应该定义 operator!=,两者中的一个应把实际工作委托给另一个。

定义相等运算符的类也常常包含关系运算符,特别是 operator<,用于关联容器和一些算法。通常 < 应满足:

  • 定义严格弱序,与关联容器关键字的要求一致。
  • 如果定义了 ==,则应与 == 保持一致,即,两对象若 !=,则必有一个对象 < 另一个。

如果存在唯一合理的 < 定义,则应考虑定义 < 运算符。

赋值运算符

除了在同类型对象间拷贝/移动赋值之外,赋值运算符还可以重载以使用不同类型的对象赋值。重载赋值运算符必须定义为成员函数,而且和内置类型一样,返回左侧运算对象的引用。虽然不是必须的,但复合赋值运算符通常也定义为成员函数。

class StrVec {
public:
  StrVec &operator=(std::initializer_list<std::string>);
};

下标运算符

表示容器的类一般会定义下标运算符 operator[],以通过索引访问元素。下标运算符必须是返回元素引用的成员函数,而且一般同时定义常量和非常量两个版本。

class StrVec {
public:
  std::string &operator[](std::size_t n) { return *(begin() + n); };
  const std::string &operator[](std::size_t n) const { return *(begin() + n); };
  // 其它成员
};

递增和递减运算符

迭代器类通常会实现递增 ++、递减 --,以使得在元素序列中前后移动。而且一般同时定义前置、后置两个版本的成员函数。为了与内置版本保持一致,前置运算符应返回递增/递减后对象的引用,后置运算符应返回递增/递减前对象的值。

后置版本接受一个额外的 int 型形参,使用后置运算符时,编译器会为其提供一个值为 0 的实参。该参数通常不会使用,它的唯一作用就是区分前置版本和后置版本。后置运算符也可以借助该参数显式调用:

class StrBlobPtr {
public:
  StrBlobPtr &operator++();
  StrBlobPtr operator++(int);
  StrBlobPtr &operator--();
  StrBlobPtr operator--(int);
};

StrBlobPtr p(a1);
p.operator++(0);
p.operator++();

成员访问运算符

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

class StrBlobPtr {
public:
  std::string &operator*();
  std::string *operator->();
};

重载箭头运算符的返回值只能是:

  • 指针。
  • 某个自定义了箭头运算符的类对象。

箭头运算符永远都执行成员访问的功能,重载箭头只能改变获取成员的源对象。自定义箭头运算符 point->mem 等价于:

point.operator->()->mem;

函数调用运算符

重载了函数调用运算符的类,可以当做函数来使用。一个类可以重载多个调用运算符,它们都必须是成员函数。

定义了调用运算符的类对象称为函数对象(function object)。函数对象类通常还包含一些数据成员,用于定制调用运算符中的操作。函数对象常作为泛型算法的实参。

class PrintString {
public:
  PrintString(std::ostream &o = std::cout, char c = ' '): os(o), sep(c) {};
  void operator()(const std::string &s) const { os << s << sep; };
private:
  std::ostream &os;
  char sep;
};

string s = "jerry";
PrintString printer;
printer(s);
PrintString errors(cerr, '\n');
errors(s);

vector<string> svec = { "hello", "world" };
for_each(svec.begin(), svec.end(), PrintString(cerr, '\n'));

lambda 是函数对象

编译器会将 lambda 表达式翻译成一个匿名类的匿名对象。该类含有一个重载的函数调用运算符,它的形参列表、函数体与 lambda 表达式完全一样。默认情况下,该类的函数调用运算符是一个 const 成员,除非 lambda 被声明为 mutable

对于 lambda 表达式的引用捕获变量,编译器可以直接使用该引用,而无需存储该数据成员。值捕获变量则需拷贝到类的数据成员中,因此 lambda 类必须为每个值捕获变量创建相应的数据成员,并创建一个通过捕获变量的值初始化数据成员的构造函数。

auto wc = find_if(svec.begin(), svec.end(), [sz](const string &s) {
  return s.size() >= sz;
});

// 等价于
class SizeComp {
public:
  SizeComp(size_t n): sz(n) {};
  bool operator()(const string &s) const {
    return s.size() >= sz;
  };
private:
  size_t sz;
};

auto wc = find_if(svec.begin(), svec.end(), SizeComp(sz));

标准库定义的函数对象

标准库 functional 头文件中定义了一组表示算术运算符、关系运算符、逻辑运算符的函数对象模板类,它们都定义了一个调用运算符执行相应操作。

std_function_object.png

这些函数对象类常用于替换算法中的默认运算符,而且标准库保证了这些函数对象同样适用于指针。

关联容器使用 less<key_type> 对元素排序,因此可以定义指针的 set,或将指针作为 map 的关键字。

// 将 string 从大到小排序
sort(svec.begin(), svec.end(), greater<string>());

// 按指针所存地址排序
vector<string *> nameTbl;
sort(nameTbl.begin(), nameTbl.end(), less<string *>());

可调用对象与 function

C++ 中的可调用对象有:函数、函数指针、lambda 表达式、bind 创建的对象、重载了函数调用运算符的类。每种可调用对象都有自己的类型,不同类型的可调用对象可能共享同一种调用签名(call signature)。调用签名对应函数类型:

returnType (type0, type1, ..., typen)

标准库 functional 头文件中定义的 function 模板类可用于对调用签名相同、但类型不同的可调用对象进行包装。

function.png

int add(int lhs, int rhs) {
  return lhs + rhs;
}

class divide {
public:
  int operator()(int lhs, int rhs) {
    return lhs / rhs;
  };
};

auto mod = [](int lhs, int rhs) {
  return lhs % rhs;
};

map<string, function<int(int, int)>> binops = {
  {"+", add},
  {"-", std::minus<int>()},
  {"*", [](int lhs, int rhs) {
    return lhs * rhs;
  }},
  {"/", divide()},
  {"%", mod}
};

为避免二义性,重载函数若要存入 function 类型的对象中,可以存储函数指针,或使用 lambda 表达式。

int add(int i, int j) {
  return i + j;
}
Sales_data add(const Sales_data&, const Sales_data&);

map<string, function<int(int, int)>> binops;
binops.insert({"+", add}); // 错误,二义性

int (*fp)(int, int) = add;
binops.insert({"+", fp});
binops.insert({"+", [](int i, int j) {
  return add(i, j);
}});

重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),也称为自定义类型转换(user-defined conversions)。

类型转换运算符

转换构造函数可以将其他类型隐式转为当前类类型。类型转换运算符(conversion operator)负责将当前类类型转为其他类型,它必须是成员函数,由于通常不改变源对象,因此一般是 const 成员。类型转换运算符的形参列表必须为空,而且不能声明返回类型,但函数体内必须返回相应类型的值。

operator type() const;

转换类型 type 可以是除 void 之外、能作为函数返回类型的任意类型。因此,type 不能是数组或函数类型,但可以是指针(包括数组指针和函数指针)或引用类型。自定义隐式类型转换可以在标准内置类型转换前/后组合使用

class SmallInt {
public:
  SmallInt(int i = 0): val(i) {
    if (i < 0 || i > 255) {
      throw new out_of_range("Bad value");
    }
  };
  operator int() const { return val; };
private:
  size_t val;
};

SmallInt si;
si = 4.13;
cout << (si + 3.14) << endl;

类似重载运算符,合理使用类型转换运算符能极大简化类设计者的工作,让类更易用。但如果类类型和转换类型间没有明确的对应关系,则类型转换具有误导性。

实践中,除了向 bool 之外,很少提供类型转换运算符。因为大多数情况下,用户不希望类型转换自动发生。由于 bool 是算术类型,因此转为 bool 之后可以直接用于算术类型的任何上下文中,这种类型转换可能引发意想不到的结果。

int i = 42;
cin << i; // cin 转为 bool 后,再转为算术类型,可进行移位

为了防止这种异常情况发生,C++11 新标准引入了显式类型转换运算符(explicit conversion operator)。编译器通常不会在隐式转换中使用显式类型转换运算符,除非表达式被用作条件,即:

  • ifwhiledo 语句的条件部分
  • for 语句头的条件表达式
  • !&&|| 的运算对象
  • ?: 的条件表达式

bool 的类型转换通常用于条件部分,因此 operator bool 一般定义为 explicit

避免有二义性的类型转换

为了避免类型转换的二义性,必须确保源类型和目标类型之间只有唯一一种转换方式。以下两种情况可能出现多重转换路径:

  • 同一种类型转换存在于两个类中。比如,A 类定义了 B 类形参的转换构造函数,B 类定义了转换目标为 A 类的类型转换运算符,此时只能显式调用转换函数以区分两者。

    如果需要显式调用构造函数/类型转换,则通常意味着程序设计存在不足。

    struct B;
    struct A {
        A(const B&);
    };
    
    struct B {
        operator A() const;
    };
    
    void f(const A&);
    B b;
    f(b); // 错误:二义性
    
  • 一个类中定义了多个转换规则,转换涉及的类型可以通过其他类型转换联系起来。比如,算术类型等标准内置类型转换,此时标准类型转换的级别决定了最佳匹配。

    struct A {
        A(int i = 0);
        A(double);
        operator int() const;
        operator double() const;
    };
    
    void f(long double);
    A a;
    f(a); // 错误:二义性
    
    long lg;
    A a1(lg); // 错误:二义性
    
    short s = 42;
    A a2(s);
    

以下经验规则有助于正确设计类的重载运算符、转换构造函数、类型转换运算符:

  • 不要在两个类中定义相同的类型转换。
  • 避免定义多个转换源或转换目标是算术类型的转换。如果已经定义了一个向算术类型的转换,那么
    • 不要再定义接受算术类型的重载运算符。
    • 不要定义转换到多种算术类型的转换,让标准类型转换完成这些工作。

除了显式向 bool 类型的转换之外,应尽量避免定义类型转换函数并尽可能限制非显式构造函数。

函数匹配与重载运算符

表达式中运算符的候选函数集包括成员函数和非成员函数的重载版本和内置版本。如果一个类中同时提供了转换目标和转换源是算术类型的类型转换,和接受算术类型的重载运算符,则会在混合类型算术表达式中出现重载运算符和内置运算符的二义性问题。

class SmallInt {
  friend SmallInt operator+(const SmallInt&, const SmallInt&);
public:
  SmallInt(int i = 0): val(i) {};
  operator int() const { return val; };
private:
  std::size_t val;
};

SmallInt s1, s2;
SmallInt s3 = s1 + s2;
int i = s3 + 0; // 错误,二义性