跳转到内容

重载 C++ 运算符

C++ 允许你为自己的类重新定义诸如 +-= 这类运算符的含义。许多面向对象语言并不提供这种能力,因此你也许会本能地低估它在 C++ 里的价值。然而,它对于让你的类表现得像 intdouble 这类内建类型至关重要。你甚至可以写出“看起来像数组、函数,或者指针”的类。

第 5 章“使用类进行设计”和第 6 章“面向复用的设计”分别介绍了面向对象设计与运算符重载。第 8 章“精进类与对象”和第 9 章“精通类与对象”则讲解了对象及基础运算符重载的语法细节。本章会从第 9 章结束的地方继续,把运算符重载展开讲透。

正如第 1 章“C++ 与标准库速成”所解释的那样,C++ 中的运算符就是像 +<*<< 这样的符号。它们作用于 intdouble 这类内建类型,以完成算术、逻辑以及其他操作。还有一些运算符,例如 ->*,用于解引用指针。在 C++ 中,“运算符”这个概念的范围很广,甚至包括 [](数组下标)、()(函数调用)、类型转换,以及内存分配和释放运算符。运算符重载允许你为自己的类改变这些语言运算符的行为。不过,这种能力也伴随着规则、限制与一系列设计抉择。

在真正学习如何重载运算符之前,你大概更关心:到底为什么要这么做?不同运算符背后的理由并不完全一样,但总体指导原则是一致的:让你的类尽可能表现得像内建类型。你的类越像内建类型,使用者就越容易上手。例如,如果你想写一个表示分数的类,那么能够定义当 +-*/ 作用于该类对象时分别意味着什么,就会非常有帮助。

重载运算符的另一个理由,是为了更细粒度地控制程序行为。例如,你可以为自己的类重载内存分配与释放运算符,从而精确指定每个新对象的内存应如何分配与回收。

必须强调的是:运算符重载并不一定会让你这个“类的编写者”更省事;它的主要目的,是让“类的使用者”更省事。

当你重载运算符时,有些事情是无论如何都做不到的:

  • 你不能添加新的运算符符号。你只能重新定义语言中已经存在的运算符的含义。本章后面“可重载运算符总结”一节中的表格,会列出所有可以重载的运算符。
  • 有一些运算符是不能重载的,例如 ..*(对象成员访问)、::(作用域解析运算符)以及 ?:(条件运算符)。那张表会列出所有可以重载的运算符。不能重载的那些,通常也不是你真正会想去重载的,因此这条限制一般不会让你觉得束手束脚。
  • 元数(arity)描述的是运算符关联的参数个数,也就是 操作数(operands)的数量。只有函数调用运算符、new 运算符、delete 运算符,以及从 C++23 起的下标运算符 [],你才能改变其元数。对于其他运算符,元数是不能改的。一元运算符(例如 ++)只作用于一个操作数;二元运算符(例如 /)则作用于两个操作数。
  • 你不能改变运算符的 优先级(precedence)或 结合性(associativity)。优先级决定在同一表达式中,哪些运算符先执行;结合性则指定同一优先级下的运算符按从左到右还是从右到左求值。多数程序里,这一限制并不严重,因为很少真的能从改变求值顺序中受益;但在某些领域里,你必须把它记在脑子里。例如,假设你写了一个表示数学向量的类,并希望重载 ^ 来表示“把向量提升到某个幂次”。要注意,^ 的优先级比 + 之类的许多运算符都低。比如,如果 xy 是数学向量,那么写 x^3+y 会被解释为 x^(3+y),而不是你可能本来希望的 (x^3)+y
  • 你不能为内建类型重新定义运算符。运算符必须是某个类的成员函数,或者至少全局重载运算符函数的某一个参数必须是用户自定义类型(例如类)。这意味着,你不能干出那种荒唐事:把 int 上的 + 重新定义成减法(当然,你可以为自己的类这么干,虽然也不推荐)。这条规则的唯一例外,是内存分配与释放运算符;你可以替换整个程序的全局内存分配运算符。

有些运算符本身就有两种含义。例如,operator- 可以是二元运算符(如 x = y - z;),也可以是一元运算符(如 x = -y;)。* 可以表示乘法,也可以表示解引用指针。<< 则可能是 stream 插入运算符,也可能是左移运算符,具体取决于上下文。对于这种“一符多义”的运算符,你可以把它们的不同含义都重载出来。

