Ch17.3: Arithmetic on Value Types
1. The Case Where + Is Right
Ch17.2 said not to overload
operators when the meaning is not the obvious one. Here is the other half: when the type
is a number, arithmetic notation is not sugar, it is the notation the reader
already owns. A two-dimensional vector should be writable as
(a + b) * 2 and not as scale(add(a, b), 2).
The test is not taste. It is four properties. Overload arithmetic when the operation is:
- Total — defined for every combination of values, with no invalid states and no domain errors.
- Allocation-free — it produces a fixed-size value on the stack,
so the cost is visible from the type alone (
sizeof(vec2)). - Non-failing — it cannot throw, so it can be
noexcept, and callers need no error path. - Law-abiding — it obeys the identities a reader expects:
a + b == b + a,(a + b) + c == a + (b + c), anda + vec2{} == a.
Fail any of the four and you are back in Ch17.2 territory, and you want a name instead.
2. A Complete, Correct Value Type
Two types: a vec2 where full arithmetic is justified, and a
price where only part of it is — and where the operator that must
not exist is the interesting part.
#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. Why Each Line Is There
- Compound assignment is the primitive.
operator+=(vec2)mutates and returnsvec2&;operator+takes its left operand by value, applies+=, and returns it. This guarantees thata + banda += bcan never disagree, and it needs no separate arithmetic to get wrong. - Both scalar compositions exist.
operator*(vec2, int)andoperator*(int, vec2)are two functions. Without the second one,2 * awould not compile whilea * 2would, and the type would leak the fact that one of them is a member. Both are one line, both areconstexpr, neither allocates. - Everything is
constexprandnoexcept.static_assert(noexcept(operator+(vec2{}, vec2{})))is not decoration: it is the contract, checked by the compiler. If somebody later adds a heap allocation tovec2's representation, this line stops compiling, which is the point. [[nodiscard]]on every value-returning operator. A statementa + b;discards the result and does nothing; the attribute turns that into a warning at the call site.pricehas+and no*between two prices.cents * centsis a squared unit, not money. Multiplying a price by a count is fine and cannot fail, sooperator*(price, int)exists and means exactly what it says.- Scaling by a rate is a named function.
apply_rate(p, num, den)carries two things an operator cannot: the rounding rule, and the fact that a rounding decision was made. It is(p.cents * num + den / 2) / den— round half up, deliberately, and the name is where the reader finds that out.
4. Fixed Point Is Where This Matters Most
Money, coordinates in a physics engine, audio samples, and game ticks are all fixed-point. The reason to model them with a class and operators is that the wrong arithmetic is then a compile error instead of a silent bug:
- Two
prices cannot be multiplied, because the result would be incents². - A
priceand an integer multiply, and the integer is a count, not money. - Division of a
priceby an integer rounds in a way the type documents — or is a named function, as above. - Mixing
priceand a floating-point type does not compile at all, which is the entire reason the type exists (Ch2.2).
Notice the pattern that keeps appearing in this chapter: the value of the type is not
that it can add — an ::std::int_least64_t can add — it is that it
refuses the operations that do not mean anything.
5. Rules of Thumb
- Arithmetic operators only for total, allocation-free, non-failing, law-abiding operations.
- Compound assignment first; binary operator defined in terms of it.
- Provide both operand orders for the scalar case, or neither.
- Mark every value-returning operator
[[nodiscard]], every operatorconstexprif it can be, and every operatornoexceptif it cannot fail. - Do not define the operator whose result has the wrong units. That is a compile error you are giving away for free.
- Put rounding, saturation, overflow policy, and unit conversion in named functions, where the name can carry the policy.