Lecture 12 (Week 6 - Thursday): 运算符重载 (Operator Overloading)
Lecture 12 (Week 6 - Thursday): 运算符重载 (Operator Overloading)
概述
本讲解决一个根本问题:如何让自定义类型获得与内置类型一样的运算符语法。开场的课堂回顾(functor、算法、ranges/views,以 Soundex 的经典版与 ranges 版对照演示)把前两讲串起来,随即抛出一句贯穿全课的名言:”Operators allow you to convey meaning about types that functions don’t“(运算符能传达函数传达不了的类型含义)。课程以 StanfordID 为例:std::map<K,V> 要求 K 有 operator<(查找依赖它),min<StanfordID> 也需要 <——于是我们学习成员/非成员两种重载方式、friend 关键字、operator==/!= 的 rule of contrariety、operator<< 流插入,以及最重要的设计哲学 Principle of Least Astonishment(最少惊讶原则)。本讲直接服务于 A5: Treebook(为 User 类实现 operator<<、operator+=、operator<)。
核心特性与语法详解
1. 为什么需要运算符重载(动机)
- 定义与目的:运算符是”对值/对象/类型执行操作并产生新值或效果”的符号。对自定义类型重载运算符,就是给
+、<、<<等符号赋予我们定义的行为,让类型表达出”数值般/可比较/可打印”的含义。 - 核心语法:
return_type operator<symbol>(parameter_list); - 设计意图与最佳实践:
money.add(otherMoney)读起来像随机函数调用,而money + otherMoney一眼就传达”钱可以相加”的数值语义——这就是”运算符传达类型含义”。std::map<K,V>与std::set<K>都依赖K的operator<做有序存储与查找,std::min同样依赖<。重载运算符是解锁这些库功能的钥匙。
2. 哪些运算符可以重载
- 定义与目的:C++ 允许重载绝大多数运算符(算术、比较、位运算、赋值、下标、调用、流插入等)。
- 核心语法:
bool operator<(const T& other) const;、T operator+(const T& rhs) const;、T& operator+=(const T& rhs);、T& operator[](size_t i);、bool operator()(int x) const;等。 - 设计意图与最佳实践:不能重载的运算符只有少数几个,必须记住:作用域解析
::、三目?:、成员访问.、成员指针访问.*、sizeof()、typeid()、各种cast。原因:这些运算符的语义与对象内存布局/类型系统深度绑定,重载会破坏语言基础。
3. 成员重载 vs 非成员重载
- 定义与目的:运算符可以在类内部声明(成员重载),也可以写成类外的自由函数(非成员重载)。
- 核心语法:
// 成员重载:左操作数是 *this,只需一个参数 bool StanfordID::operator<(const StanfordID& other) const { ... } // 非成员重载:左右操作数都作为参数传入 bool operator<(const StanfordID& lhs, const StanfordID& rhs) { ... } - 设计意图与最佳实践:STL 更偏爱非成员重载,也更符合惯用 C++,理由有二:①允许左操作数是非类类型(如
5 + myInt的5不是类,无法调用成员函数);②可以对自己不拥有的类重载(如std::string与自定义类型比较)。注意:同时定义成员与非成员版本的同签名运算符是未定义行为/歧义(编译器不知道该用哪个)。成员重载的优点是可以直接访问this->与私有成员。
4. friend 关键字
- 定义与目的:
friend允许非成员函数(或另一个类)访问某个类的私有成员。非成员重载没有this,默认碰不到private字段,friend 正好补上这个缺口。 - 核心语法:在目标类的头文件里声明
friend bool operator<(const StanfordID& lhs, const StanfordID& rhs);,然后在类外定义该函数。 - 设计意图与最佳实践:friend 声明放在类内(通常
public区或private区皆可,位置不影响语义)。若实现只依赖公有接口(getter),就不需要 friend(幻灯片明确:”friend 并非必需,如果我们没碰私有成员”)。friend 是”最小授权”的例外——能用公有接口就别开后门。
5. operator== 与 !=:Rule of Contrariety
- 定义与目的:相等性判断是自定义类型最常用的语义之一。Rule of contrariety(对立规则):实现了
==就用它定义!=,反之亦然——两个运算符必须互为否定,绝不能各自独立实现导致语义漂移。 - 核心语法:
bool StanfordID::operator==(const StanfordID& other) const { return name == other.name && sunet == other.sunet && idNumber == other.idNumber; } bool StanfordID::operator!=(const StanfordID& other) const { return !(*this == other); // 一句话搞定,保证语义一致 } - 设计意图与最佳实践:
!=永远是!(*this == other)。C++20 起甚至可以让编译器自动补齐(默认比较,见”与旧标准对比”)。
6. operator« 流插入
- 定义与目的:让
std::cout << myObject;成立。签名固定:std::ostream& operator<<(std::ostream& out, const T& obj);——第一个参数是输出流,返回流本身以支持链式cout << a << b。 - 核心语法:
std::ostream& operator<<(std::ostream& out, const StanfordID& sid) { out << sid.name << " " << sid.sunet; return out; } - 设计意图与最佳实践:实现细节(分隔符、字段顺序、要不要标签)取决于你打算怎么用这个输出——调试打印 vs 用户界面 vs 序列化,格式大不相同(幻灯片展示了两种风格)。通常作为非成员函数 + friend(需要访问私有字段),或只走公有 getter。
7. Principle of Least Astonishment(PoLA)
- 定义与目的:运算符的目的是传达类型含义,因此语义必须显而易见:
+就是相加、<就是排序/比较,功能上应与对应运算”合理相似”。 - 核心语法:设计时的检查清单(不是语法)。
- 设计意图与最佳实践:绝不要定义
operator+做集合减法(幻灯片原话)。如果某操作的含义不明显,就别用运算符,写个具名函数(如merge(...))。此外:只在需要时重载(不用流就别写<<);重载了==就顺手补!=;重载<时保证严格弱序(std::set/std::map/std::sort都依赖它)。
代码示例与逐步解说(核心)
示例 1:成员 operator< —— 让 min 可用(C++17)
代码
#include <iostream>
#include <string>
#include <utility>
class StanfordID {
public:
StanfordID(std::string name, std::string sunet, int idNumber)
: name_(std::move(name)), sunet_(std::move(sunet)), idNumber_(idNumber) {}
// 成员运算符重载:左操作数是 *this,rhs 是右操作数
bool operator<(const StanfordID& other) const {
return idNumber_ < other.idNumber_; // 按学号比较
}
int getIdNumber() const { return idNumber_; }
private:
std::string name_;
std::string sunet_;
int idNumber_;
};
// Lecture 10 的模板 min:内部只用 a < b
template <typename T>
T min(const T& a, const T& b) { return a < b ? a : b; }
int main() {
StanfordID preston{ "Preston", "pseay", 106 };
StanfordID rachel{ "Rachel", "rfern", 107 };
auto m = min(preston, rachel); // 之前编译错误,现在可以了!
std::cout << m.getIdNumber() << "\n"; // 106
}
代码做什么:给 StanfordID 实现成员 operator<(按 idNumber 比较),于是 Lecture 10 的模板 min 实例化后能正常编译运行,返回学号较小者。
特性机制解说:没有 operator< 时,min<StanfordID> 会被实例化成 StanfordID min(const StanfordID& a, const StanfordID& b) { return a < b ? a : b; },编译器在函数体内的 a < b 处报 invalid operands to binary expression ('const StanfordID' and 'const StanfordID')——因为模板实例化发生在编译期,错误”迟至实例化之后”才暴露。重载 operator< 后,a < b 被解析为对 operator< 的调用。成员重载的机制:a < b 等价于 a.operator<(b),左操作数绑定到 this,右操作数绑定到参数 other;声明为 const 成员函数表示比较不会修改对象(this 是 const)。这也是为什么 std::map<K,V> 要求 K 有 operator<——红黑树的所有查找/插入都建立在 < 之上。
示例 2:非成员 operator< + friend —— 与 std::set 协作(C++17)
代码
#include <iostream>
#include <set>
#include <string>
#include <utility>
class StudentID {
public:
StudentID(std::string name, int id) : name_(std::move(name)), id_(id) {}
int getId() const { return id_; }
// 在类内声明友元:允许这个非成员函数访问私有成员
friend bool operator<(const StudentID& lhs, const StudentID& rhs);
private:
std::string name_;
int id_;
};
// 非成员重载:左右操作数都作为参数
bool operator<(const StudentID& lhs, const StudentID& rhs) {
return lhs.id_ < rhs.id_; // 借助 friend 直接访问私有 id_
}
int main() {
std::set<StudentID> students; // std::set 要求元素类型有 operator<
students.insert(StudentID{ "Rachel", 107 });
students.insert(StudentID{ "Preston", 106 });
students.insert(StudentID{ "Anna", 106 }); // 与 Preston 学号相同 → 视为"相等"
std::cout << "size = " << students.size() << "\n"; // 2(Anna 没进去)
for (const auto& s : students)
std::cout << s.getId() << "\n"; // 106, 107
}
代码做什么:改用非成员 operator<(lhs、rhs 双参数)+ friend 访问私有 id_,把 StudentID 放进 std::set;学号相同的两个对象被 set 视为等价,第二个被丢弃。
特性机制解说:非成员重载没有 this,lhs < rhs 直接调用自由函数 operator<(lhs, rhs),两个操作数地位对等——这让”左操作数是非类类型”(如 3 < myObj)成为可能,也是 STL 偏爱它的原因。但自由函数无法访问 private 成员,所以在类内用 friend bool operator<(...) 声明”破例授权”;若实现只走公有 getter(lhs.getId() < rhs.getId()),friend 就不是必需的。std::set 内部用 !(a < b) && !(b < a) 判定等价性(严格弱序):Preston(106) 与 Anna(106) 互相都不小于对方,被视为同一元素,Anna 未被插入。绝不要同时定义成员版和非成员版的同一签名运算符——a < b 会同时匹配 a.operator<(b) 与 operator<(a, b),造成歧义/未定义行为(幻灯片:”ambiguity badddddd”)。
示例 3:operator== 与 Rule of Contrariety(C++17/20)
代码
#include <algorithm>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class User {
public:
User(std::string name, int age) : name_(std::move(name)), age_(age) {}
bool operator==(const User& other) const {
return name_ == other.name_ && age_ == other.age_;
}
// Rule of contrariety:!= 永远定义为 == 的取反
bool operator!=(const User& other) const {
return !(*this == other);
}
std::string name() const { return name_; }
int age() const { return age_; }
private:
std::string name_;
int age_;
};
int main() {
User a{ "Alice", 21 };
User b{ "Alice", 21 };
User c{ "Alice", 20 };
std::cout << (a == b) << " " << (a != c) << "\n"; // 1 1
std::vector<User> users{ { "Bob", 19 }, { "Alice", 21 }, { "Carol", 20 } };
std::sort(users.begin(), users.end(),
[](const User& x, const User& y) { return x.age() < y.age(); });
for (const auto& u : users) std::cout << u.name() << " "; // Bob Carol Alice
std::cout << "\n";
}
代码做什么:实现 operator==(名字与年龄都相同才算相等),并用 !(*this == other) 一句话实现 operator!=;随后用 lambda 比较器按年龄排序用户。
特性机制解说:== 的语义由你定义——对 User 而言”完全相同的两个人”就是两个字段都相等。rule of contrariety 的核心是保证 != 恒等于 !==:如果分别独立实现,很容易出现 a == b 为真但 a != b 也为真的逻辑 bug。(*this == other) 里 *this 是左操作数(成员重载),递归调用自身重载,外层 ! 取反——一句话、零重复。C++20 进一步支持 operator== 的参数反转重写(a == b 找不到时尝试 b == a)以及默认比较(friend bool operator==(const User&, const User&) = default; 逐个成员比较,见下节)。排序这里用的是 lambda 比较器而非重载 <——两种做法各有适用场景:比较器是一次性的、局部的;operator< 是类型固有的全局语义(std::set/std::map 需要后者)。
示例 4:operator« 流插入(C++17)
代码
#include <iostream>
#include <ostream>
#include <string>
#include <utility>
#include <vector>
class User {
public:
User(std::string name, std::vector<std::string> friends)
: name_(std::move(name)), friends_(std::move(friends)) {}
// 友元声明:非成员 operator<< 需要访问私有成员
friend std::ostream& operator<<(std::ostream& out, const User& user);
private:
std::string name_;
std::vector<std::string> friends_;
};
std::ostream& operator<<(std::ostream& out, const User& user) {
out << "User(name=" << user.name_ << ", friends=[";
for (size_t i = 0; i < user.friends_.size(); ++i) {
if (i > 0) out << ", ";
out << user.friends_[i];
}
out << "])";
return out; // 必须返回流本身,才能链式 cout << a << b
}
int main() {
User alice{ "Alice", { "Bob", "Charlie" } };
std::cout << alice << "\n";
// User(name=Alice, friends=[Bob, Charlie])
}
代码做什么:以 friend 非成员函数实现 operator<<,让 std::cout << alice 打印出 User(name=Alice, friends=[Bob, Charlie])(正是 A5 要求的输出格式)。
特性机制解说:<< 的重载形态很特殊——左操作数 std::ostream 是我们不拥有的类(无法给它加成员函数),所以必须用非成员重载(这正是”非成员重载可以对不拥有的类操作”的典型例子);而实现要读 name_、friends_ 私有字段,所以配 friend。签名固定为 std::ostream& operator<<(std::ostream& out, const T& obj):返回 out 本身是为了支持 std::cout << alice << "\n" 的链式调用——<< 是左结合二元运算符,(cout << alice) << "\n",前一个表达式的结果必须是流才能继续。格式(friends=[Bob, Charlie] 的逗号拼接)属于”使用方式决定实现”的范畴:这里选的是人类可读的调试/展示格式;若要做序列化可能换成无空格紧凑格式。若不需要打印,就别重载 <<(PoLA 的”只在需要时重载”)。
示例 5:综合练习——Pizza Order 类(C++17,课堂练习题)
代码
#include <iostream>
#include <ostream>
#include <string>
#include <utility>
class PizzaOrder {
public:
PizzaOrder(std::string customer, std::string topping, int slices)
: customer_(std::move(customer)), topping_(std::move(topping)), slices_(slices) {}
// +=:给订单增加披萨片数(返回自身引用,与内置 += 一致)
PizzaOrder& operator+=(int extra) {
slices_ += extra;
return *this;
}
// ==:三要素完全相同
bool operator==(const PizzaOrder& other) const {
return slices_ == other.slices_ && customer_ == other.customer_
&& topping_ == other.topping_;
}
// <:按片数比较
bool operator<(const PizzaOrder& other) const {
return slices_ < other.slices_;
}
// >:借 < 实现(对称写法,保持一致性)
bool operator>(const PizzaOrder& other) const {
return other < *this;
}
std::string customer() const { return customer_; }
std::string topping() const { return topping_; }
int slices() const { return slices_; }
private:
std::string customer_;
std::string topping_;
int slices_;
};
// 非成员 operator<<:只走公有 getter,不需要 friend
std::ostream& operator<<(std::ostream& out, const PizzaOrder& p) {
return out << p.customer() << ": " << p.slices() << " slices, " << p.topping();
}
int main() {
PizzaOrder mine{ "Alice", "pepperoni", 4 };
mine += 2; // 现在 6 片
std::cout << mine << "\n"; // Alice: 6 slices, pepperoni
PizzaOrder yours{ "Bob", "mushroom", 6 };
std::cout << (mine == yours ? "same" : "different") << "\n"; // different
std::cout << (yours < mine ? "yours < mine" : "yours >= mine") << "\n"; // yours >= mine
}
代码做什么:把课堂练习 Pizza Order 类补全:+= 加片数、== 三要素全等、</> 按片数比较、<< 打印订单,覆盖本讲大部分重载形态。
特性机制解说:这一例浓缩了本讲要点。①operator+= 返回 PizzaOrder&(自身引用)——与内置 += 语义一致(a += b 的结果就是 a),这也是 A5 中 operator+= 的签名模板;②== 用 && 组合所有字段,是”全等”的惯用实现;③> 用 other < *this 实现而非另写逻辑——既符合 rule of contrariety 的姊妹精神(比较运算符互为镜像),也保证 > 与 < 永不矛盾;④operator<< 只调用公有 getter,因此不需要 friend——幻灯片明确认可这种写法(”此时 friend 不是必需的,因为我们没碰私有成员”)。最后对照课堂设计准则检查:+= 增加片数、< 比较片数,全部符合直觉(PoLA)——如果把 < 定义成”比较披萨直径”,读者就会一脸问号。
与旧标准(如C++98)的对比
- 运算符重载本身:是 C++98 就有的经典特性(C++ 继承自 C 的运算符体系 + 类机制),本讲的语法在 C++98 下完全成立。所以这一节的重点不是”新特性替代旧写法”,而是C++20 对比较运算符的现代化。
- C++20 三路比较(spaceship)
<=>:写auto operator<=>(const User&) const = default;即可一键生成==、!=、<、<=、>、>=全部六个比较运算符(按成员字典序比较),把 rule of contrariety 手工劳动自动化:#include <compare> struct Point { int x, y; auto operator<=>(const Point&) const = default; // C++20 }; // 自动获得 == != < <= > >=,Point{1,2} < Point{1,3} 为 true - C++20 比较重写:
a == b找不到匹配时,编译器会尝试把参数反转成b == a(用另一个参数的operator==),对称比较不再需要写两遍;<也有类似的<=>重写规则。 - 与其他语言的对比:Java 完全不支持运算符重载(只能
compareTo),C#/Python 支持但语法不同(Python 用__eq__、__lt__等特殊方法)。C++ 选择”符号重载 + 与内置类型语法统一”的路线,也因此背负 PoLA 的设计责任。 - 与 lambda/ranges 的关系:
std::sort、std::map等现代 STL 用法之所以能优雅工作,正是靠本讲的运算符语义(<、==、<<)——functors(Lecture 11)的operator()本质上也是运算符重载的一种。
关键要点
- 运算符是”类型含义”的载体:
min、std::set/std::map、std::cout都靠operator</==/<<工作;重载运算符 = 解锁库功能。 - 非成员重载优先:STL 更偏爱它(左操作数可为非类类型、可对不拥有的类重载);需要私有成员时用
friend。别同时定义成员与非成员同签名版本(歧义)。 - Rule of contrariety:
!=永远写!(*this == other);比较运算符成对实现且互为镜像(>用other < *this)。 - PoLA:语义必须显而易见:
+不能做减法;含义不明显就写具名函数;只在需要时重载。 operator<<签名固定:std::ostream& operator<<(std::ostream&, const T&),必须返回流以支持链式输出。
常见陷阱与注意事项
- 成员与非成员同签名并存:
bool operator<(const StanfordID&) const;(成员)与bool operator<(const StanfordID&, const StanfordID&);(非成员)同时存在时,a < b匹配两个候选,产生歧义/未定义行为——二选一。 - PoLA 违背:给
operator+塞入减法/拼接等”顺手”语义。别人读代码时会把a + b理解为加法——语义违反直觉就是 bug 之源(幻灯片原话:”你不想定义 operator+ 做集合减法”)。 operator<破坏严格弱序:std::set/std::map/std::sort都假设<满足严格弱序(非自反、传递、等价性一致)。若<只比较部分字段(如只看年龄不看姓名),不同对象可能互相”等价”,元素会被静默丢弃(示例 2 的 Anna)。==/!=语义漂移:分别独立实现==与!=,忘了取反或漏字段,导致a == b与a != b同时为真——务必用 rule of contrariety 一句话定义。operator<<忘记返回流 / 忘加 friend:不返回out则链式输出编译失败;非成员实现要访问私有字段却忘了friend,编译器报”private 无法访问”。- 重载了
+=却忘了返回*this:复合赋值运算符按惯例返回自身引用(User&),便于a = b += c式链式写法;返回void会破坏惯例(虽然能编译)。
关联作业提示
本讲直接服务于 A5: Treebook(社交网络 User 类),三个部分分别对应本讲知识点:
- Part 1(Viewing Profiles):实现
operator<<——必须声明为User类的 friend 函数(user.h中friend std::ostream& operator<<(std::ostream& out, const User& user);),并在user.cpp定义。因为要遍历_friends私有字段,friend 必不可少(对应示例 4;A5 明确要求输出格式User(name=Alice, friends=[Bob, Charlie]),不要打印换行符)。 - Part 2(Unfriendly Behaviour):实现特殊成员函数(析构、拷贝构造、拷贝赋值,删除移动构造/移动赋值)——这是对 Lecture 12 之前”special member functions”内容的巩固,注意深拷贝
_friends指针数组(分配新内存 + 逐个复制 + 更新_size/_capacity/_name)。 - Part 3(Always Be Friending):实现两个成员函数运算符——
User& operator+=(User& rhs)(把对方加进自己的好友列表,必须对称:alice += charlie后 Charlie 的好友列表也要有 Alice,返回*this引用)与bool operator<(const User& rhs) const(按名字字典序比较,让User能放进std::set——正是本讲示例 2 的机制:std::set依赖operator<做有序存储)。
顺带一提:你在 A4: Ispell 里其实已经”用过”运算符重载了——Corpus = std::set<Token> 要求 Token 具备 operator<(讲义提供的 Token 已实现)。学完本讲再看 A4,你会理解这份代码为什么存在。复习重点:成员 vs 非成员的选择依据(A5 明确要求成员函数)、friend 的声明位置与必要性、operator+= 返回自身引用的惯例、以及”为 std::set 提供严格弱序的 <“。