重载运算符时,你需要编写一个全局函数或成员函数,其名字形式为 operatorX,其中 X 是某个运算符符号;operatorX 之间也可以插入空白。例如,第 9 章里为 SpreadsheetCell 对象声明 operator+ 的方式如下:

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

接下来的几节,会介绍你在编写每一个重载运算符时必须做出的若干设计选择。

首先,你必须决定:这个运算符应当写成类的成员函数,还是写成全局函数。后者也可以被声明成类的 friend,不过那应当是最后手段——给一个类添加 friend 的数量应尽可能少,因为 friend 能直接访问 private 数据成员,从而绕过信息隐藏原则。

那么该如何在成员函数与全局函数之间做选择?首先,你得搞清楚两者的根本区别:如果运算符被写成某个类的成员函数,那么该运算符表达式的左操作数就必须是该类的对象。相反,如果你把它写成全局函数,那么左操作数就可以是别的类型。

运算符大致分三类:

  • 必须写成成员函数的运算符。 C++ 语言规定,有些运算符必须是类的成员函数,因为它们脱离类本身就没有意义。例如,operator= 与类本身联系得如此紧密,以至于它根本不可能存在于别的地方。后面“可重载运算符总结”中的表会列出这些必须写成成员函数的运算符。大多数运算符并没有这种强制要求。
  • 必须写成全局函数的运算符。 只要你希望允许运算符表达式的左操作数是“不同于你的类”的其他类型,就必须把该运算符写成全局函数。这条规则尤其适用于 <<>> 这两个 stream 插入/提取运算符,因为它们的左操作数是 iostream 对象,而不是你自己的类对象。它也适用于二元 +- 这类交换律较强的运算符,因为它们通常也应允许左操作数不是你的类对象。如果你希望对二元运算符的左操作数支持隐式转换,那也必须写成全局函数。第 9 章讨论过这个问题。
  • 既可以写成成员函数,也可以写成全局函数的运算符。 在 C++ 社区里,人们对“到底更该写成员函数还是全局函数”一直存在分歧。不过,我建议你遵循这样一条规则:除非你必须把它写成全局函数,否则就把每个运算符都写成成员函数。这样做的一大优势是:成员函数可以是 virtual,而全局函数显然不行。因此,如果你计划在继承体系中编写重载运算符,那么只要可能,就应优先把它们写成成员函数。

当你把某个重载运算符写成成员函数时,如果它不会修改对象,就应当把它标记为 const。这样它才能作用于 const 对象。

当你把某个重载运算符写成全局函数时,应当把它放在与你的类同一个命名空间中。

在选择参数类型时,你其实受到不少约束。正如前面提到的,对于大多数运算符,你不能改变参数个数。比如,operator/ 如果是全局函数,就必须始终接收两个参数;如果是成员函数,就必须始终接收一个参数。只要不符合这一标准,编译器就会报错。从这个意义上说,运算符函数与普通函数不同:普通函数可以自由拥有任意数量的参数,而运算符函数不能。此外,尽管你可以为任何你想要的类型去写运算符,但通常仍会受到你所编写类本身的语义约束。例如,如果你想为类 T 实现加法,就不会去写一个接收两个 stringoperator+!真正需要你做判断的地方,通常在于:参数到底该按值传递还是按引用传递,以及它们该不该是 const

值传递还是引用传递,这个选择其实很简单:除非这个函数总是要复制传入对象,否则对于每一个非原生类型参数,你都应该按引用传递;详见第 9 章

const 的选择也同样简单:除非你确实要修改参数,否则就把它标记为 const。后面“可重载运算符总结”中的表格,会为每个运算符给出示例原型,并根据合适情况标好 const 与引用。

