K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 12 讲:运算符重载

让自定义类型拥有自然的 C++ 语法,掌握成员与非成员运算符设计。

CS106L 第 12 讲:运算符重载 的封面
C++ · CLASS-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),就是让我们为自定义类型补充这些操作规则。

学完后,我们希望能够回答:

  1. 为什么模板和标准库需要类型支持某些运算符?
  2. a < b 和普通函数调用之间有什么关系?
  3. 怎样为类定义 operator<
  4. 成员运算符和非成员运算符有什么区别?
  5. 为什么 std::cout << object 通常必须使用非成员函数?
  6. 非成员函数怎样访问类的私有成员?
  7. 哪些运算符可以重载,哪些不可以?
  8. 怎样避免定义出令人困惑的运算符?
  9. 如何完整实现一个拥有多个运算符的类?

学习本节课所需的前置知识

本节会用到以下已有知识:

  • 类与对象;
  • publicprivate
  • 构造函数;
  • 成员函数;
  • const 成员函数;
  • 引用和 const 引用;
  • 函数模板;
  • std::mapstd::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;
}

因为整数支持 <,所以程序能够编译。

但“模板可以接收任意类型”并不意味着“任意类型都一定符合模板要求”。

这个模板至少要求:

  1. 表达式 a < b 必须合法;
  2. a < b 的结果必须能够作为条件判断;
  3. ab 必须能够用来构造返回的 T
  4. 从语义上说,类型 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

  1. int
  2. std::string
  3. 一个没有定义 <StanfordID
  4. 一个定义了 <,但 < 每次随机返回结果的类型
  5. 一个按照学号比较的 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    │
└──────────────────────┘

rachelpreston 都没有被修改。


练习:找出左操作数和右操作数

给出:

StanfordID first("First", "first", 300);
StanfordID second("Second", "second", 500);

bool result = first < second;

回答:

  1. 哪个对象是 this 指向的对象?
  2. 哪个对象被绑定到参数 other
  3. 比较的整数表达式是什么?
  4. result 是什么?
  5. 是否发生了参数复制?

答案与解释

表达式:

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

这里:

  • += 修改左对象,写成成员;
  • + 创建新结果,写成非成员;
  • + 复用了 += 的逻辑。

这个模式可以减少重复代码。


练习:成员还是非成员?

判断下面的运算符更适合使用哪一种形式,并说明原因。

  1. pizza += 2
  2. first_id < second_id
  3. std::cout << first_id
  4. function_object(10)
  5. 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 声明放在哪个区域?

友元声明写在 publicprivateprotected 区域都能获得相同的访问权限。

例如下面也合法:

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

这是:

  1. 链接错误;
  2. 运行时错误;
  3. 未定义行为;
  4. 编译期重载歧义;
  5. 逻辑错误但可以正常编译。

答案与解释

正确答案是:

编译期重载歧义

对于:

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

通常会预期:

  • 结果表示某种组合或相加;
  • ab 本身不会被意外破坏;
  • 运算具有接近加法的含义。

如果我们把 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;

反而会模糊含义。

判断是否重载时,可以问:

  1. 这种类型是否天然拥有该运算?
  2. 读者看到符号后,能否大致预测行为?
  3. 操作是否与内置运算符的常见含义接近?
  4. 普通函数名是否会更清楚?
  5. 程序是否真的需要这种操作?

没有使用需求时,不必为了“让类看起来完整”而重载所有运算符。

如果程序从不直接输出某个对象,就不必急着定义 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
<   左订单片数少于右订单
>   左订单片数多于右订单
<<  输出订单信息

先尝试自行设计以下问题:

  1. 哪些运算符适合写成成员函数?
  2. 哪些适合写成非成员函数?
  3. operator+= 应返回什么?
  4. operator> 是否需要重新直接比较片数?
  5. operator<< 为什么返回 std::ostream&
  6. 哪些函数需要 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;

默认复制行为是否一定符合类的设计?

本课件没有给出下一讲的具体标题,因此不预设后续课程安排。从知识依赖来看,运算符重载之后最自然的深入方向,是继续研究对象的复制、移动、赋值和生命周期。

因为当一个类型开始像标准库类型一样参与各种表达式时,我们不仅要定义“它能做什么”,还要确保:

对象在创建、复制、移动、赋值和销毁过程中始终保持正确状态。