《C++ Primer》 拾遗 第 14 章 重载运算与类型转换
Notes Cpp Primer
Lastmod: 2021-08-04 周三 23:14:49

第 14 章 重载运算与类型转换

14.1 基本概念

重载的运算符是具有特殊名字的函数。

重载运算符的参数数量与该运算符作用的运算对象数量一样多。除了重载的函数调用运算符 operator() 之外其他重载运算符不能含有默认实参。

一个重载运算符函数是成员函数时,左侧运算对象绑定到隐式的 this 指针上,此时显式的参数个数要比运算符的对象数少 1。

对于一个运算符函数,它至少有一个类类型的参数。(不能重载作用于内置类型的运算符)。

我们只能重载现有的运算符,无法发明新的运算符。

不能改变现有运算符的优先级和结合律。

可以重载的运算符

+   -   *   /   %   ^
&   |   ~   !   ,   =
<   >   <=  >=  ++  --
<<  >>  ==  !=  &&  ||
+=  -=  /=  %=  ^=  &=
|=  *=  <<= >>= []  ()
->  ->* new new[] delete delete[]

不能重载的运算符

:: 
.*
. 
?:

我们可以像调用普通函数一样调用重载运算符

operator+(a, b);
a + b; // 两种形式等价
a.operator+=(b);
a += b;
某些算符不应该被重载

因为重载运算符本质上是函数调用,因此无法保留求职顺序的规则。例如逻辑运算符的短路特性。因此不建议重载逻辑运算符,同时不建议重载逗号运算符和取地址运算符。

选择作为成员或者非成员

赋值 =、下标 []、调用 () 和成员访问箭头 -> 必须是成员。

复合赋值运算符一般来说应该是成员,但并非必须。

改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符通常应该是成员。

具有对称性的运算符可能转换任意一段的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。如果运算对象时混合类型的则需要定义为非成员函数。

14.2 输入和输出运算符

输出类运算符尽量不要考虑格式化操作。

输入输出运算符必须是非成员函数。

#include <iostream>
#include <string>
using namespace std;

struct A {
	int i;
	string s;
};

istream& operator>>(istream &is, A &a) {
	is >> a.i >> a.s;
	if (is) {
		; // check input state
	} else {
		cerr << "read A failed\n";
	}
	return is;
}

ostream& operator<<(ostream &os, const A &a) {
	cout << "(" << a.i << "," << a.s << ")";
	return os;
}

int main() {
	A a;
	cin >> a;
	cout << a;
	return 0;
}

输入需要检查读入是否成功,输出则不需要。

标示错误

一般来说即便输入是成功的,但数据可能依旧不合法(针对具体的功能),输入运算符需要检测数据是否符合设计。此时可以设置流状态来标示失败信息。可以设置 failbit、eofbit 或者 badbit。

14.3 算术和关系运算符

如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况应该使用赋值来实现算术运算符。

14.3.1 相等运算符

== 应该具有传递性。

定义了 == 则也应该定义 != 并且应该把其中一个的实现委托给另一个。

14.3.2 关系运算符

注意 < 的定义应当与 == 一致。

14.5 下标运算符

通常下标运算符会有两个版本,返回普通引用、作为常量成员返回常量引用。

#include <iostream>
using namespace std;

struct A {
	int* a;
	A(int n) {
		a = new int[n];
		for (int i = 0; i < n; i++) a[i] = 0;
	}
	int& operator[](int i) { return a[i]; }
	const int& operator[](int i) const { return a[i]; }
};

int main() {
	A a(10);
	a[5] = 3;
	cout << a[5] << "\n";
	a[5] = 5;
	cout << a[5] << "\n";
	
	const A b(3);
	cout << b[0] << "\n";
	return 0;
}

14.6 递增和递减运算符

前置版本应该返回递增或递减后对象的引用。