C++ 在做重载决议时,并不会根据返回类型来判断。因此,从语法上说,你可以为重载运算符指定任何你想要的返回类型。然而,“你能这么做”并不意味着“你应该这么做”。这种灵活性意味着,你完全可以写出那种令人费解的代码:让比较运算符返回指针,让算术运算符返回 bool。但你不该这样做。正确做法是:让你重载出来的运算符,尽量返回与内建类型上同类运算符一致的返回类型。比较运算符就返回 bool;算术运算符就返回一个表示结果的对象。有些运算符的返回类型乍看并不那么直观。例如,第 8 章提到,operator= 应当返回“被赋值对象本身的引用”,以支持链式赋值。其他运算符也存在类似“没那么显然”的返回类型选择,它们都会在后面的表格中总结出来。

返回类型同样涉及“按值还是按引用”“是否加 const”的选择,不过这一次的判断稍微更微妙。总体原则是:能返回引用就返回引用,否则返回值。那什么时候你能返回引用?这个问题只适用于那些“返回对象”的运算符;对于返回 bool 的比较运算符、没有显式返回类型的转换运算符,以及可以自由返回任意类型的函数调用运算符,这个问题就不成立。如果你的运算符需要构造一个新对象,那么它就必须按值返回这个新对象;如果它并不构造新对象,那么通常就可以返回“运算符所作用对象本身”或“它的某个参数”的引用。后面的表格会给出相应示例。

如果返回值必须能作为 lvalue 被修改(例如可以出现在赋值表达式左侧),那它就必须是非 const 的;否则,应尽量返回 const。比你想象中更多的运算符都要求返回 lvalue,其中包括全部赋值运算符(operator=operator+=operator-= 等)。

从技术上讲,你完全可以在重载运算符中写任何你想写的实现。例如,你甚至可以写一个 operator+,它的行为是启动一局拼字游戏。但正如第 6 章所说,通常你应当让你的实现尽可能符合使用者的预期。把 operator+ 写成真正表示加法的行为,或者至少是“类似加法”的行为,例如字符串拼接。本章会解释各种运算符应该如何实现。某些特殊场景下,你或许会有偏离这些建议的理由;但总体上,仍建议你遵循这些标准模式。

有些运算符从语言上允许重载,但实际上你不应该这么做。最典型的是取地址运算符(operator&)。重载它几乎没有什么实用价值,反而会引入困惑,因为你改变的是语言最基础的行为之一——“获取变量地址”。整个标准库虽然大量使用运算符重载,却从不重载取地址运算符。

此外,你也应避免重载二元布尔运算符 operator&&operator||,因为那样你就失去了 C++ 的短路求值规则。在这种情况下,短路不再可能发生,因为左右两侧操作数都必须先被求值,然后才能作为参数传给你定义的重载 &&||。如果你的类真的需要逻辑运算符,不妨提供 operator&operator|,因为它们本来就不会短路。

最后,你也不应该重载逗号运算符(operator,)。没错,你没看错:C++ 里确实存在逗号运算符。它也叫 顺序运算符(sequencing operator),用于把两个表达式写在同一条语句里,并保证它们按从左到右的顺序求值。下面的代码片段演示了逗号运算符:

int x { 1 };
println("{}", (++x, 2 * x)); // 先把 x 递增为 2,再乘以 2,输出 4。

几乎很少存在真正充分的理由去重载逗号运算符。

下表列出了所有可重载运算符,说明它们应当写成类成员函数还是全局函数,总结了“什么时候应该(或不应该)重载它们”,并给出了示例原型,展示了合适的参数与返回值类型。那些不能重载的运算符,例如 ..*::?:,都不会出现在这张表中。

未来当你想编写某个重载运算符时,这张表会非常有用。到时候你十有八九会忘记某个运算符应该返回什么类型、以及它到底该不该写成成员函数。

在表中,T 表示你要为之编写重载运算符的类名,E 表示另一种不同类型。表中给出的示例原型并不穷尽所有可能;对于很多运算符来说,TE 还可以有其他组合方式:

运算符名称或类别成员函数还是全局函数何时重载示例原型
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--前置自增与前置自减推荐成员函数当你为算术参数(intlong 等)重载了 +=-=T& operator++();
operator++ operator--后置自增与后置自减推荐成员函数当你为算术参数(intlong 等)重载了 +=-=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!布尔否定运算符推荐成员函数很少需要;通常更适合提供 boolvoid* 转换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 为接收两个 stringoperator+ 定义了多个重载版本,它们会使用不同的左值/右值引用组合。下面是一个简化版列表:

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 章中解释移动赋值运算符时所采用的方法相同。

