重载运算与类型转换

掌握运算符重载——算术/关系/赋值/下标/递增递减/函数调用运算符,以及类型转换运算符——让你的类像内置类型一样自然使用

学习目标

  • 能解释运算符重载的成员 vs 非成员选择规则——哪些必须是成员、哪些推荐非成员、为什么赋值/下标/调用/箭头必须是成员函数
  • 能根据类的语义正确实现输入输出运算符(<< >>)、算术/关系运算符、赋值运算符、下标运算符、递增递减运算符和函数调用运算符——知道每一步返回值为什么是这个类型、return *this 为什么是链式调用的关键
  • 能回答:SalesData 类需要支持 cout << s 输出、s1 + s2 加法、s1 == s2 比较、s[0] 取字符——写出对应的四个运算符重载声明(成员或非成员),并说明每一步的设计理由。

机制总览

重载运算与类型转换:机制路径

  1. 1

    直觉:让你的类用起来像内置类型

    你写 int a = 3 + 5; 、 cout << 42; 、 if (x == y) 时,这些 + << == 符号背后是语言内置的一套规则——编译器知道 int 和 int 怎么加、 int 和 ostream 怎么输出。现在你要写自己的类了—— SalesData 、 Complex 、 …

  2. 2

    官方 Chapter 14 的运算符与转换契约

    重载只改变“某个既有运算符遇到类类型时调用哪个函数”,不会改变语法。至少一个操作数必须是类或枚举类型;不能发明新符号,也不能改变操作数个数、优先级或结合性。

  3. 3

    运算符重载的基本规则:成员还是普通函数?

    当你写 a + b 时,编译器把它翻译成两种等价形式之一:① a.operator+(b) (成员函数形式);② operator+(a, b) (非成员函数形式)。这两条背后是 运算符重载(operator overloading) ——只是语法糖,背后就是一个普通的函数调用。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

重载运算与类型转换:机制与证据

切换《重载运算与类型转换》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 直觉:让你的类用起来像内置类型

你写 int a = 3 + 5; 、 cout << 42; 、 if (x == y) 时,这些 + << == 符号背后是语言内置的一套规则——编译器知道 int 和 int 怎么加、 int 和 ostream 怎么输出。现在你要写自己的类了—— SalesData 、 Complex 、 …

可核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:让你的类用起来像内置类型」的契约。

学完《重载运算与类型转换》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

重载运算与类型转换:失效与核验

直觉:让你的类用起来像内置类型

典型失效

若把「直觉:让你的类用起来像内置类型」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:让你的类用起来像内置类型」的契约。

官方 Chapter 14 的运算符与转换契约

典型失效

若把「官方 Chapter 14 的运算符与转换契约」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方 Chapter 14 的运算符与转换契约」的契约。

运算符重载的基本规则:成员还是普通函数?

典型失效

若把「运算符重载的基本规则:成员还是普通函数?」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「运算符重载的基本规则:成员还是普通函数?」的契约。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

直觉:让你的类用起来像内置类型

你写 int a = 3 + 5;cout << 42;if (x == y) 时,这些 + << == 符号背后是语言内置的一套规则——编译器知道 intint 怎么加、intostream 怎么输出。现在你要写自己的类了——SalesDataComplexStringBlob。你希望别人用它们时也能写 a + bcout << objif (a == b)——像用内置类型一样顺手。

想象工厂流水线上的每个工位都有统一的操作界面:扳手、螺丝刀、卡尺。不管流水线上跑的是零件A还是零件B,操作员用同样的工具就行。运算符就是 C++ 语言给你内置的"统一操作界面"——+ == << [] () 这些符号。通过重载它们,你设计的类就能无缝接入这套界面——别人用你的类就像用 intstring 一样自然。

