自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

C++

  1. 01C++入门
  2. 02变量与基本类型
  3. 03特殊类型: 字符串、vector 与数组
  4. 04表达式
  5. 05语句
  6. 06函数
  7. 07类与对象
  8. 08输入与输出
  9. 09顺序容器
  10. 10泛型算法
  11. 11关联容器
  12. 12动态内存管理
  13. 13拷贝控制
  14. 14运算符重载与类型转换
  15. 15面向对象编程
  16. 16模板与泛型编程
  17. 17专用库设施
  18. 18高级技术与实用工具
正在加载课程章节内容
课程编程C++运算符重载与类型转换

运算符重载与类型转换:让值类型自然但不含糊

一个好用的值类型,会让调用者觉得表达式原本就该这样写。金额相加写成 a + b,对象输出写成 out << value,容器访问写成 row[i]。这些写法并没有绕过函数调用;它们只是把一组函数放进了 C++ 已有的表达式语法。

真正困难的地方在于守住符号的含义。+ 是否生成新值,+= 是否返回左操作数,输入失败后对象是否保持原值,< 与 == 是否描述同一种等价关系,隐式转换是否让调用变得含糊,这些决定了类型能否长期使用。

这篇文章会逐步完善一个以“分”为内部单位的 Money 值类型。整数表示让金额的相等关系精确,也便于把注意力放在接口设计上。示例使用 C++17。


运算符只是受约束的函数

对用户自定义类型来说,运算符表达式会进入普通的函数查找、参数匹配和重载决议。假设 operator+ 是非成员函数,下面两行表达同一个调用意图:

cpp
Money total = left + right;
Money total = operator+(left, right);

如果 operator+= 是成员函数,左操作数会成为隐式的当前对象:

cpp
left += right;
left.operator+=(right);

这种对应关系解释了成员与非成员的核心差别。成员版本固定把左操作数绑定到 this;非成员版本把两边都写成普通参数,因此更容易保持二元运算的对称性。

语言不会被重写

重载只能补充用户自定义类型的操作,不能改造 C++ 的语法规则:

  • 至少一个操作数必须是类类型或枚举类型,不能重新定义 int + int。
  • 只能使用已有的运算符,不能发明 **、<=>= 之类的新符号。
  • 操作数个数不能改变。二元 + 仍然需要两个操作数。
  • 优先级和结合性保持不变。重载后的 a + b * c 仍按原有层级分组。
  • .、.*、::、?:、sizeof 等运算符不能重载。
  • operator=、operator[]、operator() 和 operator-> 必须是成员函数。

&& 和 || 虽然可以重载,但重载版本是函数调用,不再提供内置运算符的短路语义。一个看似普通的条件表达式如果突然总会计算两边,维护者很难防备。因此,语法上允许不等于接口上适合。

先用一句普通中文解释符号。如果无法自然地说“两个金额相加”“两个标识相等”“按索引取元素”,就先使用命名函数。运算符应缩短已经清楚的概念,不应隐藏一条业务流程。

运算符重载的函数本质、成员左侧偏向与非成员对称性示意图
运算符表达式仍通过函数调用完成:成员 operator+ 锚定左操作数,非成员 operator+ 让 Money 的两侧参与者保持对称;只有符号含义清楚时才应重载。

小节测试

1
表达式 a += b 使用成员 operator+= 时,b 对应什么?
2
关于运算符重载,哪些说法正确?

先把值类型的语义说清楚

运算符应当建立在一个已经站得住的类型上。这里的 Money 只保存最小货币单位,不保存浮点数,也不让外部直接修改表示:

cpp
#include <cstdint>
 
class Money {
public:
    explicit Money(std::int64_t cents = 0) noexcept
        : cents_{cents} {}
 
    std::int64_t cents() const noexcept {
        return cents_;
    }
 
    Money& operator+=(const Money& rhs) noexcept {
        cents_ += rhs.cents_;
        return *this;
    }
 
private:
    std::int64_t cents_;
};

