Chapter 14

OOP Design Principles

Alternatives to virtual functions, member function design, non-member functions, and exceptions in destructors.

In this chapter

  1. The Template Method Pattern
  2. The Strategy Pattern
  3. Prefer Non-Member Non-Friend Functions
  4. Non-Member Functions & Conversions
  5. Exceptions in Destructors
1

The Template Method Pattern

Instead of making a function directly virtual, expose a public non-virtual wrapper that calls a private virtual implementation. This lets the base class control setup/teardown while still allowing derived classes to customize the core behavior:

Basic virtual (less control)
class Enemy {
public:
    virtual int HealthValue() const;
};
Template Method (more control)
class Enemy {
public:
    int HealthValue() const {
        // setup...
        int val = DoHealthValue();
        // cleanup...
        return val;
    }
private:
    virtual int DoHealthValue() const;
};

The public HealthValue() acts as a "wrapper" — it can enforce logging, locking, validation, or cleanup around whatever the derived class provides. Only DoHealthValue() can be changed by derived classes.

This is also known as the Non-Virtual Interface (NVI) idiom. It's one of the cleaner patterns in C++ OOP because it separates how a function is called from what it does.

2

The Strategy Pattern

Instead of a virtual function, store a function pointer as a member. This allows different instances of the same type to use entirely different implementations — and the implementation can be swapped at runtime:

using HealthFunc = int(*)(const Enemy*);

class Enemy {
public:
    int HealthValue() const {
        return healthFunc(this);
    }
private:
    HealthFunc healthFunc;
};
Strategy Pattern Pros
  • Different instances of the same type can have different behaviors
  • Behavior can be swapped at runtime
  • No vtable overhead
Strategy Pattern Cons
  • The function pointer only gets access to what it's passed — it can't access private members directly
  • More setup code than a simple virtual function
In modern C++ the function pointer is often replaced with std::function or a lambda, which are more flexible and can capture state.

3

Prefer Non-Member Non-Friend Functions

Member functions have access to all private data. A non-member non-friend function (just a regular free function) can only use the public interface. If functionality can be expressed through the public interface, moving it outside the class:

class Widget {
public:
    void ClearScrollbar();
    void ClearCookies();
    void ClearHistory();
private:
    void InternalData();
};

// Non-member non-friend — only uses public interface
void ClearBrowser(Widget& w) {
    w.ClearScrollbar();
    w.ClearCookies();
    w.ClearHistory();
}
ClearBrowser adds no new access to Widget's private data — it just orchestrates public calls. This is strictly more encapsulated than making it a member function. You can still put this free function in a namespace alongside the class.

4

Non-Member Functions & Conversions

When you want implicit conversions to apply to both operands of a binary operator, the operator must be a non-member. As a member function, only the right-hand side (the parameter) is eligible for conversion — the left-hand side (the caller) is not.

class Num {
public:
    Num() = default;
    Num(int x) {}  // implicit conversion from int

    // Member operator: left side must already be a Num
    Num operator*(const Num& rhs) const;
};

Num n;
n * 4;   // OK — 4 is converted to Num (it's the parameter)
4 * n;   // ERROR — 4 is the caller, not the parameter, can't be converted

Moving the operator outside the class makes both sides parameters, so both are eligible for conversion:

// Non-member: both lhs and rhs are parameters — both can be converted
Num operator*(const Num& lhs, const Num& rhs) {
    // ...
}

4 * n;   // now OK — 4 is passed as lhs and converted to Num

5

Exceptions in Destructors

Do not allow exceptions to leave destructors. If an exception is thrown in the middle of a destructor, the rest of the object may never be destroyed — leading to resource leaks and undefined behavior. This is especially dangerous if the destructor is being called during stack unwinding from another exception.

If a destructor calls code that might throw, you have three options:

OptionWhat it doesWhen to use
Terminate the programstd::abort() if the operation failsWhen continuing with a corrupted object is worse than crashing
Swallow the exceptiontry/catch inside the destructor, ignore the exceptionWhen the failure is non-critical and you'd rather limp along
Expose a separate cleanup function (recommended)Let the user call a Close() / Destroy() method manually, and only fall back to swallowing in the destructorMost cases — gives the caller a chance to handle the error
class Connection {
public:
    void Close() {
        // can throw — user handles it
    }

    ~Connection() {
        if (!closed) {
            try {
                Close();
            } catch (...) {
                Log("Close failed during destruction"); // swallow
            }
        }
    }
};
← Chapter 13 ↑ Index Chapter 15 →