这一章解决什么问题? 教你让自己的类也能用 + - == << [] () 这些符号——从基础的加减比较,到输入输出、下标访问、递增递减,再到函数调用和类型转换。你会学会什么时候用成员函数、什么时候用普通函数、什么情况下返回引用、什么情况下返回新对象——每一步都有明确的"为什么"。没有这一章,你设计的类永远只能靠笨重的成员函数名(add()print()equal())操作——别人根本不想用你的类。

官方 Chapter 14 的运算符与转换契约

重载只改变“某个既有运算符遇到类类型时调用哪个函数”,不会改变语法。至少一个操作数必须是类或枚举类型;不能发明新符号,也不能改变操作数个数、优先级或结合性。..*::?: 等运算符不能重载。

Chapter 14 从固定语法出发,分别设计成员/非成员运算符、可调用对象和双向类类型转换,最后检查二义性。

重载 &&|| 或逗号会失去内置版本的短路或序列语义,重载取地址也可能让泛型代码意外,因此通常应避免。运算符函数不能随意使用默认实参;函数调用运算符是普通成员调用接口,可拥有多个参数和重载,其返回类型完全由业务语义决定,不要求返回 *this

设计前先问:这个符号对该类型是否具有用户熟悉的含义?若 + 会修改左操作数、== 与排序等价关系矛盾,或 << 做了网络写入等隐藏副作用,具名函数通常更清晰。运算符重载的目标是保持既有直觉,不是缩短任何操作的拼写。

运算符重载的基本规则:成员还是普通函数?

当你写 a + b 时,编译器把它翻译成两种等价形式之一:① a.operator+(b)(成员函数形式);② operator+(a, b)(非成员函数形式)。这两条背后是 ——只是语法糖,背后就是一个普通的函数调用。

那么"成员还是非成员"怎么选?

的第一个操作数是隐式参数 this。这对赋值、下标、调用和箭头运算符是语言要求;算术和关系运算符则通常用非成员保持对称转换。

让两侧操作数都参与转换。比如自定义字符串支持从 C 字符串构造时,非成员加法可同时支持对象在左或在右;是否允许隐式转换仍取决于构造函数是否为 explicit。

上表总结了最常用运算符的重载方式。记住四条选择铁律:

  • 必须是成员的=[]()->——语言规定
  • 必须是非成员的<<>>——左操作数是 ostream/istream,不可能是你的类
  • 推荐非成员的:算术(+-*/)和关系(==<>)——双侧都可隐式转换
  • 推荐成员的:复合赋值(+=-=)——返回 *this 支持链式,自然做成员

输入与输出运算符:让流认识你的类

你肯定见过 cout << 42cin >> x。现在你要让 cout << myObj 也能工作。

输出运算符 << 必须是非成员函数——左操作数是 std::ostream&(流对象),不是你的类。它需要访问类的内部状态——通常声明为 函数:

class SalesData {
    friend std::ostream& operator<<(std::ostream&, const SalesData&);
    // ...
};
 
// 定义在类外部——非成员函数
std::ostream& operator<<(std::ostream &os, const SalesData &item) {
    os << item.isbn() << " " << item.units() << " " << item.revenue();
    return os;  // 返回 ostream&——支持链式输出!!!
}

返回 ostream&链式调用的关键——cout << a << b << c; 先执行 (cout << a) 返回 cout 的引用,再对其执行 << b,再 << c。这就是 OperatorChainDiagram 展示的机制:

输入运算符 >> 类似但更复杂——要处理输入失败的情况:

std::istream& operator>>(std::istream &is, SalesData &item) {
    double price = 0;
    is >> item.bookNo >> item.unitsSold >> price;
    if (is)                    // 检查输入是否成功
        item.revenue = item.unitsSold * price;
    else                       // 输入失败——把对象重置到默认状态
        item = SalesData();
    return is;
}

输入和输出的关键区别:输入的对象必须是非 const 引用(要被写入),输出的对象是 const 引用(只读)。输入要检查流状态——读失败了把对象重置到某种安全状态,而不是留下半坏的数据。

算术与关系运算符:让你的类加减乘除