为了区分前置和后置运算符,后置版本将接受一个额外的(不被使用的)int 类型的形参。编译器会为这个形参提供一个值为 0 的实参。尽管语法上可以使用这个额外的形参,但是实际通常不会这么做。显式调用时需要给出这个参数来调用后置运算符。

class A {
    A& operator++();   // 前置
    A operator++(int); // 后置
};
A a;
a.operator++();
a.operator++(0);

可以把后置运算符的定义委托给前置运算符。

14.7 成员访问运算符

#include <iostream>
using namespace std;

struct A {
	int i;
	A(int i) : i(i) {}
	void func() { cout << "a function\n"; }
};

struct AP {
	A &a;
	AP(A &a) : a(a) {}
	A& operator*() const {
		return a;
	}
	A* operator->() const {
		return &a;
	}
};
struct APP {
	AP &p;
	APP(AP &p) : p(p) {}
	AP& operator*() const {
		return p;
	}
	AP& operator->() const {
		return p;
	}
};

int main() {
	A a(10);
	AP p(a);
	cout << (*p).i << endl;
	cout << p->i << endl;
	(*p).func();
	p->func();

	APP pp(p);
	cout << (**pp).i <<endl;
	cout << (*pp)->i << endl;
	cout << pp->i << endl;
	return 0;
}

重载的 operator* 不一定是类的成员,也可以自己定义一些操作。

箭头运算符则不能改变获取成员这个事实。

形如 point->mum 的表达式,point 必须是指向类对象的指针或者是一个重载了 operator-> 的类。它将分别等价于以下形式

(*point).mem;             // point 是指针
point.operator->()->mem;  // point 是类

这也解释了上面程序中 pp->i 会链式调用的原因。

14.8 函数调用运算符

如果类定义了调用运算符,则该类的对象称作函数对象 function object

含有状态的函数对象类

因为作为对象可以保存一些状态,因此函数对象使用起来比函数更灵活。

泛型对象也经常作为泛型算法的实参。

#include <iostream>
#include <algorithm>
using namespace std;

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;
};

int main() {
	vector<string> vec = {"1", "3", "5", "7", "9"};
	for_each(vec.begin(), vec.end(), PrintString(cerr, '\n'));
	return 0;
}

14.8.1 lambda 是函数对象

编译器会将 lambda 翻译成一个未命名类的未命名对象。在 lambda 表达式产生的类中含有一个重载的函数调用运算符,它的形参列表和函数体与 lambda 表达式完全一样。默认情况下 lambda 不能改变它捕获的变量,在此情况下,该函数调用运算符是一个 const 成员函数。如果 lambda 被声明为可变的,则调用运算符就不是 const 的了。

通过引用捕获变量时,由成语确保 lambda 执行时引用所引的对象确实存在。编译器可以直接使用该引用而无须在 lambda 产生的类中将其储存为数据成员。

通过值捕获的变量被拷贝到 lambda 中。lambda 产生的类必须为每个值捕获的变量建立数据成员,同时创建构造函数,其使用捕获的变量的值来初始化数据成员。

lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。

14.8.2 标准库定义的函数对象

在 functional 头文件中

plus<T> minus<T> multiplies<T> divides<T> modulus<T> negate<T>
equal_to<T> not_equal_to<T> greater<T> greater_equal<T>
less<T> less_equal<T>
logical_and<T> logical_or<T> logical_not<T>

特别的,标准库规定其函数对象对于指针同样适用。

vector<string *> vec;
// sort(vec.begin(), vec.end(), [](string *a, string *b){
//     return a < b; }); // 未定义行为
sort(vec.begin(), vec.end(), less<string *>());

14.8.3 可调用对象与 function

C++ 中有几种可调用的对象:函数、函数指针、lambda 表达式、bind 创建的对象以及重载了函数调用运算符的类对象。

和其他对象一样,可调用的对象也有类型。lambda 有它自己唯一的类类型。函数及函数指针的类型由返回值类型和实参类型决定。

两个不同类型的可调用对象可能共享同一种调用形式 call signature。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。

