Advanced OOP

Reviewed & published by Brayan K

By the end of this lesson you'll be able to share data across a class with static members, grant trusted access with friend, tame multiple inheritance and the diamond problem, query an object's real type at runtime with dynamic_cast, lock hierarchies down with final/override, and correctly manage resources with the rule of three/five.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of a class as a company. Each employee (object) has their own desk and tasks (instance members). But the company has one shared noticeboard everyone reads — that's a static member: it belongs to the company, not to any single person. A friend is the external auditor you deliberately give a key to the private filing cabinet. Multiple inheritance is an employee reporting to two managers — and the diamond problem is when both managers ultimately report to the same director: do you get one director or two? virtual inheritance guarantees there's only ever one director.

1. Static Members & Methods

A static member belongs to the class itself, not to any individual object — there's exactly one shared copy no matter how many objects you create. A static method can be called on the class (BankAccount::howMany()) without an object, but it can only touch static data because it has no this. One catch beginners always hit: a static data member must be defined once outside the class. Read this worked example, run it, then you'll write your own.

#include <iostream>
using namespace std;

class BankAccount {
    static int accountCount;   // ONE shared copy for the whole class
    int balance;               // each object has its OWN balance
public:
    BankAccount(int opening) : balance(opening) {
        ++accountCount;        // every new account bumps the shared counter
    }
    // A static method belongs to the class, not to any one object.
    // It can only touch static members (there's no 'this').
    static int howMany() { return accountCount; }
    int getBalance() const { return balance; }
};

// Static data members must be defined ONCE outside the class:
int BankAccount::accountCount = 0;

int main() {
    cout << "Accounts so far: " << BankAccount::howMany() << endl; // 0
    BankAccount a(100), b(250);
    cout << "Accounts so far: " << BankAccount::howMany() << endl; // 2
    {
        BankAccount c(0);
        cout << "Inside block: " << BankAccount::howMany() << endl; // 3
    }
    // Note: this counter only goes UP here because we never destroy & decrement.
    cout << "a balance: " << a.getBalance() << endl;               // 100
    return 0;
}

// ✅ Expected output:
//    Accounts so far: 0
//    Accounts so far: 2
//    Inside block: 3
//    a balance: 100

Your turn. The program below is almost complete — fill in the three blanks marked ___ using the // 👉 hints, then run it.

#include <iostream>
using namespace std;

class Player {
    static int playerCount;   // shared across ALL players
    string name;
public:
    // 🎯 YOUR TURN — fill in the ___ blanks, then press "Try it Yourself".

    Player(string n) : name(n) {
        // 1) Increase the shared counter by one each time a Player is made
        ___;                  // 👉 ++playerCount;
    }

    // 2) Write a STATIC method that returns the count
    static int count() { return ___; }   // 👉 playerCount

    string getName() const { return name; }
};

// 3) Define the static member outside the class, starting at 0
int Player::playerCount = ___;           // 👉 0

int main() {
    Player p1("Mario"), p2("Luigi");
    cout << "Players: " << Player::count() << endl;

    // ✅ Expected output:
    //    Players: 2
    return 0;
}

2. friend Functions & Classes

Normally private members are off-limits to the outside world. A friend declaration is you deliberately granting one specific function — or an entire class — permission to reach inside. It's a controlled exception, not a leak: the class still decides exactly who gets in. The classic use is letting a printing helper or a tightly-paired class read your internals without exposing them to everyone.

#include <iostream>
using namespace std;

class Vault {
    int secretCode;   // private — normally untouchable from outside
public:
    Vault(int code) : secretCode(code) {}

    // Grant ONE function access to our private members:
    friend void reveal(const Vault& v);

    // Grant a whole class access:
    friend class Auditor;
};

// 'reveal' is a free function, yet it can read secretCode:
void reveal(const Vault& v) {
    cout << "Vault code is: " << v.secretCode << endl;  // 4242
}

class Auditor {
public:
    void check(const Vault& v) {
        // Auditor is a friend, so it sees private data too:
        cout << "Audit: code = " << v.secretCode << endl; // 4242
    }
};

int main() {
    Vault v(4242);
    reveal(v);                 // Vault code is: 4242
    Auditor().check(v);        // Audit: code = 4242
    return 0;
}

// ✅ Expected output:
//    Vault code is: 4242
//    Audit: code = 4242

3. Multiple Inheritance & the Diamond Problem

