第 14 章 重载运算与类型转换
标签(空格分隔): C++Primer 学习记录 运算符重载 类型转换
14.1 基本概念
-
重载的运算符是具有特殊名字的函数,他们的名字由关键字 operator和其后要定义的运算符号共同组成。
- 重载运算符函数与该运算符作用的运算对象数量一样多。
- 除了重载的函数调用运算符 operator()之外,其他重载运算符不能含有默认实参。
- 当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
- 对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:
// 错误,不能为 int重定义内置的运算符 int operator+(int, int);
- 只能重载已有的运算符,无权发明新的运算符号。
- 重载的运算符,其优先级和结合律与对应的内置运算符保持一致。
-
可以用以下 3种方式来调用重载的运算符函数:
- 将运算符作用于类型正确的实参,以间接方式“调用”重载的运算符函数。
data1 + data2;
- 像调用普通函数一样直接调用运算符函数。
operator+(data1, data2);
- 如果重载的运算符函数是成员函数的话,还可以像调用其他成员函数一样显式地调用运算符函数。
data1.operator+=(data2);
-
因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。所以,不建议重载逗号、取地址、逻辑与和逻辑或运算符。
-
重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容。
-
定义重载的运算符时,要首先决定是将其声明为类的成员函数还是声明为一个普通的非成员函数。可以使用下面的准则来做出抉择:
- 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,如算术、相等性、关系和位运算符等,因此他们通常是普通函数。
14.2 输入和输出运算符
-
输出运算符的第一个形参是一个非常量 ostream对象的引用,第二个形参是一个常量的引用。一般还要返回它的 ostream形参。
ostream& operator<<(ostream &os, const Sales_data &item);
-
与 iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数,一般是被声明为友元。另外,输出运算符应该尽量减少格式化操作,已使用户有权控制输出的细节。
-
输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。返回某个给定流的引用。
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 = Sales_data(); return is; }
-
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。上面代码中,如果在使用 price检测到 is出错,则会执行默认初始化,将 item置为空,可以(略微)保护使用者免于受到输入错误的影响。不过对于
10 24.95 0-210-999
这样的输入时,上面的代码在执行时不会出错,但内部逻辑已经跑偏了。
14.3 算术和关系运算符
-
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值运算符来实现算术运算符。
-
如果某个类在逻辑上有相等性的含义,则该类应该定义 operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。而如果类定义了 operator==,通常也应该定义 operator!=,而这两个运算符中的一个通常要把工作委托给另外一个。
-
如果存在唯一一种逻辑可靠的
<
定义,则应该考虑为这个类定义<
运算符。如果类同时还包含==
,则如果两个对象是!=
的,那么一个对象应该<
另外一个。如果不满足这个条件,那么这个类也许不定义<
运算符会更好。
14.4 赋值运算符
-
除了拷贝赋值和移动赋值运算符之外,标准库中还定义了第三种赋值运算符,接受花括号内的元素列表作为参数。这个运算符无须检查自赋值,因为它的形参是花括号,不可能是它自身。
StrVec &operator=(std::initializer_list<std::string>);
-
复合赋值运算符不是非得是类的成员,但还是倾向于把包括复合赋值在内的所有赋值运算符都定义在类的内部。
14.5 下标运算符
- 如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。其形参可以使用 std::size_t类型,切勿使用 int类型!
14.6 递增和递减运算符
-
定义递增/递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定义成类的成员。为了区分前置和后置运算符,后置版本接受一个额外的(不被使用的)int类型的形参。
- 为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
StrBlobPtr& operator++(); // 前置
- 为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。另外,因为不会用到 int形参,所以无须为其命名。
StrBlobPtr& operator++(int); // 后置
- 可以通过类对象,显式地调用前置/后置递增或递减运算符。
StrBlobPtr p(a1); p.operator++(0); // 后置递增 p.operator++(); // 前置递增
14.7 成员访问运算符
- 箭头运算符(->)必须是类的成员,解引用运算符(*)通常也是类的成员,尽管并非必须如此。一个类中往往会同时定义这两种运算符,通常情况下,箭头运算符可以不执行任何操作,而是调用解引用运算符并返回解引用结果元素的地址。
- 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
-
如果类重载了函数调用运算符,则可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比他们更加灵活。
class PrintString { public: PrintString(ostream &o = cout, char c = ' ') : os(o), sep(c) {} void operator()(const string &s) const {os << s << sep;} private: ostream &os; // 用于写入的目的流 char sep; // 用于将不同输出隔开的字符 }; PrintString printer; // 使用默认值,打印到 cout printer(s); // 在 cout中打印 s,后面跟一个空格 PrintString errors(cerr, ' '); errors(s); // 在 cerr中打印 s,后面跟一个换行符
-
如果类定义了调用运算符,则该类的对象称作函数对象。函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
-
当定义一个 lambda时,编译器会隐式地生成一个与 lambda对应的新的未命名的类类型。其中,捕获列表中的参数就是构造函数的参数,且是这个未命名类的数据成员 ,并且在 lambda对象创建时被初始化。而 lambda表达式中的参数与函数调用运算符的参数对应。下面代码说明了 一个lambda表达式会生成什么样的函数对象。
// 获得第一个指向满足条件元素的迭代器,该元素满足 size() >= sz auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() >= sz; } ); // 该 lambda表达式产生的类将形如: class SizeComp { public: SizeComp(size_t n) : sz(n) {} // 构造函数形参对应捕获的变量 // 调用运算符的返回类型、形参和函数体都与 lambda一致 bool operator()(const string &s) const { return s.size() >= sz; } private: size_t sz; // 该数据成员对应通过值捕获的变量 }; auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
-
默认情况下,由 lambda产生的类当中的函数调用运算符是一个 const成员函数,而且 lambda表达式的函数体通常与重载的函数调用运算符的函数体一致。因此,lambda不能改变它捕获的变量。如果 lambda被声明为可变的,则调用运算符就不是 const的了。
-
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
plus<int> intAdd; // 可执行加法的函数对象 negate<int> intNegate; // 可对 int值取反的函数对象
-
标准库规定其函数对象对于指针同样适用。一般情况下,比较两个无关指针将产生未定义行为,然而如果希望比较指针的内存地址来 sort指针的 vector,可以使用一个标准库函数对象来实现该目的。
vector<string *> nameTable; // 错误,nameTable中的指针彼此之间没有联系,所以 <将产生未定义行为 sort(nameTable.begin(), nameTable.end(), [](string *a, string *b) { return a < b; } ); // 正确,标准库规定的指针的 less操作时定义良好的 sort(nameTable.begin(), nameTable.end(), less<string *>() );
-
C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象和重载了函数调用运算符的类。不同类型的可调用对象可以共享一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。例如,
int(int, int)
,下面几个不同类型的可调用对象都对应着这个调用形式。// 普通函数 int add(int i, int j) { return i + j; } lambda,其产生一个未命名的函数对象类 auto mod = [](int i, int j) { return i % j; }; // 函数对象类 struct divide { int operator()(int denominator, int divisor) { return denominator / divisor; };
-
function是一个模板,参数类型是一种调用形式。不过,不能将重载函数的名字存入 function类型的对象中,这会引起二义性问题。一种方法是存储函数指针而非函数名字,另一种方法是使用 lambda表达式。
int add(int i, int j) { return i + j; } Sales_data add(const Sales_data&, const Sales_data&); map<string, fucntion<int(int, int)>> binops; binops.insert({"+", add}); // 错误,哪个 add? // 存储函数指针 int (*fp)(int, int) = add; // 指针所指向的 add是接受两个 int的版本 binops.insert({"+", fp}); // 正确 // 使用 lambda来消除二义性 binops.insert({"+", [](int i, int j) { return add(i, j); }});
14.9 重载、类型转换与运算符
-
类类型转换通常包括转换构造函数和类型转换运算符两部分。重载的类型转换运算符函数必须是类的成员函数,它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是 const。
// 该类表示 0到 255之间的一个整数 class SmallInt { public: SmallInt(int i = 0) : val(i) { if (i < 0 || i >255) throw std::out_of_range("Bad SmallInt value"); } operator int() const { return val; } private: std::size_t val; }; SmallInt si; si = 4; // 首先将 4隐式转换成 SmallInt,然后调用 SmallInt::operator== si + 3; // 首先将 SmallInt转换成 int,然后执行整数的加法
-
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义的类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
// 内置类型将 double实参转换成 int SmallInt si = 3.14; // 调用 SmallInt(int)构造函数 // SmallInt的类型转换运算符将 si转换成 int si + 3.14; // 内置类型转换将所得到的 int继续转换成 double
-
在大多数情况下,自动发生的类型转换,可能会超出使用者的预期,增加使用难度。所以实践中很少定义类型转换运算符,不过为了方便将表达式用作条件判读,定义向 bool的类型转换还是较为普遍。为了防止自动发生的类型转换,C++11新标准引入了显式的类型转换运算符。显式转换必须通过显式的强制类型转换才可以使用,不过当用作条件判断时,编译器还是会自动执行显式的类型转换。
class SmallInt { public: // 编译器不会自动执行这一类型转换 explicit operator int() const { return val; } // 其它成员与之前的一致 }; SmallInt si = 3; // 正确,SmallInt的构造函数不是显式的 si + 3; // 错误,此处需要显式类型转换 static_cast<int>(si) + 3; // 正确
-
当两个类提供相同的类型转换时,会引起二义性问题。
struct B; struct A { A() = default; A(const B&); // 把一个 B转换成 A }; struct B { operator A() const; // 也是把一个 B转换成 A }; A f(const A&); // 函数声明 B b; A a = f(b); // 二义性错误,不知道是调用 f(B::operator A()) // 还是 f(A::A(const B&)); // 想要执行上面的调用,就必须显式调用转换构造函数或类型转换运算符 A a = f(b.operator A()); A a = f(A(b));
-
如果在同一个类中定义了多个转换源(或转换目标)是算术类型的转换时,也会产生二义性问题。不过,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配是哪一个。
struct A { A(int = 0); A(double); operator int() const; operator double() const; }; void f2(long double); A a; f2(a); // 错误,不能确定是调用 f(A::operator int())还是 f(A::operator double()) long lg; A a2(lg); // 错误,不能确定是调用 A::A(int)还是 A::A(double) short s = 42; A a3(s); // 把 short提升成 int比转换成 double优先级更高,所以调用 A::A(int)
-
如果有多个不同的类定义了同样的转换构造函数,则二义性问题将进一步提升。此时,不会考虑任何额外的标准类型转换的级别。
struct C { C(int); }; struct D { D(int); }; struct E { E(double); }; void manip(const C&); void manip(const C&); manip(10); // 二义性错误,不能确定是 manip(C(10))还是 manip(D(10)) void manip2(const C&); void manip2(const E&); manip2(10); // 二义性错误,不能确定是 manip(C(10))还是 manip(E(double(10)))
-
表达式中,重载的运算符的候选函数集既应该包括成员函数,也包括非成员函数和内置版本。而如果使用命名的函数来调用时,成员函数和非成员函数不会彼此重载。如果我们对一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
class SmallInt { friend SmallInt operator+(const SmallInt&, const SmallInt&); public: SmallInt(int i = 0); // 转换源为 int的类型转换 operator int() const { return val; } // 转换目标为 int的类型转换 private: std::size_t val; }; SmallInt s1, s2; SmallInt s3 = s1 + s2; // 使用重载的 operator+ int i = s3 + 0; // 二义性错误