C++ Programming
Lesson 18 of 24

Lesson 18 · 24 lesson course

18. Classes and Objects

Constructing object-oriented solutions

Programming glossary 80 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

Abstract class scaffold producing several object instances with distinct encapsulated internal states.

 

 

Lesson: Object Orientation in C++Encapsulation, Behaviour, and State

Procedural programming puts functions at the centre: you call functions that accept parameters and return results; over time those calls mutate program state. This works—but the language doesn’t natively regulate the state itself. Bugs thrive where state is misused. Object-oriented programming (OOP) answers this by coupling state with its allowed behaviours. An object is a piece of state plus the only operations that may change it.

C++ popularised OOP at industry scale. It is, however, a multi-paradigm language: you can (and often should) mix OO with generic, functional, and procedural styles. Because C++ exposes low-level memory, you can also circumvent encapsulation—so good design and discipline really matter.

Programming Insight (AI) — Where Should the State Live?
  • Paste a procedural snippet; ask AI to propose object boundaries (what data to encapsulate, which methods expose behaviour).
  • Have AI list invariants (truths that must always hold) and draft unit tests to enforce them.

1) From Procedural to Object-Oriented

Procedural: functions operate on externally owned state.

#include <vector>
#include <algorithm>

void addNumber(std::vector<int>& v, int x) { v.push_back(x); }
void deleteNumber(std::vector<int>>& v, int x) {
    v.erase(std::remove(v.begin(), v.end(), x), v.end());
}
void sortNumbers(std::vector<int>& v) { std::sort(v.begin(), v.end()); }

// ❌ Any caller can forget to call sort, or mutate v directly and break assumptions.

Object-oriented: the object owns the state and regulates access through methods.

#include <vector>
#include <algorithm>
#include <stdexcept>

class IntBag {
public:
    void add(int x) { data_.push_back(x); is_sorted_ = false; }
    void remove(int x) {
        data_.erase(std::remove(data_.begin(), data_.end(), x), data_.end());
        // removing doesn't break sortedness if it was sorted before
    }
    void sort() { std::sort(data_.begin(), data_.end()); is_sorted_ = true; }

    // Query operations (do not mutate state)
    bool contains(int x) const {
        return is_sorted_ ? std::binary_search(data_.begin(), data_.end(), x)
                          : std::find(data_.begin(), data_.end(), x) != data_.end();
    }
    std::size_t size() const noexcept { return data_.size(); }
    int at(std::size_t i) const { 
        if (i >= data_.size()) throw std::out_of_range("IntBag::at");
        return data_[i];
    }
private:
    std::vector<int> data_;
    bool is_sorted_ = false;   // <- invariant flag guarded by methods
};

The invariant (is_sorted_ correctly reflects ordering) is maintained solely by the methods, reducing misuse of state.

Programming Insight (AI) — Invariant Designer
  • Ask AI to extract invariants from your class, propose asserts, and generate tests to verify them after every mutating method.

2) Classes, Methods, and Access Control

  • class & struct are the same except default access: class is private by default; struct is public.
  • Use public for your API (behaviours), private for representation (state), and protected for derived classes.
  • Prefer keeping data members private; expose behaviour via methods. This is encapsulation.
class Account {
public:
    explicit Account(double opening) : balance_{opening} {}
    void deposit(double amt) { require(amt > 0); balance_ += amt; }
    void withdraw(double amt) { require(0 < amt && amt <= balance_); balance_ -= amt; }
    double balance() const noexcept { return balance_; }

private:
    double balance_{};
    static void require(bool cond) { if (!cond) throw std::logic_error("precondition failed"); }
};

Note the preconditions (simple “contracts”) that guard valid state transitions.


3) Construction, Destruction, and RAII

  • Constructors establish invariants; destructors release resources (files, memory). This is RAII.
  • Follow the Rule of Zero where possible: rely on the defaults; use standard types that manage resources for you.
#include <fstream>
#include <string>

class LineReader {
public:
    explicit LineReader(std::string path) : f_(std::move(path)) {
        if (!f_) throw std::runtime_error("open failed");
    }
    bool next(std::string& out) { return static_cast(std::getline(f_, out)); }
private:
    std::ifstream f_;  // closed automatically in destructor
};  // No custom destructor needed: Rule of Zero.
Programming Insight (AI) — Rule of Zero/Five Helper
  • Ask AI whether your class should define copy/move/dtor or rely on defaults; get a suggested implementation if needed.