C++ lets a class inherit from more than one base at once. That power creates the diamond problem: when two bases (Printer and Scanner) both inherit the same ancestor (Device), a class inheriting both would get two copies of Device — so any member like serial becomes ambiguous. The fix is virtual inheritance: write class Printer : virtual public Device on both sides, and the compiler keeps a single shared Device. With a virtual base, the most-derived class is responsible for constructing it.

#include <iostream>
using namespace std;

// Base shared by both sides of the diamond.
class Device {
protected:
    string serial;
public:
    // 'virtual public' keeps ONLY ONE Device inside MultiFunctionPrinter.
    Device(string sn) : serial(sn) {
        cout << "Device(" << sn << ") built" << endl;
    }
    virtual ~Device() = default;   // virtual dtor for safe deletion
    string getSerial() const { return serial; }
};

class Printer : virtual public Device {   // note: virtual
public:
    Printer(string sn) : Device(sn) {}
    void print() const { cout << "Printing from " << serial << endl; }
};

class Scanner : virtual public Device {   // note: virtual
public:
    Scanner(string sn) : Device(sn) {}
    void scan() const { cout << "Scanning from " << serial << endl; }
};

// The diamond: MFP inherits Device twice (via Printer AND Scanner).
class MultiFunctionPrinter : public Printer, public Scanner {
public:
    // With virtual inheritance the MOST-DERIVED class builds Device directly:
    MultiFunctionPrinter(string sn)
        : Device(sn), Printer(sn), Scanner(sn) {}
};

int main() {
    MultiFunctionPrinter mfp("MFP-001");   // Device built ONCE
    mfp.print();                           // Printing from MFP-001
    mfp.scan();                            // Scanning from MFP-001
    // Only one 'serial' exists, so this is NOT ambiguous:
    cout << "Serial: " << mfp.getSerial() << endl; // Serial: MFP-001
    return 0;
}

// ✅ Expected output:
//    Device(MFP-001) built
//    Printing from MFP-001
//    Scanning from MFP-001
//    Serial: MFP-001

4. dynamic_cast, RTTI, final & override

RTTI (Run-Time Type Information) lets your program ask, while it's running, what an object's real type is. The tool is dynamic_cast: cast a base pointer down to a derived pointer and, if the object really is that type, you get a usable pointer — otherwise you get nullptr (no crash), which you test with an if. It only works on polymorphic types (those with at least one virtual function), because the check reads the vtable. Two safety keywords complete the picture: override makes the compiler verify you actually overrode a base method (catching signature typos), and final forbids any further overriding or subclassing.

#include <iostream>
using namespace std;

// A type is "polymorphic" once it has a virtual function — that's what
// makes dynamic_cast and RTTI possible (they read the hidden vtable).
class Animal {
public:
    virtual void speak() const { cout << "..." << endl; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() const override { cout << "Woof" << endl; } // override = checked
    void fetch() const { cout << "Dog fetches the ball" << endl; }
};

// 'final' forbids any further subclassing of Cat:
class Cat final : public Animal {
public:
    void speak() const override { cout << "Meow" << endl; }
};

void interact(Animal* a) {
    a->speak();
    // dynamic_cast asks at RUNTIME: "is this really a Dog?"
    Dog* d = dynamic_cast<Dog*>(a);
    if (d) {                 // succeeds -> usable pointer
        d->fetch();
    } else {                 // fails -> nullptr (NOT a crash)
        cout << "(not a dog, can't fetch)" << endl;
    }
}

int main() {
    Dog dog;
    Cat cat;
    interact(&dog);   // Woof  /  Dog fetches the ball
    interact(&cat);   // Meow  /  (not a dog, can't fetch)
    return 0;
}

// ✅ Expected output:
//    Woof
//    Dog fetches the ball
//    Meow
//    (not a dog, can't fetch)

Now you try. inspect receives a Shape* and should call radius() only when the shape is really a Circle. Fill in the two blanks:

#include <iostream>
using namespace std;

class Shape {
public:
    virtual double area() const = 0;   // pure virtual -> polymorphic type
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double r;
public:
    Circle(double radius) : r(radius) {}
    double area() const override { return 3.14159 * r * r; }
    double radius() const { return r; }
};

class Square : public Shape {
    double s;
public:
    Square(double side) : s(side) {}
    double area() const override { return s * s; }
};

void inspect(Shape* shape) {
    // 🎯 YOUR TURN — fill in the ___ blanks.

    // 1) Try to cast 'shape' down to a Circle*
    Circle* c = ___;                    // 👉 dynamic_cast<Circle*>(shape)

    // 2) Only Circles have a radius() — check the cast succeeded first
    if (___) {                          // 👉 c   (non-null means it IS a Circle)
        cout << "Circle radius: " << c->radius() << endl;
    } else {
        cout << "Not a circle (area " << shape->area() << ")" << endl;
    }
}

int main() {
    Circle circ(2.0);
    Square sq(3.0);
    inspect(&circ);
    inspect(&sq);

    // ✅ Expected output:
    //    Circle radius: 2
    //    Not a circle (area 9)
    return 0;
}

5. The Rule of Three / Five

When a class owns a resource (heap memory, a file handle, a socket), the compiler's automatic copy behaviour is wrong — it copies the pointer, so two objects end up sharing and then double-freeing the same memory. The rule of three says: if you write any one of the destructor, copy constructor, or copy assignment, you almost certainly need all three. Modern C++ adds two more — the move constructor and move assignment — which cheaply steal a resource instead of copying it; together that's the rule of five. (The happiest path is the rule of zero: use std::string/std::vector/std::unique_ptr so you write none of them.)

#include <iostream>
#include <cstring>
using namespace std;

// This class OWNS a raw resource (heap memory), so it must manage copying
// and moving itself — the "rule of five".
class Buffer {
    char* data;
    size_t size;
public:
    // Constructor — acquire the resource
    Buffer(const char* s) : size(strlen(s)) {
        data = new char[size + 1];
        strcpy(data, s);
    }
    // 1) Destructor — release the resource
    ~Buffer() { delete[] data; }

