Lecture 12 (Week 6 - Thursday): 运算符重载 (Operator Overloading)

目录 · ← l11 · l13 →

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> 要求 Koperator<(查找依赖它),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> 都依赖 Koperator< 做有序存储与查找,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 + myInt5 不是类,无法调用成员函数);②可以对自己不拥有的类重载(如 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 成员函数表示比较不会修改对象(thisconst)。这也是为什么 std::map<K,V> 要求 Koperator<——红黑树的所有查找/插入都建立在 < 之上。

示例 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<lhsrhs 双参数)+ friend 访问私有 id_,把 StudentID 放进 std::set;学号相同的两个对象被 set 视为等价,第二个被丢弃。

特性机制解说:非成员重载没有 thislhs < 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::sortstd::map 等现代 STL 用法之所以能优雅工作,正是靠本讲的运算符语义(<==<<)——functors(Lecture 11)的 operator() 本质上也是运算符重载的一种。

关键要点

  • 运算符是”类型含义”的载体minstd::set/std::mapstd::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 == ba != 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.hfriend 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 提供严格弱序的 <“。