算术运算符(+ - * /)强烈推荐非成员——原因在前面已经提过了:两侧隐式转换。它们的实现通常借助对应的复合赋值运算符:

// 非成员:借助 += 实现 +——不需要 friend(靠 +=,+= 是成员已可访问私有数据)
SalesData operator+(const SalesData &lhs, const SalesData &rhs) {
    SalesData sum = lhs;   // 拷贝左操作数
    sum += rhs;            // 用 += 把右操作数加进去
    return sum;            // 返回新对象——不是引用!!!
}

返回的是(新对象),不是引用——a + b 产生了一个新的临时结果,当然不能返回局部的引用。如果你定义了 +,通常也应该定义 +=——前者借助后者实现。注意顺序——先定义 +=(成员,返回 *this& 支持 a += b += c),再让 +(非成员)委托给 +=

关系运算符(== < > !=)也应该非成员——两侧都可隐式转换,且保持对称性(期望 a == bb == a 效果一样):

bool operator==(const SalesData &lhs, const SalesData &rhs) {
    return lhs.isbn() == rhs.isbn()
        && lhs.units() == rhs.units()
        && lhs.revenue() == rhs.revenue();
}
 
bool operator!=(const SalesData &lhs, const SalesData &rhs) {
    return !(lhs == rhs);  // != 直接委托给 ==——永不重复逻辑
}

定义了 ==,就一定定义 !=——而且是委托给 ==,不重复实现同一比较逻辑。定义了 <,通常也定义 ><=>=——但本章只演示基础和关系最常用的两个,其余按同样模式扩展即可。

赋值、下标、递增、递减:按表达式语义选返回类型

适用于赋值、复合赋值和前缀递增等“修改后表达式仍代表当前对象”的场景,但不能机械套到下标、后缀递增或函数调用运算符。

赋值运算符

= 必须是成员。你已经在前一章(拷贝控制)见过拷贝赋值和移动赋值——它们正是赋值运算符的重载。除此之外,C++11 引入了 ——让对象可以通过花括号列表重新赋值:

StrVec& StrVec::operator=(std::initializer_list<std::string> il) {
    auto newData = alloc_n_copy(il.begin(), il.end());
    free();
    elements = newData.first;
    first_free = cap = newData.second;
    return *this;
}

复合赋值运算符

+= -= *= /= 推荐成员——返回 *this& 支持链式:

SalesData& SalesData::operator+=(const SalesData &rhs) {
    unitsSold += rhs.unitsSold;
    revenue += rhs.revenue;
    return *this;  // 链式:a += b += c
}
 
// 非成员 + 委托给 +=
SalesData operator+(const SalesData &lhs, const SalesData &rhs) {
    SalesData sum = lhs;
    sum += rhs;
    return sum;
}

这就是「先 +=→后 +」模式的完整落地——成员做实际工作、非成员封装调用。

下标运算符

让你的类支持 obj[i] 语法——像访问数组一样。它必须是成员函数——这是语言强制要求。通常提供一对重载:非常量版本和 const 版本:

class StrVec {
public:
    std::string& operator[](std::size_t n) {
        return elements[n];  // 非常量版本——返回普通引用,可读可写
    }
    const std::string& operator[](std::size_t n) const {
        return elements[n];  // 常量版本——返回 const 引用,只读
    }
};

为什么需要两个版本?const StrVec sv; sv[0] 必须返回 const string&——不能允许通过 const 对象修改内部数据。返回引用而不是值——因为通常期望能对它赋值(v[0] = "hello")。

递增递减运算符

++-- 有一个有趣的难题——前缀和后缀用同一个符号。C++ 的解决方案是:后缀版本接受一个 int 形参(编译器传 0,只用于区分,不实际使用)

class StrBlobPtr {
public:
    // 前缀:++p ——先加后返回,返回引用
    StrBlobPtr& operator++() {
        check(curr, "increment past end");
        ++curr;
        return *this;         // 返回递增后的自身引用
    }
 
