Class Design Cheatsheet¶
A class is a promise: this is what the type means, and these are the only ways it can change. Everything here is a way of keeping that promise, or a way of accidentally breaking it.
Pairs with Module 2 — Classes and Objects and Module 4 — Encapsulation
The design checklist¶
Run this for every class you write. It takes two minutes and catches most design defects.
- [ ] The responsibility fits in one sentence with no "and".
- [ ] The invariant is stated, and there is one place that establishes it and one that preserves it.
- [ ] Every data member is private.
- [ ] Every member function that does not modify the object is
const. - [ ] The interface is the smallest set of operations a caller actually needs — no getters "in case".
- [ ] No getter returns a mutable reference to a member (see the leak table below).
- [ ] Every public function either succeeds completely or changes nothing (where it can).
- [ ] Nothing outside the class has to know the representation to use it.
- [ ] You can name what the class owns, and it is expressed in the member types.
The interface: commands and queries¶
| Kind | Modifies the object? | const? |
Example |
|---|---|---|---|
| Query | No | Yes | int balance() const; |
| Command | Yes | No | void deposit(int amount); |
| Factory / static | No object yet | n/a, and cannot be const |
static Account open(...); |
Give the query a noun and the command a verb. It reads as the domain does: account.balance(),
account.deposit(500).
Four ways a class leaks its representation¶
Each row is a real leak, and each has the same fix: return a value, or return const.
| Leak | Why it is a leak | Fix |
|---|---|---|
| Public data member | Anyone can set any state, including invalid | Make it private, expose a command |
T& items() { return items_; } |
Caller can insert anything, breaking the invariant | Return by value, or expose queries |
T& items() const { return items_; } |
Even from a const object, the caller mutates through the reference |
const T& items() const |
setX for every member |
A setter per member is public data with extra steps | Replace with operations that mean something |
error: binding reference of type 'std::vector<std::__cxx11::basic_string<char> >&' to
'const std::vector<std::__cxx11::basic_string<char> >' discards qualifiers
The compiler is doing your design review. std::__cxx11::basic_string<char> is GCC's internal
name for std::string.
Invariants: state them, then make them impossible to break¶
| Invariant flavour | Where it is enforced |
|---|---|
A single value's range (balance >= 0) |
In the setter-command, before the change |
A relation between members (capacity >= size) |
In every command that touches either |
| A relation across a collection (no duplicate ids) | In the class that owns the collection |
| A relation across two objects (no item lent twice) | In exactly one of them, and the other asks |
void Account::withdraw(int amount)
{
if (amount <= 0) { throw std::invalid_argument("amount must be positive"); }
if (amount > balance_) { throw std::logic_error("insufficient balance"); }
balance_ -= amount; // the only line that changes the state
}
The pattern: validate everything, then change exactly one thing. That is what makes the "leaves the object untouched on failure" guarantee easy to keep and easy to test.
const-correctness, quickly¶
| Position | Means |
|---|---|
const T& parameter |
"I will not copy it and will not modify it" — the default for anything larger than a pointer |
T f() const |
"Calling this does not change the object" |
const T* |
Pointer to something you will not modify |
T* const |
A pointer you will not reseat (rarely what you meant) |
Mark it const and the compiler will tell you when you lied — that is the whole benefit. Doing
this at the end, rather than as you go, is how people spend an evening adding const to fifty
declarations.
Coupling: the two habits that matter most¶
Tell, do not ask. Instead of reading an object's state and deciding for it, ask the object to do the thing.
if (account.balance() >= 500) { account.withdraw(500); } // asking, then acting — double validation
account.withdraw(500); // telling: the rule lives in one place
Do not reach through. order.customer().address().city() couples you to three classes at
once, and any of them changing breaks you. Ask the object you already have for what you need.
Class sizes, as a smell detector¶
| Symptom | What it usually means |
|---|---|
| More than ~7 public functions | Probably two responsibilities |
| A name with "And", "Manager" or "Util" in it | The responsibility was never found |
| A class with only getters and setters | It is a struct pretending to be an object |
Every function needs three parameters and this |
The data is in the wrong class |
| A member used by only one function | It belongs in that function, or in another type |
Checklist before you call a class finished¶
- [ ] I can state its invariant out loud, in one sentence.
- [ ] I tried to break it from
mainand could not. - [ ] It compiles with
-Wall -Wextra -Werrorand no warnings. - [ ] Copying it is either correct or forbidden — checked by Rule of Zero/Three/Five.
- [ ] A caller can use it without reading its header comments for anything but what it does.
Related: C++ OOP Quick Reference · Rule of Zero, Three and Five · SOLID · Module 4 — Encapsulation