Chapter 12 of 24 | C++
User-defined types and data modeling
User-defined types let a program represent concepts in its problem domain. A struct or class groups data and operations; an enum class represents a closed set of named alternatives. Choose names and invariants that make invalid states difficult to create.
Structs and classes
#include <stdexcept>
#include <string>
struct Point {
double x{};
double y{};
};
class BankAccount {
public:
explicit BankAccount(int opening_cents) : cents_{opening_cents} {
if (opening_cents < 0) {
throw std::invalid_argument{"opening balance must be nonnegative"};
}
}
int balance_cents() const { return cents_; }
void deposit(int cents) {
if (cents > 0) cents_ += cents;
}
private:
int cents_;
};
Members of a struct are public by default; members of a class are private by default. Both can have constructors, member functions, and access control. Keep representation private when callers must not bypass rules that protect the object's validity.
A constructor establishes an object's initial state. Member initializer lists initialize members directly, in declaration order. explicit prevents an unintended implicit conversion from a single constructor argument.
Model choices, not magic numbers
enum class TrafficLight { red, yellow, green };
TrafficLight light = TrafficLight::green;
enum class scopes its enumerators and does not implicitly convert them to integers. It communicates a meaningful set of alternatives more clearly than unrelated numeric constants.
Const correctness and invariants
Mark observer member functions const when they do not change the observable state. Pass read-only objects by const reference when copying is unnecessary. Define invariants in plain language—for example, a width must be nonnegative—and validate or encode them in constructors and operations.
Prefer value semantics when a type is naturally copied as a value. A small Point is usually simplest as a plain value. Add inheritance only when substitutability is part of the design, not just to reuse a few lines.
Design the public interface first
Before writing private fields, list what a caller should be able to ask the object to do. A library book may be checked out, returned, or queried for availability; callers should not be able to change a private availability flag in a way that contradicts other state. A class interface puts those operations in one place and can reject invalid transitions.
This protection is an invariant: a rule that must remain true for every valid object. Examples include a nonnegative account balance, a start time that is not after an end time, or a normalized coordinate. Constructors establish invariants when objects are created, and every public mutating function must preserve them. If invalid states can be represented freely, every caller must remember how to avoid them.
Initialization and construction
Members are initialized in the order they are declared in the class, not the order written in the constructor's initializer list. Write initializers in declaration order to avoid confusion and warnings. Prefer direct initialization in that list, which constructs the member in place instead of default-constructing it and assigning later.
A constructor should either establish a valid object or report why it cannot do so. For input that can fail in normal operation, another design is to use a factory function that returns an explicit success-or-error result. Do not silently create an object with a plausible-looking but invalid state.
Value semantics and equality
Types such as points, dates, and measurements are often natural values: copying one produces another independent value with the same data. The compiler-generated copy operations work well when members are themselves value types. Resources such as a file handle have different semantics; copying may not be meaningful, and the type should express that.
When defining equality or ordering, ensure the behavior matches the domain and is consistent. If a custom type is used as a key in an ordered container, its comparison must provide a consistent strict ordering. Hash-based containers require a hash function consistent with equality. Incorrect relations can cause standard algorithms and containers to behave unpredictably.
Prefer composition before inheritance
Composition means one type contains another type as a member. It often models a “has a” relationship directly and keeps behavior localized. Inheritance models an “is a” relationship only when a derived object can safely be used anywhere the base object is expected. Public inheritance affects substitutability and usually requires a virtual destructor in a polymorphic base class. These are design commitments, not merely ways to share code.
Start with simple structs and value types. Add encapsulation where rules need protection, and inheritance or dynamic polymorphism only when the design requires a stable family of interchangeable behaviors.
Practice
- Define a
Datetype and state which combinations of day, month, and year are valid. - Create a
enum classfor a task's status. - Make an object's data private and provide operations that preserve its invariant.
- Explain when a
structis a better fit than a class with a complex interface. - Write down two invariants for a type before implementing its members.
- Make invalid construction fail clearly or represent it explicitly as a result.
- Compare “has a” composition and “is a” inheritance for a simple domain model.
Key idea: types are design tools. A good model communicates intent, protects invariants, and makes invalid operations harder to express.
Special member functions and value semantics
Classes can have a default constructor, destructor, copy constructor, copy assignment operator, move constructor, and move assignment operator. Collectively, these are the special member functions. The compiler generates some operations under specific rules; declaring one can suppress or change implicit generation of others. A resource-owning class that defines a destructor but leaves copying enabled may accidentally copy a raw owning pointer and cause double deletion.
Prefer the Rule of Zero: store resources in members such as vectors, strings, and unique pointers, then let their well-defined copy and move operations compose. If the type truly needs custom ownership behavior, explicitly decide whether it is copyable, movable, both, or neither. Test self-assignment, exceptions during copying, and moved-from states as part of the type's contract.
Invariant-preserving operations
A class invariant must hold after construction and after each public operation. For a bank account, a deposit operation may reject nonpositive amounts and check that addition cannot overflow. If an operation consists of multiple mutations and one can fail, order the work so failure does not leave the object half-updated, or use a temporary state and commit only after validation.
Constructors should initialize members directly and in declaration order. A member initializer list is not merely style: it determines how members are initialized. If a member is a reference or const object, it must be initialized there. If a base class exists, its initialization order and access also matter. Make invariants visible in the API and test them at the boundaries.
Polymorphism and substitutability
Runtime polymorphism through virtual functions is useful when callers need to work with different concrete implementations through a common interface. A base class used polymorphically should usually have a virtual destructor so deleting a derived object through a base pointer destroys the full object correctly. Use override on derived functions so the compiler verifies that a virtual function is actually overridden.
Inheritance also imposes a behavioral contract: a derived object must remain usable wherever the base abstraction is expected. If derived classes weaken a precondition or violate a base postcondition, substitutability fails even if the signatures compile. Composition, templates, or type erasure can be better alternatives when runtime inheritance does not express a genuine relationship.
Value types, identity, and type erasure
Some objects are values: two points with the same coordinates are interchangeable. Others have identity: two open connections are not equivalent merely because their configuration matches. This distinction guides equality, copying, storage, and ownership. A value type should generally have predictable copy and comparison semantics; an identity-bearing resource may be non-copyable and managed through an owner.
Type erasure stores different concrete types behind a uniform runtime interface without requiring them to share a public base class. Facilities such as std::function provide familiar examples. Type erasure can simplify APIs at the cost of indirection, allocation, or a narrower set of exposed operations. Use it when runtime flexibility matters and measure its costs in a performance-sensitive path.
Advanced exercise
- Implement a resource-owning type using the Rule of Zero, then compare it with a deliberately non-copyable owner.
- Define a class invariant and test every public operation for preservation, including exceptional exits.
- Design a polymorphic interface with a virtual destructor and
override, then evaluate whether composition or a value-based alternative would be simpler. - Specify whether a domain type represents value or identity and derive its copy/equality policy.