4) Interfaces & Polymorphism (Runtime vs Compile-Time)

Runtime polymorphism via virtual functions:

struct Sorter {
    virtual ~Sorter() = default;
    virtual void sort(std::vector<int>&) const = 0;
};

struct QuickSort : Sorter {
    void sort(std::vector<int>& v) const override { std::sort(v.begin(), v.end()); }
};

void sort_with(const Sorter& s, std::vector<int>& v) { s.sort(v); } // dynamic dispatch

Compile-time polymorphism via templates (no vtable; often faster, more inlinable):

template <typename Algo>
void sort_with(Algo algo, std::vector<int>& v) { algo(v); }

struct Quick {
    void operator()(std::vector<int>& v) const { std::sort(v.begin(), v.end()); }
};

Choose based on needs: runtime substitution vs maximal performance/optimisation.

Programming Insight (AI) — Pick Polymorphism Style
  • Describe your extensibility/perf constraints; AI will recommend virtual interfaces vs templates (or both via policy classes).

5) Encapsulation Pitfalls (and how C++ lets you cheat)

  • Public data members: bypass invariants. Prefer private + methods.
  • Leaking internal references/pointers: gives external code a handle to mutate invariants.
  • friend: powerful but pierces encapsulation. Use sparingly for tightly coupled helpers or tests.
  • Const-correctness: mark non-mutating methods const; avoid lying with mutable/const_cast unless justified.
// ❌ Leaks internal state (any caller can mutate 'data_' arbitrarily)
class Bad {
public: std::vector<int>& data() { return data_; }
private: std::vector<int> data_;
};

Better: expose read-only views or controlled mutators.

class Better {
public:
    std::size_t size() const noexcept { return data_.size(); }
    int at(std::size_t i) const { return data_.at(i); }
    void push(int x) { data_.push_back(x); }
private:
    std::vector<int> data_;
};

6) Composition > Inheritance (most of the time)

Before subclassing, consider building objects by composing smaller ones. Composition is simpler, safer, and keeps hierarchies shallow. Reserve inheritance for “is-a” relationships with stable interfaces.

class Logger { /* ... */ };
class Repo   { /* ... */ };

class Service {          // composed behaviour
public:
    Service(Logger log, Repo repo) : log_(std::move(log)), repo_(std::move(repo)) {}
    void run();
private:
    Logger log_;
    Repo   repo_;
};
Programming Insight (AI) — Inherit or Compose?
  • Paste a planned hierarchy; get a proposal to flatten with composition or define a minimal, stable base interface.

7) Worked Example — A Sorted Set with Encapsulated State

We encapsulate a multiset that always remains sorted after insert/erase operations. External code cannot break the invariant.

#include <vector>
#include <algorithm>

class SortedSet {
public:
    bool insert(int x) {
        auto it = std::lower_bound(v_.begin(), v_.end(), x);
        if (it != v_.end() && *it == x) return false; // already there
        v_.insert(it, x);
        return true;
    }
    bool erase(int x) {
        auto it = std::lower_bound(v_.begin(), v_.end(), x);
        if (it == v_.end() || *it != x) return false;
        v_.erase(it);
        return true;
    }
    bool contains(int x) const {
        return std::binary_search(v_.begin(), v_.end(), x);
    }
    std::size_t size() const noexcept { return v_.size(); }
    int at(std::size_t i) const { return v_.at(i); }

private:
    std::vector<int> v_;  // representation hidden; invariant: v_ is always sorted
};

All mutations go through insert/erase; consumers get queries only. The representation (vector) can change later without breaking users.

Programming Insight (AI) — Representation Swap
  • Ask AI to trade std::vector for std::set or a B-tree while keeping the same public API; compare complexity and performance.

8) Mini Exercise — Refactor to an Object

Task: You have these free functions:

void open(File& f, const std::string& path);
void write(File& f, std::string_view bytes);
void close(File& f);

