一个好用的值类型,会让调用者觉得表达式原本就该这样写。金额相加写成 a + b,对象输出写成 out << value,容器访问写成 row[i]。这些写法并没有绕过函数调用;它们只是把一组函数放进了 C++ 已有的表达式语法。
真正困难的地方在于守住符号的含义。+ 是否生成新值,+= 是否返回左操作数,输入失败后对象是否保持原值,< 与 == 是否描述同一种等价关系,隐式转换是否让调用变得含糊,这些决定了类型能否长期使用。
这篇文章会逐步完善一个以“分”为内部单位的 Money 值类型。整数表示让金额的相等关系精确,也便于把注意力放在接口设计上。示例使用 C++17。
对用户自定义类型来说,运算符表达式会进入普通的函数查找、参数匹配和重载决议。假设 operator+ 是非成员函数,下面两行表达同一个调用意图:
Money total = left + right;
Money total = operator+(left, right);如果 operator+= 是成员函数,左操作数会成为隐式的当前对象:
left += right;
left.operator+=(right);这种对应关系解释了成员与非成员的核心差别。成员版本固定把左操作数绑定到 this;非成员版本把两边都写成普通参数,因此更容易保持二元运算的对称性。
重载只能补充用户自定义类型的操作,不能改造 C++ 的语法规则:
int + int。**、<=>= 之类的新符号。+ 仍然需要两个操作数。a + b * c 仍按原有层级分组。.、.*、::、?:、sizeof 等运算符不能重载。operator=、operator[]、operator() 和 operator-> 必须是成员函数。&& 和 || 虽然可以重载,但重载版本是函数调用,不再提供内置运算符的短路语义。一个看似普通的条件表达式如果突然总会计算两边,维护者很难防备。因此,语法上允许不等于接口上适合。
先用一句普通中文解释符号。如果无法自然地说“两个金额相加”“两个标识相等”“按索引取元素”,就先使用命名函数。运算符应缩短已经清楚的概念,不应隐藏一条业务流程。

operator+ 锚定左操作数,非成员 operator+ 让 Money 的两侧参与者保持对称;只有符号含义清楚时才应重载。运算符应当建立在一个已经站得住的类型上。这里的 Money 只保存最小货币单位,不保存浮点数,也不让外部直接修改表示:
#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
这个最小接口已经做了几个决定:
Money{1250} 表示 1250 分,值的单位是类型契约的一部分。explicit,所以裸整数不会在函数调用中悄悄变成金额。cents() 是只读观察函数,非成员运算符可通过它实现,不必获得私有访问权。operator+= 是修改操作,因此放在类内并返回 *this。生产代码还要明确溢出策略、币种和舍入规则。那些决策不应塞进每个运算符各自处理,而应先成为类型不变量或集中检查。否则,+、+=、输入和构造很快会出现不同规则。
可以按下面的顺序判断:
先确认语言是否要求成员形式。赋值、下标、调用和箭头运算符没有选择余地。
再看操作是否修改左操作数。+= 依附并修改当前对象,成员形式最直接。
对称的二元运算优先考虑非成员形式。这样左右操作数都经过同一套参数匹配规则。
最后才判断是否需要 friend。公开接口足够实现时,不要让运算符直接依赖私有表示。
friend 是精确授予访问权的工具,不是“非成员函数”的同义词。比如 Money 的比较和输出只需要 cents(),声明友元反而增加了了解内部表示的函数数量。
+= 和 + 的状态语义不同。lhs += rhs 修改 lhs,而 lhs + rhs 应生成新值,不改变两个原操作数。最稳妥的实现方式是只维护一份核心逻辑:
Money operator+(Money lhs, const Money& rhs) noexcept {
lhs += rhs;
return lhs;
}左参数按值传递,函数得到一个可修改副本。随后调用已经负责修改语义的 +=,再返回这个副本。以后若金额相加需要检查币种或溢出,核心规则只需集中在 += 的实现路径中。
把 + 写成非成员还有一个接口上的好处:两边都是显式参数。对于同类型金额,a + b 和 b + a 都能以相同方式查找候选。若将来确实需要支持另一个类型,也能分别审查左右两个方向,而不是让成员形式偏向左侧。
复合赋值返回左操作数引用,所以这段代码可以成立:
Money a{100};
Money b{250};
Money c{50};
a += b += c;赋值类运算符保持右结合,表达式等价于 a += (b += c)。执行后 b 是 300 分,a 是 400 分,c 不变。这个例子适合验证返回类型,却不一定适合日常业务代码;拆成两句通常更容易审查状态变化。
b += c;
a += b;不要为了“齐全”给金额定义所有算术符号。金额加金额合理,金额乘金额通常没有清楚单位。比例乘金额可以是一个命名操作,或只在单位和舍入规则明确后提供相应重载。
用一个基础操作派生其余运算,不只是少写代码。它让不变量检查、错误策略和边界行为只有一个权威实现点。