在包含多个运算符的语句中,运算符的 优先级(precedence)决定了哪些运算符需要比其他运算符更早求值。例如,*/ 总是先于 +- 被执行。

结合性(associativity)则要么是从左到右,要么是从右到左,用来决定同一优先级的运算符按什么顺序求值。

下表列出了 C++ 中所有可用运算符的优先级与结合性,包括那些不能重载的运算符,以及本书尚未介绍到的运算符。优先级数字越小,越先求值。表中 T 表示某个类型,而 xyz 表示对象:

优先级运算符结合性
1::从左到右
2x++ x-- x() x[] T() T{} . ->从左到右
3++x --x +x -x ! ~ *x &x (T) sizeof co_await new delete new[] delete[]从右到左
4.* ->*从左到右
5x*y x/y x%y从左到右
6x+y x-y从左到右
7<< >>从左到右
8<=>从左到右
9< <= > >=从左到右
10== !=从左到右
11x&y从左到右
12^从左到右
13|从左到右
14&&从左到右
15||从左到右
16x?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

第 9 章展示了如何编写二元算术运算符和简写算术赋值运算符,但没有涉及如何重载其他算术运算符。

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

给某个变量加 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 “重载位运算符与二元逻辑运算符”

位运算符与算术运算符很相似,而位运算简写赋值运算符也与算术简写赋值运算符相似。不过,它们的使用频率要低得多,因此这里不再展开示例。你可以参考“可重载运算符总结”中的样例原型;如果哪天真的需要实现它们,按那张表去写通常就够了。

逻辑运算符则更棘手。通常不建议去重载 &&||。这两个运算符并不真正“属于某个类型”;它们本质上是在聚合布尔表达式的结果。除此之外,一旦重载它们,你还会失去短路求值,因为左右两侧操作数都必须先求值,才能绑定到你定义的重载 &&|| 的参数上。因此,它们几乎从来都不是值得为某个具体类型去重载的东西。

在 C++ 中,运算符不仅用于算术,还用于从 stream 读写数据。例如,当你把 intstring 输出到 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 值。

插入运算符和提取运算符左侧的对象,是 istreamostream(例如 cincout),而不是 SpreadsheetCell 对象。由于你不可能给 istreamostream 这些类随意添加成员函数,因此这两个运算符必须写成全局函数。它们的声明如下:

export std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);
export std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);

把插入运算符的第一个参数写成 ostream&,意味着它不仅能用于 cout,还能用于文件输出 stream、字符串输出 stream、cerrclog 等各种输出 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;
}

假设一下:你暂时忘记了标准库里已经有 vectorarray 这两个类模板,因此你决定自己写一个动态分配数组类。这个类应当允许你在指定索引位置设置与读取元素,并把所有内存管理都藏在幕后。这样一个动态数组类的第一版定义,也许会像下面这样:

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

这个接口支持设置与访问元素,并提供随机访问保证:使用者可以先创建一个默认数组,然后直接去设置下标 11001000 处的元素值,而无需操心底层内存管理。

下面是这些成员函数的实现:

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[] 返回一个可充当 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[],仅仅是因为参数 arrconst。如果 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{} 进行初始化。最终到底应该采用“抛异常版本”,还是“返回空值版本”,取决于你的具体使用场景。

从 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;
// 其余部分略。
};

语法上的区别只是:现在为这两个二维下标运算符显式指定了两个参数 xy。它们的实现与原本 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

把“下标访问”推广到更一般的集合访问,其实是很自然的:在一般意义上,下标参数就是某种 key,而 vector(更一般地说,线性数组)只不过是一种特殊情况,其中“key”恰好就是数组中的位置。因此,你完全可以把 operator[] 的参数理解成“在 key 域与 value 域之间建立映射”的东西。基于这个思路,你可以编写接受任意类型作为索引的 operator[],它并不一定非得是整数类型。标准库中的关联容器(如第 18 章“标准库容器”中讨论的 std::map)就是这样做的。

例如,你完全可以创建一个 关联数组(associative array),它使用 string,或者更好地说 string_view,作为 key,而不是整数下标。这样的 operator[] 会把 string / string_view 作为参数。实现这种类,将作为本章末尾的练习留给你完成。