有时我们可以使用函数表 function table 来存储指向可调用对象的“指针”。

如果我们使用如下形式,则只能存储函数指针

map<string, int(*)(int,int)> binops;
标准库 function 类型

function 定义在 functional 头文件中。

function<T> f; // T 是 retType(args)
function<T> f(nullptr);
function<T> f(obj); // obj 可以是任何的可调用对象
f;                  // 返回 f 是否为空
f(args);            // 调用 f
function<T>::result_type;
function<T>::argument_type;
function<T>::first_argument_type;
function<T>::second_argument_type;
重载函数与 function

注意重载函数我们无法直接用函数名初始化 function (二义性)。这时我们可以通过函数指针或套一层 lambda 表达式来传入想要的重载函数。

14.9 重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换 class type conversions,也称用户定义的类型转换 user-defined conversions

14.9.1 类型转换运算符

类型转换运算符 conversion operator 是类的一种特殊成员函数,它负责将一个类类型的值准换成其他类型。

operator type() const;

必须是成员函数,不能声明返回类型,形参列表也必须为空,通常应该是 const 的。

定义含有类型转换运算符的类
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;
};
类型转换运算符可能产生意外结果

实践中很少提供类型转换运算符。在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感受到了帮助。

int i = 42;
cin << i;

例如这里 cin 本身没有定义 << 算符,如果隐式转换存在,这里就可以通过编译,而不是是发生错误。

显式的类型转换运算符

C++11 中引入了显式的类型转换运算符 explicit conversion operator

explicit operator int() const { return val; }

编译器通常不会将一个显式的类型转换运算符用于隐式类型转换。除非表达式被用作条件,显式类型转换将被隐式的执行:

if、while 及 do 语句的条件部分

for 语句头的条件表达式

逻辑非运算符 ! 、逻辑或运算符 ||、逻辑与运算符 && 的运算对象

条件运算符 ?: 的条件表达式

14.9.2 避免有二义性的类型转换

两种情况下可能出现多重转换路径。第一种情况是两个类提供相同的类型转换:例如,当 A 类定义了一个接受 B 类对象的转换构造函数,同时 B 类定义了一个转换目标是 A 类的类型转换运算符时。

第二种情况是类定义了多个转换规则。

实参匹配和相同的类型转换
struct B;
struct A {
    A() = default;
    A(const B&);
};
struct B {
    operator A() const;
};
A f(const A&);
B b;
// A a = f(b); // f(B::operator A()) ? f(A::A(const B&)) ?

这时可以使用显示的调用方式

A a1 = f(b.operator A());
A a2 = f(A(b));

注意无法用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。

二义性与转换目标为内置类型的多重类型转换
struct A {
    A(int = 0);
    A(double);
    operator int() const;
    operator double() const;
};
void f(long double);
A a;
// f(a); // f(A::operator int()) ? f(A::operator double()) ?

long lg;
// A a2(lg); // A::A(int) ? A::A(double) ?
重载函数与转换构造函数
struct C {
    C(int);
};
struct D {
    D(int);
};
void manip(const C&);
void manip(const D&);
// manip(10);
重载函数与用户定义的类型转换
struct E {
    E(double);
};
void manip2(const C&);
void manip2(const E&);
// manip(10);

14.9.3 函数匹配与重载运算符

重载运算符也是重载的函数,也适用于普通函数的匹配规则。

而且也要注意成员函数和非成员函数彼此不会重载。

class SmallInt {
    friend SamllInt operator+(const SmallInt&, const SmallInt&);
  public:
    SmallInt(int = 0);
    operator int() const { return val; }
  private:
    std::size_t val;
};
SmallInt s1, s2;
SmallInt s3 = s1 + s2;
int i = s3 + 0; // 二义性错误!
Prev: [模板][数据结构] 下标池 IdPool
Next: 《C++ Primer》 拾遗 第 16 章 模板与泛型编程