    // 2) Copy constructor — DEEP copy (own memory, not a shared pointer)
    Buffer(const Buffer& other) : size(other.size) {
        data = new char[size + 1];
        strcpy(data, other.data);
        cout << "(deep-copied)" << endl;
    }
    // 3) Copy assignment — clean up old, then deep copy
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;   // guard self-assignment
        delete[] data;
        size = other.size;
        data = new char[size + 1];
        strcpy(data, other.data);
        return *this;
    }
    // 4) Move constructor — STEAL the pointer (cheap, no new alloc)
    Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;               // leave source empty but valid
        cout << "(moved)" << endl;
    }
    // 5) Move assignment
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data; size = other.size;
        other.data = nullptr;
        return *this;
    }
    void print() const { cout << (data ? data : "(empty)") << endl; }
};

int main() {
    Buffer a("hello");
    Buffer b = a;              // copy constructor -> (deep-copied)
    Buffer c = std::move(a);   // move constructor -> (moved)
    b.print();                 // hello
    c.print();                 // hello
    a.print();                 // (empty)  -> 'a' was moved-from
    return 0;
}

// ✅ Expected output:
//    (deep-copied)
//    (moved)
//    hello
//    hello
//    (empty)

Common Errors (and the fix)

📋 Quick Reference

ConceptSyntaxResult
Static data memberstatic int count;One copy shared by all objects
Define static memberint C::count = 0;Required once, outside the class
Friend functionfriend void f(const C&);f may read private members
Virtual inheritanceclass D : virtual public BOne shared copy of B (diamond fix)
Safe downcastdynamic_cast<Dog*>(a)Dog* if it is one, else nullptr
Checked overridevoid f() const override;Compiler verifies it overrides
Seal a class/methodclass Cat finalNo further subclassing/overriding
Rule of five~C(); C(const C&); C& operator=(…); C(C&&); …Manage copy + move + destroy

Pro Tips

Mini-Challenge: Counted Widgets

No blanks this time — just a brief and an outline. Combine a static counter with a friend printer, build it, run it, and check your output against the example in the comments.

#include <iostream>
using namespace std;

// 🎯 MINI-CHALLENGE: Counted widgets with a friend printer
//
// 1. Make a class Widget with:
//    - a STATIC int 'total' shared by all widgets
//    - a private int 'id'
//    - a constructor that sets id = ++total  (so ids are 1, 2, 3...)
//    - a STATIC method count() returning total
// 2. Add a FRIEND function  void show(const Widget& w)  that prints the id
//    (a friend may read the private 'id').
// 3. In main: create 3 widgets, show() each, then print Widget::count().
//
// Remember: define the static member outside the class -> int Widget::total = 0;
//
// ✅ Expected output:
//    Widget #1
//    Widget #2
//    Widget #3
//    Total widgets: 3

class Widget {
    // your code here
};

// define static member here