Refactor into a class that owns its resource (RAII), enforces valid call order (open → write* → close), and cannot be copied but can be moved. Add guard conditions and error handling. Then, sketch a pure virtual interface (IFile) to allow test doubles.

Programming Insight (AI) — Starter Patch
  • Ask AI to generate a skeleton with RAII, deleted copy, defaulted move, and a matching test double interface.

Summary Checklist

  • Encapsulate state and expose only behaviours that maintain invariants.
  • Prefer composition and clear interfaces; use inheritance when you truly need runtime substitution.
  • Use RAII to align lifetime with scope; aim for the Rule of Zero.
  • Keep representation private; don’t leak internal pointers/references that let callers corrupt your state.
  • Choose polymorphism style (virtual vs templates) based on extensibility and performance needs.
  • Remember: C++ lets you bypass encapsulation—design is your guardrail.

Advanced perspective: access control is useful only when the type accepts responsibility

A class earns its boundary by making invalid states or transitions harder to express

Putting variables between class braces does not create a useful abstraction. Nor does changing public to private while supplying a setter for every field. The real test is whether the type takes responsibility for a coherent state model: what must be true after successful construction, which operations may change the state, which requests are rejected, and what remains true after rejection.

An object has identity, state and lifetime. Its class definition describes the type shared by such objects; it does not create one universal object. Two Account objects can obey the same rules while holding different balances. An ordinary int is also an object, although its type is supplied by the language. We introduce a user-defined class when the program needs a responsibility that a collection of unrelated values and functions does not state clearly enough.

Design questionEvidence of a useful answerReassuring but insufficient substitute
What states are valid?A precise invariant that can be checked at construction and after mutation.All fields are private.
Which changes are permitted?Operations named for domain behaviour with stated preconditions and outcomes.A setter exists for each field.
Who owns the state?Copy, move, lifetime and aliasing policy match the model.The class has a destructor, whether needed or not.
Can representation change?Callers depend on behaviour rather than internal containers or addresses.The member names are hidden in a header.

Construction must either establish a usable object or fail

#include <iostream>
#include <stdexcept>

class Account {
public:
    explicit Account(int openingBalance) : balance_{openingBalance} {
        if (openingBalance < 0) {
            throw std::invalid_argument{"opening balance cannot be negative"};
        }
    }

    bool withdraw(int amount) {
        if (amount < 0 || amount > balance_) {
            return false;
        }
        balance_ -= amount;
        return true;
    }

    int balance() const {
        return balance_;
    }

private:
    int balance_;
};

int main() {
    Account account{100};
    account.withdraw(30);
    std::cout << account.balance() << '\n';
}

A successful Account promises that balance_ >= 0. Construction with 100 establishes that state. The withdrawal request is valid because 30 is non-negative and no greater than the current balance, so the state changes from 100 to 70 and the program prints 70.

The constructor also exposes an important boundary in the word "invariant". With a negative argument, balance_ is initialised before the constructor body detects the problem. The exception prevents construction from completing, so no usable Account with a negative balance escapes to the caller. The externally promised invariant holds for every successfully constructed object. If a type requires the member never to contain a rejected value even during construction, validation must occur before that member is initialised, for example through a checked helper or factory. State the required boundary; do not pretend every class needs the stronger one.

RequestState beforeOutcomeRequired state after
Construct with 100No Account object.Construction succeeds.A live account with balance 100.
Construct with -1No Account object.An exception reports the rejected argument.No successfully constructed account escapes.
Withdraw 30 from 100Balance 100.Returns true.Balance 70.
Withdraw 90 from 70Balance 70.Returns false.Balance remains 70.
Withdraw -5 from 70Balance 70.Returns false.Balance remains 70.

The rejected cases matter as much as the accepted one. A method that returns false after partially changing the balance has not honoured the model. Tests should inspect both the reported outcome and the resulting state. The invariant is a relation over program states; a method name is not evidence that the relation survived.

Encapsulation controls transitions, not merely spelling

private prevents an ordinary caller from writing account.balance_ = -500;. That closes one path. A member function can still write an invalid value, expose a mutable reference to the member, retain a dangling alias or perform work that belongs to another component. Access control determines which code may name the representation directly. Encapsulation depends on what that privileged code actually permits.

