C++ / 2026-08-01
CS106L 第 12 讲:运算符重载
让自定义类型拥有自然的 C++ 语法,掌握成员与非成员运算符设计。
本课程依据 Stanford CS106L Spring 2026 Lecture 12 课件重构。
本节课要解决的核心问题
前面几节课中,我们已经学会了:
- 使用类把数据和操作封装成一个完整类型;
- 使用类模板和函数模板,让同一份代码适用于不同类型;
- 使用函数对象、Lambda 和算法处理容器;
- 使用范围(range)与视图(view)组合数据处理流程。
但是,当我们把自己编写的类交给模板、标准库容器或算法时,会遇到一个新的问题:
编译器不知道两个自定义对象应该怎样比较、相加、输出或调用。
例如,整数天然支持:
3 + 4
字符串天然支持:
name1 < name2
输出流天然支持:
std::cout << 42;
可是对于我们自己定义的 StanfordID:
StanfordID rachel(...);
StanfordID preston(...);
rachel < preston;
编译器并不知道“小于”究竟应该表示:
- 学号更小;
- 姓名在字典序中更靠前;
- SUNet ID 更靠前;
- 创建时间更早;
- 还是其他含义。
本节课要学习的运算符重载(operator overloading),就是让我们为自定义类型补充这些操作规则。
学完后,我们希望能够回答:
- 为什么模板和标准库需要类型支持某些运算符?
a < b和普通函数调用之间有什么关系?- 怎样为类定义
operator<? - 成员运算符和非成员运算符有什么区别?
- 为什么
std::cout << object通常必须使用非成员函数? - 非成员函数怎样访问类的私有成员?
- 哪些运算符可以重载,哪些不可以?
- 怎样避免定义出令人困惑的运算符?
- 如何完整实现一个拥有多个运算符的类?
学习本节课所需的前置知识
本节会用到以下已有知识:
- 类与对象;
public和private;- 构造函数;
- 成员函数;
const成员函数;- 引用和
const引用; - 函数模板;
std::map和std::set;- 基础输入输出。
不要求你提前掌握:
- 模板内部实现;
- 隐式类型转换的完整规则;
- 左值和右值;
- 移动语义;
- 三路比较运算符
<=>; - 严格弱序的形式化数学定义。
涉及这些概念时,我们会先建立足够使用的直觉。
从上一节课走到运算符重载
函数对象为什么可以像函数一样被调用?
上一节课学习了函数对象(functor)和 Lambda。
一个函数对象本质上是一个普通对象,但它支持这样的语法:
object(argument);
这并不是对象真的“变成了函数”,而是它的类定义了函数调用运算符:
operator()
例如:
#include <iostream>
class LessThan {
public:
explicit LessThan(int limit) : limit_(limit) {}
bool operator()(int value) const {
return value < limit_;
}
private:
int limit_;
};
int main() {
LessThan less_than_ten(10);
std::cout << std::boolalpha;
std::cout << less_than_ten(7) << '\n';
std::cout << less_than_ten(15) << '\n';
}
程序输出:
true
false
表达式:
less_than_ten(7)
可以暂时理解为:
less_than_ten.operator()(7)
这已经是一次运算符重载。只是上一节课的重点放在函数对象,因此我们没有系统学习其他运算符。
Lambda 也与此有关。编译器会为 Lambda 生成一个匿名类型,这个类型通常拥有一个 operator(),所以 Lambda 才能够像函数一样被调用。
范围管道中的 | 还是按位或吗?
课件回顾了范围(range)和视图(view)之间的管道语法:
range
| filter(...)
| transform(...);
对于整数,| 通常表示按位或。但在范围库中,它被赋予了“把左边的数据送入右边处理步骤”的意义。
下面是可以使用 C++20 编译的完整示例:
#include <cctype>
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<char> letters{'a', 'b', 'c', 'd', 'e'};
auto is_vowel = [](char c) {
return c == 'a' || c == 'e' || c == 'i'
|| c == 'o' || c == 'u';
};
auto to_upper = [](char c) {
return static_cast<char>(
std::toupper(static_cast<unsigned char>(c))
);
};
auto upper_vowels = letters
| std::views::filter(is_vowel)
| std::views::transform(to_upper);
for (char c : upper_vowels) {
std::cout << c << ' ';
}
std::cout << '\n';
}
编译命令:
g++ -std=c++20 main.cpp -o main
程序输出:
A E
这里的 | 不再表达普通整数的按位或,而是表达视图之间的组合关系。
这正是运算符重载的价值之一:
运算符不仅执行计算,还能够向读者传达一个类型具有什么性质。
operator() 告诉我们“这个对象可以被调用”。
范围中的 operator| 告诉我们“这些操作可以像管道一样组合”。
接下来,我们要让自定义对象能够比较、相加和输出。
一个模板为什么不能处理所有类型?
从通用的最小值函数开始
考虑下面的函数模板:
template <typename T>
T min_value(const T& a, const T& b) {
return a < b ? a : b;
}
它看起来可以接收任意类型 T。
对于整数:
int smaller = min_value(10, 20);
模板实例化后,可以近似理解为:
int min_value(const int& a, const int& b) {
return a < b ? a : b;
}
因为整数支持 <,所以程序能够编译。
但“模板可以接收任意类型”并不意味着“任意类型都一定符合模板要求”。
这个模板至少要求:
- 表达式
a < b必须合法; a < b的结果必须能够作为条件判断;a或b必须能够用来构造返回的T;- 从语义上说,类型
T应该存在合理的大小关系。
前三项是编译器能够检查的技术要求。
第四项是程序员必须保证的语义要求。
例如,整数拥有自然的数值顺序:
-3 < -1 < 0 < 2 < 10
所以“两个整数中的最小值”有清楚的含义。
把 StanfordID 放进模板
先定义一个只包含数据的类:
#include <string>
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int id_number
)
: name_(name),
sunet_(sunet),
id_number_(id_number) {}
private:
std::string name_;
std::string sunet_;
int id_number_;
};
然后尝试:
StanfordID rachel("Rachel", "rfer", 1002);
StanfordID preston("Preston", "pseay", 1001);
StanfordID smaller = min_value(rachel, preston);
模板会尝试执行:
rachel < preston
但当前的 StanfordID 没有定义 <。
编译器只能知道:
左操作数类型:StanfordID
右操作数类型:StanfordID
运算符:<
它无法自己决定按照哪个成员比较,因此会产生编译错误。
这不是运行时错误。程序在生成可执行文件之前就已经失败。
技术上能比较,还不代表语义合理
假设我们随便规定:
StanfordID 对象的内存地址更小,就认为对象更小。
即使勉强写出这样的程序,它也无法表达“哪个 Stanford ID 更小”的稳定含义。
一个合理的比较规则应当回答:
当我们说
a < b时,究竟在比较什么?
本课程选择按照 id_number 比较:
id_number 更小的 StanfordID 被认为更小
于是:
Rachel: 1002
Preston: 1001
满足:
preston < rachel
练习:模板真正要求什么?
下面哪些类型可以合理地用于前面的 min_value?
intstd::string- 一个没有定义
<的StanfordID - 一个定义了
<,但<每次随机返回结果的类型 - 一个按照学号比较的
StanfordID
答案与解释
int 可以使用,因为整数拥有稳定的数值顺序。
std::string 可以使用,因为字符串支持字典序比较。
没有定义 < 的 StanfordID 会产生编译错误。模板实例化时找不到合法的比较操作。
随机返回结果的 < 可能通过编译,但语义存在严重问题。同一对对象多次比较可能得到不同结果,排序、查找和有序容器都会失去可靠性。
按照学号稳定比较的 StanfordID 可以使用,因为:
- 表达式
a < b合法; - 返回结果是
bool; - 比较规则能够稳定地确定顺序。
运算符究竟是什么?
运算符(operator)是 C++ 中用于对值、对象或类型执行操作的特殊语法。
例如,对值进行操作:
3 + 4
对对象进行操作:
first_id < second_id
对类型或存储进行操作:
sizeof(int)
new int(5)
运算符可能:
- 产生一个新值;
- 修改已有对象;
- 访问对象中的内容;
- 分配或释放存储;
- 触发函数调用;
- 完成其他由语言规定的操作。
运算符重载,就是为某些已有运算符补充“当操作数是自定义类型时应该怎样工作”的规则。
为什么不全部使用普通函数?
假设我们定义一个金额类型:
class Money {
public:
explicit Money(int cents) : cents_(cents) {}
int cents() const {
return cents_;
}
private:
int cents_;
};
可以使用普通函数完成相加:
Money add(const Money& lhs, const Money& rhs) {
return Money(lhs.cents() + rhs.cents());
}
调用方式:
Money total = add(Money(100), Money(50));
这段代码没有技术错误,但读起来更像“一次名为 add 的普通函数调用”。
金额具有天然的数值性质,所以更自然的表达是:
Money total = Money(100) + Money(50);
看到 + 时,读者立刻知道:
Money是一种可以相加的类型;- 两个金额相加后会产生一个新金额;
- 原来的两个金额通常不会被修改。
运算符在这里传达了普通函数名不容易直接传达的类型性质。
当然,这不表示任何函数都应该换成运算符。
如果一个操作叫作:
applyDiscount()
它的含义比某个符号更清楚,就应当保留普通函数。
运算符重载的基本语法
运算符函数的一般形式是:
返回类型 operator运算符(参数列表);
例如:
bool operator<(const StanfordID& other) const;
这里的函数名不是普通标识符,而是:
operator<
其他例子包括:
Money operator+(const Money& other) const;
bool operator==(const StanfordID& other) const;
PizzaOrder& operator+=(int extra_slices);
std::ostream& operator<<(
std::ostream& out,
const StanfordID& id
);
需要特别纠正一个容易出现的术语错误:
这里是运 const StanfordID& id );
需要特别纠正一个容易算符重载(overloading),不是运算符覆盖(overriding)。
覆盖通常与继承、虚函数和动态多态有关。
运算符重载与普通函数重载更接近:编译器根据操作数类型选择合适的运算符函数。
---
# 第一种写法:把 `operator<` 定义成成员函数
## 完整示例
```cpp
#include <iostream>
#include <string>
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int id_number
)
: name_(name),
sunet_(sunet),
id_number_(id_number) {}
const std::string& getName() const {
return name_;
}
int getIdNumber() const {
return id_number_;
}
bool operator<(const StanfordID& other) const {
return id_number_ < other.id_number_;
}
private:
std::string name_;
std::string sunet_;
int id_number_;
};
template <typename T>
T min_value(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
const StanfordID rachel(
"Rachel",
"rfer",
1002
);
const StanfordID preston(
"Preston",
"pseay",
1001
);
const StanfordID smaller =
min_value(rachel, preston);
std::cout
<< smaller.getName()
<< " has the smaller ID number: "
<< smaller.getIdNumber()
<< '\n';
}
程序输出:
Preston has the smaller ID number: 1001
分解成员运算符声明
bool operator<(const StanfordID& other) const;
可以分成五个部分。
返回类型 bool
小于比较只需要回答:
左边是否小于右边?
因此返回 bool。
函数名 operator<
它告诉编译器,这是为 < 定义的函数。
参数 const StanfordID& other
右操作数通过常量引用传入。
常量引用具有两个作用:
- 不复制整个
StanfordID; - 保证函数不会通过
other修改右操作数。
末尾的 const
bool operator<(...) const
最后这个 const 修饰当前对象。
它表示该比较不会修改左操作数。
因此下面的代码可以正常工作:
const StanfordID a(...);
const StanfordID b(...);
bool result = a < b;
如果漏掉末尾的 const,就不能在常量对象上调用该成员函数。
函数体
return id_number_ < other.id_number_;
成员函数可以直接访问:
- 当前对象的私有成员;
- 另一个同类型对象的私有成员。
“私有成员只能由当前对象自己访问”并不准确。
更准确地说:
StanfordID的成员函数可以访问任意StanfordID对象的私有成员。
所以 other.id_number_ 是合法的。
a < b 到底调用了谁?
对于成员形式:
bool StanfordID::operator<(
const StanfordID& other
) const;
表达式:
a < b
可以暂时理解为:
a.operator<(b)
这里:
a:左操作数,也是调用成员函数的对象
b:右操作数,传给参数 other
在成员函数内部,还存在一个隐含的 this 指针。
this
↓
左操作数 a
所以:
id_number_
等价于:
this->id_number_
整个函数可以写成:
bool StanfordID::operator<(
const StanfordID& other
) const {
return this->id_number_ < other.id_number_;
}
通常不必显式写出 this->。
一步步跟踪 min_value
执行:
const StanfordID smaller =
min_value(rachel, preston);
调用前的对象状态:
rachel
┌──────────────────────┐
│ name_ = Rachel │
│ sunet_ = rfer │
│ id_number_ = 1002 │
└──────────────────────┘
preston
┌──────────────────────┐
│ name_ = Preston │
│ sunet_ = pseay │
│ id_number_ = 1001 │
└──────────────────────┘
模板参数被推导为:
T = StanfordID
所以函数近似成为:
StanfordID min_value(
const StanfordID& a,
const StanfordID& b
) {
return a < b ? a : b;
}
参数关系:
a 引用 rachel
b 引用 preston
没有因为传参而复制两个对象。
接着计算:
a < b
也就是:
rachel.operator<(preston)
函数内部比较:
1002 < 1001
结果是:
false
条件表达式选择 b:
a < b ? a : b
on
没有因为传参而复制两个对象。
接着计算:
a < b
也就是:
rachel.operator<(preston)
函数内部比较:
100 ↑
preston
函数返回类型是 StanfordID,不是引用,因此被选中的对象会用于构造返回结果。
最终:
smaller
┌──────────────────────┐
│ name_ = Preston │
│ sunet_ = pseay │
│ id_number_ = 1001 │
└──────────────────────┘
rachel 和 preston 都没有被修改。
练习:找出左操作数和右操作数
给出:
StanfordID first("First", "first", 300);
StanfordID second("Second", "second", 500);
bool result = first < second;
回答:
- 哪个对象是
this指向的对象? - 哪个对象被绑定到参数
other? - 比较的整数表达式是什么?
result是什么?- 是否发生了参数复制?
答案与解释
表达式:
first < second
对于成员运算符,可以理解为:
first.operator<(second)
所以:
this → first
other → second
函数内部计算:
first.id_number_ < second.id_number_
也就是:
300 < 500
结果为:
true
因此:
result == true
参数类型是:
const StanfordID&
所以 second 没有被复制,而是被常量引用。
std::map 为什么关心 <?
有序容器必须知道键的顺序
课件使用了一个树状查找图来解释 std::map 为什么需要比较规则。
可以把其中的关系重构成:
"CS106L"
/ \
"Chris" "Nick"
/ / \
"Alex" "Keith" "Sean"
查找 "Alex" 时,可以不断比较:
"Alex" < "CS106L" → 向左
"Alex" < "Chris" → 再向左
找到 "Alex"
这种图建立了一个有用直觉:
有序容器利用键之间的顺序缩小查找范围。
不过需要区分课堂直觉和严格规则。
课件将其简化为:
std::map<K, V> 要求 K 拥有 operator<
更准确地说,std::map 的模板形式近似是:
std::map<Key, Value, Compare>
默认比较器是:
std::less<Key>
对于普通自定义类型,默认比较器通常会依赖该类型能够进行 < 比较。
我们也可以显式提供其他比较器,因此不是所有情况下都必须把 operator< 写进类中。
此外,标准并没有要求 std::map 必须使用课件图中那一种具体树结构。图只是说明有序查找的基本思想。
把 StanfordID 放入 std::map
#include <iostream>
#include <map>
#include <string>
class StanfordID {
public:
StanfordID(
std::string name,
int id_number
)
: name_(name),
id_number_(id_number) {}
const std::string& getName() const {
return name_;
}
bool operator<(const StanfordID& other) const {
return id_number_ < other.id_number_;
}
private:
std::string name_;
int id_number_;
};
int main() {
StanfordID rachel("Rachel", 1002);
StanfordID preston("Preston", 1001);
std::map<StanfordID, int> visits;
visits[rachel] = 3;
visits[preston] = 5;
for (const auto& [id, count] : visits) {
std::cout
<< id.getName()
<< ": "
<< count
<< '\n';
}
}
程序输出:
Preston: 5
Rachel: 3
之所以先输出 Preston,是因为我们规定:
1001 < 1002
所以 preston 在有序容器中排在 rachel 前面。
插入和查找过程中,std::map 会多次调用比较操作。具体调用次数和内部节点结构由实现决定,不应依赖某一种固定形状。
比较规则必须保持稳定
为有序容器定义 < 时,不能随便返回结果。
下面的设计就有问题:
bool operator<(const StanfordID& other) const {
return std::rand() % 2 == 0;
}
同样的两个对象第一次比较可能得到 true,第二次得到 false。
有序容器将无法稳定判断对象应该放在哪个位置。
对于初学阶段,可以记住几个要求:
- 一个对象不应当小于自己;
- 比较结果应当稳定;
- 如果
a < b,就不应同时满足b < a; - 排序规则不应在容器使用过程中随意改变。
这个要求通常称为严格弱序(strict weak ordering)。
这里先建立使用直觉,后续学习标准库约束时可以再研究它的形式化定义。
哪些运算符可以重载?
C++ 中大多数常见运算符都可以重载。
算术运算符
+ - * / %
比较运算符
== != < > <= >=
复合赋值运算符
+= -= *= /= %=
自增和自减
++ --
位运算符
& | ^ ~ << >>
逻辑运算符
! && ||
下标、调用和成员访问相关运算符
[] () -> ->*
内存相关运算符
new new[] delete delete[]
此外,逗号运算符等也可以重载。
但“可以重载”不等于“应该重载”。
例如,给一个普通学生对象重载:
student && course
通常难以让人直接理解它的含义。
哪些运算符不能重载?
本节课需要记住以下常见例子。
作用域解析运算符
::
例如:
std::cout
StanfordID::operator<
它的含义由语言本身决定。
成员访问运算符
.
例如:
student.name
不能改变 . 的基本含义。
注意:
->
可以在特定条件下重载,但:
.
不可以。
成员指针直接访问运算符
.*
不能重载。
条件运算符
condition ? first : second
即:
?:
不能重载。
对象大小和类型信息相关操作
sizeof(...)
typeid(...)
不能为自定义类型重新规定它们的基本含义。
命名强制转换:
static_cast
dynamic_cast
const_cast
reinterpret_cast
也不能像普通运算符一样被重载。
不过,C++ 允许类定义用户自定义转换函数,例如:
operator bool() const;
这和“重载 static_cast”不是同一件事。
运算符重载不能改变什么?
即使某个运算符可以重载,也仍然受到语言规则限制。
不能创造新的符号
不能自行发明:
**
<+>
???
作为新运算符。
只能重载 C++ 已经存在的运算符。
不能改变优先级
假设重载了 + 和 *:
a + b * c
仍然先计算:
b * c
再计算:
a + 结果
不能让自定义类型中的 + 优先于 *。
不能改变结合方向
a + b + c
仍按照 + 原本的结合规则解析。
不能随意改变操作数数量
二元 + 仍然需要两个操作数。
不能把它变成:
a + b + c
一次接收三个对象的单个运算符调用。
至少一个操作数必须是自定义类型
不能重新定义:
int + int
例如不能让:
1 + 2
得到 100。
运算符重载必须涉及类类型或枚举类型。
重载逻辑运算符要格外小心
内置的:
lhs && rhs
lhs || rhs
具有短路求值。
例如当 lhs 已经是 false 时,内置 && 不需要执行 rhs。
重载后的 operator&& 和 operator|| 更接近普通函数调用,不能依赖同样的内置短路行为。
因此实际代码中通常不应随意重载它们。
成员运算符并不是唯一写法
到目前为止,我们使用的是成员运算符:
class StanfordID {
public:
bool operator<(
const StanfordID& other
) const;
};
实现:
bool StanfordID::operator<(
const StanfordID& other
) const {
return id_number_ < other.id_number_;
}
但运算符也可以写成普通的非成员函数(non-member function):
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
这里:
lhs是左操作数;rhs是右操作数。
表达式:
a < b
对于非成员形式,可以暂时理解为:
operator<(a, b)
成员形式与非成员形式的参数区别
成员形式
bool StanfordID::operator<(
const StanfordID& rhs
) const;
表面上只有一个参数,因为左操作数通过 this 隐式传入。
a < b
this → a
rhs → b
非成员形式
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
两个操作数都需要显式出现在参数列表中。
a < b
lhs → a
rhs → b
对比:
| 表达式 | 成员形式的近似调用 | 非成员形式的近似调用 |
|---|---|---|
a < b |
a.operator<(b) |
operator<(a, b) |
| 左操作数 | 隐含的 this |
第一个参数 |
| 右操作数 | 显式参数 | 第二个参数 |
| 是否属于类 | 是 | 否 |
这里的“近似调用”用于帮助理解。编译器实际还会执行完整的重载决议,而不只是机械替换文本。
为什么需要非成员运算符?
成员运算符要求左边对象负责调用函数
考虑下面的设计:
#include <string>
class StanfordID {
public:
explicit StanfordID(std::string sunet)
: sunet_(sunet) {}
bool operator<(
const std::string& other
) const {
return sunet_ < other;
}
private:
std::string sunet_;
};
这使下面的表达式合法:
StanfordID rachel("rfer");
std::string name = "zzhang";
bool result = rachel < name;
因为它可以理解为:
rachel.operator<(name)
左边是 StanfordID,它拥有这个成员函数。
但反过来:
name < rachel
近似变成:
name.operator<(rachel)
左边是 std::string。
我们不能进入标准库的 std::string 类中,为它增加一个接收 StanfordID 的成员函数,所以编译失败。
非成员函数不要求左边拥有该成员
可以定义两个非成员运算符:
#include <string>
class StanfordID {
public:
explicit StanfordID(std::string sunet)
: sunet_(sunet) {}
const std::string& getSunet() const {
return sunet_;
}
private:
std::string sunet_;
};
bool operator<(
const StanfordID& lhs,
const std::string& rhs
) {
return lhs.getSunet() < rhs;
}
bool operator<(
const std::string& lhs,
const StanfordID& rhs
) {
return lhs < rhs.getSunet();
}
现在两个方向都可以使用:
StanfordID rachel("rfer");
std::string name = "zzhang";
bool first = rachel < name;
bool second = name < rachel;
需要注意:
非成员形式不会自动让两个方向都成立。
如果左右类型不同,通常仍然需要分别定义:
operator<(StanfordID, std::string)
operator<(std::string, StanfordID)
课件中的“能够双向比较”,应理解为非成员函数让我们有能力分别支持两个方向,而不是一个函数自动反向工作。
可以与无法修改的类型协作
我们不能修改 std::string 的类定义,也不应该向 namespace std 中随意添加自己的函数。
但是可以在自己的代码中定义一个非成员运算符,让自己的类型和 std::string 协作。
因此非成员运算符特别适合:
- 左操作数可能不是自己的类;
- 两个操作数地位对称;
- 需要允许两个操作数都参与类型转换;
- 运算符本质上不修改左对象;
- 流输出等左侧属于标准库的情况。
不过,能写出来不代表设计一定合理。
把一个完整的 StanfordID 和任意字符串直接比较,含义可能不够清楚。这里主要用它解释成员与非成员的查找差异。
在真实程序中,可能更清楚的写法是:
rachel.getSunet() < name
或者:
compareSunet(rachel, name)
``楚。这里主要用它解释成员与非成员的查找差异。
在真实程序中,可能更清`
---
# 哪些运算符适合写成成员?
有些运算符在 C++20 中必须是成员函数,例如:
```text
operator=
operator[]
operator()
operator->
还有类型转换函数:
operator bool() const;
也写在类中。
此外,下面这些操作虽然不一定受到“必须是成员”的语言限制,但通常也适合写成成员:
- 修改左操作数的
+=; - 修改左操作数的
-=; - 前置和后置
++; - 与对象内部状态紧密相关的调用操作。
例如:
class Money {
public:
Money& operator+=(const Money& other) {
cents_ += other.cents_;
return *this;
}
private:
int cents_ = 0;
};
表达式:
wallet += payment;
本来就是在修改 wallet,所以让 wallet 负责调用成员函数很自然。
哪些运算符常适合写成非成员?
当两个操作数地位对称时,非成员形式通常更自然,例如:
a + b
a == b
a < b
另外,流插入运算符:
std::cout << object
几乎总是写成非成员函数,因为左边是 std::ostream,而不是我们的类。
一种常见设计是:
class Money {
public:
Money& operator+=(const Money& other) {
cents_ += other.cents_;
return *this;
}
private:
int cents_ = 0;
friend Money operator+(
Money lhs,
const Money& rhs
);
};
Money operator+(
Money lhs,
const Money& rhs
) {
lhs += rhs;
return lhs;
}
这里:
+=修改左对象,写成成员;+创建新结果,写成非成员;+复用了+=的逻辑。
这个模式可以减少重复代码。
练习:成员还是非成员?
判断下面的运算符更适合使用哪一种形式,并说明原因。
pizza += 2first_id < second_idstd::cout << first_idfunction_object(10)numbers[3]
答案与解释
pizza += 2 修改左边的 pizza,适合成员函数:
PizzaOrder& operator+=(int extra_slices);
first_id < second_id 的两个对象地位相近。成员和非成员都能实现,但对称的非成员形式通常更灵活:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
std::cout << first_id 的左操作数是 std::ostream。我们不能给 std::ostream 添加自己的成员,因此应使用非成员:
std::ostream& operator<<(
std::ostream& out,
const StanfordID& id
);
函数调用运算符 operator() 在 C++20 中必须是成员函数:
bool operator()(int value) const;
下标运算符 operator[] 在 C++20 中也必须是成员函数。
非成员函数访问不到私有成员怎么办?
成员函数可以直接访问类的私有数据:
bool StanfordID::operator<(
const StanfordID& other
) const {
return id_number_ < other.id_number_;
}
但普通非成员函数默认不能这样做:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.id_number_ < rhs.id_number_;
}
如果 id_number_ 是 private,上面的代码通常会产生编译错误。
因为 operator< 不是 StanfordID 的成员函数。
有两种主要解决方式。
方法一:通过公开接口访问
类可以提供一个公开的只读函数:
class StanfordID {
public:
int getIdNumber() const {
return id_number_;
}
private:
int id_number_;
};
非成员运算符使用它:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.getIdNumber() < rhs.getIdNumber();
}
这种写法不需要额外权限。
它只使用类已经公开的接口,因此耦合较低。
方法二:使用友元
友元(friend)允许指定的非成员函数或类访问当前类的私有成员。
在类中声明:
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int id_number
);
friend bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
private:
std::string name_;
std::string sunet_;
int id_number_;
};
然后在类外实现:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.id_number_ < rhs.id_number_;
}
虽然函数能够访问私有成员,但它仍然不是成员函数。
因此定义时不要写:
StanfordID::operator<
正确形式是:
bool operator<(...) {
// ...
}
friend 只是在授权访问。
friend 声明放在哪个区域?
友元声明写在 public、private 或 protected 区域都能获得相同的访问权限。
例如下面也合法:
class StanfordID {
private:
friend bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
int id_number_;
};
把友元运算符放在 public 区域通常更便于读者发现它是这个类型对外提供的一部分操作。
不能同时保留完全竞争的成员和非成员版本
假设同时定义:
class StanfordID {
public:
bool operator<(
const StanfordID& rhs
) const;
};
以及:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
然后调用:
a < b;
编译器可能同时发现:
a.operator<(b)
和:
operator<(a, b)
如果两个候选都同样合适,就会产生重载歧义。
课件将这种情况描述为“未定义行为”,这里需要修正:
它通常是编译期重载歧义,而不是未定义行为。
程序往往无法通过编译,因此不会进入运行阶段。
对于同一种操作和同一组操作数类型,应选择一种主要实现方式,不要同时提供完全竞争的成员与非成员版本。
练习:这里是什么错误?
class Number {
public:
bool operator<(const Number& other) const {
return value_ < other.value_;
}
private:
int value_ = 0;
};
bool operator<(
const Number& lhs,
const Number& rhs
) {
return false;
}
int main() {
Number a;
Number b;
bool result = a < b;
}
这是:
- 链接错误;
- 运行时错误;
- 未定义行为;
- 编译期重载歧义;
- 逻辑错误但可以正常编译。
答案与解释
正确答案是:
编译期重载歧义
对于:
a < b
编译器同时看到成员候选:
a.operator<(b)
和非成员候选:
operator<(a, b)
两者的参数匹配程度都很好,编译器无法唯一决定使用哪一个。
因此程序通常在编译阶段失败。
解决方法是删除其中一个版本,或者让两个版本接收不同类型、承担不同且不会竞争的职责。
完整的 StanfordID 多文件实现
下面使用非成员友元运算符,完整实现:
<==!=<<
StanfordID.h
#ifndef STANFORD_ID_H
#define STANFORD_ID_H
#include <iosfwd>
#include <string>
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int id_number
);
const std::string& getName() const;
const std::string& getSunet() const;
int getIdNumber() const;
friend bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
friend bool operator==(
const StanfordID& lhs,
const StanfordID& rhs
);
friend bool operator!=(
const StanfordID& lhs,
const StanfordID& rhs
);
friend std::ostream& operator<<(
std::ostream& out,
const StanfordID& id
);
private:
std::string name_;
std::string sunet_;
int id_number_;
};
#endif
<iosfwd> 提供 std::ostream 的前置声明。
头文件中只需要声明输出流类型,不需要立刻包含完整的 <iostream>。
StanfordID.cpp
#include "StanfordID.h"
#include <ostream>
StanfordID::StanfordID(
std::string name,
std::string sunet,
int id_number
)
: name_(name),
sunet_(sunet),
id_number_(id_number) {}
const std::string& StanfordID::getName() const {
return name_;
}
const std::string& StanfordID::getSunet() const {
return sunet_;
}
int StanfordID::getIdNumber() const {
return id_number_;
}
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.id_number_ < rhs.id_number_;
}
bool operator==(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.name_ == rhs.name_
&& lhs.sunet_ == rhs.sunet_
&& lhs.id_number_ == rhs.id_number_;
}
bool operator!=(
const StanfordID& lhs,
const StanfordID& rhs
) {
return !(lhs == rhs);
}
std::ostream& operator<<(
std::ostream& out,
const StanfordID& id
) {
out
<< id.name_
<< " ("
<< id.sunet_
<< ", "
<< id.id_number_
<< ')';
return out;
}
main.cpp
#include "StanfordID.h"
#include <iostream>
#include <map>
template <typename T>
T min_value(const T& a, const T& b) {
return a < b ? a : b;
}
int main() {
const StanfordID rachel(
"Rachel",
"rfer",
1002
);
const StanfordID preston(
"Preston",
"pseay",
1001
);
const StanfordID smaller =
min_value(rachel, preston);
std::cout
<< "smaller: "
<< smaller
<< '\n';
std::map<StanfordID, int> visits;
visits[rachel] = 3;
visits[preston] = 5;
for (const auto& [id, count] : visits) {
std::cout
<< id
<< " -> "
<< count
<< '\n';
}
std::cout << std::boolalpha;
std::cout
<< "rachel == preston: "
<< (rachel == preston)
<< '\n';
std::cout
<< "rachel != preston: "
<< (rachel != preston)
<< '\n';
}
编译命令
g++ -std=c++20 main.cpp StanfordID.cpp -o main
运行:
./main
程序输出:
smaller: Preston (pseay, 1001)
Preston (pseay, 1001) -> 5
Rachel (rfer, 1002) -> 3
rachel == preston: false
rachel != preston: true
完整程序的执行过程
创建对象
执行:
const StanfordID rachel(
"Rachel",
"rfer",
1002
);
调用构造函数:
StanfordID::StanfordID(
std::string name,
std::string sunet,
int id_number
)
成员按照它们在类中声明的顺序初始化:
name_
sunet_
id_number_
构造后:
rachel
┌──────────────────────┐
│ name_ = Rachel │
│ sunet_ = rfer │
│ id_number_ = 1002 │
└──────────────────────┘
preston 的构造过程相同。
本例为了避免引入移动语义,按值接收字符串参数,再复制进成员。
调用 min_value
const StanfordID smaller =
min_value(rachel, preston);
模板中的:
a < b
会查找到非成员函数:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
参数绑定:
lhs → rachel
rhs → preston
没有因为比较而复制对象。
计算:
1002 < 1001
得到 false,所以选择 preston。
因为 min_value 按值返回,所选对象被用于构造 smaller。
插入 std::map
执行:
visits[rachel] = 3;
std::map 保存自己的键对象,因此会把 rachel 作为键存入容器。
插入第二个键时:
visits[preston] = 5;
容器使用 operator< 判断两个键之间的顺序。
因为:
preston < rachel
为 true,遍历时 preston 排在前面。
输出对象
表达式:
std::cout << smaller;
会找到:
operator<<(std::cout, smaller)
参数关系:
out → std::cout
id → smaller
函数依次向 out 写入:
Preston
空格和左括号
pseay
逗号和空格
1001
右括号
最后返回同一个输出流 out。
课件中 StanfordID 示例的几个代码问题
原课件使用逐步动画引出实现,因此部分页面中的代码并不完整。重构为完整程序时,需要修正以下问题。
getIdNumber() 的返回类型不一致
某一阶段写成:
std::string getIdNumber();
但 idNumber 的类型是:
int
实现中又返回:
return idNumber;
正确声明应为:
int getIdNumber() const;
这里还补充了末尾的 const,因为读取学号不应修改对象。
#include 缺少引号
课件画面中出现:
#include StanfordID.h
正确形式是:
#include "StanfordID.h"
类定义末尾需要分号
类定义结束后必须有:
};
而不是只有:
}
“覆盖运算符行为”不是准确术语
这里定义的是新的重载候选,不是继承意义上的覆盖。
应称为:
重载 operator<
同时定义成员和非成员版本不是未定义行为
如果两个版本同样匹配,通常会产生编译期歧义,而不是运行时未定义行为。
为什么 operator== 和 operator!= 应当互相联系?
假设相等被定义为三个成员全部相同:
bool operator==(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.name_ == rhs.name_
&& lhs.sunet_ == rhs.sunet_
&& lhs.id_number_ == rhs.id_number_;
}
不相等应当是相等的反面:
bool operator!=(
const StanfordID& lhs,
const StanfordID& rhs
) {
return !(lhs == rhs);
}
不要重新复制所有比较条件:
bool operator!=(
const StanfordID& lhs,
const StanfordID& rhs
) {
return lhs.name_ != rhs.name_
|| lhs.sunet_ != rhs.sunet_
|| lhs.id_number_ != rhs.id_number_;
}
后一个版本在当前情况下也能工作,但会制造两份需要同步维护的逻辑。
假设以后给 operator== 增加一个新成员,却忘记修改 operator!=,两个运算符可能出现矛盾。
“一个运算符由另一个运算符推导”的思想,在课件中被称为反向规则或相反性规则(rule of contrariety)。
在 C++20 中,编译器在许多情况下可以根据 operator== 重写 != 表达式,因此不一定需要显式编写 operator!=。
但设计原则仍然不变:
相反的运算不应拥有两套彼此独立的核心判断逻辑。
练习:找出逻辑错误
class Student {
public:
bool operator==(const Student& other) const {
return id_ == other.id_;
}
bool operator!=(const Student& other) const {
return name_ != other.name_;
}
private:
int id_;
std::string name_;
};
可能出现什么问题?
答案与解释
operator== 按学号判断:
id_ == other.id_
而 operator!= 按姓名判断:
name_ != other.name_
这两个定义不是互相相反的。
例如:
a.id_ = 100
b.id_ = 100
a.name_ = Alice
b.name_ = Bob
则:
a == b
得到 true,因为学号相同。
同时:
a != b
也得到 true,因为姓名不同。
一个对象不应同时“相等”又“不相等”。
应改成:
bool operator!=(const Student& other) const {
return !(*this == other);
}
或者在 C++20 中仅定义适当的 operator==,让 != 使用语言提供的重写规则。
流插入运算符 <<
为什么它必须是非成员形式?
我们希望使用:
std::cout << sid;
左操作数是:
std::cout
它的类型是 std::ostream。
如果把 operator<< 写成 StanfordID 的成员:
class StanfordID {
public:
std::ostream& operator<<(std::ostream& out) const;
};
它对应的调用形式会更像:
sid.operator<<(std::cout)
语法方向会成为:
sid << std::cout
这不是我们想要的形式。
真正的表达式:
std::cout << sid
需要把输出流作为左操作数:
std::ostream& operator<<(
std::ostream& out,
const StanfordID& sid
);
于是可以近似理解为:
operator<<(std::cout, sid)
参数为什么这样设计?
std::ostream& operator<<(
std::ostream& out,
const StanfordID& sid
);
out 是非常量引用
输出操作会修改流的内部状态。
它需要记录:
- 当前格式设置;
- 是否发生写入错误;
- 输出位置;
- 缓冲区状态。
因此不能写成:
const std::ostream& out
也不应按值复制流对象。
正确形式是:
std::ostream& out
sid 是常量引用
输出对象不应修改对象本身,也不需要复制整个对象:
const StanfordID& sid
返回 std::ostream&
返回同一个流对象,才能继续链式输出:
std::cout << sid << '\n';
执行关系可以理解为:
第一步:
operator<<(std::cout, sid)
返回 std::cout
第二步:
返回的 std::cout << '\n'
如果返回 void:
void operator<<(std::ostream&, const StanfordID&);
第一步之后就没有流对象可供第二个 << 使用。
输出格式没有唯一答案
可以实现为简洁格式:
std::ostream& operator<<(
std::ostream& out,
const StanfordID& sid
) {
out
<< sid.name_
<< ' '
<< sid.sunet_
<< ' '
<< sid.id_number_;
return out;
}
输出:
Rachel rfer 1002
也可以实现为带标签的格式:
std::ostream& operator<<(
std::ostream& out,
const StanfordID& sid
) {
out
<< "Name: " << sid.name_
<< ", SUNet: " << sid.sunet_
<< ", ID number: " << sid.id_number_;
return out;
}
输出:
Name: Rachel, SUNet: rfer, ID number: 1002
两者都可能合理。
选择取决于使用场景:
- 面向用户展示;
- 调试信息;
- 日志记录;
- 需要被其他程序读取的稳定格式。
运算符含义应当自然,但具体格式仍然有设计空间。
练习:为什么必须返回引用?
观察:
std::cout << sid << '\n';
假设 operator<< 返回 void,会发生什么?
答案与解释
表达式从左向右执行。
首先:
std::cout << sid
调用我们定义的运算符。
如果返回 std::ostream&,结果仍然是一个输出流,因此可以继续:
返回的输出流 << '\n'
如果返回 void,第一部分结果的类型就是 void。
接下来相当于尝试:
void结果 << '\n'
这不是合法表达式,因此程序无法编译。
返回:
std::ostream&
还避免复制流对象,并确保链中的每一步操作的是同一个流。
运算符应该表达怎样的含义?
运算符拥有熟悉的符号,所以读者会自动带着预期理解代码。
例如看到:
a + b
通常会预期:
- 结果表示某种组合或相加;
a和b本身不会被意外破坏;- 运算具有接近加法的含义。
如果我们把 operator+ 写成集合删除:
result = lhs 中删除 rhs;
即使代码能够编译,读者也很难从 + 预测行为。
这种设计违反最小惊讶原则(Principle of Least Astonishment,PoLA):
程序接口的行为应尽量符合使用者根据名称和语法形成的合理预期。
运算符并不适合所有操作
假设一个 Student 类需要执行:
student.enroll(course);
student.drop(course);
student.submit(assignment);
这些动作拥有清楚的动词名称。
把它们强行写成:
student + course;
student - course;
student << assignment;
反而会模糊含义。
判断是否重载时,可以问:
- 这种类型是否天然拥有该运算?
- 读者看到符号后,能否大致预测行为?
- 操作是否与内置运算符的常见含义接近?
- 普通函数名是否会更清楚?
- 程序是否真的需要这种操作?
没有使用需求时,不必为了“让类看起来完整”而重载所有运算符。
如果程序从不直接输出某个对象,就不必急着定义 operator<<。
容易混淆的概念对比
运算符重载与普通函数重载
普通函数重载:
void print(int value);
void print(const std::string& value);
运算符重载:
Money operator+(
const Money& lhs,
const Money& rhs
);
Vector operator+(
const Vector& lhs,
const Vector& rhs
);
共同点是:
- 名称相同;
- 参数类型不同;
- 编译器通过参数类型选择版本。
区别是运算符函数拥有特殊名称和表达式语法。
运算符重载与函数覆盖
运算符重载:
bool operator<(const StanfordID& other) const;
主要发生在编译期重载决议中。
函数覆盖通常出现在继承中:
class Base {
public:
virtual void draw();
};
class Derived : public Base {
public:
void draw() override;
};
后者涉及虚函数和动态绑定。
这两个概念不应混用。
初始化与复合赋值
Money total = first + second;
这里通常创建一个新的 Money 结果,再初始化 total。
而:
total += second;
通常直接修改已有的 total。
对应的常见设计:
Money operator+(
const Money& lhs,
const Money& rhs
);
Money& Money::operator+=(
const Money& rhs
);
+ 返回新值,+= 返回被修改对象的引用。
== 与 <
它们回答不同问题。
a == b
表示两个对象是否被视为相同。
a < b
表示两个对象在某个排序规则中的先后位置。
两者不一定比较完全相同的成员,但必须认真考虑一致性。
例如,披萨订单可以规定:
==:顾客、配料、数量全部相同
< :只比较披萨片数
这符合课堂练习给出的语义,但如果把订单作为 std::map 的键,就可能出现一个问题:
Alice:6 片蘑菇
Bob: 6 片腊肠
二者都不满足:
alice < bob
bob < alice
对于有序容器,它们会被视为同一排序等价组。
因此“只按片数比较”的 < 适合回答“谁的片数更少”,却未必适合充当唯一键排序。
运算符是否合理,要结合实际使用场景判断。
综合练习:实现 PizzaOrder
课件最后给出了一个披萨订单类练习。
每个订单包含:
customer:顾客姓名
topping:配料
slices:披萨片数
需要提供读取函数:
getCustomer()
getTopping()
getSlices()
还要实现:
+= 给订单增加披萨片数
== 顾客、配料、片数全部相同时为 true
< 左订单片数少于右订单
> 左订单片数多于右订单
<< 输出订单信息
先尝试自行设计以下问题:
- 哪些运算符适合写成成员函数?
- 哪些适合写成非成员函数?
operator+=应返回什么?operator>是否需要重新直接比较片数?operator<<为什么返回std::ostream&?- 哪些函数需要
const?
参考实现
PizzaOrder.h
#ifndef PIZZA_ORDER_H
#define PIZZA_ORDER_H
#include <iosfwd>
#include <string>
class PizzaOrder {
public:
PizzaOrder(
std::string customer,
std::string topping,
int slices
);
const std::string& getCustomer() const;
const std::string& getTopping() const;
int getSlices() const;
PizzaOrder& operator+=(int extra_slices);
private:
std::string customer_;
std::string topping_;
int slices_;
};
bool operator==(
const PizzaOrder& lhs,
const PizzaOrder& rhs
);
bool operator<(
const PizzaOrder& lhs,
const PizzaOrder& rhs
);
bool operator>(
const PizzaOrder& lhs,
const PizzaOrder& rhs
);
std::ostream& operator<<(
std::ostream& out,
const PizzaOrder& order
);
#endif
PizzaOrder.cpp
#include "PizzaOrder.h"
#include <ostream>
PizzaOrder::PizzaOrder(
std::string customer,
std::string topping,
int slices
)
: customer_(customer),
topping_(topping),
slices_(slices) {}
const std::string& PizzaOrder::getCustomer() const {
return customer_;
}
const std::string& PizzaOrder::getTopping() const {
return topping_;
}
int PizzaOrder::getSlices() const {
return slices_;
}
PizzaOrder& PizzaOrder::operator+=(
int extra_slices
) {
slices_ += extra_slices;
return *this;
}
bool operator==(
const PizzaOrder& lhs,
const PizzaOrder& rhs
) {
return lhs.getCustomer() == rhs.getCustomer()
&& lhs.getTopping() == rhs.getTopping()
&& lhs.getSlices() == rhs.getSlices();
}
bool operator<(
const PizzaOrder& lhs,
const PizzaOrder& rhs
) {
return lhs.getSlices() < rhs.getSlices();
}
bool operator>(
const PizzaOrder& lhs,
const PizzaOrder& rhs
) {
return rhs < lhs;
}
std::ostream& operator<<(
std::ostream& out,
const PizzaOrder& order
) {
out
<< order.getCustomer()
<< ": "
<< order.getSlices()
<< " slices, "
<< order.getTopping();
return out;
}
这里假设传入的片数和增加数量均为非负数。
实际项目中还应在构造函数和 operator+= 中检查输入,避免订单进入负数片数状态。
main.cpp
#include "PizzaOrder.h"
#include <iostream>
int main() {
PizzaOrder alice(
"Alice",
"mushroom",
4
);
PizzaOrder bob(
"Bob",
"pepperoni",
6
);
PizzaOrder alice_copy(
"Alice",
"mushroom",
6
);
alice += 2;
std::cout << alice << '\n';
std::cout << bob << '\n';
std::cout << std::boolalpha;
std::cout
<< "alice == alice_copy: "
<< (alice == alice_copy)
<< '\n';
std::cout
<< "alice < bob: "
<< (alice < bob)
<< '\n';
std::cout
<< "alice > bob: "
<< (alice > bob)
<< '\n';
}
编译命令
g++ -std=c++20 main.cpp PizzaOrder.cpp -o main
程序输出:
Alice: 6 slices, mushroom
Bob: 6 slices, pepperoni
alice == alice_copy: true
alice < bob: false
alice > bob: false
综合程序逐步执行
初始状态
alice
┌──────────────────────┐
│ customer_ = Alice │
│ topping_ = mushroom │
│ slices_ = 4 │
└──────────────────────┘
bob
┌──────────────────────┐
│ customer_ = Bob │
│ topping_ = pepperoni│
│ slices_ = 6 │
└──────────────────────┘
alice_copy
┌──────────────────────┐
│ customer_ = Alice │
│ topping_ = mushroom │
│ slices_ = 6 │
└──────────────────────┘
执行 alice += 2
表达式:
alice += 2;
可以理解为:
alice.operator+=(2);
函数调用时:
this → alice
extra_slices = 2
执行:
slices_ += extra_slices;
状态变化:
调用前:
alice.slices_ = 4
调用后:
alice.slices_ = 6
随后:
return *this;
*this 表示当前的 alice 对象。
返回类型是:
PizzaOrder&
所以返回的是 alice 本身的引用,不是一个新复制品。
这允许链式操作:
(alice += 2) += 1;
不过为了可读性,实际程序通常不必刻意写得这么紧凑。
执行相等比较
alice == alice_copy
调用:
operator==(alice, alice_copy)
依次比较:
customer:
Alice == Alice → true
topping:
mushroom == mushroom → true
slices:
6 == 6 → true
三个条件通过 && 连接,因此最终结果为:
true
执行小于比较
alice < bob
比较:
alice.getSlices() < bob.getSlices()
也就是:
6 < 6
结果:
false
执行大于比较
alice > bob
operator> 没有重新编写片数比较,而是复用 <:
return rhs < lhs;
因此:
alice > bob
转化为:
bob < alice
比较:
6 < 6
结果仍是:
false
当两个订单片数相同时,二者既不小于对方,也不大于对方。
练习:预测修改后的输出
把 main() 改成:
int main() {
PizzaOrder first(
"Mishi",
"cheese",
3
);
PizzaOrder second(
"Kass",
"mushroom",
5
);
first += 4;
std::cout << first << '\n';
std::cout << std::boolalpha;
std::cout << (first < second) << '\n';
std::cout << (first > second) << '\n';
}
预测输出。
答案与解释
初始状态:
first.slices_ = 3
second.slices_ = 5
执行:
first += 4;
之后:
first.slices_ = 7
所以第一次输出:
Mishi: 7 slices, cheese
小于比较:
first < second
计算:
7 < 5
结果为:
false
大于比较:
first > second
实现为:
second < first
计算:
5 < 7
结果为:
true
完整输出:
Mishi: 7 slices, cheese
false
true
初学者最容易出现的错误
忘记成员函数末尾的 const
错误或受限的版本:
bool operator<(const StanfordID& other) {
return id_number_ < other.id_number_;
}
这会导致常量对象无法比较:
const StanfordID a(...);
const StanfordID b(...);
a < b;
更合适的是:
bool operator<(
const StanfordID& other
) const;
把参数按值传递
bool operator<(StanfordID other) const;
每次比较都要复制右操作数。
通常应使用:
bool operator<(
const StanfordID& other
) const;
非成员运算符只写一个参数
错误:
bool operator<(const StanfordID& rhs);
非成员函数没有隐含的 this,因此二元 < 需要显式接收两个操作数:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
在非成员定义前加类作用域
友元非成员函数不是成员。
错误:
bool StanfordID::operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
// ...
}
正确:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
) {
// ...
}
认为 friend 会把函数变成成员
下面的函数:
friend bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
仍然是非成员函数。
friend 只给予私有访问权限。
输出运算符返回 void
错误:
void operator<<(
std::ostream& out,
const StanfordID& sid
);
它会破坏:
std::cout << sid << '\n';
应返回:
std::ostream&
输出运算符按值接收流
错误:
std::ostream operator<<(
std::ostream out,
const StanfordID& sid
);
流对象通常不能这样复制,而且我们需要持续操作原来的流。
应使用:
std::ostream& out
给运算符定义意外含义
例如:
Set operator+(
const Set& lhs,
const Set& rhs
) {
return subtract(lhs, rhs);
}
+ 却执行删除,会让使用者难以预测。
这种情况下应选择:
subtract(lhs, rhs)
或者使用语义更合适的运算符。
比较依据会发生变化
如果对象作为 std::map 的键,插入后却修改了用于排序的成员,容器原先建立的顺序可能失去意义。
标准容器中的键通常会被视为不可修改,正是为了保护排序结构。
设计比较运算符时,应选择稳定且适合对象身份的成员。
只为了“完整”而重载所有运算符
一个类不需要同时支持:
+ - * / < > == != << >> ++ --
只实现真实业务中有自然含义并且确实会使用的操作。
一组综合判断题
题目一
class Point {
public:
bool operator<(const Point& other) const {
return x_ < other.x_;
}
private:
int x_;
int y_;
};
两个 x_ 相同但 y_ 不同的点放进 std::set<Point>,一定能够同时保留吗?
答案与解释
不一定。
如果:
a.x_ == b.x_
则:
a < b
为 false,同时:
b < a
也为 false。
对有序容器来说,它们在比较关系中属于同一等价组,std::set 可能认为第二个点与第一个键重复。
若希望每个不同坐标都能作为不同键,需要在 x_ 相同时继续比较 y_:
bool operator<(const Point& other) const {
if (x_ != other.x_) {
return x_ < other.x_;
}
return y_ < other.y_;
}
题目二
class Counter {
public:
Counter operator+=(int amount) {
value_ += amount;
return *this;
}
private:
int value_ = 0;
};
这段代码能否工作?返回类型有什么改进空间?
答案与解释
它可能能够编译并修改对象。
但返回类型是:
Counter
意味着返回一个新的对象副本。
复合赋值运算符通常返回当前对象的引用:
Counter& operator+=(int amount) {
value_ += amount;
return *this;
}
这更符合内置复合赋值运算符的行为,也避免不必要的复制。
题目三
bool operator<(
StanfordID lhs,
StanfordID rhs
);
它与使用常量引用相比有什么问题?
答案与解释
两个参数都按值传递,因此每次比较都会创建两个参数对象。
如果 StanfordID 包含字符串或其他较大成员,这些复制没有必要。
更合适的是:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
这样:
- 不复制操作数;
- 不修改操作数;
- 可以接收常量对象。
题目四
std::ostream& operator<<(
std::ostream& out,
StanfordID& sid
);
为什么 sid 更适合写成 const StanfordID&?
答案与解释
输出对象通常只是读取其状态,不应修改它。
如果参数是:
StanfordID& sid
则下面的常量对象无法输出:
const StanfordID sid(...);
std::cout << sid;
改为:
const StanfordID& sid
既避免复制,又允许输出常量对象,并清楚表达函数不会修改 sid。
本节知识的完整串联
我们最初遇到的问题是:
min_value(rachel, preston)
无法编译。
原因不是模板失效,而是模板内部需要执行:
rachel < preston
而 StanfordID 没有告诉编译器怎样比较。
于是引入运算符重载:
bool operator<(...);
它让自定义类型能够参与已有的 C++ 表达式。
成员版本:
bool StanfordID::operator<(
const StanfordID& rhs
) const;
把左操作数放在隐含的 this 中。
非成员版本:
bool operator<(
const StanfordID& lhs,
const StanfordID& rhs
);
显式接收左右两个操作数。
当左边可能是其他类型,或者两个操作数地位对称时,非成员形式通常更灵活。
非成员函数默认不能访问私有数据,因此可以:
- 使用公开读取函数;
- 或把它声明为
friend。
运算符不仅让语法更短,还告诉读者这个类型拥有什么性质:
operator() → 对象可以调用
operator< → 对象可以排序
operator+ → 对象可以相加或组合
operator<< → 对象可以写入输出流
operator+= → 对象可以原地累加
但这种表达力也带来了责任。
一个运算符只有在含义自然、稳定、符合预期时才应该存在。
本节课的最终结论
运算符重载不是创建新的运算符,而是为已有运算符补充自定义类型的行为。
它能够让:
min_value(a, b)
std::map<Key, Value>
std::cout << object
first + second
order += 2
这些通用代码自然地支持我们自己编写的类。
定义运算符时,需要同时考虑两个层面。
技术层面包括:
- 参数类型是否正确;
- 是否使用常量引用;
- 成员函数是否需要末尾
const; - 返回类型是否支持预期语法;
- 成员和非成员版本是否产生歧义;
- 非成员函数是否具有所需访问权限。
语义层面包括:
- 比较规则是否稳定;
- 运算符含义是否容易预测;
- 是否符合内置运算符带来的直觉;
- 是否真的比普通函数更清楚;
- 是否适合标准库容器和算法的要求。
运算符重载的目标不是让类拥有尽可能多的符号,而是让类的使用方式更自然。
原课件配套练习
课件提供了两组在线练习:
https://106b.vercel.app/cs106l-operator-overloading
https://106b.vercel.app/cs106l-operator-overloading-2
从本节课自然留下的问题
现在,我们已经能让对象进行比较、输出和相加。
但新的问题也随之出现:
StanfordID smaller = min_value(rachel, preston);
这里返回对象时是否发生了复制?
Money result = first + second;
临时结果对象什么时候被创建,又什么时候销毁?
std::vector<MyClass> objects;
容器扩容时,自定义对象会怎样被搬到新的存储位置?
PizzaOrder copy = original;
默认复制行为是否一定符合类的设计?
本课件没有给出下一讲的具体标题,因此不预设后续课程安排。从知识依赖来看,运算符重载之后最自然的深入方向,是继续研究对象的复制、移动、赋值和生命周期。
因为当一个类型开始像标准库类型一样参与各种表达式时,我们不仅要定义“它能做什么”,还要确保:
对象在创建、复制、移动、赋值和销毁过程中始终保持正确状态。