从 C++23 开始,只要下标运算符的实现不需要访问 this——换句话说,不需要访问任何非 static 数据成员和非 static 成员函数——那么它就可以被标记为 static。这与本章前面介绍过的“可标记为 static 的下标运算符”思路一致,而这样做能让编译器更好地优化代码,因为它不必再考虑任何 this 指针。下面给出一个例子,其中 operator[] 被同时标记为 staticconstexpr(见第 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]);
}

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 类再加一个接收 doubleoperator()

int operator()(int value) const;
double operator()(double value) const;

这个 double 版本可以这样实现:

double Squarer::operator()(double value) const { return value * value; }

从 C++23 开始,只要函数调用运算符的实现不需要访问 this——换句话说,不需要访问任何非 static 数据成员和非 static 成员函数——它就可以被标记为 static。这和本章前面介绍的“可标记为 static 的下标运算符”类似,这样做能让编译器生成更高效的代码。

下面是一个示例:简化版的 Squarer 函数对象,其函数调用运算符同时被标记为 staticconstexprnoexcept

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 指针。

你可以重载三个与解引用相关的运算符:*->->*。暂时先把 ->* 放在一边(后面我会再回来讲它),先来看 *-> 的内建含义。* 会解引用一个指针,让你直接访问它所指向的值;而 -> 则可以看成是“先做一次 * 解引用,再做一次 . 成员访问”的简写。下面的代码展示了这种等价关系:

SpreadsheetCell* cell { new SpreadsheetCell };
(*cell).set(5); // 解引用后,再访问成员。
cell->set(5); // 箭头形式,把解引用与成员访问合并起来。

你可以为自己的类重载这些解引用运算符,从而让类对象表现得像指针。它最典型的用途,就是实现 smart pointer——这一点在第 7 章“内存管理”中已经介绍过。它对迭代器也同样很重要,而迭代器正是标准库大量使用的核心机制之一。迭代器会在第 17 章“理解迭代器与 Ranges 库”中详细讨论。本章则只是在一个简单 smart pointer 类模板的背景下,向你讲清这些运算符重载的基础机制。

C++ 标准库已经提供了两个标准 smart pointer:std::unique_ptrshared_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-> 中的一个,而漏掉另一个。通常你应该总是把它们两个一起实现。否则,会让类的使用者感到非常困惑。

当你解引用一个指针时,你期望能够访问该指针所指向的内存。如果那块内存保存的是一个简单类型,例如 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* 那样直接实现成“解引用 + 成员访问”的组合,你就等于得实现一个类似 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->*

回到 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 }; // 可以正常工作

你可以用同样的语法,为任意目标类型编写转换运算符。例如,下面是 SpreadsheetCellstd::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) };

除了显式写出转换运算符的返回类型外,你还可以指定 auto,让编译器自行推导。例如,SpreadsheetCelldouble 转换运算符,其实也可以写成:

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 通过 SpreadsheetCelldouble 构造函数转成 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

有时,让对象能够出现在布尔表达式里会非常有用。比如,程序员经常会这样使用指针:

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 nullptr
not 0
not 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

这通常不会是你真正想要的行为。为了阻止这类赋值,你可以显式把到 intlonglong long 等类型的转换运算符删除掉。但这样一来,设计就开始变得越来越凌乱了。因此,很多程序员会更偏向使用 operator void*(),而不是 operator bool()

从这个例子可以看出,重载运算符其实带有明显的设计成分。你对“重载哪些运算符”的选择,会直接影响类的使用者究竟能以什么方式来使用你的类。

C++ 允许你重新定义程序中内存分配与释放的工作方式。这种定制既可以发生在全局层面,也可以发生在类层面。当你非常担心 内存碎片 时,这项能力尤其有用——例如,当程序频繁分配和释放大量小对象时,就容易出现这种情况。举例来说,与其每次需要内存都调用默认的 C++ 内存分配器,不如实现一个内存池分配器,反复复用固定大小的内存块。本节会解释内存分配与释放例程中的细节,并展示如何定制它们。有了这些工具,一旦真的有需求,你就有能力自己编写分配器。