int main() {
    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

A static data member of a class is best described as:

  • One copy per object
  • Always private
  • One copy shared by the whole class
  • A function with no body

Answer: One copy shared by the whole class. There is exactly one shared static member no matter how many objects exist; it must also be defined once outside the class.

Why can a static member function only access static members?

  • It has no 'this' pointer, so there is no object to reach instance members through
  • It runs at compile time
  • Static members are always public
  • It is implicitly const

Answer: It has no 'this' pointer, so there is no object to reach instance members through. A static method is called on the class itself (BankAccount::howMany()) with no object, so it has no 'this'.

What does a 'friend' declaration grant?

  • Inheritance from another class
  • Automatic copy construction
  • Thread safety
  • A specific function or class access to this class's private members

Answer: A specific function or class access to this class's private members. friend is a controlled exception that lets one named function or class reach the private members.

In the diamond problem, what does marking the inheritance 'virtual' achieve?

  • It makes methods virtual
  • It keeps a single shared copy of the common base instead of two
  • It deletes the base class
  • It speeds up dispatch

Answer: It keeps a single shared copy of the common base instead of two. virtual inheritance (class Printer : virtual public Device) keeps one shared Device, removing the ambiguity.

With a virtual base class, which class is responsible for constructing that base?

  • The most-derived class
  • The first base listed
  • Each intermediate class
  • The compiler, automatically with defaults only

Answer: The most-derived class. The most-derived class constructs the virtual base directly, e.g. MFP(sn) : Device(sn), Printer(sn), Scanner(sn).

dynamic_cast<Dog*>(animalPtr) returns what when the object is NOT a Dog?

  • A garbage pointer
  • It throws an exception
  • nullptr
  • It crashes

Answer: nullptr. On a pointer, a failed dynamic_cast yields nullptr, which you test with an if — no crash.

dynamic_cast only works on which kind of types?

  • Any type
  • Polymorphic types (with at least one virtual function)
  • Only POD structs
  • Only template types

Answer: Polymorphic types (with at least one virtual function). RTTI reads the vtable, so the type needs at least one virtual function (a virtual destructor is enough).

What does adding 'override' to a member function do?

  • Makes it virtual for the first time
  • Prevents inheritance
  • Makes it static
  • Tells the compiler to verify it actually overrides a base method, catching signature typos

Answer: Tells the compiler to verify it actually overrides a base method, catching signature typos. If your signature doesn't match a base virtual (e.g. a missing const), override turns a silent bug into a compile error.

The 'rule of three' says that if you write one of these, you likely need all three:

  • Constructor, getter, setter
  • Destructor, copy constructor, copy assignment
  • Move constructor, move assignment, swap
  • begin, end, size

Answer: Destructor, copy constructor, copy assignment. Managing a resource means defining the destructor, copy constructor, and copy assignment together (the rule of five adds the two move operations).

After Buffer c = std::move(a); in the rule-of-five example, what is the state of 'a'?

  • Unchanged, still holding its data
  • A deep copy of c
  • Moved-from: left empty but valid
  • Destroyed and unusable

Answer: Moved-from: left empty but valid. The move constructor steals a's pointer and sets a.data to nullptr, leaving 'a' empty but in a valid state.

Continue this course

Frequently asked questions

When should I use a static member instead of a global variable?

Use a static member when the data belongs to a class but is shared by every object of that class — like a counter of how many objects exist. It lives inside the class's scope and access rules (public/private), so it's tidier and safer than a loose global variable floating in the file.

Is friend a hole in encapsulation? Should I avoid it?

friend deliberately grants one specific function or class access to your private members — it's a controlled exception, not a leak. Use it sparingly for tightly-coupled helpers (operator<< for printing, or a paired class). If you reach for friend constantly, your design probably needs rethinking.

Why do I need 'virtual' inheritance for the diamond problem?

Without virtual, a class that inherits the same base twice (once down each side of the diamond) gets two separate copies of that base — so a member like serialNumber becomes ambiguous. Marking the inheritance 'virtual' tells the compiler to keep just one shared copy, removing the ambiguity.

Why does dynamic_cast return nullptr instead of crashing?

dynamic_cast on a pointer is the safe, checked cast: at runtime it asks 'is this object really that derived type?'. If yes you get a usable pointer; if no you get nullptr, which you test with an if. That's why it only works on polymorphic types (those with at least one virtual function) — RTTI needs the vtable to do the check.

What's the difference between the rule of three and the rule of five?

The rule of three says: if you write any one of the destructor, copy constructor, or copy assignment, you almost certainly need all three (because you're managing a resource). The rule of five adds the move constructor and move assignment, which let modern C++ steal resources cheaply instead of copying. Best of all is the rule of zero: design so you need to write none of them.

Related lessons