    // 后缀:p++ ——先返回旧值再加,返回值(副本)
    StrBlobPtr operator++(int) {  // int 只用于区分——不实际使用
        StrBlobPtr ret = *this;   // 保存当前状态
        ++*this;                  // 调用前缀++实现实际递增
        return ret;               // 返回旧的副本——值不是引用
    }
};

核心就是返回值——前缀返回引用(*this,零拷贝),后缀返回值(副本,多一次拷贝)。因此能用前缀就不要用后缀——除非确实需要旧值。

函数调用运算符:让对象变成函数

是一个重载了 operator() 的类——它的实例可以直接被"调用":

class Abs {
public:
    int operator()(int val) const {
        return val < 0 ? -val : val;
    }
};
 
Abs absObj;
int x = absObj(-5);  // x = 5——看起来像函数调用,其实是 absObj.operator()(-5)

这有什么优势?函数对象可以有状态——普通函数指针不能携带任何成员变量:

class PrintString {
public:
    PrintString(std::ostream &o = std::cout, char c = ' ')
        : os(o), sep(c) {}
    void operator()(const std::string &s) const {
        os << s << sep;
    }
private:
    std::ostream &os;   // 状态——输出到哪个流
    char sep;            // 状态——分隔符用什么
};
 
PrintString printer(std::cerr, '\n');
printer("error");       // 输出到 cerr,每行换行符分隔

operator() 必须是成员函数——这是语言规定。如果你的类需要支持 f(args...) 这种调用语法——这就是唯一的实现方式。

可调用对象:函数指针、lambda 和函数对象的关系

现在你学过了函数指针(C 语言基础)、lambda 表达式(上一章)、和 operator() 重载(本章)。这三者其实属于一个统一的概念——