除非你对内存分配策略有足够深的了解,否则尝试重载内存分配例程,通常都不值得。不要仅仅因为“听起来很酷”就去做。只有在你确实有明确需求,并且拥有足够知识时,才应该这样做。

C++ 中最难真正吃透的细节之一,就是 newdelete。请看这行代码:

SpreadsheetCell* cell { new SpreadsheetCell {} };

其中 new SpreadsheetCell{} 这一部分,叫做 new-expression。它会做两件事。第一,它通过调用 operator newSpreadsheetCell 对象分配内存;第二,它调用该对象的构造函数。只有当构造函数执行完成之后,才会把指针返回给你。

delete 的工作方式与此类似。请看这行:

delete cell;

这行代码叫做 delete-expression。它会先调用 cell 的析构函数,然后再调用 operator delete 来释放内存。

你可以通过重载 operator newoperator delete 来控制内存分配和释放,但你不能重载 new-expression 或 delete-expression 本身。也就是说,你可以定制“实际的内存分配与释放过程”,但不能改变“构造函数与析构函数的调用”这一层语义。

new-expression 一共有 6 种不同形式,每一种都对应着自己的 operator new。本书前面章节已经展示过其中 4 种:newnew[]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 只有两种:deletedelete[];并不存在 nothrow 版或 placement 版的 delete-expression。不过,operator delete 却总共有 6 个重载。为什么会有这种不对称?这是因为那两个 nothrow 版本和两个 placement 版本,只会在“构造函数抛出异常”时才会被用到。在这种情况下,调用的是与之前分配内存所用的 operator new 相匹配的 operator delete。但如果你只是正常地 delete 一个指针,那么 delete 只会调用 operator deletedelete[] 只会调用 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 newoperator delete。除非某个类自己提供了更具体的版本,否则程序中的每一个 new-expression 和 delete-expression 最终都会调用它们。不过,借用 Bjarne Stroustrup 的一句话来说,“……替换全局 operator newoperator delete 并不适合胆小的人”(The C++ Programming Language, 第三版,Addison-Wesley,1997)。我也不推荐这么做!

如果你偏偏不听劝,决定替换全局 operator new,请务必记住:在这个运算符里绝不能再写任何会调用 new 的代码,否则你会得到无限递归。例如,你甚至不能用 print() 向控制台输出一条日志。

一个更实际的技巧,是只为特定类重载 operator newoperator delete。这类重载只会在你分配和释放该类对象时被调用。下面给出一个类的示例,它重载了 4 种非 placement 形式的 operator newoperator 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 new
operator delete
operator new[]
operator delete[]
operator new nothrow
operator delete
operator new[] nothrow
operator delete[]

很显然,这些 operator newoperator delete 的实现本身非常简单,几乎谈不上实际用途。这里的目的只是帮助你理解它们的语法,以便将来如果你真的需要编写更复杂版本时,不至于无从下手。

只要你重载了某种形式的 operator new,就应当同时重载与之对应的 operator delete。否则,内存会按你定制的规则分配,却按内建规则释放,这两者未必兼容。

一眼看上去,把 operator newoperator delete 的各种形式都一并重载,似乎有点过头。但通常这么做反而是个好主意,因为它能避免内存分配策略出现不一致。如果你不想实现某些特定重载,也可以显式用 =delete 把它们删掉,阻止别人调用它们。下一节会继续解释这一点。

要么重载所有形式的 operator newoperator delete,要么显式删除你不希望被使用的那些重载,以防内存分配行为不一致。

显式删除或默认生成 operator newoperator delete

Section titled “显式删除或默认生成 operator new 与 operator delete”

第 8 章展示过如何显式删除或默认生成构造函数与赋值运算符。而“显式删除/默认生成”并不仅限于构造函数和赋值运算符。例如,下面这个类删除了 operator newoperator new[],这意味着该类对象不能通过 newnew[] 动态分配:

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 newoperator delete

Section titled “带额外参数的 operator new 与 operator delete”

除了重载标准形式的 operator new 外,你还可以自行定义带额外参数的版本。这些额外参数可以用来向内存分配例程传递各种标志、计数器等。例如,有些运行时库会在调试模式下,通过额外参数传入对象被分配时所在的文件名和行号,这样一旦出现内存泄漏,就可以直接定位到当初执行分配的那一行代码。