这个最小接口已经做了几个决定:

  • Money{1250} 表示 1250 分,值的单位是类型契约的一部分。
  • 默认构造得到零金额,这是清楚且有效的默认值。
  • 构造函数是 explicit,所以裸整数不会在函数调用中悄悄变成金额。
  • cents() 是只读观察函数,非成员运算符可通过它实现,不必获得私有访问权。
  • operator+= 是修改操作,因此放在类内并返回 *this。

生产代码还要明确溢出策略、币种和舍入规则。那些决策不应塞进每个运算符各自处理,而应先成为类型不变量或集中检查。否则,+、+=、输入和构造很快会出现不同规则。

成员、非成员与友元的取舍

可以按下面的顺序判断:

先确认语言是否要求成员形式。赋值、下标、调用和箭头运算符没有选择余地。

再看操作是否修改左操作数。+= 依附并修改当前对象,成员形式最直接。

对称的二元运算优先考虑非成员形式。这样左右操作数都经过同一套参数匹配规则。

最后才判断是否需要 friend。公开接口足够实现时,不要让运算符直接依赖私有表示。

friend 是精确授予访问权的工具,不是“非成员函数”的同义词。比如 Money 的比较和输出只需要 cents(),声明友元反而增加了了解内部表示的函数数量。

小节测试

3
非成员运算符如果不是 friend,就无法读取对象的任何信息。
4
会修改左操作数的复合赋值通常返回 ____,从而保留左操作数身份。

用复合赋值派生普通算术

+= 和 + 的状态语义不同。lhs += rhs 修改 lhs,而 lhs + rhs 应生成新值,不改变两个原操作数。最稳妥的实现方式是只维护一份核心逻辑:

cpp
Money operator+(Money lhs, const Money& rhs) noexcept {
    lhs += rhs;
    return lhs;
}

左参数按值传递,函数得到一个可修改副本。随后调用已经负责修改语义的 +=,再返回这个副本。以后若金额相加需要检查币种或溢出,核心规则只需集中在 += 的实现路径中。

把 + 写成非成员还有一个接口上的好处:两边都是显式参数。对于同类型金额,a + b 和 b + a 都能以相同方式查找候选。若将来确实需要支持另一个类型,也能分别审查左右两个方向,而不是让成员形式偏向左侧。

返回引用带来的组合能力

复合赋值返回左操作数引用,所以这段代码可以成立:

cpp
Money a{100};
Money b{250};
Money c{50};
 
a += b += c;

赋值类运算符保持右结合,表达式等价于 a += (b += c)。执行后 b 是 300 分,a 是 400 分,c 不变。这个例子适合验证返回类型,却不一定适合日常业务代码;拆成两句通常更容易审查状态变化。

cpp
b += c;
a += b;

不要为了“齐全”给金额定义所有算术符号。金额加金额合理,金额乘金额通常没有清楚单位。比例乘金额可以是一个命名操作,或只在单位和舍入规则明确后提供相应重载。

用一个基础操作派生其余运算,不只是少写代码。它让不变量检查、错误策略和边界行为只有一个权威实现点。

复合赋值链与由加等运算派生普通加法的数据流图
复合赋值从右向左修改左操作数并返回其引用;普通加法则复制左操作数、复用 +=,最终产生新值而保持两个原对象不变。

小节测试

5
operator+(Money lhs, const Money& rhs) 为什么把 lhs 按值接收?
6
只要语法允许,就应该为值类型补齐 +、-、*、/ 四个运算符。

让流操作可组合、可失败

流运算符最能说明为什么有些操作必须是非成员。out << money 的左操作数是 std::ostream,不是 Money;把它写成 Money 成员会得到方向相反的调用形式。

输出返回原来的流

下面选用稳定、可往返的 USD{分值} 格式:

cpp
#include <ostream>
 
std::ostream& operator<<(std::ostream& out, const Money& value) {
    return out << "USD{" << value.cents() << '}';
}

第一个参数必须是非常量引用,因为写入会改变流缓冲区和状态;std::ostream 也不按值复制。金额只被读取,所以第二个参数是 const Money&。返回 out 让同一个流继续参与后续操作:

cpp
std::cout << Money{1250} << ' ' << Money{-75} << '\n';

输出为:

text
USD{1250} USD{-75}

