Course outline · 0% complete

0/27 lessons0%

Course overview →

Encapsulation, const Methods, and Destructors

lesson 7-2 · ~11 min · 18/27

Private data, public interface

Encapsulation means: hide the data, expose only methods that keep it valid. In Python, private is a naming convention (_balance). In C++, private: is enforced by the compiler.

class BankAccount {
public:
    void deposit(double amount) {
        if (amount > 0) balance_ += amount;
    }
    bool withdraw(double amount) {
        if (amount <= 0 || amount > balance_) return false;
        balance_ -= amount;
        return true;
    }
    double balance() const { return balance_; }

private:
    double balance_ = 0.0;   // default member initializer
};

Outside code cannot write acct.balance_ = -999;, it does not compile. Every change flows through deposit/withdraw, where the validation lives. This is lesson 7-1's invariant made enforceable: "the balance is never negative" holds for the object's whole lifetime, because the only code that can touch balance_ is code that checks first. The trailing underscore on balance_ is a common naming convention for private members.

const methods

The const after balance() promises this method does not modify the object. Mark every read-only method const. It documents intent, and it is required when someone holds a const BankAccount& (remember lesson 4-2's read-only references): only const methods are callable through one.

class BankAccount public: deposit(amount) withdraw(amount) balance() const private: double balance_ = 0.0; outside code allowed acct.balance_ = -999; does not compile
Encapsulation puts a wall around the data. Outside code reaches balance_ only through deposit, withdraw, and balance, so the validation inside those methods cannot be bypassed.

A class rejecting its own invalid operations

The failed withdrawal is refused by the class rather than by the calling code, which is the entire benefit of putting the check behind the interface.

#include <iostream>

class BankAccount {
public:
    void deposit(double amount) {
        if (amount > 0) balance_ += amount;
    }
    bool withdraw(double amount) {
        if (amount <= 0 || amount > balance_) return false;
        balance_ -= amount;
        return true;
    }
    double balance() const { return balance_; }
private:
    double balance_ = 0.0;
};

int main() {
    BankAccount acct;
    acct.deposit(100.0);

    std::cout << std::boolalpha;
    std::cout << "withdraw 30: " << acct.withdraw(30.0) << "\n";
    std::cout << "withdraw 500: " << acct.withdraw(500.0) << "\n";
    std::cout << "balance: " << acct.balance() << "\n";
    return 0;
}

Output

withdraw 30: true
withdraw 500: false
balance: 70

BankAccount acct; compiles with no constructor written anywhere, because the class declares no constructor of its own and the compiler supplies a default one. The = 0.0 on the member is what makes that safe, since it guarantees a defined starting balance.

withdraw returns a bool rather than silently failing, and that return value is the class's way of reporting a refusal. Ignoring it at the call site would leave the caller believing money moved, so a modern version would mark the method [[nodiscard]] to make the compiler warn about that mistake.

The std::boolalpha from lesson 2-1 is why the results read as true and false instead of 1 and 0, and it stays in effect for the rest of the stream's life.

Destructors close the loop on RAII

A destructor (~ClassName()) runs automatically when the object dies: scope exit for stack objects, delete (or the owning unique_ptr dying) for heap objects. You saw it fire in lesson 6-2's Probe. Now you can read what a real RAII wrapper looks like inside:

class IntArray {
public:
    IntArray(int n) : data_(new int[n]), size_(n) {}   // acquire in constructor
    ~IntArray() { delete[] data_; }                    // release in destructor

    int& at(int i) { return data_[i]; }
    int size() const { return size_; }
private:
    int* data_;
    int size_;
};

Callers of IntArray never write new or delete. The object cannot leak: its destructor runs on every exit path.

One warning for later: if you copy an IntArray, the compiler-generated copy duplicates the pointer, not the array, and both destructors will delete the same memory. Managing that correctly (the "rule of three/five") is a deeper topic. The practical takeaway for now: prefer std::vector, which is this class done right by experts.

What a trailing const buys you

A method declared double balance() const promises not to modify the object, and that promise is enforced in both directions.

Inside the body, any write to a member fails to compile, so the guarantee is checked rather than merely documented. Outside, it widens where the method can be called, because a const object and a const reference permit only const methods:

void report(const BankAccount& a) {
    std::cout << a.balance();   // fine, balance() is const
    // a.deposit(5.0);          // compile error, deposit() is not const
}

Without the trailing const on balance(), that function would not compile at all, which is why omitting it on read-only methods causes errors in code far away from the class.

Two things it does not mean are worth stating. It says nothing about the returned value, which is an ordinary copy the caller may modify freely, and it has nothing to do with compile-time evaluation, which is constexpr. Note also that a const method is where the const& parameters from lesson 4-2 finally pay off, since the two features only work together.

A class with mutating and read-only methods

A Stopwatch whose counter is private, with two methods that change it and one that only reads it.

#include <iostream>

class Stopwatch {
public:
    void tick() { ticks_++; }
    void reset() { ticks_ = 0; }
    int count() const { return ticks_; }
private:
    int ticks_ = 0;
};

int main() {
    Stopwatch s;
    s.tick();
    s.tick();
    s.tick();
    std::cout << s.count() << "\n";
    s.reset();
    std::cout << s.count() << "\n";
    return 0;
}

Output

3
0

Only count() carries the trailing const, and that split is exactly right. tick and reset both write to ticks_, so marking either of them const would fail to compile, while count reads and therefore should be const so it can be called on a const Stopwatch.

The counter is private with no setter, which means external code can move it by exactly one at a time or reset it to zero, and cannot jump it to 500. That is a small invariant, but it is the same technique the BankAccount used, and it is why int ticks_; being public would defeat the whole class.

The default member initializer = 0 matters here too. Without it, ticks_ would start as whatever bytes were on the stack, and the first output line would be 3 plus garbage.