Ch13.5: Encapsulation: Keeping the Invariant True
1. An Invariant Is a Sentence That Is Always True
“The balance of an account is never negative.” That sentence is the whole reason the class exists. If it is true, every function that receives an account may assume it and stop checking. If it is false, every function that receives an account must defend itself, forever.
Encapsulation is the technique that makes the sentence true by construction: the data member is private, so the only code in the entire program that can write to it is the code you wrote inside the class — and that code checks the sentence before every write.
Compare the two versions of the same idea. With a public member, the invariant is a comment that anything anywhere can violate:
struct account
{
::std::int_least64_t balance; // "never negative" — but nothing enforces it
};
account a{100};
a.balance = -1000; // compiles; the sentence is now false
With a private member and a checked interface, every write goes through code that refuses to make the sentence false. The check cannot be forgotten by a caller, because the caller cannot reach the data:
#include <fast_io.h>
#include <cstdint>
using namespace ::fast_io::io;
class account
{
public:
explicit account(::std::int_least64_t opening) throws : balance_{opening}
{
if (opening < 0)
{
throw throws ::std::errc::invalid_argument;
}
}
::std::int_least64_t balance() const noexcept
{
return balance_;
}
void deposit(::std::int_least64_t amount) throws
{
if (amount <= 0)
{
throw throws ::std::errc::invalid_argument;
}
balance_ += amount;
}
void withdraw(::std::int_least64_t amount) throws
{
if (amount <= 0 || amount > balance_)
{
throw throws ::std::errc::invalid_argument;
}
balance_ -= amount;
}
private:
::std::int_least64_t balance_{}; // never negative, always
};
int main()
{
try
{
account a{100};
println("balance: ", a.balance());
a.deposit(50);
println("balance: ", a.balance());
try
{
a.withdraw(1000); // more than the balance
}
catch throws (::std::error e)
{
print("withdraw refused: ", e, "\n");
}
println("balance: ", a.balance());
account bad{-1}; // refused by the constructor
println("never reached: ", bad.balance());
}
catch throws (::std::error e)
{
print("account rejected: ", e, "\n");
}
}
balance: 100
balance: 150
withdraw refused: [posix](22)Invalid argument
balance: 150
account rejected: [posix](22)Invalid argument
2. Reading the Example
- The constructor establishes the invariant. Every account begins its life by passing the check, and there is no other way to build one. This is the difference between an object and a variable that happens to have the right type.
-
Every modifier preserves it.
depositrefuses non-positive amounts,withdrawrefuses overdrafts. Neither function has to re-check anything the constructor already guaranteed. -
Failure is reported, not hidden. A refused operation throws
::std::errc::invalid_argument, so the caller learns about it at the exact moment it happens, with the caller’s own context in hand (Ch3.16). The alternative — silently clamping to zero, or setting a flag somebody may read later — turns a bug into a mysterious number. -
The refusal leaves the object untouched. The check happens
before the write, which is why the balance is still
150after the failed withdrawal. Write every modifier in that order: validate, then modify.
account{-1} and a.withdraw(1000) both throw
::std::errc::invalid_argument, both are caught with
catch throws (::std::error e), both cost nothing on the path where
nothing goes wrong. There is no separate “constructor error” concept
to learn.
3. The Payoff: You May Rewrite the Inside
The interface of account is four names:
account(...), balance(), deposit(...),
withdraw(...). Nothing outside the class knows how the balance is
stored. So all of these are private decisions you can make later without
touching a single line of calling code:
| You decide | What changes outside |
|---|---|
Store cents in an ::std::int_least64_t instead of whole
units. |
Nothing. The modifiers and the accessor absorb the conversion. |
| Keep a history of transactions and compute the balance from it. | Nothing. Same four names, different data members. |
| Add a currency field, an owner, an interest rate. | Nothing, as long as the constructor still accepts what it used to. |
Add transfer_to(account &, amount) that keeps both
sides consistent. |
Nothing. It is a new name, and the invariant is still enforced in one place. |
With public data members, every one of those decisions is a breaking change:
every line of code that wrote a.balance or read it must be found
and rewritten, and no compiler will tell you which lines those were if the
member stays but changes meaning.
4. Accessors: Give Out Values, Not the Storage
An accessor is only worth writing when it hands out something the caller cannot get otherwise. And what it hands out matters more than whether it exists:
-
By value is the default —
::std::int_least64_t balance() const noexceptreturns the number itself, as a value. The caller cannot use it to change the account. -
const &is for members that are expensive to hand over by value, like::fast_io::string. It still hands out no writable access:::fast_io::string const &name() const noexcept. -
A non-
constreference is a hole in the wall.::std::int_least64_t &balance() noexceptreturns the storage itself, soa.balance() = -1000;is legal again, and the invariant is gone. Do not write this accessor. If a caller genuinely needs to modify the value, give them a member function that checks (a.deposit(1000)).
balance_ with balance() and
set_balance(v) that accepts anything is exactly as unprotected as
a public field, only longer. The wall is worth building when the functions
behind it do something: check, convert, count, log, refuse. Otherwise
you have added ceremony and kept the bug.