运算符不主动打印换行,也不把错误提示写到另一个流。行布局和错误呈现属于调用者。

输入先解析,再提交

输入不能假设文本有效。下面的实现先把所有部分读进局部变量,格式完全匹配后才更新目标对象:

cpp
#include <istream>
 
std::istream& operator>>(std::istream& in, Money& value) {
    char u{};
    char s{};
    char d{};
    char left_brace{};
    char right_brace{};
    std::int64_t cents{};
 
    if (in >> u >> s >> d >> left_brace >> cents >> right_brace) {
        if (u == 'U' && s == 'S' && d == 'D' &&
            left_brace == '{' && right_brace == '}') {
            value = Money{cents};
        } else {
            in.setstate(std::ios::failbit);
        }
    }
    return in;
}

这个接口给出两项重要保证:

  • 读取失败或格式错误时,流保留失败状态,调用者能用 if (in) 或 while (in >> value) 处理。
  • 只有完整值成功解析后才赋给 value,目标不会停留在“前半部分已更新”的状态。

输入位置无法总是回滚。解析器可能直到最后一个字符才发现右花括号错误。可靠的契约是报告失败并保留目标值,而不是承诺把所有字符放回流中。

cpp
#include <iostream>
#include <sstream>
 
std::istringstream good{"USD{875}"};
Money parsed{0};
good >> parsed;
std::cout << parsed << ' ' << std::boolalpha << good.fail() << '\n';
 
std::istringstream bad{"USD[900]"};
Money preserved{123};
bad >> preserved;
std::cout << preserved << ' ' << bad.fail() << '\n';

输出为:

text
USD{875} false
USD{123} true

小节测试

7
一个常规输出运算符通常有哪些特征?
8
自定义输入格式不匹配时,应给输入流设置 ____ 状态。

比较关系必须说同一种语言

Money 的值完全由分值决定,所以相等和顺序都有单一、精确的定义:

cpp
bool operator==(const Money& lhs, const Money& rhs) noexcept {
    return lhs.cents() == rhs.cents();
}
 
bool operator!=(const Money& lhs, const Money& rhs) noexcept {
    return !(lhs == rhs);
}
 
bool operator<(const Money& lhs, const Money& rhs) noexcept {
    return lhs.cents() < rhs.cents();
}

!= 由 == 派生,避免两份成员比较逐渐分叉。< 也只观察分值,因此下面两个“等价”概念一致:

cpp
const bool equal = (a == b);
const bool order_equivalent = !(a < b) && !(b < a);

排序算法和有序容器经常用第二种方式判断两个值在排序意义下是否等价。若 == 比较全部字段,而 < 只比较其中一个非唯一字段,两个对象可能排序等价却又“不相等”。这不一定违反语法,却会给查找、去重和读代码的人带来意外。

金额采用整数最小单位,也避免了浮点计算结果直接做 == 的常见问题。若类型本来表示近似测量值,应明确容差策略;不要把“接近”悄悄塞进 operator==,因为近似关系往往不具备可靠的传递性。

并非每个类型都有自然全序。复数、二维方向或带多个业务排序键的记录,都可能需要命名比较器:

cpp
sort(records.begin(), records.end(), ByName{});
sort(records.begin(), records.end(), ByCreatedTime{});

当调用点必须说出排序标准时,代码反而更清楚。

小节测试

9
有序算法常用哪条表达式判断 a 与 b 在排序意义下等价?
10
一个类型没有唯一自然顺序时,仍应随便选一个字段实现 operator<。

下标、增减与调用各有自己的契约

有些运算符不是普通二元算术的变体。它们的返回类型和成员限定直接决定表达式能做什么。

C++ 流运算符、下标、自增与调用运算符的不同接口契约图
不同运算符承担不同契约:流操作保持链式与失败状态,下标区分读写权限,前后置自增分别返回新状态引用和旧值快照,而调用运算符让对象携带可复用的策略状态。

下标要同时照顾可写对象和常量对象

PriceRow 保存三个金额。非常量下标返回可写引用,常量下标返回常量引用:

cpp
#include <array>
#include <cstddef>
 
