第17.3章 值类型上的算术
1. + 正确的场合
第17.2章
说当含义不是显然的那个时不要重载运算符。这里说另一半:
当这个类型就是一个数时,算术记号不是语法糖,它是读者本来就掌握的记号。
一个二维向量应该写成 (a + b) * 2,而不是
scale(add(a, b), 2)。
判断标准不是品味,而是四条性质。当操作满足下面全部条件时才重载算术:
- 全域 —— 对值的所有组合都有定义,没有非法状态,没有定义域错误。
- 不分配 —— 它产出一个固定大小的栈上的值,
所以代价光看类型就知道(
sizeof(vec2))。 - 不失败 —— 它不会抛,所以可以是
noexcept, 调用者不需要错误路径。 - 守规律 —— 它满足读者预期的恒等式:
a + b == b + a、(a + b) + c == a + (b + c)、a + vec2{} == a。
任何一条不满足,你就回到 第17.2章 的地盘, 而且你要的是名字。
2. 一个完整正确的值类型
两个类型:一个是 vec2,完整算术是正当的;一个是 price,
其中只有一部分算术正当 —— 而必须不存在的那个运算符才是重点。
#include <fast_io.h>
#include <cstdint>
struct vec2
{
::std::int_least32_t x{};
::std::int_least32_t y{};
constexpr vec2 &operator+=(vec2 other) noexcept
{
x += other.x;
y += other.y;
return *this;
}
constexpr vec2 &operator-=(vec2 other) noexcept
{
x -= other.x;
y -= other.y;
return *this;
}
constexpr vec2 &operator*=(::std::int_least32_t s) noexcept
{
x *= s;
y *= s;
return *this;
}
};
[[nodiscard]] inline constexpr vec2 operator+(vec2 a, vec2 b) noexcept
{
return a += b;
}
[[nodiscard]] inline constexpr vec2 operator-(vec2 a, vec2 b) noexcept
{
return a -= b;
}
[[nodiscard]] inline constexpr vec2 operator*(vec2 a, ::std::int_least32_t s) noexcept
{
return a *= s;
}
[[nodiscard]] inline constexpr vec2 operator*(::std::int_least32_t s, vec2 a) noexcept
{
return a *= s;
}
struct price
{
::std::int_least64_t cents{};
constexpr price &operator+=(price other) noexcept
{
cents += other.cents;
return *this;
}
};
[[nodiscard]] inline constexpr price operator+(price a, price b) noexcept
{
return a += b;
}
[[nodiscard]] inline constexpr price operator*(price p, ::std::int_least32_t units) noexcept
{
return price{p.cents * units};
}
/*
No operator* between two prices. cents * cents is not money; scaling a price by a rate
needs a rounding rule, so it is a named function with a documented contract.
*/
[[nodiscard]] inline constexpr price apply_rate(price p, ::std::int_least64_t num, ::std::int_least64_t den) noexcept
{
return price{(p.cents * num + den / 2) / den};
}
static_assert(noexcept(operator+(vec2{}, vec2{})));
static_assert(noexcept(operator+(price{}, price{})));
int main()
{
try
{
vec2 a{3, 4};
vec2 b{1, 2};
vec2 const sum{a + b};
::fast_io::io::println("a + b = (", sum.x, ", ", sum.y, ")");
::fast_io::io::println("a - b = (", (a - b).x, ", ", (a - b).y, ")");
::fast_io::io::println("a * 2 = (", (a * 2).x, ", ", (a * 2).y, ") 2 * a = (", (2 * a).x,
", ", (2 * a).y, ")");
a *= 10;
::fast_io::io::println("after a *= 10 = (", a.x, ", ", a.y, ")");
price const net{10000};
price const tax{2500};
price const total{net + tax};
::fast_io::io::println("total = ", total.cents, " cents; 7/8 of it = ",
apply_rate(total, 7, 8).cents, " cents; 7 units = ",
(total * 7).cents, " cents");
}
catch throws (::std::error e)
{
::fast_io::io::print("error: ");
::fast_io::io::println(e);
}
}
a + b = (4, 6)
a - b = (2, 2)
a * 2 = (6, 8) 2 * a = (6, 8)
after a *= 10 = (30, 40)
total = 12500 cents; 7/8 of it = 10938 cents; 7 units = 87500 cents
3. 每一行为什么在那儿
- 复合赋值是原语。
operator+=(vec2)修改并返回vec2&;operator+按值接收左操作数, 做+=,然后返回它。这保证a + b和a += b永远不可能不一致,也不需要另写一套算术来写错。 - 两个标量组合都存在。
operator*(vec2, int)和operator*(int, vec2)是两个函数。没有第二个,2 * a就不能编译而a * 2能, 类型就泄漏了“其中一个是成员”这个事实。两个都只有一行, 两个都是constexpr,两个都不分配。 - 一切都是
constexpr和noexcept。static_assert(noexcept(operator+(vec2{}, vec2{})))不是装饰: 它就是契约,由编译器检查。如果以后有人给vec2的表示加上一次堆分配, 这一行就不再编译,而这正是目的。 - 每个返回值的运算符都标
[[nodiscard]]。 语句a + b;丢弃结果、什么也不做; 这个属性把调用点上的写法变成警告。 price有+,但两个price之间 没有*。cents * cents是一个平方单位, 不是钱。价格乘一个数量没问题也不会失败,所以operator*(price, int)存在,而且含义就是它字面写的那样。- 按比率缩放是一个命名函数。
apply_rate(p, num, den)携带了运算符无法携带的两件事:舍入规则,以及“这里做了一次舍入决定”这个事实。 它是(p.cents * num + den / 2) / den—— 故意采用四舍五入 —— 而读者就是在名字那里发现这一点的。
4. 定点数是最能说明问题的地方
钱、物理引擎里的坐标、音频采样、游戏的时间步,都是定点数。 用一个类加运算符来建模它们的理由在于:错误的算术会变成编译错误,而不是静默的 bug:
- 两个
price不能相乘,因为结果的单位会是cents²。 price和整数能相乘,而这个整数是数量,不是钱。price除以整数时,舍入方式由类型文档规定 —— 或者像上面那样写成命名函数。price和浮点类型混合根本不编译,而这正是这个类型存在的全部理由 (第2.2章)。
注意这一章反复出现的模式:类型的价值不在于它能加 ——
::std::int_least64_t 也能加 —— 而在于它拒绝那些没有意义的操作。
5. 经验法则
- 只有对全域、不分配、不失败、守规律的操作才用算术运算符。
- 先写复合赋值;二元运算符拿它定义。
- 标量情形要么两个操作数顺序都提供,要么都不提供。
- 每个返回值的运算符标
[[nodiscard]];能constexpr就constexpr;不会失败就noexcept。 - 不要定义结果单位错误的那个运算符。那是你白送出去的一个编译错误。
- 舍入、饱和、溢出策略、单位换算都放进命名函数,让名字承载策略。