This is why withdraw(amount) describes more than set_balance(value). The withdrawal operation receives a request, checks it against the current state and either performs one valid transition or rejects it without change. A general setter transfers the responsibility back to every caller. If the class accepts any value a caller chooses, the data is privately spelt but publicly controlled.

Interface choiceResponsibility kept by the classRisk transferred to callers
bool withdraw(int amount)Checks amount and available balance; preserves state on rejection.The caller decides how to respond to failure.
void set_balance(int value)Only whatever validation the setter explicitly performs.The caller must understand which arbitrary states make sense.
int balance() constExposes a value observation without a mutable path to the member.The caller receives a snapshot, not ownership of internal state.
int& balance()Very little after the reference escapes.External code can mutate the representation without the class's checks.

The const on balance() allows the member to be called through a const account and prevents ordinary modification of that account through this. It supports the claim that this operation is an observation. It is not a moral guarantee: indirection, global state or carelessly used mutable data can still create externally visible effects. Review the behaviour as well as the qualifier.

Decide whether objects are values or entities

The compiler can copy this simple Account by copying its integer member. Whether that operation is meaningful is a design question, not a mechanical one. If an account object represents a value-like snapshot, independent copies may be sensible. If it represents one continuing real-world identity, accidental duplication may violate the model even though every copied integer is valid.

Ask what equality means, whether two objects may represent the same entity, and what copying is supposed to produce. Then make copy and move operations agree with that answer. Deleting copying is appropriate when duplication has no meaning or a unique resource forbids it. Default copying is appropriate when well-behaved members already express independent value semantics. A hand-written copy constructor is justified only when the type has a real policy to implement.

Representation privacy should buy freedom to change representation

A class that exposes its internal vector by mutable reference has published more than a convenience. It has published storage choice, invalidation behaviour and unrestricted mutation. Replacing the vector later may break callers even if the class's intended behaviour has not changed. Returning a read-only value, an operation-specific result or a carefully bounded view can preserve more design freedom.

This does not mean every getter is wrong or every data member belongs behind a behavioural ceremony. A coordinate with two independently meaningful public values may be an honest open record. C++ permits both struct and class to have constructors, member functions, templates and inheritance. Their principal language differences are default member access and default base-class access: public for struct, private for class. The common convention of using struct for an open record and class for an invariant-protecting abstraction communicates intent; it is not a separate capability tier.

QuestionOpen record may fit when...Invariant boundary may fit when...
Are fields independently meaningful?Callers legitimately read and replace them directly.Fields form a relation that must be preserved.
Are operations more stable than storage?The representation is itself the intended interface.Behaviour should survive a representation change.
Can arbitrary mutation create nonsense?No stronger validity rule is required.Only selected transitions should be permitted.

Composition and inheritance answer different substitution questions

Composition says that one object uses or owns another as part of its implementation. Inheritance says more than two types share fields: public inheritance presents a derived object where a base object is expected and therefore accepts the base interface's behavioural obligations. The phrase "is a" is a useful prompt, but it is not a completed substitution argument.

If a Team owns a std::vector<Player>, composition already gives it managed storage, destruction, and defined copy and move behaviour inherited from its members. Unless the class adds a raw resource or a different identity policy, a hand-written destructor and copy constructor add code without adding meaning. This is the Rule of Zero: choose members that already own their responsibilities, then let generated special member functions express the composition.

Runtime polymorphism is justified when callers must work through one stable interface while selecting implementations at runtime. Templates can express compile-time variation when concrete types may be known during compilation. "Virtual is slow" and "templates are fast" are proxies, not decisions. Substitution needs, ownership, binary boundaries, code size, diagnostics and measured cost all belong in the judgement.

Correction to the earlier LineReader example: static_cast<std::getline(f_, out)> is ill-formed because the angle brackets must contain a type, while std::getline(f_, out) is an expression. The intended statement is return static_cast<bool>(std::getline(f_, out));. The read occurs, then the resulting stream state is explicitly converted to the bool promised by the interface. The preserved source still illustrates ownership by an ifstream, but this correction must accompany any claim that the example compiles.

Programming Insight (AI): ask for the invariant, transitions and escape routes