例如,下面是为 MemoryDemo 类额外定义的 operator newoperator 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: 5
operator delete

当你定义了带额外参数的 operator new 时,也应同时定义带同样额外参数的对应 operator delete。不过,你无法自己显式调用这个带额外参数的 operator delete;只有在你使用了“带额外参数的 operator new”,且对象构造函数抛出异常时,它才会被自动调用。

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

这项能力只有在你真的为自己的类设计了较复杂的内存分配/释放策略时,才会显得有意义。

C++ 拥有若干内建字面量类型,你在代码里经常会直接用到。比如:

  • 'a':字符
  • "A string":以零结尾的字符序列,也就是 C 风格字符串
  • 3.14ffloat 单精度浮点值
  • 0xabc:十六进制值

C++ 还允许你定义自己的字面量,而标准库本身就这么做了:它额外提供了一批可用于构造标准库对象的字面量。我们先来看看标准库里的这些例子,再看你如何定义自己的。

C++ 标准库定义了以下标准字面量。注意,这些字面量都以下划线开头:

字面量创建的对象类型示例所需命名空间
sstringauto myString { "Hello"s };string_literals
svstring_viewauto myStringView { "Hello"sv };string_view_literals
h, min, s, ms, us, nschrono::duration1auto myDuration { 42min };chrono_literals
y, dchrono::yearday1auto thisYear { 2024y };chrono_literals
i, il, ifcomplex<T>,其中 T 分别为 doublelong doublefloatauto myComplexNumber { 1.3i };complex_literals

从技术上说,这些字面量都定义在 std::literals 的子命名空间中,例如 std::literals::string_literals。不过,string_literalsliterals 都是 inline namespace,因此其内容会自动在父命名空间中可见。所以,如果你想使用 s 字符串字面量,可以使用下面任意一种 using 指令:

using namespace std;
using namespace std::literals;
using namespace std::string_literals;
using namespace std::literals::string_literals;

用户自定义字面量必须恰好以下划线开头。比如 _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 模式字面量运算符应当具备以下两种形式之一:

  • 处理数值: 接收一个参数,类型为 unsigned long longlong doublecharwchar_tchar8_tchar16_tchar32_t
  • 处理字符串: 接收两个参数,第一个是 C 风格字符串,第二个是字符串长度,例如 (const char* str, std::size_t len)

下面的例子定义了一个 Length 类,用米来保存长度。它的构造函数是 private 的,因为使用者只能通过这里提供的用户自定义字面量来构造 Length。示例中为 _km_m 定义了 cooked 字面量运算符。它们都被声明为 Lengthfriend,这样就能调用其 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 模式字面量运算符要求接收一个 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 提供的代码下载包中。不过,如果你在某个练习上卡住了,建议先回头重读本章相关部分,尽量自己找到答案,再去查看网站上的解答。

  1. 练习 15-1: 实现一个 AssociativeArray 类模板。这个类应当用一个 vector 来保存若干元素,每个元素都由一个 key 和一个 value 组成。key 固定是 string,而 value 的类型则通过模板类型参数指定。请提供重载下标运算符,使得元素能够通过 key 访问。并在你的 main() 函数中测试这一实现。注意:这个练习只是为了练习“使用非整数下标实现下标运算符”。在真实项目中,对于这种关联数组需求,你应当直接使用标准库在第 18 章中介绍的 std::map 类模板。
  2. 练习 15-2: 基于你在练习 13-2 中实现的 Person 类,为它补上插入运算符与提取运算符的实现。请确保你的提取运算符能够正确读回由插入运算符输出的内容。
  3. 练习 15-3: 在练习 15-2 的基础上,为 Person 再添加一个 string 转换运算符。这个运算符只需要返回一个由人的 first name 与 last name 组合而成的 string
  4. 练习 15-4: 从练习 15-3 的解法继续,添加一个用户自定义字面量运算符 _p,使其能够从字符串字面量构造 Person。它应当支持“last name 中包含空格”,但不支持“first name 中包含空格”。例如,"Peter Van Weert"_p 应当构造出一个 Person 对象,其 first name 为 Peter,last name 为 Van Weert
  1. 会在第 22 章“日期与时间工具”中讨论。 2