Ch13.5: Encapsulation: Keeping the Invariant True

English

提示:本页尚未翻译,当前显示英文原文。

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

Constructor failures and operation failures use the same mechanism. 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:

Encapsulation is not “a private field plus a getter and a setter”. A private 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.
Where do you put the check? In the constructors and in the modifiers — nowhere else. If reading the object could fail, the design is wrong: an object that exists should be readable and usable without further validation. That is what “the invariant is always true” means.