C++ 三类可调用对象① 函数指针int (*pf)(int, int)最基础的可调用对象✗ 无状态——只指向一个函数✗ 不能捕获外部变量✓ 类型安全、C 兼容② Lambda 表达式[x](int a){ return a+x; }编译器生成匿名函数对象✓ 可捕获上下文(闭包)✓ 本质是重载了 operator()✗ 每个 lambda 是独立类型③ 函数对象(Functor)class Abs{ operator()(int x)一个类,重载了 operator()✓ 拥有独立状态(成员变量)✓ 可复用——多次调用同一实例可配合标准算法使用统一抽象:可调用对象(Callable)三者通过 std::function / 模板参数统一使用——只要满足「能用 () 调用」即可std::function<int(int,int)> f = lambda; // 可持有任意可调用对象特性对比有状态?捕获外部变量?可复用(同一类型)?可直接用标准算法?✗(捕获后是)✗(每个独特类型)✓(通过构造函数)
函数指针:最基础、无状态、C 兼容。Lambda:编译器生成的匿名函数对象——可捕获上下文、本质是重载 operator()。函数对象:手动定义类并重载 operator()——有独立状态、可复用。三者通过 std::function 或模板统一使用。

看上面这张图——lambda 的本质就是编译器自动给你生成的函数对象(重载了 operator() 的匿名类)。函数指针是最基础的形式——只指向已有函数,无状态。而函数对象拥有自己的成员变量(状态),可以重复使用、可以配合标准算法做灵活的事情。

三者通过 std::function 或模板可以统一使用——只要一个东西能用 () 调用,就可以传进接受可调用对象的模板参数里:

#include <functional>
 
int add(int a, int b) { return a + b; }
 
std::function<int(int,int)> f1 = add;            // 函数指针
std::function<int(int,int)> f2 = std::divides<int>(); // 标准函数对象
std::function<int(int,int)> f3 = [](int a, int b) { return a % b; };  // lambda
 
// 三者都能以相同方式使用:
std::cout << f1(4, 2) << '\n';  // 6

类型转换运算符:让你的类可以"变成" int、double、bool

前面学转换构造函数时你学过"内置类型 → 类类型"的隐式转换——那么反过来呢?类类型能不能自动转成内置类型?答案是需要

class SmallInt {
public:
    SmallInt(int i = 0) : val(i) {  // 转换构造:int → SmallInt
        if (i < 0 || i > 255) throw std::out_of_range("Bad SmallInt");
    }
    operator int() const { return val; }  // 类型转换:SmallInt → int
 
private:
    std::size_t val;
};
 
SmallInt si(3);
int i = si + 4;  // si 隐式转换为 int → 3+4=7。省略了写 int(si)+4

但这太危险了——你定义了 operator int() 后,任何需要 int 的地方 SmallInt 都会隐式转——这可能不是你想要的行为。更安全的做法是 C++11 的 ——特别是 explicit operator bool()

class SmallInt {
public:
    explicit operator int() const { return val; }   // 只能显式转换
    explicit operator bool() const { return val != 0; }
};
 
SmallInt si(3);
// int x = si + 4;        // ✗ 编译错误——explicit 不允许隐式
int x = static_cast<int>(si) + 4;  // ✓ 显式转换 OK
if (si) { /* ... */ }              // ✓ explicit bool 在条件中仍可用!

explicit operator bool() 的特殊之处在于——条件判断语境(ifwhilefor?:!&&||)中它仍然隐式生效。这条规则让 if (obj)while (obj) 等常见写法能自然工作,同时阻止 int x = obj + 5; 这种离谱的隐式转换。

类类型 ↔ 内置类型:三条转换路径内置类型int, double, bool...类类型SalesData, SmallInt...① 转换构造函数SalesData(double price) // 非 explicit② explicit 转换构造(加 explicit 防隐式)必须显式写 SmallInt si(num); / static_cast③ 类型转换运算符operator double() const; // 类→doubleexplicit operator bool()必须显式转换 bool——防止隐式的条件判断混乱(if(obj) 可以,int x = obj; 不行)
转换构造函数(非 explicit)支持隐式转换:内置类型 → 类类型。explicit 阻止隐式转换。类型转换运算符(operator int/double/bool)支持类类型 → 内置类型。explicit operator bool 在条件判断中仍可用,但禁止隐式转为 int。

上图总结了三条转换路径。只有当转换无损、含义唯一且与目标类型直觉一致时才考虑隐式转换;可能丢信息、开销明显或存在多条路径时使用 explicit 或具名函数。explicit operator bool() 是资源句柄与状态对象的常见安全布尔接口。

二义性转换:可走的路太多也是错误

类型转换运算符必须是成员函数,没有显式返回类型和形参列表,通常声明为 const。隐式转换链最多包含一个用户定义转换;当多个用户定义路径后的标准转换同样合适时,编译器不会猜你的意图。

常见于同时提供多个算术转换:

class Number {
public:
    operator int() const;
    operator double() const;
};
 
Number n;
// long double value = n; // 二义:int 和 double 路径都可再转 long double
long double value = static_cast<double>(n); // 明确选择路径

转换构造函数也有同样风险:一个类同时接受 intdouble,用 long 初始化时两条路径可能同等可行。不要成套提供“转成所有算术类型”的运算符,也避免在两个类之间同时开放互相隐式转换;保留一个语义明确的主转换,其余用具名函数或显式 cast。

动手:一步步看 return *this 如何让链式调用跑起来

下面用一个 Stepper 演示链式调用的核心机制——每一步 operator=operator<<operator+= 都返回 *this,使得上一步的结果恰好就是下一步需要的类型。每条链式语句都配一张 OperatorChainDiagram 帮你把抽象规则翻译成可视化的步骤。

猜一猜:a = b = c;cout << a << b; 为什么能串联?如果把 operator= 的返回类型改成 void——会发生什么?用 Stepper 逐步验证你的猜想。

分步1 / 3

① 链式赋值:a = b = c

a = b = c 先执行 b = coperator=(c) 被执行——拷贝 c 的数据到 b,然后 return *this 返回 b 自身的引用。这个返回值成为下一步的右操作数——a = (b的引用),调用 a.operator=(b的引用)。两步都返回 *this,所以链可以无限延伸。

代码逐段拆解:完整运算符重载示例

以下是覆盖本章主要运算符的一套完整实现——输入输出、算术、复合赋值、关系、下标、递增递减、函数调用、类型转换——每段有独立的设计决策解说。

类定义骨架 + 输入输出运算符

#include <iostream>
#include <string>
 
class SalesData {
    friend std::ostream& operator<<(std::ostream&, const SalesData&);
    friend std::istream& operator>>(std::istream&, SalesData&);
    friend SalesData operator+(const SalesData&, const SalesData&);
    friend bool operator==(const SalesData&, const SalesData&);
    friend bool operator<(const SalesData&, const SalesData&);
 
public:
    SalesData(const std::string &s, unsigned n, double p)
        : bookNo(s), unitsSold(n), revenue(p * n) {}
    SalesData() = default;

输入输出运算符被声明为友元——它们需要访问私有成员 bookNounitsSoldrevenue,但不是类的成员(左操作数是 ostream/istream,不是 SalesData)。算术和关系运算符同样声明为友元——同样是非成员,需要访问内部状态。注意构造函数——不是 explicit、允许隐式转换(可接受 string 参数构造)。

算术 + 复合赋值

    // 复合赋值:成员——返回 *this 支持链式
    SalesData& operator+=(const SalesData &rhs) {
        unitsSold += rhs.unitsSold;
        revenue += rhs.revenue;
        return *this;
    }
};
 
// 算术:非成员——借助 += 实现
SalesData operator+(const SalesData &lhs, const SalesData &rhs) {
    SalesData sum = lhs;   // 拷贝左参数——不让 operator+ 修改 lhs
    sum += rhs;            // 委托给成员 +=
    return sum;            // 返回值——新对象,不能返回引用
}

+= 是成员、返回 *this&+ 是非成员——委托给 += 实现、返回新对象(值)。const SalesData& 参数保证不修改源数据。为什么 + 不返回引用?因为 sum 是局部变量——函数结束就销毁了、返回引用就是 dangling reference。

关系运算符

bool operator==(const SalesData &lhs, const SalesData &rhs) {
    return lhs.bookNo == rhs.bookNo
        && lhs.unitsSold == rhs.unitsSold
        && lhs.revenue == rhs.revenue;
}
 
bool operator!=(const SalesData &lhs, const SalesData &rhs) {
    return !(lhs == rhs);    // != 委托给 ==——永远不复制比较逻辑
}
 
bool operator<(const SalesData &lhs, const SalesData &rhs) {
    return lhs.bookNo < rhs.bookNo;  // 按 ISBN 字典序比较
}

!= 严格委托给 ==——这是铁律:一处逻辑,不要在两处重复,保证 a != b 的语义总是和 !(a == b) 一致。

下标运算符 + 输入运算符

// 假设 SalesData 内部有一个 chars 数组成员用于下标访问
class SalesData {
public:
    // 下标:成员(必须)
    char& operator[](std::size_t n) { return chars[n]; }
    const char& operator[](std::size_t n) const { return chars[n]; }
    // ...
};
 
// 输入:非成员
std::istream& operator>>(std::istream &is, SalesData &item) {
    double price = 0;
    is >> item.bookNo >> item.unitsSold >> price;
    if (is)
        item.revenue = item.unitsSold * price;
    else
        item = SalesData();    // 输入失败——重置到默认状态
    return is;
}

下标必须是一对——常量和非常量版本。常量对象只能调常量版本、返回 const char& 不可修改。输入运算符在失败时将对象重置——防止外部拿到一个半坏的对象继续使用。

递增递减 + 函数调用运算符

class StrBlobPtr {
public:
    // 前缀 ++
    StrBlobPtr& operator++() { check(curr, "increment"); ++curr; return *this; }
    // 后缀 ++(int 形参仅用于区分)
    StrBlobPtr operator++(int) {
        StrBlobPtr ret = *this; ++*this; return ret;
    }
    // 解引用
    std::string& operator*() const { check(curr, "dereference"); return (*wptr.lock())[curr]; }
};
 
// 函数对象:有状态的 operator()
class PrintString {
public:
    PrintString(std::ostream &o = std::cout, char c = ' ') : os(o), sep(c) {}
    void operator()(const std::string &s) const { os << s << sep; }
private:
    std::ostream &os;
    char sep;
};

后缀 ++ 返回(副本)——保存当前状态、自增、返回旧值。前缀 ++ 返回引用*this)——直接自增并返回自己、零拷贝开销。函数对象 PrintString 的状态(ossep)在构造时设定——此后每次调用无需重复指定参数。