class PriceRow {
public:
    PriceRow(Money first, Money second, Money third)
        : values_{first, second, third} {}
 
    Money& operator[](std::size_t index) {
        return values_.at(index);
    }
 
    const Money& operator[](std::size_t index) const {
        return values_.at(index);
    }
 
private:
    std::array<Money, 3> values_;
};

现在可变对象能更新元素,而常量视图只能读取:

cpp
PriceRow row{Money{100}, Money{200}, Money{300}};
row[1] += Money{25};
 
const PriceRow& view = row;
std::cout << view[1] << '\n';
// view[1] += Money{1};  // 错误:得到 const Money&

这里使用 std::array::at,所以越界会报告异常。若你的 operator[] 选择不检查范围,也必须把这一点写进契约,并另行提供受检查的 at()。不要让同一个符号在不同构建模式下悄悄改变边界行为。

前置返回新状态,后置返回旧快照

票号具有自然的“前进到下一号”操作,适合展示两个 ++:

cpp
class TicketNumber {
public:
    explicit TicketNumber(int value = 0) noexcept
        : value_{value} {}
 
    TicketNumber& operator++() noexcept {
        ++value_;
        return *this;
    }
 
    TicketNumber operator++(int) noexcept {
        TicketNumber old = *this;
        ++*this;
        return old;
    }
 
    int value() const noexcept {
        return value_;
    }
 
private:
    int value_;
};

后置版本中的未命名 int 不表示步长,只用来区分 ticket++ 与 ++ticket。后置实现先保存旧值,再复用前置实现;因此状态更新逻辑仍然只有一份。

cpp
TicketNumber ticket{7};
TicketNumber old = ticket++;
TicketNumber& current = ++ticket;
 
// old.value() == 7
// ticket.value() == 9
// current 引用 ticket

前置形式通常不需要旧值副本,因此在泛型代码里只需要前进而不需要旧值时,优先写 ++it。

调用运算符让对象携带策略状态

operator() 把对象变成可调用对象。下面的谓词保存最低金额,再判断传入值是否达到门槛:

cpp
class AtLeast {
public:
    explicit AtLeast(Money limit) noexcept
        : limit_{limit} {}
 
    bool operator()(const Money& value) const noexcept {
        return !(value < limit_);
    }
 
private:
    Money limit_;
};

调用 predicate(value) 等价于 predicate.operator()(value)。operator() 声明为 const,因为判断过程只读取保存的阈值。

cpp
#include <algorithm>
#include <vector>
 
std::vector<Money> prices{Money{200}, Money{900}, Money{1200}};
const auto count = std::count_if(
    prices.begin(), prices.end(), AtLeast{Money{800}}
);
// count == 2

小节测试

11
关于下标与增量运算符,哪些说法正确?
12
后置自增的典型签名 T operator++(____) 中,空缺的类型是?

控制两个方向的用户自定义转换

类类型转换有两个方向。构造函数把其他类型变成当前类型,转换函数把当前对象解释成另一个类型。两条路径都会参与重载决议,因此每增加一条隐式转换,候选空间都会变大。

转换构造函数:从参数类型到当前类型

Money(std::int64_t) 可以由一个实参调用,所以它具备转换构造函数的形态。explicit 关闭隐式路径:

cpp
void charge(Money amount);
 
charge(Money{500});  // 正确:调用者明确写出单位包装
// charge(500);      // 错误:不能隐式把整数变成 Money

直接初始化仍然可用:

cpp
Money fee{500};
Money tax(25);

explicit 仍允许直接构造,但要求调用者在转换发生的位置表态。尺寸、时间、金额、文件句柄等带单位或资源含义的类型,通常都不应从裸整数隐式生成。

有些隐式构造确实自然。例如把一个标量看成虚部为零的复数,可能符合整个数值接口的预期。少写字符不是判断标准;应检查转换是否无损、唯一,并且几乎不会让读者惊讶。

转换函数:从当前类型到目标类型

若确实要把金额显式换算成以元为单位的 double,可以在 Money 的公开区加入:

cpp
explicit operator double() const noexcept {
    return static_cast<double>(cents_) / 100.0;
}

