17 / 163 · C++11 · 8 min
Encapsulation, Inheritance, and Composition: The Boundaries of OOP
Encapsulation maintains an object's invariants. Public inheritance expresses a substitutable interface relationship; composition expresses ownership or use. Do not build an inheritance hierarchy just because you can reuse a few lines of code. First define legal states and the call contract, then decide whether you need runtime polymorphism.
In this lesson
Encapsulation is not mass-producing getters
The goal of encapsulation is to keep an object always satisfying its invariants, not merely putting fields under private. For example, if a balance cannot be negative, provide a withdrawal operation that checks rather than returning a mutable reference to the balance. Callers only need to understand the operation's preconditions, results, and failure modes; they need not know whether the balance is an integer or some kind of ledger.
The constructor is responsible for establishing the initial legal state; member functions are responsible for maintaining it. If every field exposes a setter that allows arbitrary assignment, the checking responsibility is still scattered among external callers. Encapsulation's benefit truly appears only when the implementation needs to change while the call contract stays the same.
Public inheritance is a substitution promise
Wallet : public Payment means a wallet can be used wherever a payment interface is accepted. Besides matching function signatures, a derived class should honor the interface contract: insufficient balance returns failure and the balance remains unchanged. If some derived class still deducts money on failure, behavioral substitutability is broken even if the code compiles.
Virtual functions allow a concrete implementation to be selected through a base-class reference; inheritance itself does not automatically enable dynamic dispatch. Users of the interface generally should not repeatedly inspect the concrete derived type; otherwise every new implementation requires modifying callers that ought to remain independent.
Prefer composition for implementation reuse
A wallet owns a balance manager, so placing Balance as a member is more direct than having the wallet inherit from a balance manager. Composition does not automatically expose every public interface of the reused type, and it avoids writing an internal implementation relationship as an external type relationship. Members are constructed and destroyed automatically with the outer object, so lifetimes are easy to track.
The example uses both relationships at once: the wallet publicly implements the payment interface and internally composes a balance object. Introduce virtual functions only when you truly need to invoke different implementations through the same interface. For simple objects whose types are fixed at compile time, direct composition and ordinary member functions are usually enough.
Pitfalls
- Public inheritance enables upcasts; that does not mean the derived class automatically satisfies the base class's business contract. Post-failure state is part of the contract.
- Returning a mutable reference to an internal field bypasses checks; private is not a memory-safety or encryption mechanism.
Run an example
Minimum C++11 · complete program · Download .cpp
#include <cassert>
#include <stdexcept>
class Balance {
int cents_;
public:
explicit Balance(int cents) : cents_(cents) {
if (cents < 0) throw std::invalid_argument("negative balance");
}
bool withdraw(int amount) {
if (amount < 0 || amount > cents_) return false;
cents_ -= amount;
return true;
}
int value() const { return cents_; }
};
struct Payment {
virtual bool pay(int cents) = 0;
virtual ~Payment() = default;
};
class Wallet final : public Payment {
Balance balance_;
public:
explicit Wallet(int cents) : balance_(cents) {}
bool pay(int cents) override { return balance_.withdraw(cents); }
int remaining() const { return balance_.value(); }
};
int main() {
Wallet wallet(100);
Payment& payment = wallet;
const bool paid = payment.pay(30);
const bool rejected = payment.pay(80);
assert(paid && !rejected);
assert(wallet.remaining() == 70);
}
Compile locally
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread objects-oop.cpp -o example && ./exampleExpected result
Expected: exit 0, no output; every assert holds.
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
When adding a refund feature to Wallet, why not expose Balance& directly? How should the interface be constrained?
Show a reference answer
Exposing Balance& would leak the internal implementation and any operations added later. Add an explicit refund(int) operation that rejects negatives and, before adding, checks whether the amount would exceed the representable upper bound; on a failed check, leave the balance unchanged. That way the balance representation can be replaced, and the payment interface need not include refund capability.
Check the sources
Drafts and official chapters change. The version mark is only the example’s minimum.