类型转换运算符

class SmallInt {
public:
    SmallInt(int i = 0) : val(i) {
        if (i < 0 || i > 255) throw std::out_of_range("Bad SmallInt");
    }
    explicit operator int() const { return val; }   // 必须显式转
    explicit operator bool() const { return val != 0; }
private:
    std::size_t val;
};

operator int() 加了 explicit——防止 SmallInt 在需要 int 的任何地方被自动转换。operator bool() 也加了 explicit——保证条件判断(if(si))正常、但算术(si + 5)必须显式转 int

容易踩的坑

小结

  • 运算符重载只是语法糖——a+b 背后是 operator+(a,b)a.operator+(b) 的普通函数调用。记住铁律:=/[]/()/-> 必须是成员,<</>> 必须非成员,算术/关系推荐非成员
  • 赋值、复合赋值和前缀递增常返回自身引用;算术与后缀递增返回新值,下标返回元素引用,函数调用运算符按业务语义选择返回类型
  • 类型转换只保留语义唯一的必要路径;可能丢信息或引发二义性时使用 explicit 或具名函数,explicit operator bool() 仍可用于条件判断
  • 函数对象(重载 operator() 的类)有状态、可复用——lambda 是编译器的语法糖,本质就是生成这样的匿名函数对象
  • 输入输出、算术、复合赋值、关系形成"组合拳"——+= 先实现 → + 委托给它 → ==先实现 → != 委托给它。一处逻辑不重复

