references/types.md
Types and class design
How to make the compiler enforce what a comment would otherwise ask for.
The special members
Rule of zero. Declare none of the five. A class whose members are all vector, string, unique_ptr, and values gets a correct destructor, copy, and move for free, and keeps getting them when a member changes type.
Rule of five. If you declare any one of destructor, copy constructor, copy assignment, move constructor, move assignment, declare all five. The trap is that they interact:
| You declare | The compiler stops generating |
|---|---|
| A destructor | Both moves. Copies still generated, but deprecated. |
| A copy constructor | Both moves. |
| A move constructor | Both copies (they are deleted). |
So adding a destructor for a log line silently turns every move in the codebase into a copy. Nothing warns. This is the reason for the rule of zero: the only class that should declare any of these is one whose single job is managing a resource, and that class should have no other members.
struct Connection {
Connection(Connection&&) noexcept;
Connection& operator=(Connection&&) noexcept;
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
~Connection();
};
Move operations are noexcept or std::vector will copy them instead. State the reason when you delete something, which C++26 lets you do inline: = delete("Connection owns a socket and cannot be duplicated").
explicit
On every single-argument constructor unless the implicit conversion is the point. Without it, Duration d = 5; compiles and means whatever unit you guessed. Also on conversion operators, including operator bool, unless you want it participating in arithmetic by accident.
C++20 adds explicit(bool) for templates that should be implicit only when their underlying conversion is.
[[nodiscard]]
On anything whose return value is the whole point: a factory, a std::expected, an accessor, a try_lock, a function whose name starts with is, has, or to. Ignoring the result is then a warning rather than a silent bug.
Put it on the type when every function returning it has this property. Marking std::expected-like error types [[nodiscard]] once covers every future signature.
enum class, always
Scoped, non-converting, and forward declarable with an explicit underlying type. Plain enum leaks its names into the enclosing scope and converts to int silently, which is how two unrelated enums end up comparing equal.
enum class OrderStatus : std::uint8_t { draft, paid, settled };
auto raw = std::to_underlying(status); // C++23, replaces the static_cast
Switch over an enum class with no default: and warnings on: adding a new enumerator then fails the build at every switch that needs updating. A default: throws that away, so only write one when the set is genuinely open.
Concepts
Constrain everything. The three forms, cheapest first:
void draw(const std::ranges::input_range auto& shapes); // terse
template <std::ranges::input_range R>
void draw(const R& shapes); // named parameter
template <class R>
requires std::ranges::input_range<R> && Drawable<std::ranges::range_value_t<R>>
void draw(const R& shapes); // compound
Write your own when the requirement has a name in your domain:
template <class T>
concept Repository = requires(T repo, OrderId id) {
{ repo.load(id) } -> std::same_as<std::expected<Order, LoadError>>;
{ repo.save(Order{}) } -> std::same_as<void>;
};
Two payoffs beyond the error message. Overload resolution prefers the more constrained candidate, so subsumption replaces tag dispatch and enable_if chains. And a constrained overload set is smaller, so the compiler does less work per call: this is a measurable build-time win in template-heavy code, not a stylistic preference.
static_assert with a message is still the right tool for a one-off invariant that has no reusable name.
Deducing this (C++23)
Collapses the const/non-const duplication that doubles the size of accessor-heavy classes:
// before: four near-identical overloads
const T& value() const&;
T& value() &;
const T&& value() const&&;
T&& value() &&;
// after: one
template <class Self>
auto&& value(this Self&& self) { return std::forward<Self>(self).value_; }
It also gives you CRTP without the curiously recurring part, and recursive lambdas without std::function.
<=>
auto operator<=>(const T&) const = default; generates all six comparisons from the members, in declaration order. Default it whenever member-wise ordering is the right ordering. Write it by hand only when it is not, and remember == is generated separately: defaulting <=> gives you == too, but a hand-written <=> does not.
variant versus virtual
std::variant + visit | virtual | |
|---|---|---|
| Set of types | Closed, known at compile time | Open, extensible by callers |
| Exhaustiveness | Checked by the compiler | Not checked |
| Semantics | Value: copyable, comparable, storable in a vector directly | Reference: needs indirection |
| Adding a type | Recompile, every visit that needs updating fails | Add a class, nothing else changes |
| Adding an operation | Add one visitor | Touch every class in the hierarchy |
Rule of thumb: if the set of types changes less often than the set of operations, use a variant. Most domain modelling is that shape, and reflex inheritance is how it ends up otherwise.
Free functions and hidden friends
Prefer a free function to a member when it does not need private access. It keeps the class interface minimal and it works uniformly across types.
For operators, define them as hidden friends: declared friend inside the class, found only by ADL. They stay out of every unrelated overload set, which both speeds up overload resolution and stops them being considered in surprising places.
struct Money {
friend Money operator+(Money a, Money b) { return {a.minor_ + b.minor_}; }
friend bool operator==(const Money&, const Money&) = default;
};
Strong types
Any value that crosses a boundary and could be confused with a neighbour deserves its own type rather than a comment.
void transfer(int from, int to, int amount); // three ints, one order to get wrong
void transfer(AccountId from, AccountId to, Money amount); // now it will not compile wrong
A strong type is a struct with one member, an explicit constructor, and = defaulted comparison. That is enough to buy the entire class of bug. Where the type system still cannot help, units go in the name: timeoutMs, priceMinor, sizeBytes. See the naming-conventions skill.