重载 C++ 运算符
C++ 允许你为自己的类重新定义诸如 +、-、= 这类运算符的含义。许多面向对象语言并不提供这种能力,因此你也许会本能地低估它在 C++ 里的价值。然而,它对于让你的类表现得像 int、double 这类内建类型至关重要。你甚至可以写出“看起来像数组、函数,或者指针”的类。
第 5 章“使用类进行设计”和第 6 章“面向复用的设计”分别介绍了面向对象设计与运算符重载。第 8 章“精进类与对象”和第 9 章“精通类与对象”则讲解了对象及基础运算符重载的语法细节。本章会从第 9 章结束的地方继续,把运算符重载展开讲透。
运算符重载概览
Section titled “运算符重载概览”正如第 1 章“C++ 与标准库速成”所解释的那样,C++ 中的运算符就是像 +、<、*、<< 这样的符号。它们作用于 int、double 这类内建类型,以完成算术、逻辑以及其他操作。还有一些运算符,例如 -> 与 *,用于解引用指针。在 C++ 中,“运算符”这个概念的范围很广,甚至包括 [](数组下标)、()(函数调用)、类型转换,以及内存分配和释放运算符。运算符重载允许你为自己的类改变这些语言运算符的行为。不过,这种能力也伴随着规则、限制与一系列设计抉择。
为什么要重载运算符?
Section titled “为什么要重载运算符?”在真正学习如何重载运算符之前,你大概更关心:到底为什么要这么做?不同运算符背后的理由并不完全一样,但总体指导原则是一致的:让你的类尽可能表现得像内建类型。你的类越像内建类型,使用者就越容易上手。例如,如果你想写一个表示分数的类,那么能够定义当 +、-、*、/ 作用于该类对象时分别意味着什么,就会非常有帮助。
重载运算符的另一个理由,是为了更细粒度地控制程序行为。例如,你可以为自己的类重载内存分配与释放运算符,从而精确指定每个新对象的内存应如何分配与回收。
必须强调的是:运算符重载并不一定会让你这个“类的编写者”更省事;它的主要目的,是让“类的使用者”更省事。
运算符重载的限制
Section titled “运算符重载的限制”当你重载运算符时,有些事情是无论如何都做不到的:
- 你不能添加新的运算符符号。你只能重新定义语言中已经存在的运算符的含义。本章后面“可重载运算符总结”一节中的表格,会列出所有可以重载的运算符。
- 有一些运算符是不能重载的,例如
.和.*(对象成员访问)、::(作用域解析运算符)以及?:(条件运算符)。那张表会列出所有可以重载的运算符。不能重载的那些,通常也不是你真正会想去重载的,因此这条限制一般不会让你觉得束手束脚。 - 元数(arity)描述的是运算符关联的参数个数,也就是 操作数(operands)的数量。只有函数调用运算符、new 运算符、delete 运算符,以及从 C++23 起的下标运算符
[],你才能改变其元数。对于其他运算符,元数是不能改的。一元运算符(例如++)只作用于一个操作数;二元运算符(例如/)则作用于两个操作数。 - 你不能改变运算符的 优先级(precedence)或 结合性(associativity)。优先级决定在同一表达式中,哪些运算符先执行;结合性则指定同一优先级下的运算符按从左到右还是从右到左求值。多数程序里,这一限制并不严重,因为很少真的能从改变求值顺序中受益;但在某些领域里,你必须把它记在脑子里。例如,假设你写了一个表示数学向量的类,并希望重载
^来表示“把向量提升到某个幂次”。要注意,^的优先级比+之类的许多运算符都低。比如,如果x和y是数学向量,那么写x^3+y会被解释为x^(3+y),而不是你可能本来希望的(x^3)+y。 - 你不能为内建类型重新定义运算符。运算符必须是某个类的成员函数,或者至少全局重载运算符函数的某一个参数必须是用户自定义类型(例如类)。这意味着,你不能干出那种荒唐事:把
int上的+重新定义成减法(当然,你可以为自己的类这么干,虽然也不推荐)。这条规则的唯一例外,是内存分配与释放运算符;你可以替换整个程序的全局内存分配运算符。
有些运算符本身就有两种含义。例如,operator- 可以是二元运算符(如 x = y - z;),也可以是一元运算符(如 x = -y;)。* 可以表示乘法,也可以表示解引用指针。<< 则可能是 stream 插入运算符,也可能是左移运算符,具体取决于上下文。对于这种“一符多义”的运算符,你可以把它们的不同含义都重载出来。
运算符重载中的选择
Section titled “运算符重载中的选择”重载运算符时,你需要编写一个全局函数或成员函数,其名字形式为 operatorX,其中 X 是某个运算符符号;operator 与 X 之间也可以插入空白。例如,第 9 章里为 SpreadsheetCell 对象声明 operator+ 的方式如下:
SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);接下来的几节,会介绍你在编写每一个重载运算符时必须做出的若干设计选择。
成员函数还是全局函数
Section titled “成员函数还是全局函数”首先,你必须决定:这个运算符应当写成类的成员函数,还是写成全局函数。后者也可以被声明成类的 friend,不过那应当是最后手段——给一个类添加 friend 的数量应尽可能少,因为 friend 能直接访问 private 数据成员,从而绕过信息隐藏原则。
那么该如何在成员函数与全局函数之间做选择?首先,你得搞清楚两者的根本区别:如果运算符被写成某个类的成员函数,那么该运算符表达式的左操作数就必须是该类的对象。相反,如果你把它写成全局函数,那么左操作数就可以是别的类型。
运算符大致分三类:
- 必须写成成员函数的运算符。 C++ 语言规定,有些运算符必须是类的成员函数,因为它们脱离类本身就没有意义。例如,
operator=与类本身联系得如此紧密,以至于它根本不可能存在于别的地方。后面“可重载运算符总结”中的表会列出这些必须写成成员函数的运算符。大多数运算符并没有这种强制要求。 - 必须写成全局函数的运算符。 只要你希望允许运算符表达式的左操作数是“不同于你的类”的其他类型,就必须把该运算符写成全局函数。这条规则尤其适用于
<<和>>这两个 stream 插入/提取运算符,因为它们的左操作数是iostream对象,而不是你自己的类对象。它也适用于二元+、-这类交换律较强的运算符,因为它们通常也应允许左操作数不是你的类对象。如果你希望对二元运算符的左操作数支持隐式转换,那也必须写成全局函数。第 9 章讨论过这个问题。 - 既可以写成成员函数,也可以写成全局函数的运算符。 在 C++ 社区里,人们对“到底更该写成员函数还是全局函数”一直存在分歧。不过,我建议你遵循这样一条规则:除非你必须把它写成全局函数,否则就把每个运算符都写成成员函数。这样做的一大优势是:成员函数可以是
virtual,而全局函数显然不行。因此,如果你计划在继承体系中编写重载运算符,那么只要可能,就应优先把它们写成成员函数。
当你把某个重载运算符写成成员函数时,如果它不会修改对象,就应当把它标记为 const。这样它才能作用于 const 对象。
当你把某个重载运算符写成全局函数时,应当把它放在与你的类同一个命名空间中。
参数类型如何选择
Section titled “参数类型如何选择”在选择参数类型时,你其实受到不少约束。正如前面提到的,对于大多数运算符,你不能改变参数个数。比如,operator/ 如果是全局函数,就必须始终接收两个参数;如果是成员函数,就必须始终接收一个参数。只要不符合这一标准,编译器就会报错。从这个意义上说,运算符函数与普通函数不同:普通函数可以自由拥有任意数量的参数,而运算符函数不能。此外,尽管你可以为任何你想要的类型去写运算符,但通常仍会受到你所编写类本身的语义约束。例如,如果你想为类 T 实现加法,就不会去写一个接收两个 string 的 operator+!真正需要你做判断的地方,通常在于:参数到底该按值传递还是按引用传递,以及它们该不该是 const。
值传递还是引用传递,这个选择其实很简单:除非这个函数总是要复制传入对象,否则对于每一个非原生类型参数,你都应该按引用传递;详见第 9 章。
const 的选择也同样简单:除非你确实要修改参数,否则就把它标记为 const。后面“可重载运算符总结”中的表格,会为每个运算符给出示例原型,并根据合适情况标好 const 与引用。
返回类型如何选择
Section titled “返回类型如何选择”C++ 在做重载决议时,并不会根据返回类型来判断。因此,从语法上说,你可以为重载运算符指定任何你想要的返回类型。然而,“你能这么做”并不意味着“你应该这么做”。这种灵活性意味着,你完全可以写出那种令人费解的代码:让比较运算符返回指针,让算术运算符返回 bool。但你不该这样做。正确做法是:让你重载出来的运算符,尽量返回与内建类型上同类运算符一致的返回类型。比较运算符就返回 bool;算术运算符就返回一个表示结果的对象。有些运算符的返回类型乍看并不那么直观。例如,第 8 章提到,operator= 应当返回“被赋值对象本身的引用”,以支持链式赋值。其他运算符也存在类似“没那么显然”的返回类型选择,它们都会在后面的表格中总结出来。
返回类型同样涉及“按值还是按引用”“是否加 const”的选择,不过这一次的判断稍微更微妙。总体原则是:能返回引用就返回引用,否则返回值。那什么时候你能返回引用?这个问题只适用于那些“返回对象”的运算符;对于返回 bool 的比较运算符、没有显式返回类型的转换运算符,以及可以自由返回任意类型的函数调用运算符,这个问题就不成立。如果你的运算符需要构造一个新对象,那么它就必须按值返回这个新对象;如果它并不构造新对象,那么通常就可以返回“运算符所作用对象本身”或“它的某个参数”的引用。后面的表格会给出相应示例。
如果返回值必须能作为 lvalue 被修改(例如可以出现在赋值表达式左侧),那它就必须是非 const 的;否则,应尽量返回 const。比你想象中更多的运算符都要求返回 lvalue,其中包括全部赋值运算符(operator=、operator+=、operator-= 等)。
行为如何选择
Section titled “行为如何选择”从技术上讲,你完全可以在重载运算符中写任何你想写的实现。例如,你甚至可以写一个 operator+,它的行为是启动一局拼字游戏。但正如第 6 章所说,通常你应当让你的实现尽可能符合使用者的预期。把 operator+ 写成真正表示加法的行为,或者至少是“类似加法”的行为,例如字符串拼接。本章会解释各种运算符应该如何实现。某些特殊场景下,你或许会有偏离这些建议的理由;但总体上,仍建议你遵循这些标准模式。
那些你不应重载的运算符
Section titled “那些你不应重载的运算符”有些运算符从语言上允许重载,但实际上你不应该这么做。最典型的是取地址运算符(operator&)。重载它几乎没有什么实用价值,反而会引入困惑,因为你改变的是语言最基础的行为之一——“获取变量地址”。整个标准库虽然大量使用运算符重载,却从不重载取地址运算符。
此外,你也应避免重载二元布尔运算符 operator&& 与 operator||,因为那样你就失去了 C++ 的短路求值规则。在这种情况下,短路不再可能发生,因为左右两侧操作数都必须先被求值,然后才能作为参数传给你定义的重载 && 或 ||。如果你的类真的需要逻辑运算符,不妨提供 operator& 和 operator|,因为它们本来就不会短路。
最后,你也不应该重载逗号运算符(operator,)。没错,你没看错:C++ 里确实存在逗号运算符。它也叫 顺序运算符(sequencing operator),用于把两个表达式写在同一条语句里,并保证它们按从左到右的顺序求值。下面的代码片段演示了逗号运算符:
int x { 1 };println("{}", (++x, 2 * x)); // 先把 x 递增为 2,再乘以 2,输出 4。几乎很少存在真正充分的理由去重载逗号运算符。
可重载运算符总结
Section titled “可重载运算符总结”下表列出了所有可重载运算符,说明它们应当写成类成员函数还是全局函数,总结了“什么时候应该(或不应该)重载它们”,并给出了示例原型,展示了合适的参数与返回值类型。那些不能重载的运算符,例如 .、.*、:: 和 ?:,都不会出现在这张表中。
未来当你想编写某个重载运算符时,这张表会非常有用。到时候你十有八九会忘记某个运算符应该返回什么类型、以及它到底该不该写成成员函数。
在表中,T 表示你要为之编写重载运算符的类名,E 表示另一种不同类型。表中给出的示例原型并不穷尽所有可能;对于很多运算符来说,T 和 E 还可以有其他组合方式:
| 运算符 | 名称或类别 | 成员函数还是全局函数 | 何时重载 | 示例原型 |
|---|---|---|---|---|
operator+ operator- operator* operator/ operator% | 二元算术运算符 | 推荐全局函数 | 当你希望为自己的类提供这些操作时 | T operator+(const T&, const T&); T operator+(const T&, const E&); |
operator- operator+ operator~ | 一元算术与位运算符 | 推荐成员函数 | 当你希望为自己的类提供这些操作时 | T operator-() const; |
operator++ operator-- | 前置自增与前置自减 | 推荐成员函数 | 当你为算术参数(int、long 等)重载了 += 和 -= 时 | T& operator++(); |
operator++ operator-- | 后置自增与后置自减 | 推荐成员函数 | 当你为算术参数(int、long 等)重载了 += 和 -= 时 | T operator++(int); |
operator= | 赋值运算符 | 必须是成员函数 | 当你的类拥有动态分配资源,或包含引用成员时 | T& operator=(const T&); |
operator+= operator-= operator*= operator/= operator%= | 简写 / 复合算术赋值运算符 | 推荐成员函数 | 当你重载了对应的二元算术运算符,且你的类不是不可变类时 | T& operator+=(const T&); T& operator+=(const E&); |
operator<< operator>> operator& operator| operator^ | 二元位运算符 | 推荐全局函数 | 当你希望提供这些操作时 | T operator<<(const T&, const T&); T operator<<(const T&, const E&); |
operator<<= operator>>= operator&= operator|= operator^= | 简写 / 复合位赋值运算符 | 推荐成员函数 | 当你重载了对应位运算符,且你的类不是不可变类时 | T& operator<<=(const T&); T& operator<<=(const E&); |
operator<=> | 三路比较运算符 | 推荐成员函数 | 当你想为类提供比较支持时;若可行,应优先通过 =default 默认生成 | auto operator<=>(const T&) const = default; partial_ordering operator<=>(const E&) const; |
operator== | 二元相等运算符 | C++20 之后:推荐成员函数;C++20 之前:推荐全局函数 | 当你希望为类提供比较支持、且无法默认生成三路比较运算符时 | bool operator==(const T&) const; bool operator==(const E&) const; bool operator==(const T&, const T&); bool operator==(const T&, const E&); |
operator!= | 二元不等运算符 | C++20 之后:推荐成员函数;C++20 之前:推荐全局函数 | C++20 之后:不再需要,因为编译器会在支持 == 时自动提供 !=;C++20 之前:当你想为类提供比较支持时 | bool operator!=(const T&) const; bool operator!=(const E&) const; bool operator!=(const T&, const T&); bool operator!=(const T&, const E&); |
operator< operator> operator<= operator>= | 二元比较运算符 | 推荐全局函数 | 当你希望提供这些操作时;若已提供 <=>,则不再需要 | bool operator<(const T&, const T&); bool operator<(const T&, const E&); |
operator<< operator>> | I/O stream 运算符(插入与提取) | 必须是全局函数 | 当你希望支持这些操作时 | ostream& operator<<(ostream&, const T&); istream& operator>>(istream&, T&); |
operator! | 布尔否定运算符 | 推荐成员函数 | 很少需要;通常更适合提供 bool 或 void* 转换 | bool operator!() const; |
operator&& operator|| | 二元布尔运算符 | 推荐全局函数 | 几乎不建议,因为你会失去短路;更好的办法是改为重载 & 与 |,因为这两个本来就不短路 | bool operator&&(const T&, const T&); |
operator[] | 下标(数组索引)运算符 | 必须是成员函数 | 当你希望支持下标访问时 | E& operator[](size_t); const E& operator[](size_t) const; |
operator() | 函数调用运算符 | 必须是成员函数 | 当你希望对象表现得像函数指针时 | 返回类型与参数都可变化;见本章后文示例 |
operator type() | 转换运算符 / cast 运算符(每种类型各自一个) | 必须是成员函数 | 当你希望支持从你的类转换到其他类型时 | operator double() const; |
operator ""_x | 用户自定义字面量运算符 | 必须是全局函数 | 当你希望支持自定义字面量时 | T operator""_i(long double d); |
operator new operator new[] | 内存分配运算符 | 推荐成员函数 | 当你想控制类对象的内存分配时(很少需要) | void* operator new(size_t size); void* operator new[](size_t size); |
operator delete operator delete[] | 内存释放运算符 | 推荐成员函数 | 当你重载了对应的内存分配运算符时(很少需要) | void operator delete(void* ptr) noexcept; void operator delete[](void* ptr) noexcept; |
operator* operator-> | 解引用运算符 | operator* 推荐成员函数;operator-> 必须是成员函数 | 对 smart pointer 很有用 | E& operator*() const; E* operator->() const; |
operator& | 取地址运算符 | N/A | 永远不要重载 | N/A |
operator->* | 成员指针解引用运算符 | N/A | 永远不要重载 | N/A |
operator, | 逗号运算符 | N/A | 永远不要重载 | N/A |
第 9 章讨论了移动语义与右值引用,并通过定义移动赋值运算符来演示它们。在“源对象是一个临时对象、赋值后即将销毁”,或者显式使用 std::move() 将对象移动走的情况下,编译器会优先使用这些移动赋值运算符。前面那张表中,普通赋值运算符的原型是:
T& operator=(const T&);而移动赋值运算符的原型几乎完全一样,只不过它接收的是右值引用。由于它会修改传入参数,因此参数不能是 const。关于细节,请参见第 9 章。
T& operator=(T&&) noexcept;前面那张表里并没有把“带右值引用版本”的示例原型也列进去。不过,对大多数运算符而言,编写一个接收普通左值引用的版本,再额外提供一个接收右值引用的版本,往往都是有意义的。是否值得这么做,取决于你这个类的实现细节。operator= 就是第 9 章中的一个例子。另一个例子则是 operator+:它可以避免不必要的内存分配。例如,标准库中的 std::string 就实现了一个使用右值引用的 operator+(下面是简化版):
string operator+(string&& lhs, string&& rhs);这个运算符的实现会复用某个参数已有的内存,因为这两个参数都是以右值引用传入的,这意味着它们都是临时对象,而当这个 operator+ 结束时,它们就会被销毁。根据两个操作数的大小和 capacity,这个 operator+ 的实现效果大致会变成下面两种之一:
return move(lhs.append(rhs));或者:
return move(rhs.insert(0, lhs));实际上,string 为接收两个 string 的 operator+ 定义了多个重载版本,它们会使用不同的左值/右值引用组合。下面是一个简化版列表:
string operator+(const string& lhs, const string& rhs); // 不复用内存。string operator+(string&& lhs, const string& rhs); // 可复用 lhs 的内存。string operator+(const string& lhs, string&& rhs); // 可复用 rhs 的内存。string operator+(string&& lhs, string&& rhs); // 可复用 lhs 或 rhs 的内存。复用某个右值引用参数内存的技巧,其本质与第 9 章中解释移动赋值运算符时所采用的方法相同。
优先级与结合性
Section titled “优先级与结合性”在包含多个运算符的语句中,运算符的 优先级(precedence)决定了哪些运算符需要比其他运算符更早求值。例如,* 和 / 总是先于 + 和 - 被执行。
结合性(associativity)则要么是从左到右,要么是从右到左,用来决定同一优先级的运算符按什么顺序求值。
下表列出了 C++ 中所有可用运算符的优先级与结合性,包括那些不能重载的运算符,以及本书尚未介绍到的运算符。优先级数字越小,越先求值。表中 T 表示某个类型,而 x、y、z 表示对象:
| 优先级 | 运算符 | 结合性 |
|---|---|---|
| 1 | :: | 从左到右 |
| 2 | x++ x-- x() x[] T() T{} . -> | 从左到右 |
| 3 | ++x --x +x -x ! ~ *x &x (T) sizeof co_await new delete new[] delete[] | 从右到左 |
| 4 | .* ->* | 从左到右 |
| 5 | x*y x/y x%y | 从左到右 |
| 6 | x+y x-y | 从左到右 |
| 7 | << >> | 从左到右 |
| 8 | <=> | 从左到右 |
| 9 | < <= > >= | 从左到右 |
| 10 | == != | 从左到右 |
| 11 | x&y | 从左到右 |
| 12 | ^ | 从左到右 |
| 13 | | | 从左到右 |
| 14 | && | 从左到右 |
| 15 | || | 从左到右 |
| 16 | x?y:z throw co_yield = += -= *= /= %= <<= >>= &= ^= |= | 从右到左 |
| 17 | , | 从左到右 |
<utility> 中的 std::rel_ops 命名空间定义了以下关系运算符函数模板:
template<class T> bool operator!=(const T& a, const T& b); // 需要 operator==template<class T> bool operator>(const T& a, const T& b); // 需要 operator<template<class T> bool operator<=(const T& a, const T& b); // 需要 operator<template<class T> bool operator>=(const T& a, const T& b); // 需要 operator<这些函数模板会基于 == 和 <,为任意类自动定义 !=、>、<= 和 >=。也就是说,如果你为某个类实现了 operator== 与 <,那么其余关系运算符就可以通过这些模板“免费得到”。
不过,这种技术存在不少问题。第一个问题是:这些运算符可能会被为所有参与关系运算的类生成,而不仅仅是你自己的类。
第二个问题是:像 std::greater<T> 这样的工具模板(会在第 19 章“函数指针、函数对象与 Lambda 表达式”中讨论),并不能与这些自动生成的关系运算符良好配合。
第三个问题是:隐式转换在这里并不会正常工作。
最后,随着 C++20 引入了三路比较运算符,而且 std::rel_ops 命名空间本身也在 C++20 中被弃用,现在已经完全没有理由再继续使用 rel_ops 了。
永远不要使用 std::rel_ops;它从 C++20 起就已经被弃用了!如果你想为某个类补全全部六个比较运算符,只需显式默认生成或实现 operator<=>,必要时再补一个 operator== 即可。具体细节见第 9 章。
C++ 为一部分运算符提供了如下替代写法。这些写法主要源自很久以前:当时某些字符集里不包含 ~、|、^ 等字符时,就只能使用这些单词形式。
| 运算符 | 替代写法 | 运算符 | 替代写法 | |
|---|---|---|---|---|
&& | and | != | not_eq | |
&= | and_eq | || | or | |
& | bitand | |= | or_eq | |
| | bitor | ^ | xor | |
~ | compl | ^= | xor_eq | |
! | not |
重载算术运算符
Section titled “重载算术运算符”第 9 章展示了如何编写二元算术运算符和简写算术赋值运算符,但没有涉及如何重载其他算术运算符。
重载一元负号与一元正号
Section titled “重载一元负号与一元正号”C++ 提供了若干一元算术运算符,其中两个就是一元负号和一元正号。下面是它们作用于 int 的示例:
int i, j { 4 };i = -j; // 一元负号i = +i; // 一元正号j = +(-i); // 先对 i 取一元负号,再对结果应用一元正号。j = -(-i); // 先对 i 取一元负号,再对结果再次取一元负号。一元负号会对操作数取相反数;一元正号则直接返回操作数本身。注意,你完全可以把一元正号或一元负号再应用到前一个一元正/负号的结果上。这两个运算符都不会修改它们所作用的对象,因此都应标记为 const。
下面给出一个 SpreadsheetCell 类的一元 operator- 成员函数示例。一元正号通常只是恒等操作,所以这个类并没有重载它。
SpreadsheetCell SpreadsheetCell::operator-() const{ return SpreadsheetCell { -getValue() };}operator- 不会修改操作数,因此该成员函数必须构造一个“值被取负后”的新 SpreadsheetCell 并返回它。因此,它不能返回引用。你可以这样使用这个运算符:
SpreadsheetCell c1 { 4 };SpreadsheetCell c3 { -c1 };重载自增与自减
Section titled “重载自增与自减”给某个变量加 1,有好几种写法:
i = i + 1;i = 1 + i;i += 1;++i;i++;最后两种形式就是 自增 运算符。前者是 前置自增(prefix increment):它先把变量加 1,再把“已经递增后的新值”返回给表达式的后续部分使用。后者是 后置自增(postfix increment):它同样会把变量加 1,但返回的是“递增之前的旧值”。自减运算符的工作方式完全类似。
operator++ 和 operator-- 的这两种不同含义(前置与后置),会在重载时带来一个问题:你写下一个 operator++ 时,编译器怎么知道你重载的是前置版本还是后置版本?C++ 为此引入了一个略显“黑魔法”的约定:前置版本的 operator++ / operator-- 不接收参数,而后置版本则接收一个类型为 int、但不会被真正使用的参数。
对于 SpreadsheetCell 类来说,这些重载运算符的原型如下:
SpreadsheetCell& operator++(); // 前置SpreadsheetCell operator++(int); // 后置SpreadsheetCell& operator--(); // 前置SpreadsheetCell operator--(int); // 后置前置版本返回的是“操作结束后的对象值”,因此它可以返回对当前对象自身的引用;而后置版本返回的是“修改之前的旧值”,这与对象结束时的状态并不相同,所以它不能返回引用。
下面是 operator++ 的实现:
SpreadsheetCell& SpreadsheetCell::operator++(){ set(getValue() + 1); return *this;}
SpreadsheetCell SpreadsheetCell::operator++(int){ auto oldCell { *this }; // 保存当前值 ++(*this); // 通过前置 ++ 完成递增 return oldCell; // 返回旧值}operator-- 的实现与此几乎完全一致。这样一来,你就可以愉快地对 SpreadsheetCell 对象做自增自减了:
SpreadsheetCell c1 { 4 };SpreadsheetCell c2 { 4 };c1++;++c2;自增与自减运算符也适用于指针。当你编写像 smart pointer 这类“看起来像指针”的类时,也可以通过重载 operator++ 与 operator-- 来支持“指针式”的前进与后退。
重载位运算符与二元逻辑运算符
Section titled “重载位运算符与二元逻辑运算符”位运算符与算术运算符很相似,而位运算简写赋值运算符也与算术简写赋值运算符相似。不过,它们的使用频率要低得多,因此这里不再展开示例。你可以参考“可重载运算符总结”中的样例原型;如果哪天真的需要实现它们,按那张表去写通常就够了。
逻辑运算符则更棘手。通常不建议去重载 && 和 ||。这两个运算符并不真正“属于某个类型”;它们本质上是在聚合布尔表达式的结果。除此之外,一旦重载它们,你还会失去短路求值,因为左右两侧操作数都必须先求值,才能绑定到你定义的重载 && 或 || 的参数上。因此,它们几乎从来都不是值得为某个具体类型去重载的东西。
重载插入与提取运算符
Section titled “重载插入与提取运算符”在 C++ 中,运算符不仅用于算术,还用于从 stream 读写数据。例如,当你把 int 和 string 输出到 cout 时,你用的是插入运算符 <<:
int number { 10 };cout << "数字是 " << number << endl;而当你从 stream 中读取数据时,使用的则是提取运算符 >>:
int number;string str;cin >> number >> str;你同样可以为自己的类编写插入与提取运算符,这样就能像下面这样读写它们:
SpreadsheetCell myCell, anotherCell, aThirdCell;cin >> myCell >> anotherCell >> aThirdCell;cout << myCell << " " << anotherCell << " " << aThirdCell << endl;在真正动手写插入与提取运算符之前,你需要先决定:你的类在 stream 中应当以什么样的形式输出,以及应当以什么样的形式读入。在这个示例中,SpreadsheetCell 只是简单地读写一个 double 值。
插入运算符和提取运算符左侧的对象,是 istream 或 ostream(例如 cin 或 cout),而不是 SpreadsheetCell 对象。由于你不可能给 istream 或 ostream 这些类随意添加成员函数,因此这两个运算符必须写成全局函数。它们的声明如下:
export std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);export std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);把插入运算符的第一个参数写成 ostream&,意味着它不仅能用于 cout,还能用于文件输出 stream、字符串输出 stream、cerr、clog 等各种输出 stream。关于 stream 细节,请参考第 13 章“揭开 C++ I/O 的面纱”。类似地,把提取运算符的第一个参数写成 istream&,则使它能用于各种输入 stream,例如文件输入 stream、字符串输入 stream,以及 cin。
operator<< 和 operator>> 的第二个参数,是你想要写入或读出的 SpreadsheetCell 对象引用。插入运算符不会修改被写出的 SpreadsheetCell,因此它接收的是“对 const 的引用”;而提取运算符会修改 SpreadsheetCell 对象,所以它必须接收“对非 const 的引用”。
这两个运算符都应当返回其第一个参数,也就是 stream 的引用,以支持嵌套调用。请记住,运算符语法其实只是对全局 operator>> 或 operator<< 函数的一层语法糖。来看这句:
cin >> myCell >> anotherCell >> aThirdCell;它等价于:
operator>>(operator>>(operator>>(cin, myCell), anotherCell), aThirdCell);正如你所见,第一次 operator>> 的返回值会被当作下一次 operator>> 的输入。因此,你必须返回 stream 引用,这样它才能参与下一层嵌套调用;否则这种链式写法就无法编译。
下面是 SpreadsheetCell 类的 operator<< 与 operator>> 实现:
ostream& operator<<(ostream& ostr, const SpreadsheetCell& cell){ ostr << cell.getValue(); return ostr;}
istream& operator>>(istream& istr, SpreadsheetCell& cell){ double value; istr >> value; cell.set(value); return istr;}重载下标运算符
Section titled “重载下标运算符”假设一下:你暂时忘记了标准库里已经有 vector 和 array 这两个类模板,因此你决定自己写一个动态分配数组类。这个类应当允许你在指定索引位置设置与读取元素,并把所有内存管理都藏在幕后。这样一个动态数组类的第一版定义,也许会像下面这样:
export template <typename T>class Array{ public: // 创建一个具有默认大小、并可按需增长的数组。 Array(); virtual ~Array();
// 禁止拷贝构造与拷贝赋值。 Array& operator=(const Array& rhs) = delete; Array(const Array& src) = delete;
// 移动构造与移动赋值。 Array(Array&& src) noexcept; Array& operator=(Array&& rhs) noexcept;
// 返回索引 x 处的值。若索引越界,抛出 out_of_range。 const T& getElementAt(std::size_t x) const;
// 设置索引 x 处的值。若索引越界,自动扩容。 void setElementAt(std::size_t x, const T& value);
// 返回数组中元素个数。 std::size_t getSize() const noexcept; private: static constexpr std::size_t AllocSize { 4 }; void resize(std::size_t newSize); T* m_elements { nullptr }; std::size_t m_size { 0 };};这个接口支持设置与访问元素,并提供随机访问保证:使用者可以先创建一个默认数组,然后直接去设置下标 1、100、1000 处的元素值,而无需操心底层内存管理。
下面是这些成员函数的实现:
template <typename T> Array<T>::Array(){ m_elements = new T[AllocSize] {}; // 元素会被零初始化! m_size = AllocSize;}
template <typename T> Array<T>::~Array(){ delete[] m_elements; m_elements = nullptr; m_size = 0;}
template <typename T> Array<T>::Array(Array&& src) noexcept : m_elements { std::exchange(src.m_elements, nullptr) } , m_size { std::exchange(src.m_size, 0) }{}
template <typename T> Array<T>& Array<T>::operator=(Array<T>&& rhs) noexcept{ if (this == &rhs) { return *this; } delete[] m_elements; m_elements = std::exchange(rhs.m_elements, nullptr); m_size = std::exchange(rhs.m_size, 0); return *this;}
template <typename T> void Array<T>::resize(std::size_t newSize){ // 创建一个更大的新数组,并对元素做零初始化。 auto newArray { std::make_unique<T[]>(newSize) };
// newSize 总是大于当前 m_size。 for (std::size_t i { 0 }; i < m_size; ++i) { // 把旧数组元素复制到新数组中。 newArray[i] = m_elements[i]; }
// 删除旧数组,并切换到新数组。 delete[] m_elements; m_size = newSize; m_elements = newArray.release();}
template <typename T> const T& Array<T>::getElementAt(std::size_t x) const{ if (x >= m_size) { throw std::out_of_range { "" }; } return m_elements[x];}
template <typename T> void Array<T>::setElementAt(std::size_t x, const T& val){ if (x >= m_size) { // 在使用者需要的位置之后额外再多分配 AllocSize 个元素。 resize(x + AllocSize); } m_elements[x] = val;}
template <typename T> std::size_t Array<T>::getSize() const noexcept{ return m_size;}请特别留意 resize() 的异常安全实现。它会先用 make_unique() 创建一个大小合适的新数组,并把它存进 unique_ptr 中;然后再把旧数组中的元素逐个复制到新数组里。如果复制过程中任何一步出错,那么 unique_ptr 会自动清理新分配出来的内存。只有当“新数组分配成功”且“所有元素复制也成功”——也就是没有任何异常抛出——之后,才会真正删除旧的 m_elements 数组,并让成员指针切换到新数组。最后一行必须使用 release(),以释放 unique_ptr 对新数组的所有权;否则,当 unique_ptr 析构时,新数组会被自动销毁。
为了保证强异常安全(见第 14 章“处理错误”),resize() 当前采用的是“从旧数组复制元素到新数组”的策略。第 26 章“高级模板”会讨论并实现一个 move_assign_if_noexcept() 辅助函数。这个辅助函数可以用在 resize() 中:如果元素类型的移动赋值运算符被标记成 noexcept,就使用移动;否则就退回到复制。无论最终采用移动还是复制,强异常安全仍然可以得到保证。
下面是这个类的一个简单使用示例:
Array<int> myArray;for (size_t i { 0 }; i < 20; i += 2) { myArray.setElementAt(i, 100);}for (size_t i { 0 }; i < 20; ++i) { print("{} ", myArray.getElementAt(i));}输出如下:
100 0 100 0 100 0 100 0 100 0 100 0 100 0 100 0 100 0 100 0从这个例子可以看出:你完全不需要事先告诉数组需要多大。它会根据你存进去的元素,自动分配足够空间。
不过,总是通过 setElementAt() 和 getElementAt() 来访问元素,显然有些不够方便。
这时,重载下标运算符就派上用场了。你可以像下面这样,把 operator[] 加进这个类:
export template <typename T>class Array{ public: T& operator[](std::size_t x); // 其余部分略。};实现如下:
template <typename T> T& Array<T>::operator[](std::size_t x){ if (x >= m_size) { // 在使用者需要的位置之后额外再多分配 AllocSize 个元素。 resize(x + AllocSize); } return m_elements[x];}有了这个改动之后,你就能像使用普通数组那样,使用下标语法:
Array<int> myArray;for (size_t i { 0 }; i < 20; i += 2) { myArray[i] = 100;}for (size_t i { 0 }; i < 20; ++i) { print("{} ", myArray[i]);}operator[] 既能用于设置元素,也能用于读取元素,因为它返回的是“位置 x 上元素的引用”。这个引用可以直接作为赋值目标使用。当 operator[] 出现在赋值语句左侧时,该赋值实际上会修改 m_elements 数组中位置 x 的值。
用 operator[] 提供只读访问
Section titled “用 operator[] 提供只读访问”尽管有时让 operator[] 返回一个可充当 lvalue 的元素很方便,但你并不总是希望如此。理想情况下,你还应该能够提供“只读访问”,也就是返回“对 const 的引用”。要实现这一点,你需要两个 operator[] 重载:一个返回“对非 const 的引用”,另一个返回“对 const 的引用”:
T& operator[](std::size_t x);const T& operator[](std::size_t x) const;请记住:你不能只靠返回类型来重载成员函数或运算符,因此第二个重载不仅返回的是“对 const 的引用”,同时它自身也必须被标记为 const。
下面是 const operator[] 的实现。与其尝试扩容,它在下标越界时直接抛出异常;因为当你只是读取值时,再去分配新空间是没有意义的。
template <typename T> const T& Array<T>::operator[](std::size_t x) const{ if (x >= m_size) { throw std::out_of_range { "" }; } return m_elements[x];}下面的代码演示了这两种形式的 operator[]:
void printArray(const Array<int>& arr){ for (size_t i { 0 }; i < arr.getSize(); ++i) { print("{} ", arr[i]); // 因为 arr 是 const 对象, // 所以这里调用的是 const operator[]。 } println("");}
int main(){ Array<int> myArray; for (size_t i { 0 }; i < 20; i += 2) { myArray[i] = 100; // 因为 myArray 是非 const 对象, // 所以这里调用的是非 const operator[]。 } printArray(myArray);}注意,在 printArray() 中调用 const operator[],仅仅是因为参数 arr 是 const。如果 arr 不是 const,那么即便你并不打算修改返回结果,也会优先调用非 const operator[]。
由于 const operator[] 作用于 const 对象,因此它不可能扩容。当前的实现是在索引越界时抛出异常。另一种选择,是在越界时返回一个“被零初始化的元素”,而不是抛异常。可以这样写:
template <typename T> const T& Array<T>::operator[](std::size_t x) const{ if (x >= m_size) { static T nullValue { T{} }; return nullValue; } return m_elements[x];}这里的 nullValue 是一个 static 变量,并通过零初始化语法 T{} 进行初始化。最终到底应该采用“抛异常版本”,还是“返回空值版本”,取决于你的具体使用场景。
多维下标运算符
Section titled “ 多维下标运算符”从 C++23 开始,下标运算符可以支持多维索引。语法很直接:与其编写一个只接收单个索引参数的下标运算符,不如直接写一个能接收“你所需维数对应数量参数”的下标运算符。
为了演示这一点,我们回到第 12 章“使用模板编写泛型代码”中介绍过的 Grid 类模板。它的接口中本来包含一对 at(x, y) 成员函数(一个 const,一个非 const)。现在,我们可以把它们替换为二维下标运算符:
template <typename T>class Grid{ public: std::optional<T>& operator[](std::size_t x, std::size_t y); const std::optional<T>& operator[](std::size_t x, std::size_t y) const; // 其余部分略。};语法上的区别只是:现在为这两个二维下标运算符显式指定了两个参数 x 和 y。它们的实现与原本 at() 成员函数的实现几乎完全一样:
template <typename T>const std::optional<T>& Grid<T>::operator[](std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x + y * m_width];}template <typename T>std::optional<T>& Grid<T>::operator[](std::size_t x, std::size_t y){ return const_cast<std::optional<T>&>(std::as_const(*this)[x, y]);}下面是这些新运算符的使用示例:
Grid<int> myIntGrid { 4, 4 };int counter { 0 };for (size_t y { 0 }; y < myIntGrid.getHeight(); ++y) { for (size_t x { 0 }; x < myIntGrid.getWidth(); ++x) { myIntGrid[x, y] = ++counter; }}for (size_t y { 0 }; y < myIntGrid.getHeight(); ++y) { for (size_t x { 0 }; x < myIntGrid.getWidth(); ++x) { print("{:3} ", myIntGrid[x, y].value_or(0)); } println("");}输出如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16非整数数组下标
Section titled “非整数数组下标”把“下标访问”推广到更一般的集合访问,其实是很自然的:在一般意义上,下标参数就是某种 key,而 vector(更一般地说,线性数组)只不过是一种特殊情况,其中“key”恰好就是数组中的位置。因此,你完全可以把 operator[] 的参数理解成“在 key 域与 value 域之间建立映射”的东西。基于这个思路,你可以编写接受任意类型作为索引的 operator[],它并不一定非得是整数类型。标准库中的关联容器(如第 18 章“标准库容器”中讨论的 std::map)就是这样做的。
例如,你完全可以创建一个 关联数组(associative array),它使用 string,或者更好地说 string_view,作为 key,而不是整数下标。这样的 operator[] 会把 string / string_view 作为参数。实现这种类,将作为本章末尾的练习留给你完成。
static 下标运算符
Section titled “ static 下标运算符”从 C++23 开始,只要下标运算符的实现不需要访问 this——换句话说,不需要访问任何非 static 数据成员和非 static 成员函数——那么它就可以被标记为 static。这与本章前面介绍过的“可标记为 static 的下标运算符”思路一致,而这样做能让编译器更好地优化代码,因为它不必再考虑任何 this 指针。下面给出一个例子,其中 operator[] 被同时标记为 static、constexpr(见第 9 章)以及 noexcept(见第 14 章):
enum class Figure { Diamond, Heart, Spade, Club };
class FigureEnumToString{ public: static constexpr string_view operator[](Figure figure) noexcept { switch (figure) { case Figure::Diamond: return "Diamond"; case Figure::Heart: return "Heart"; case Figure::Spade: return "Spade"; case Figure::Club: return "Club"; } }};
int main(){ Figure f { Figure::Spade }; FigureEnumToString converter; println("{}", converter[f]); println("{}", FigureEnumToString{}[f]);}重载函数调用运算符
Section titled “重载函数调用运算符”C++ 允许你重载函数调用运算符,也就是 operator()。如果你为某个类编写了 operator(),那么该类的对象就能像函数指针一样使用。拥有函数调用运算符的类对象,被称为 函数对象(function object),简称 functor。下面是一个简单示例:一个带有重载 operator() 的类,以及一个行为相同的普通成员函数:
class Squarer{ public: int operator()(int value) const; // 重载的函数调用运算符。 int doSquare(int value) const; // 普通成员函数。};// 重载函数调用运算符的实现。int Squarer::operator()(int value) const { return doSquare(value); }// 普通成员函数的实现。int Squarer::doSquare(int value) const { return value * value; }下面的代码演示了函数调用运算符的用法,并把它与普通成员函数调用作对比:
int x { 3 };Squarer square;int xSquared { square(x) }; // 调用函数调用运算符。int xSquaredAgain { square.doSquare(xSquared) }; // 调用普通成员函数。println("{} 的平方是 {},再平方一次是 {}。", x, xSquared, xSquaredAgain);输出如下:
3 的平方是 9,再平方一次是 81。一开始,函数调用运算符可能看起来有点古怪。为什么要专门为一个类写这样一个成员函数,让它的对象看起来像函数指针?为什么不直接写普通全局函数,或者类的普通成员函数呢?
函数对象相对于“对象上的普通成员函数”的优势,其实很简单:这些对象有时可以伪装成函数指针;也就是说,你可以把函数对象当作回调函数传给其他函数。这一点会在第 19 章中详细讨论。
而函数对象相对于全局函数的优势,则稍微更微妙一些,主要有两点:
- 对象可以通过数据成员,在多次调用其函数调用运算符之间保留信息。例如,一个函数对象可以在每次调用时不断累加输入值,从而保存运行中的求和结果。
- 你可以通过设置数据成员,定制函数对象的行为。例如,你可以写一个函数对象,用它将传给函数调用运算符的实参与某个数据成员进行比较;而这个数据成员本身又是可配置的,于是该对象就可以被定制成“你想比较成什么就比较成什么”。
当然,上述两种能力你也可以通过全局变量或 static 变量来实现。但函数对象提供的是一种更干净的方式;而且,全局变量和 static 变量本来就应尽量避免使用,在多线程应用里尤其容易引发问题。函数对象的真正优势,会在标准库的使用中更加明显,尤其是第 20 章“精通标准库算法”中。
遵循普通成员函数的重载规则,你完全可以为一个类编写多个 operator()。例如,你可以给 Squarer 类再加一个接收 double 的 operator():
int operator()(int value) const;double operator()(double value) const;这个 double 版本可以这样实现:
double Squarer::operator()(double value) const { return value * value; } static 函数调用运算符
Section titled “ static 函数调用运算符”从 C++23 开始,只要函数调用运算符的实现不需要访问 this——换句话说,不需要访问任何非 static 数据成员和非 static 成员函数——它就可以被标记为 static。这和本章前面介绍的“可标记为 static 的下标运算符”类似,这样做能让编译器生成更高效的代码。
下面是一个示例:简化版的 Squarer 函数对象,其函数调用运算符同时被标记为 static、constexpr 和 noexcept:
class Squarer{ public: static constexpr int operator()(int value) noexcept { return value * value; }};你可以这样使用这个函数对象:
int x { 3 };int xSquared { Squarer::operator()(x) };int xSquaredAgain { Squarer{}(xSquared) };println("{} 的平方是 {},再平方一次是 {}。", x, xSquared, xSquaredAgain);static 函数调用运算符的另一个好处是:你可以轻松取得它的地址,例如 &Squarer::operator(),从而像使用函数指针一样使用它。这在与标准库算法配合时,可能带来更好的性能;第 20 章会详细讨论这些算法。许多算法都接收某种 callable(例如函数对象)来定制自身行为。如果你的函数对象使用了 static 函数调用运算符,那么把这个运算符地址传给算法,通常能让编译器生成比非 static 情况更高效的代码,因为它再也不需要考虑任何 this 指针。
重载解引用运算符
Section titled “重载解引用运算符”你可以重载三个与解引用相关的运算符:*、-> 和 ->*。暂时先把 ->* 放在一边(后面我会再回来讲它),先来看 * 和 -> 的内建含义。* 会解引用一个指针,让你直接访问它所指向的值;而 -> 则可以看成是“先做一次 * 解引用,再做一次 . 成员访问”的简写。下面的代码展示了这种等价关系:
SpreadsheetCell* cell { new SpreadsheetCell };(*cell).set(5); // 解引用后,再访问成员。cell->set(5); // 箭头形式,把解引用与成员访问合并起来。你可以为自己的类重载这些解引用运算符,从而让类对象表现得像指针。它最典型的用途,就是实现 smart pointer——这一点在第 7 章“内存管理”中已经介绍过。它对迭代器也同样很重要,而迭代器正是标准库大量使用的核心机制之一。迭代器会在第 17 章“理解迭代器与 Ranges 库”中详细讨论。本章则只是在一个简单 smart pointer 类模板的背景下,向你讲清这些运算符重载的基础机制。
C++ 标准库已经提供了两个标准 smart pointer:std::unique_ptr 和 shared_ptr。在真实代码里,你应当使用这些标准 smart pointer,而不是自己再造一个。这里的例子,仅仅是为了说明如何编写解引用运算符。
下面先给出一个 smart pointer 类模板定义,其中与解引用相关的运算符还暂时没写进去:
export template <typename T> class Pointer{ public: explicit Pointer(T* ptr) : m_ptr { ptr } {} virtual ~Pointer() { reset(); } // 禁止拷贝构造与拷贝赋值。 Pointer(const Pointer& src) = delete; Pointer& operator=(const Pointer& rhs) = delete; // 允许移动构造。 Pointer(Pointer&& src) noexcept : m_ptr{ std::exchange(src.m_ptr, nullptr)} { } // 允许移动赋值。 Pointer& operator=(Pointer&& rhs) noexcept { if (this != &rhs) { reset(); m_ptr = std::exchange(rhs.m_ptr, nullptr); } return *this; }
// 解引用运算符稍后写在这里…… private: void reset() { delete m_ptr; m_ptr = nullptr; } T* m_ptr { nullptr };};这个 smart pointer 已经是最简单的那种了:它只是保存了一个普通的原始指针,并在 smart pointer 自身销毁时,把该指针所指向的存储释放掉。实现也很简单:构造函数接收一个原始指针,并把它保存成类中唯一的数据成员;析构函数则释放这个指针所引用的存储。
理想情况下,你希望像下面这样使用这个 smart pointer 类模板:
Pointer<int> smartInt { new int };*smartInt = 5; // 解引用 smart pointer。println("{} ", *smartInt);
Pointer<SpreadsheetCell> smartCell { new SpreadsheetCell };smartCell->set(5); // 解引用后再调用 set() 成员函数。println("{} ", smartCell->getValue());正如这个例子所展示的,为了让类支持这种用法,你必须为它提供 operator* 和 operator-> 的实现。接下来的两节就来完成这一点。
几乎永远不应该只实现 operator* 或 operator-> 中的一个,而漏掉另一个。通常你应该总是把它们两个一起实现。否则,会让类的使用者感到非常困惑。
实现 operator*
Section titled “实现 operator*”当你解引用一个指针时,你期望能够访问该指针所指向的内存。如果那块内存保存的是一个简单类型,例如 int,那么你应该能直接修改它的值;如果那块内存里存放的是一个更复杂的类型,例如对象,那么你也应该能通过 . 运算符访问其数据成员或成员函数。
为了提供这样的语义,operator* 应当返回一个引用。对于 Pointer 类,可以这样实现:
export template <typename T> class Pointer{ public: // 其余部分略 T& operator*() { return *m_ptr; } const T& operator*() const { return *m_ptr; } // 其余部分略};正如你所看到的,operator* 返回的是“底层原始指针所指向对象或变量的引用”。和本章前面重载下标运算符时一样,同时提供 const 和非 const 两个重载会很有用:前者返回 const 引用,后者返回非 const 引用。
实现 operator->
Section titled “实现 operator->”箭头运算符要稍微麻烦一些。应用 -> 的结果,本质上应当是“某个对象的成员或成员函数”。如果想把它像 operator* 那样直接实现成“解引用 + 成员访问”的组合,你就等于得实现一个类似 operator* 加 operator. 的效果;但 C++ 之所以不允许重载 operator.,正是因为几乎不可能写出一个统一原型来捕捉任意可能的成员选择。因此,C++ 把 operator-> 视作一个特例。看这句代码:
smartCell->set(5);C++ 会把它翻译成:
(smartCell.operator->())->set(5);正如你看到的,C++ 会继续对你重载 operator-> 的返回值,再应用一次 operator->。因此,你必须返回一个指针,像这样:
export template <typename T> class Pointer{ public: // 其余部分略 T* operator->() { return m_ptr; } const T* operator->() const { return m_ptr; } // 其余部分略};你或许会觉得 operator* 和 operator-> 的这种不对称有点奇怪;不过看几次之后,你就会习惯了。
operator.* 和 operator->* 到底是什么?
Section titled “operator.* 和 operator->* 到底是什么?”在 C++ 中,取得类的数据成员和成员函数地址,从而得到指向它们的指针,是完全合法的。不过,没有对象时,你无法访问非 static 数据成员,也无法调用非 static 成员函数。类成员的本质,就是它们存在于每个对象实例之上。因此,当你想通过这类指针访问成员函数或数据成员时,就必须在某个对象语境下对这些指针进行解引用。关于 operator.* 和 operator->* 的具体语法细节,会推迟到第 19 章再讲,因为那需要先理解函数指针的定义方式。
C++ 不允许你重载 operator.*(就像你不能重载 operator. 一样),但理论上你可以重载 operator->*。不过,这件事既麻烦,也几乎不值得做。事实上,大多数 C++ 程序员甚至都不知道“可以通过指针访问成员函数和数据成员”这件事。标准库里的 shared_ptr 就没有重载 operator->*。
编写转换运算符
Section titled “编写转换运算符”回到 SpreadsheetCell 这个例子,来看下面两行代码:
SpreadsheetCell cell { 1.23 };double d1 { cell }; // 无法编译!SpreadsheetCell 内部包含一个 double 表示,因此从直觉上讲,你大概会觉得它理应能被赋值给一个 double 变量。可惜事实并非如此。编译器会告诉你:它并不知道如何把 SpreadsheetCell 转成 double。你也许会忍不住试图用强制类型转换来“逼编译器听话”,比如写成这样:
double d1 { (double)cell }; // 仍然无法编译!首先,这段代码依然无法编译,因为编译器仍然不知道该如何把 SpreadsheetCell 转换成 double。它在第一行时其实就已经知道你“想做什么”了,只是它做不到而已。其次,从整体设计上看,也不应该为了让代码通过而胡乱往程序里塞一堆无意义的 cast。
如果你想允许这类转换,那就必须明确告诉编译器应当如何进行。具体来说,你可以编写一个转换运算符,把 SpreadsheetCell 转成 double。其原型如下:
operator double() const;这个函数名叫做 operator double。它没有返回类型,因为返回类型已经直接体现在运算符名字里——就是 double。它被标记为 const,因为它不会修改其所作用的对象。其实现如下:
SpreadsheetCell::operator double() const{ return getValue();}这就是为 SpreadsheetCell 编写到 double 的转换运算符所需做的全部工作。现在,编译器就能接受下面这些代码,并在运行时按你的预期行事:
SpreadsheetCell cell { 1.23 };double d1 { cell }; // 可以正常工作你可以用同样的语法,为任意目标类型编写转换运算符。例如,下面是 SpreadsheetCell 到 std::string 的转换运算符声明:
operator std::string() const;对应实现如下:
SpreadsheetCell::operator std::string() const{ return doubleToString(getValue());}现在你就可以把 SpreadsheetCell 转成 string 了。不过,由于 string 的构造函数设计方式,下面这种写法仍然不行:
string str { cell };此时,你可以改用普通赋值语法,或者显式使用 static_cast():
string str1 = cell;string str2 { static_cast<string>(cell) };operator auto
Section titled “operator auto”除了显式写出转换运算符的返回类型外,你还可以指定 auto,让编译器自行推导。例如,SpreadsheetCell 的 double 转换运算符,其实也可以写成:
operator auto() const { return getValue(); }不过这里有一个注意点:带有 auto 返回类型推导的成员函数,其实现必须对类的使用者可见。因此,这个例子把实现直接写在了类定义内部。
另外,还要记住第 1 章讲过的一点:auto 会去掉引用与 const 限定符。因此,如果你的 operator auto 原本想返回“对某个类型 T 的引用”,最终被推导出来的类型仍然会是按值返回的 T,从而触发一次复制。如有需要,你可以显式补上引用与 const 限定,例如:
operator const auto&() const { /* … */ }用显式转换运算符解决二义性问题
Section titled “用显式转换运算符解决二义性问题”为 SpreadsheetCell 编写 double 转换运算符后,会引入一个 二义性(ambiguity)问题。看下面这行:
SpreadsheetCell cell { 6.6 };double d1 { cell + 3.4 }; // 一旦定义了 operator double(),这里就无法编译为什么这行现在编译不了了?它在你写 operator double() 之前明明是可以工作的。问题在于:编译器现在不知道,它到底该把 cell 通过 operator double() 转成 double,然后执行 double + double;还是该把 3.4 通过 SpreadsheetCell 的 double 构造函数转成 SpreadsheetCell,然后执行 SpreadsheetCell + SpreadsheetCell。在你写 operator double() 之前,编译器只有一种选择:把 3.4 转成 SpreadsheetCell。但现在,它可以走两条路,于是干脆拒绝替你做决定。
在 C++11 之前,解决这一问题的传统做法,是把那个相关构造函数标记成 explicit,从而阻止自动隐式转换(见第 8 章)。但你也许并不希望那个构造函数变成 explicit,因为你其实很喜欢 double 自动转成 SpreadsheetCell。从 C++11 开始,你可以改为把 double 转换运算符标记为 explicit,而不是构造函数:
explicit operator double() const;这样一改,下面这行代码就又能顺利编译了:
double d1 { cell + 3.4 }; // 10前面讲过的 operator auto 同样也可以被标记成 explicit。
为布尔表达式提供转换
Section titled “为布尔表达式提供转换”有时,让对象能够出现在布尔表达式里会非常有用。比如,程序员经常会这样使用指针:
if (ptr != nullptr) { /* 执行某些解引用相关操作。 */ }有时还会写成更短的形式:
if (ptr) { /* 执行某些解引用相关操作。 */ }还有时会看到下面这样的代码:
if (!ptr) { /* 做点什么。 */ }目前,前面这些写法都无法直接作用于前面定义过的 Pointer smart pointer 类模板。要让它们工作,我们可以给这个类增加一个转换运算符,把它转换成某种指针类型。这样,无论是与 nullptr 做比较,还是单独把对象放进 if 中,都会先触发到目标指针类型的转换。通常,这种转换运算符会选择返回 void*,因为它是一种“用途非常有限”的指针类型,除了在布尔表达式里测试真值外,几乎做不了别的。实现如下:
operator void*() const { return m_ptr; }现在,下面这些代码都能编译,并按预期工作:
void process(const Pointer<SpreadsheetCell>& p){ if (p != nullptr) { println("not nullptr"); } if (p != 0) { println("not 0"); } if (p) { println("not nullptr"); } if (!p) { println("nullptr"); }}
int main(){ Pointer<SpreadsheetCell> smartCell { nullptr }; process(smartCell); println("");
Pointer<SpreadsheetCell> anotherSmartCell { new SpreadsheetCell { 5.0 } }; process(anotherSmartCell);}输出如下:
nullptr
not nullptrnot 0not nullptr另一种做法,是直接重载 operator bool(),而不是 operator void*()。毕竟,这个对象最终就是要被放进布尔表达式里,那为什么不直接把它转换成 bool 呢?
operator bool() const { return m_ptr != nullptr; }这样一来,下面这些比较依然可以正常工作:
if (p != 0) { println("not 0"); }if (p) { println("not nullptr"); }if (!p) { println("nullptr"); }不过,如果你使用的是 operator bool(),那么下面这种和 nullptr 的比较反而会编译失败:
if (p != nullptr) { println("not nullptr"); } // 错误这是因为 nullptr 拥有自己的类型 nullptr_t,而它不会自动转换为整数 0(也就是 false)。编译器找不到一个接收 Pointer 对象与 nullptr_t 对象的 operator!=。你当然可以把这样一个 operator!= 写成 Pointer 类的 friend:
export template <typename T>class Pointer{ public: // 其余部分略 template <typename T> friend bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs); // 其余部分略};
export template <typename T>bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs){ return lhs.m_ptr != rhs;}然而,一旦你实现了这个 operator!=,下面这种和 0 的比较又会失效,因为编译器反而不知道该选哪个 operator!= 了:
if (p != 0) { println("not 0"); }从这个例子中,你也许会得出结论:operator bool() 似乎只适用于那些“不代表指针”的对象,因为对这类对象来说,转换成某种指针类型本身就没什么意义。可惜即便如此,把对象转换成 bool 仍然还会带来其他意想不到的后果。C++ 会根据 提升(promotion)规则,在有机会时悄悄把 bool 再转换成 int。因此,一旦你提供了 operator bool(),下面这种代码居然也能编译并运行:
Pointer<SpreadsheetCell> anotherSmartCell { new SpreadsheetCell { 5.0 } };int i { anotherSmartCell }; // Pointer -> bool -> int这通常不会是你真正想要的行为。为了阻止这类赋值,你可以显式把到 int、long、long long 等类型的转换运算符删除掉。但这样一来,设计就开始变得越来越凌乱了。因此,很多程序员会更偏向使用 operator void*(),而不是 operator bool()。
从这个例子可以看出,重载运算符其实带有明显的设计成分。你对“重载哪些运算符”的选择,会直接影响类的使用者究竟能以什么方式来使用你的类。
重载内存分配与释放运算符
Section titled “重载内存分配与释放运算符”C++ 允许你重新定义程序中内存分配与释放的工作方式。这种定制既可以发生在全局层面,也可以发生在类层面。当你非常担心 内存碎片 时,这项能力尤其有用——例如,当程序频繁分配和释放大量小对象时,就容易出现这种情况。举例来说,与其每次需要内存都调用默认的 C++ 内存分配器,不如实现一个内存池分配器,反复复用固定大小的内存块。本节会解释内存分配与释放例程中的细节,并展示如何定制它们。有了这些工具,一旦真的有需求,你就有能力自己编写分配器。
除非你对内存分配策略有足够深的了解,否则尝试重载内存分配例程,通常都不值得。不要仅仅因为“听起来很酷”就去做。只有在你确实有明确需求,并且拥有足够知识时,才应该这样做。
new 与 delete 到底是怎样工作的
Section titled “new 与 delete 到底是怎样工作的”C++ 中最难真正吃透的细节之一,就是 new 和 delete。请看这行代码:
SpreadsheetCell* cell { new SpreadsheetCell {} };其中 new SpreadsheetCell{} 这一部分,叫做 new-expression。它会做两件事。第一,它通过调用 operator new 为 SpreadsheetCell 对象分配内存;第二,它调用该对象的构造函数。只有当构造函数执行完成之后,才会把指针返回给你。
delete 的工作方式与此类似。请看这行:
delete cell;这行代码叫做 delete-expression。它会先调用 cell 的析构函数,然后再调用 operator delete 来释放内存。
你可以通过重载 operator new 与 operator delete 来控制内存分配和释放,但你不能重载 new-expression 或 delete-expression 本身。也就是说,你可以定制“实际的内存分配与释放过程”,但不能改变“构造函数与析构函数的调用”这一层语义。
new-expression 与 operator new
Section titled “new-expression 与 operator new”new-expression 一共有 6 种不同形式,每一种都对应着自己的 operator new。本书前面章节已经展示过其中 4 种:new、new[]、new(nothrow) 与 new(nothrow)[]。下面列出了定义在 <new> 中、与之对应的 4 个 operator new 重载:
void* operator new(std::size_t size);void* operator new[](std::size_t size);void* operator new(std::size_t size, const std::nothrow_t&) noexcept;void* operator new[](std::size_t size, const std::nothrow_t&) noexcept;还有两种比较特殊的 new-expression,它们本身并不分配内存,而是直接在一块已经分配好的内存上调用构造函数。这些形式叫做 placement new(既包括单对象形式,也包括数组形式)。它们允许你在预先分配好的内存中构造对象,例如:
void* ptr { allocateMemorySomehow() };SpreadsheetCell* cell { new (ptr) SpreadsheetCell {} };与之对应的两个 operator new 重载如下。不过,C++ 标准禁止你重载它们:
void* operator new(std::size_t size, void* p) noexcept;void* operator new[](std::size_t size, void* p) noexcept;这个特性稍显冷门,但知道它的存在非常重要。当你想实现内存池、并在不释放内存的情况下反复复用已有内存时,它就可能派上用场。这样你就能在同一块内存上反复构造与析构对象,而不用为每一个新实例重新分配内存。第 29 章“编写高效 C++”会给出一个内存池实现示例。
delete-expression 与 operator delete
Section titled “delete-expression 与 operator delete”你真正能直接调用的 delete-expression 只有两种:delete 和 delete[];并不存在 nothrow 版或 placement 版的 delete-expression。不过,operator delete 却总共有 6 个重载。为什么会有这种不对称?这是因为那两个 nothrow 版本和两个 placement 版本,只会在“构造函数抛出异常”时才会被用到。在这种情况下,调用的是与之前分配内存所用的 operator new 相匹配的 operator delete。但如果你只是正常地 delete 一个指针,那么 delete 只会调用 operator delete,delete[] 只会调用 operator delete[],绝不会去调用 nothrow 或 placement 版本。实践中,这件事其实意义并不大,因为 C++ 标准规定:如果 delete 本身抛出异常(比如来自析构函数),结果就是未定义行为。换句话说,delete 根本就不应该抛异常,因此 nothrow 版的 operator delete 在语义上多少有些多余。至于 placement delete,则应当什么都不做,因为 placement new 本身并没有分配内存,自然也就没有什么可释放的。
下面是与那 6 个 operator new 对应的 6 个 operator delete 原型:
void operator delete(void* ptr) noexcept;void operator delete[](void* ptr) noexcept;void operator delete(void* ptr, const std::nothrow_t&) noexcept;void operator delete[](void* ptr, const std::nothrow_t&) noexcept;void operator delete(void* ptr, void*) noexcept;void operator delete[](void* ptr, void*) noexcept;重载 operator new 与 operator delete
Section titled “重载 operator new 与 operator delete”如果你愿意,你甚至可以替换全局的 operator new 与 operator delete。除非某个类自己提供了更具体的版本,否则程序中的每一个 new-expression 和 delete-expression 最终都会调用它们。不过,借用 Bjarne Stroustrup 的一句话来说,“……替换全局 operator new 和 operator delete 并不适合胆小的人”(The C++ Programming Language, 第三版,Addison-Wesley,1997)。我也不推荐这么做!
如果你偏偏不听劝,决定替换全局 operator new,请务必记住:在这个运算符里绝不能再写任何会调用 new 的代码,否则你会得到无限递归。例如,你甚至不能用 print() 向控制台输出一条日志。
一个更实际的技巧,是只为特定类重载 operator new 与 operator delete。这类重载只会在你分配和释放该类对象时被调用。下面给出一个类的示例,它重载了 4 种非 placement 形式的 operator new 与 operator delete:
export class MemoryDemo{ public: virtual ~MemoryDemo() = default;
void* operator new(std::size_t size); void operator delete(void* ptr) noexcept;
void* operator new[](std::size_t size); void operator delete[](void* ptr) noexcept;
void* operator new(std::size_t size, const std::nothrow_t&) noexcept; void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void* operator new[](std::size_t size, const std::nothrow_t&) noexcept; void operator delete[](void* ptr, const std::nothrow_t&) noexcept;};下面是这些运算符的实现。它们只是简单地向标准输出打印一条消息,然后把参数原样转发给全局版本的运算符。注意,nothrow 实际上是一个 nothrow_t 类型的变量。
void* MemoryDemo::operator new(size_t size){ println("operator new"); return ::operator new(size);}void MemoryDemo::operator delete(void* ptr) noexcept{ println("operator delete"); ::operator delete(ptr);}void* MemoryDemo::operator new[](size_t size){ println("operator new[]"); return ::operator new[](size);}void MemoryDemo::operator delete[](void* ptr) noexcept{ println("operator delete[]"); ::operator delete[](ptr);}void* MemoryDemo::operator new(size_t size, const nothrow_t&) noexcept{ println("operator new nothrow"); return ::operator new(size, nothrow);}void MemoryDemo::operator delete(void* ptr, const nothrow_t&) noexcept{ println("operator delete nothrow"); ::operator delete(ptr, nothrow);}void* MemoryDemo::operator new[](size_t size, const nothrow_t&) noexcept{ println("operator new[] nothrow"); return ::operator new[](size, nothrow);}void MemoryDemo::operator delete[](void* ptr, const nothrow_t&) noexcept{ println("operator delete[] nothrow"); ::operator delete[](ptr, nothrow);}下面是若干种分配和释放这种对象的方式:
MemoryDemo* mem { new MemoryDemo{} };delete mem;mem = new MemoryDemo[10];delete [] mem;mem = new (nothrow) MemoryDemo{};delete mem;mem = new (nothrow) MemoryDemo[10];delete [] mem;程序输出如下:
operator newoperator deleteoperator new[]operator delete[]operator new nothrowoperator deleteoperator new[] nothrowoperator delete[]很显然,这些 operator new 与 operator delete 的实现本身非常简单,几乎谈不上实际用途。这里的目的只是帮助你理解它们的语法,以便将来如果你真的需要编写更复杂版本时,不至于无从下手。
只要你重载了某种形式的 operator new,就应当同时重载与之对应的 operator delete。否则,内存会按你定制的规则分配,却按内建规则释放,这两者未必兼容。
一眼看上去,把 operator new 与 operator delete 的各种形式都一并重载,似乎有点过头。但通常这么做反而是个好主意,因为它能避免内存分配策略出现不一致。如果你不想实现某些特定重载,也可以显式用 =delete 把它们删掉,阻止别人调用它们。下一节会继续解释这一点。
要么重载所有形式的 operator new 和 operator delete,要么显式删除你不希望被使用的那些重载,以防内存分配行为不一致。
显式删除或默认生成 operator new 与 operator delete
Section titled “显式删除或默认生成 operator new 与 operator delete”第 8 章展示过如何显式删除或默认生成构造函数与赋值运算符。而“显式删除/默认生成”并不仅限于构造函数和赋值运算符。例如,下面这个类删除了 operator new 与 operator new[],这意味着该类对象不能通过 new 或 new[] 动态分配:
class MyClass{ public: void* operator new(std::size_t) = delete; void* operator new[](std::size_t) = delete; void* operator new(std::size_t, const std::nothrow_t&) noexcept = delete; void* operator new[](std::size_t, const std::nothrow_t&) noexcept = delete;};下面这些用法都会导致编译错误:
MyClass* p1 { new MyClass };MyClass* p2 { new MyClass[2] };MyClass* p3 { new (std::nothrow) MyClass };带额外参数的 operator new 与 operator delete
Section titled “带额外参数的 operator new 与 operator delete”除了重载标准形式的 operator new 外,你还可以自行定义带额外参数的版本。这些额外参数可以用来向内存分配例程传递各种标志、计数器等。例如,有些运行时库会在调试模式下,通过额外参数传入对象被分配时所在的文件名和行号,这样一旦出现内存泄漏,就可以直接定位到当初执行分配的那一行代码。
例如,下面是为 MemoryDemo 类额外定义的 operator new 与 operator delete 原型,它们各自多接收一个 int 参数:
void* operator new(std::size_t size, int extra);void operator delete(void* ptr, int extra) noexcept;实现如下:
void* MemoryDemo::operator new(std::size_t size, int extra){ println("operator new with extra int: {}", extra); return ::operator new(size);}void MemoryDemo::operator delete(void* ptr, int extra) noexcept{ println("operator delete with extra int: {}", extra); return ::operator delete(ptr);}一旦你写出了带额外参数的 operator new,编译器就会自动允许对应形式的 new-expression。额外参数通过函数调用语法传给 new(与 nothrow 版本类似)。因此,现在你就可以这样写:
MemoryDemo* memp { new(5) MemoryDemo{} };delete memp;输出如下:
operator new with extra int: 5operator delete当你定义了带额外参数的 operator new 时,也应同时定义带同样额外参数的对应 operator delete。不过,你无法自己显式调用这个带额外参数的 operator delete;只有在你使用了“带额外参数的 operator new”,且对象构造函数抛出异常时,它才会被自动调用。
带内存大小参数的 operator delete
Section titled “带内存大小参数的 operator delete”operator delete 还有一种变体:除了指针本身之外,它还会额外告诉你“需要释放的内存大小”。你只需为 operator delete 声明一个带额外 size 参数的原型即可。
如果一个类同时声明了两个 operator delete 重载,其中一个接收 size 参数,而另一个不接收,那么始终会调用“不带 size 参数”的那个版本。如果你希望使用带 size 参数的版本,就只能保留它,别写另一个。
对于 operator delete 的各种重载,你都可以独立地换成“带 size 参数”的版本。下面是 MemoryDemo 的类定义,其中第一个 operator delete 被改成接收“待释放内存大小”:
export class MemoryDemo{ public: // 其余部分略 void* operator new(std::size_t size); void operator delete(void* ptr, std::size_t size) noexcept; // 其余部分略};这个 operator delete 的实现,同样只是简单地调用全局 operator delete:
void MemoryDemo::operator delete(void* ptr, size_t size) noexcept{ println("operator delete with size {}", size); ::operator delete(ptr, size);}这项能力只有在你真的为自己的类设计了较复杂的内存分配/释放策略时,才会显得有意义。
重载用户自定义字面量运算符
Section titled “重载用户自定义字面量运算符”C++ 拥有若干内建字面量类型,你在代码里经常会直接用到。比如:
'a':字符"A string":以零结尾的字符序列,也就是 C 风格字符串3.14f:float单精度浮点值0xabc:十六进制值
C++ 还允许你定义自己的字面量,而标准库本身就这么做了:它额外提供了一批可用于构造标准库对象的字面量。我们先来看看标准库里的这些例子,再看你如何定义自己的。
标准库字面量
Section titled “标准库字面量”C++ 标准库定义了以下标准字面量。注意,这些字面量都不以下划线开头:
| 字面量 | 创建的对象类型 | 示例 | 所需命名空间 |
|---|---|---|---|
s | string | auto myString { "Hello"s }; | string_literals |
sv | string_view | auto myStringView { "Hello"sv }; | string_view_literals |
h, min, s, ms, us, ns | chrono::duration1 | auto myDuration { 42min }; | chrono_literals |
y, d | chrono::year 和 day1 | auto thisYear { 2024y }; | chrono_literals |
i, il, if | complex<T>,其中 T 分别为 double、long double、float | auto myComplexNumber { 1.3i }; | complex_literals |
从技术上说,这些字面量都定义在 std::literals 的子命名空间中,例如 std::literals::string_literals。不过,string_literals 和 literals 都是 inline namespace,因此其内容会自动在父命名空间中可见。所以,如果你想使用 s 字符串字面量,可以使用下面任意一种 using 指令:
using namespace std;using namespace std::literals;using namespace std::string_literals;using namespace std::literals::string_literals;用户自定义字面量
Section titled “用户自定义字面量”用户自定义字面量必须恰好以下划线开头。比如 _i、_s、_km、_miles、_K 等。
用户自定义字面量是通过编写 字面量运算符(literal operator)来实现的。字面量运算符可以工作在 raw 模式,也可以工作在 cooked 模式。raw 模式下,字面量运算符接收到的是一串字符;cooked 模式下,接收到的是已经解释好的某个具体类型值。例如,字面量 123:在 raw 模式下,运算符接收到的是字符序列 '1'、'2'、'3';而在 cooked 模式下,它接收到的是整数 123。字面量 0x23 在 raw 模式下接收到的是 '0'、'x'、'2'、'3',而在 cooked 模式下接收到的是整数 35。像 3.14 这样的字面量,在 raw 模式下会被看作 '3'、'.'、'1'、'4',而 cooked 模式下则直接是浮点值 3.14。
Cooked 模式字面量运算符
Section titled “Cooked 模式字面量运算符”一个 cooked 模式字面量运算符应当具备以下两种形式之一:
- 处理数值: 接收一个参数,类型为
unsigned long long、long double、char、wchar_t、char8_t、char16_t或char32_t - 处理字符串: 接收两个参数,第一个是 C 风格字符串,第二个是字符串长度,例如
(const char* str, std::size_t len)
下面的例子定义了一个 Length 类,用米来保存长度。它的构造函数是 private 的,因为使用者只能通过这里提供的用户自定义字面量来构造 Length。示例中为 _km 和 _m 定义了 cooked 字面量运算符。它们都被声明为 Length 的 friend,这样就能调用其 private 构造函数。注意,这类运算符的 "" 与下划线之间不能有空格。
// 一个表示长度的类。内部统一以米为单位存储长度。class Length{ public: long double getMeters() const { return m_length; } // 用户自定义字面量 _km 与 _m 是 Length 的友元, // 这样它们就能访问私有构造函数。 friend Length operator ""_km(long double d); friend Length operator ""_m(long double d); private: // 私有构造函数:使用者只能通过提供的自定义字面量构造 Length。 Length(long double length) : m_length { length } {} long double m_length;};Length operator ""_km(long double d) // Cooked 模式 _km 字面量运算符{ return Length { d * 1000 }; // 转成米。}Length operator ""_m(long double d) // Cooked 模式 _m 字面量运算符{ return Length { d };}你可以这样使用这些字面量运算符:
Length d1 { 1.2_km };auto d2 { 1.2_m };println("d1 = {}m; d2 = {}m", d1.getMeters(), d2.getMeters());输出如下:
d1 = 1200m; d2 = 1.2m为了演示 cooked 模式中“接收 const char* 与 size_t 这两个参数”的变体,我们可以重新实现标准库提供的字符串字面量 s,让它构造 std::string。假设我们把它叫做 _s。
string operator ""_s(const char* str, size_t len){ return string { str, len };}这个字面量运算符可以这样使用:
string str1 { "Hello World"_s };auto str2 { "Hello World"_s }; // str2 的类型是 string如果没有 _s 字面量运算符,那么 auto 推导出来的就会是 const char*:
auto str3 { "Hello World" }; // str3 的类型是 const char*Raw 模式字面量运算符
Section titled “Raw 模式字面量运算符”一个 raw 模式字面量运算符要求接收一个 const char* 参数,也就是一个以零结尾的 C 风格字符串。下面这个例子把前面的 _m 字面量运算符改写为 raw 模式:
Length operator ""_m(const char* str){ // 实现略;它需要先解析这个 C 风格字符串, // 把它转换成 long double,再构造一个 Length。 …}使用这个 raw 模式字面量运算符,与使用 cooked 版本的写法是一样的。
本章总结了运算符重载的设计理由,并通过示例与解释,展示了各种运算符类别的重载方式。理想情况下,本章已经帮助你真正意识到运算符重载所带来的表达力。在整本书中,运算符重载都会被用来提供更好的抽象和更容易使用的类接口。
现在,终于轮到正式深入 C++ 标准库了。下一章会先从 C++ 标准库所提供功能的整体概览开始,之后的章节再分别深入其中的具体特性。
通过完成下面这些练习,你可以巩固本章讨论过的内容。所有习题解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某个练习上卡住了,建议先回头重读本章相关部分,尽量自己找到答案,再去查看网站上的解答。
- 练习 15-1: 实现一个
AssociativeArray类模板。这个类应当用一个vector来保存若干元素,每个元素都由一个 key 和一个 value 组成。key 固定是string,而 value 的类型则通过模板类型参数指定。请提供重载下标运算符,使得元素能够通过 key 访问。并在你的main()函数中测试这一实现。注意:这个练习只是为了练习“使用非整数下标实现下标运算符”。在真实项目中,对于这种关联数组需求,你应当直接使用标准库在第 18 章中介绍的std::map类模板。 - 练习 15-2: 基于你在练习 13-2 中实现的
Person类,为它补上插入运算符与提取运算符的实现。请确保你的提取运算符能够正确读回由插入运算符输出的内容。 - 练习 15-3: 在练习 15-2 的基础上,为
Person再添加一个string转换运算符。这个运算符只需要返回一个由人的 first name 与 last name 组合而成的string。 - 练习 15-4: 从练习 15-3 的解法继续,添加一个用户自定义字面量运算符
_p,使其能够从字符串字面量构造Person。它应当支持“last name 中包含空格”,但不支持“first name 中包含空格”。例如,"Peter Van Weert"_p应当构造出一个Person对象,其 first name 为Peter,last name 为Van Weert。