When AI proposes a class, require one sentence describing every valid state and a table of every constructor and mutating operation. For each operation, record its precondition, success transition, failure report and state after failure. Then search for escape routes: public members, general setters, mutable references, aliases whose lifetime exceeds the object, friends and inherited access.

Do not accept "encapsulated" because the fields are private or "RAII-compliant" because a destructor exists. Ask which resource is owned, which member releases it, what copying means and what happens when construction or an operation fails. The answer should let you derive tests. If it supplies only labels, it has described the costume of a class rather than its responsibility.

Transfer task: design the state model before designing the methods

Design an InventoryItem with stock and reserved quantities. Its invariant is 0 <= reserved && reserved <= stock. The system must receive stock, reserve an available quantity, cancel a reservation and dispatch reserved items. For each operation, state the accepted inputs, the state transition on success and the unchanged state required on failure.

Now decide whether callers need set_stock, set_reserved, neither or both. Decide whether copying an item duplicates an independent value or creates an impossible second representation of one inventory identity. Finally, list the minimum observations needed for tests without returning mutable references to the representation. Write the public interface only after those decisions. Otherwise the first draft of the methods will quietly become the specification.

Reveal answer

I would treat an InventoryItem as one continuing inventory identity, not as a freely copied arithmetic value. Every quantity-changing operation accepts a strictly positive quantity. Rejection is reported as a result and leaves both stored quantities unchanged.

OperationAdditional acceptance conditionSuccessful transitionFailure state
receive(q)q > 0 and stock + q is representable.stock' = stock + q; reserved' = reserved.Both values unchanged.
reserve(q)q > 0 and q <= stock - reserved.reserved' = reserved + q; stock' = stock.Both values unchanged.
cancel_reservation(q)q > 0 and q <= reserved.reserved' = reserved - q; stock' = stock.Both values unchanged.
dispatch(q)q > 0 and q <= reserved.stock' = stock - q; reserved' = reserved - q.Both values unchanged.

Subtracting from both quantities during dispatch matters. Reducing only reserved would put dispatched stock back into the available quantity, while reducing only stock could make reserved > stock. The successful transition preserves the invariant by construction.

A suitable public boundary is:

#include <cstdint>

class InventoryItem {
public:
    using quantity_type = std::int64_t;

    enum class Result {
        applied,
        non_positive_quantity,
        insufficient_available,
        exceeds_reserved,
        quantity_overflow
    };

    explicit InventoryItem(quantity_type initial_stock);

    InventoryItem(const InventoryItem&) = delete;
    InventoryItem& operator=(const InventoryItem&) = delete;
    InventoryItem(InventoryItem&&) = delete;
    InventoryItem& operator=(InventoryItem&&) = delete;

    [[nodiscard]] Result receive(quantity_type quantity) noexcept;
    [[nodiscard]] Result reserve(quantity_type quantity) noexcept;
    [[nodiscard]] Result cancel_reservation(quantity_type quantity) noexcept;
    [[nodiscard]] Result dispatch(quantity_type quantity) noexcept;

    [[nodiscard]] quantity_type stock() const noexcept;
    [[nodiscard]] quantity_type reserved() const noexcept;
    [[nodiscard]] quantity_type available() const noexcept;

private:
    quantity_type stock_ = 0;
    quantity_type reserved_ = 0;
};

The constructor accepts a non-negative initial stock and otherwise fails without creating an object. Neither set_stock nor set_reserved belongs in this interface. Each would let the caller bypass the relationship that the four named transitions protect.

This design also deletes moves because it treats the object as a stable identity. A different system could define an independent InventorySnapshot value and make that value copyable. What it should not do is copy one live inventory identity merely because two integers are mechanically easy to copy.

The minimum useful observations are stock(), reserved(), available() and the result returned by each attempted transition. Tests should cover construction at zero, every successful transition, zero and negative quantities, reserving more than is available, cancelling or dispatching more than is reserved, receiving beyond the numeric limit, and a rejected operation followed by checks that all three observations are unchanged. No mutable reference is needed to prove any of those behaviours.

A class is not successful because it contains data and functions. It is successful when its constructors establish a usable state, its operations preserve the stated model, its failures leave a defined result, and its interface keeps callers from assuming responsibilities the type was created to own.