+=,最终产生新值而保持两个原对象不变。流运算符最能说明为什么有些操作必须是非成员。out << money 的左操作数是 std::ostream,不是 Money;把它写成 Money 成员会得到方向相反的调用形式。
下面选用稳定、可往返的 USD{分值} 格式:
#include <ostream>
std::ostream& operator<<(std::ostream& out, const Money& value) {
return out << "USD{" << value.cents() << '}';
}第一个参数必须是非常量引用,因为写入会改变流缓冲区和状态;std::ostream 也不按值复制。金额只被读取,所以第二个参数是 const Money&。返回 out 让同一个流继续参与后续操作:
std::cout << Money{1250} << ' ' << Money{-75} << '\n';输出为:
USD{1250} USD{-75}运算符不主动打印换行,也不把错误提示写到另一个流。行布局和错误呈现属于调用者。
输入不能假设文本有效。下面的实现先把所有部分读进局部变量,格式完全匹配后才更新目标对象:
#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
这个接口给出两项重要保证:
if (in) 或 while (in >> value) 处理。value,目标不会停留在“前半部分已更新”的状态。输入位置无法总是回滚。解析器可能直到最后一个字符才发现右花括号错误。可靠的契约是报告失败并保留目标值,而不是承诺把所有字符放回流中。
#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
输出为:
USD{875} false
USD{123} trueMoney 的值完全由分值决定,所以相等和顺序都有单一、精确的定义:
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
!= 由 == 派生,避免两份成员比较逐渐分叉。< 也只观察分值,因此下面两个“等价”概念一致:
const bool equal = (a == b);
const bool order_equivalent = !(a < b) && !(b < a);排序算法和有序容器经常用第二种方式判断两个值在排序意义下是否等价。若 == 比较全部字段,而 < 只比较其中一个非唯一字段,两个对象可能排序等价却又“不相等”。这不一定违反语法,却会给查找、去重和读代码的人带来意外。
金额采用整数最小单位,也避免了浮点计算结果直接做 == 的常见问题。若类型本来表示近似测量值,应明确容差策略;不要把“接近”悄悄塞进 operator==,因为近似关系往往不具备可靠的传递性。
并非每个类型都有自然全序。复数、二维方向或带多个业务排序键的记录,都可能需要命名比较器:
sort(records.begin(), records.end(), ByName{});
sort(records.begin(), records.end(), ByCreatedTime{});当调用点必须说出排序标准时,代码反而更清楚。
有些运算符不是普通二元算术的变体。它们的返回类型和成员限定直接决定表达式能做什么。

PriceRow 保存三个金额。非常量下标返回可写引用,常量下标返回常量引用:
#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
现在可变对象能更新元素,而常量视图只能读取:
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()。不要让同一个符号在不同构建模式下悄悄改变边界行为。
票号具有自然的“前进到下一号”操作,适合展示两个 ++:
class TicketNumber {
public:
explicit TicketNumber(int value = 0) noexcept
: value_{value} {}
TicketNumber& operator++() noexcept {
++value_;
return *this;
}
TicketNumber operator++(int) noexcept {
TicketNumber old =
后置版本中的未命名 int 不表示步长,只用来区分 ticket++ 与 ++ticket。后置实现先保存旧值,再复用前置实现;因此状态更新逻辑仍然只有一份。
TicketNumber ticket{7};
TicketNumber old = ticket++;
TicketNumber& current = ++ticket;
// old.value() == 7
// ticket.value() == 9
// current 引用 ticket前置形式通常不需要旧值副本,因此在泛型代码里只需要前进而不需要旧值时,优先写 ++it。
operator() 把对象变成可调用对象。下面的谓词保存最低金额,再判断传入值是否达到门槛:
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,因为判断过程只读取保存的阈值。
#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类类型转换有两个方向。构造函数把其他类型变成当前类型,转换函数把当前对象解释成另一个类型。两条路径都会参与重载决议,因此每增加一条隐式转换,候选空间都会变大。
Money(std::int64_t) 可以由一个实参调用,所以它具备转换构造函数的形态。explicit 关闭隐式路径:
void charge(Money amount);
charge(Money{500}); // 正确:调用者明确写出单位包装
// charge(500); // 错误:不能隐式把整数变成 Money直接初始化仍然可用:
Money fee{500};
Money tax(25);explicit 仍允许直接构造,但要求调用者在转换发生的位置表态。尺寸、时间、金额、文件句柄等带单位或资源含义的类型,通常都不应从裸整数隐式生成。
有些隐式构造确实自然。例如把一个标量看成虚部为零的复数,可能符合整个数值接口的预期。少写字符不是判断标准;应检查转换是否无损、唯一,并且几乎不会让读者惊讶。
若确实要把金额显式换算成以元为单位的 double,可以在 Money 的公开区加入:
explicit operator double() const noexcept {
return static_cast<double>(cents_) / 100.0;
}转换函数没有普通返回类型,目标类型已经写在 operator 后面。它必须是成员函数;只读转换通常还应声明为 const。
Money amount{875};
double dollars = static_cast<double>(amount); // 8.75
// double hidden = amount; // 错误:转换是 explicit即使如此,命名函数 as_dollars() 有时更好,因为名字直接说明单位。转换运算符适合目标含义唯一且上下文已经足够清楚的场景。
explicit operator bool() 的条件语境布尔转换有一个实用特例。显式布尔转换可以用于 if、while 和逻辑否定等条件语境,但不会自动进入普通赋值:
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; // 错误:普通隐式转换被禁止
这种设计适合“是否有效”“操作是否成功”这类二态语义。不要给一个拥有多种真假解释的业务对象随意添加布尔转换。

explicit 分别约束构造与目标类型转换:直接初始化和显式转换可通过,普通隐式转换被拦截;条件语境可使用 explicit operator bool,而两条同等级的 Reading 到 long 路径会触发二义性。重载决议会优先选择精确匹配,然后考虑提升、标准转换和用户自定义转换。若两个候选处在同一最佳等级,且没有一个整体更好,编译器不会猜测意图,而会拒绝调用。
下面的类型同时提供两条到算术类型的隐式路径:
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() 这类命名函数。调用点写出目标后,重载决议和读者都不必猜。
下面把核心接口放在同一个程序中,验证对称加法、链式复合赋值、流失败保证、比较一致性、常量下标、前后置自增、函数对象和显式转换:
#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} {}
程序输出:
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 或命名函数。