转换函数没有普通返回类型,目标类型已经写在 operator 后面。它必须是成员函数;只读转换通常还应声明为 const。

cpp
Money amount{875};
double dollars = static_cast<double>(amount);  // 8.75
// double hidden = amount;                     // 错误:转换是 explicit

即使如此,命名函数 as_dollars() 有时更好,因为名字直接说明单位。转换运算符适合目标含义唯一且上下文已经足够清楚的场景。

explicit operator bool() 的条件语境

布尔转换有一个实用特例。显式布尔转换可以用于 if、while 和逻辑否定等条件语境,但不会自动进入普通赋值:

cpp
class ReadStatus {
public:
    explicit ReadStatus(bool ok) noexcept : ok_{ok} {}
 
    explicit operator bool() const noexcept {
        return ok_;
    }
 
private:
    bool ok_;
};
 
ReadStatus status{true};
if (status) {
    // 正确:条件语境允许 explicit operator bool()
}
 
// bool copied = status;  // 错误:普通隐式转换被禁止
bool copied = static_cast<bool>(status);

这种设计适合“是否有效”“操作是否成功”这类二态语义。不要给一个拥有多种真假解释的业务对象随意添加布尔转换。

C++ 用户自定义转换的 explicit 门闸、条件布尔转换与二义性解析线路图
explicit 分别约束构造与目标类型转换:直接初始化和显式转换可通过,普通隐式转换被拦截;条件语境可使用 explicit operator bool,而两条同等级的 Reading 到 long 路径会触发二义性。

小节测试

13
给单实参构造函数加 explicit 后,哪种写法仍然有效?
14
explicit operator bool() 可以直接支持 if (object),但不允许 bool b = object。

让编译器暴露二义性,并完成接口审计

重载决议会优先选择精确匹配,然后考虑提升、标准转换和用户自定义转换。若两个候选处在同一最佳等级,且没有一个整体更好,编译器不会猜测意图,而会拒绝调用。

下面的类型同时提供两条到算术类型的隐式路径:

cpp
class Reading {
public:
    operator int() const noexcept { return 7; }
    operator double() const noexcept { return 7.0; }
};
 
void accept(long value);
 
Reading reading;
// accept(reading);  // 错误:转成 int 或 double 后再到 long,路径含糊

修复方式不是添加更多重载。可以删除不必要的转换,把转换设为 explicit,或改成 as_count()、as_ratio() 这类命名函数。调用点写出目标后,重载决议和读者都不必猜。

一份可运行的整合例

下面把核心接口放在同一个程序中,验证对称加法、链式复合赋值、流失败保证、比较一致性、常量下标、前后置自增、函数对象和显式转换:

cpp
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <istream>
#include <ostream>
#include <sstream>
#include <type_traits>
#include <vector>
class Money {
public:
    explicit Money(std::int64_t cents = 0) noexcept : cents_{cents} {}
    std::int64_t cents() const noexcept { return cents_; }
    Money& operator+=(const Money& rhs) noexcept {
        cents_ += rhs.cents_;
        return *this;
    }
    explicit operator double() const noexcept {
        return static_cast<double>(cents_) / 100.0;
    }
 
private:
    std::int64_t cents_;
};
Money operator+(Money lhs, const Money& rhs) noexcept {
    lhs += rhs;
    return lhs;
}
bool operator==(const Money& lhs, const Money& rhs) noexcept {
    return lhs.cents() == rhs.cents();
}
 
bool operator!=(const Money& lhs, const Money& rhs) noexcept {
    return !(lhs == rhs);
}
bool operator<(const Money& lhs, const Money& rhs) noexcept {
    return lhs.cents() < rhs.cents();
}
 