练习

问题 1(改代码型) 下面的 Complex 类定义了加法和乘法的重载——找出至少三个设计问题,写出修正后的完整版本(只修运算符相关部分)。

class Complex {
public:
    Complex(double r = 0, double i = 0) : real(r), imag(i) {}
 
    // 问题:成员函数形式
    Complex operator+(const Complex &rhs) {
        return Complex(real + rhs.real, imag + rhs.imag);
    }
    Complex operator*(const Complex &rhs) {
        return Complex(real*rhs.real - imag*rhs.imag,
                       real*rhs.imag + imag*rhs.real);
    }
    // 没有 += 和 *=
 
private:
    double real, imag;
};

问题 2(独立实现题) 设计并实现一个 Counter 类:

  • 内部用 int value 计数值,初始为 0
  • 提供 ++ 前缀(Counter&)和后缀(Counter
  • 提供 operator()(int delta)——函数调用运算符,把 value 加上 delta,返回 *this 引用(支持 counter(1)(2)(3) 链式递增)
  • 提供 explicit operator int() constexplicit operator bool() const(value > 0 为 true)
  • 在 main 中测试:创建 Counter c; 执行 ++c; c(5); 然后用 static_cast<int>(c) 验证 value 是 6

问题 3(问答型) StringBlobPtr 类需要支持 ++pp++*pp[n] 这四种操作。++pp++ 的区别对 *pp[n] 有什么要求?这四个运算符函数中哪些返回引用、哪些返回值?为什么?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

运算符重载(operator overloading)

C++ 中让自定义类型也能使用 + == << [] 等内置运算符的机制。本质是定义一个叫 operatorX 的函数——a + b 等价于调用 a.operator+(b)(成员)或 operator+(a,b)(非成员)。让类用起来像内置类型一样自然。不是创造新运算符——是给已有运算符赋予新的参数类型。

成员运算符函数(member operator)

定义在类内部的运算符重载——左操作数是隐式 this 指针指向的对象。形参比操作数少一个(右操作数是唯一显式参数)。=[]()-> 必须是成员函数——语言强制要求。算术/关系也可用成员——但不支持左操作数的隐式转换。

非成员运算符函数(non-member operator)

定义在类外部的运算符重载——两个操作数都以函数参数传入。两侧都可享受隐式类型转换——这是推荐用于算术/关系运算符的核心原因。<< >> 必须是非成员——左操作数是 ostream/istream(标准库类,你不可能给它加成员函数)。

友元(friend)

friend 关键字在类内声明的外部函数或类——它不是类的成员,但可以访问类的 privateprotected 成员。常用于运算符重载中需要访问私有成员的非成员函数(如 operator<<)。打破了封装性——仅在确实需要且不能通过公有接口访问时才使用。

return *this 模式

修改型成员运算符在表达式应代表修改后对象时返回自身引用的模式。赋值、复合赋值和前缀递增常采用它;后缀递增返回旧值,下标返回元素引用,operator() 的返回类型由业务决定。

初始化列表赋值(initializer_list assignment)

C++11 引入的赋值运算符重载——形如 ClassName& operator=(initializer_list<T> il)。让对象可直接从花括号列表重新赋值——如 vec = {a, b, c}。本质仍是赋值(已有对象被覆盖),不是构造(新对象才走构造)。

下标运算符(subscript operator)

operator[] 让对象支持方括号索引语法 obj[i]。必须是成员函数——语言规定。通常提供一对:非常量版返回 Elem&(可读写)、常量版返回 const Elem&(只读)。返回引用(不是值)——因为通常期望能通过索引赋值(v[0] = x)。

前缀与后缀的区别

递增递减运算符的两种形式。前缀(++p)先自增再返回自增后的自身引用——零拷贝、高效。后缀(p++)先保存旧值副本、再自增、返回旧值副本——多一次拷贝开销。后缀的 int 形参纯粹用来区分前缀——编译器自动传 0,实际不使用。能用前缀就不要用后缀——除非确实需要旧值。

函数对象(function object / functor)

重载了 operator() 的类的实例——可以像函数一样被调用(obj(args))。和普通函数的区别:函数对象可以拥有状态(成员变量)——构造时设定参数、调用时无需重复传递。lambda 本质就是编译器自动生成的匿名函数对象。配合标准算法(for_each 等)灵活使用。

可调用对象(Callable)

C++ 中所有可以用 () 调用的实体——包括普通函数、函数指针、lambda 表达式、重载了 operator() 的类实例、std::function 包装器等。通过模板或 std::function 可以统一处理——只要满足「能像函数一样调用」,就算是可调用对象。

类型转换运算符(conversion operator)

类内定义的将类类型转换为其他类型的特殊函数,没有显式返回类型或形参。仅语义唯一且无损的路径适合隐式;其余使用 explicit 或具名函数。explicit operator bool() 可参与条件判断。

显式类型转换运算符

C++11 引入的显式类型转换——explicit operator int() const。阻止在算术、赋值等语境中的意外隐式转换。需要转换时必须显式写 static_cast<int>(obj)。特殊的 explicit operator bool() 在条件判断中仍然可以隐式使用——保留了 if (obj) 的自然写法。

二义性转换(ambiguous conversion)

两条或更多用户定义转换路径同等可行,重载决议无法选出唯一最佳路径。应删除冗余转换、使用 explicit、改用具名函数,或在调用点显式选择目标类型。

讨论

评论区加载中…