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:

  1. Total — defined for every combination of values, with no invalid states and no domain errors.
  2. Allocation-free — it produces a fixed-size value on the stack, so the cost is visible from the type alone (sizeof(vec2)).
  3. Non-failing — it cannot throw, so it can be noexcept, and callers need no error path.
  4. Law-abiding — it obeys the identities a reader expects: a + b == b + a, (a + b) + c == a + (b + c), and a + 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

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:

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