std::ostream& operator<<(std::ostream& out, const Money& value) {
    return out << "USD{" << value.cents() << '}';
}
std::istream& operator>>(std::istream& in, Money& value) {
    char u{};
    char s{};
    char d{};
    char left{};
    char right{};
    std::int64_t cents{};
    if (in >> u >> s >> d >> left >> cents >> right) {
        if (u == 'U' && s == 'S' && d == 'D' &&
            left == '{' && right == '}') {
            value = Money{cents};
        } else {
            in.setstate(std::ios::failbit);
        }
    }
    return in;
}
class PriceRow {
public:
    PriceRow(Money first, Money second, Money third)
        : values_{first, second, third} {}
    Money& operator[](std::size_t index) { return values_.at(index); }
    const Money& operator[](std::size_t index) const {
        return values_.at(index);
    }
 
private:
    std::array<Money, 3> values_;
};
class TicketNumber {
public:
    explicit TicketNumber(int value = 0) noexcept : value_{value} {}
    TicketNumber& operator++() noexcept {
        ++value_;
        return *this;
    }
    TicketNumber operator++(int) noexcept {
        TicketNumber old = *this;
        ++*this;
        return old;
    }
    int value() const noexcept { return value_; }
 
private:
    int value_;
};
class AtLeast {
public:
    explicit AtLeast(Money limit) noexcept : limit_{limit} {}
    bool operator()(const Money& value) const noexcept {
        return !(value < limit_);
    }
 
private:
    Money limit_;
};
int main() {
    static_assert(std::is_constructible_v<Money, std::int64_t>);
    static_assert(!std::is_convertible_v<std::int64_t, Money>);
    const Money left{125};
    const Money right{375};
    std::cout << "sums: " << left + right << ' ' << right + left << '\n';
    Money a{100};
    Money b{200};
    Money c{300};
    a += b += c;
    std::cout << "chain: " << a << ' ' << b << ' ' << c << '\n';
 
    std::istringstream good{"USD{875}"};
    Money parsed{0};
    good >> parsed;
    std::cout << "read: " << parsed << ' ' << std::boolalpha
              << good.fail() << '\n';
 
    std::istringstream bad{"USD[900]"};
    Money preserved{123};
    bad >> preserved;
    std::cout << "failed: " << bad.fail() << ' ' << preserved << '\n';
 
    const Money same{500};
    const Money also_same{500};
    std::cout << "equivalent: " << (same == also_same) << ' '
              << (!(same < also_same) && !(also_same < same)) << '\n';
 
    PriceRow row{Money{100}, Money{200}, Money{300}};
    row[1] += Money{25};
    const PriceRow& view = row;
    std::cout << "row: " << view[0] << ' ' << view[1] << ' '
              << view[2] << '\n';
 
    TicketNumber ticket{7};
    const TicketNumber old = ticket++;
    const TicketNumber current = ++ticket;
    std::cout << "tickets: " << old.value() << ' '
              << current.value() << ' ' << ticket.value() << '\n';
 
    const std::vector<Money> prices{
        Money{200}, Money{900}, Money{1200}
    };
    const auto count = std::count_if(
        prices.begin(), prices.end(), AtLeast{Money{800}}
    );
    std::cout << "at least: " << count << '\n';
    std::cout << "dollars: " << static_cast<double>(parsed) << '\n';
}

程序输出:

text
sums: USD{500} USD{500}
chain: USD{600} USD{500} USD{300}
read: USD{875} false
failed: true USD{123}
equivalent: true true
row: USD{100} USD{225} USD{300}
tickets: 7 9 9
at least: 2
dollars: 8.75

发布前检查

  • 符号是否保留读者熟悉的含义,是否存在更清楚的命名函数。
  • 修改型运算是否返回左操作数引用,生成型运算是否保持原操作数不变。
  • 对称二元运算是否避免不必要的成员偏向。
  • 流运算是否返回原流,输入失败是否保留目标对象。
  • ==、!= 与顺序等价是否一致;没有自然全序时是否改用比较器。
  • 下标是否提供常量与非常量版本,返回类型是否正确传递写权限。
  • 后置增减是否返回旧值并复用前置实现。
  • 转换是否无损、唯一、自然;否则是否使用 explicit 或命名函数。
  • 是否能删掉一条隐式转换路径,从根本上消除二义性。

小节测试

15
遇到用户自定义转换导致的二义性,哪些处理通常有效?
16
用于判断 From 能否隐式转换为 To 的类型特征名是 std::____。
上一章拷贝控制下一章面向对象编程