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
- Share state across every object with static members & methods
- Grant controlled private access using friend functions and classes
- Use multiple inheritance and solve the diamond problem with virtual bases
- Query an object's real type safely with dynamic_cast and RTTI
- Lock down hierarchies with final and catch typos with override
- Manage resources correctly with the rule of three (and five)
💡 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: 100Your 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 = 42423. 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-0014. 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)
- "request for member 'x' is ambiguous" (diamond ambiguity): a class inherited the same base twice and now has two copies of x. Make the inheritance virtual public Base on both intermediate classes so only one copy exists.
- Forgetting to construct the virtual base: with virtual inheritance the most-derived class must call the base constructor itself — e.g. MFP(sn) : Device(sn), Printer(sn), Scanner(sn). Leave out Device(sn) and you get a "no matching constructor" error (or the wrong default).
- "cannot dynamic_cast … (target is not pointer or reference to complete type)" / it just won't compile: you used dynamic_cast on a non-polymorphic type. Add at least one virtual function (a virtual ~Base() = default; is enough) so RTTI exists.
- Double free / corrupted heap (rule-of-three violation): you wrote a destructor that deletes a pointer but let the compiler generate the copy constructor — two objects now own the same pointer and both free it. Provide a deep-copying copy constructor and copy assignment (or delete them).
- "marked 'override' but does not override": your signature doesn't match the base (often a missing const). Fix the signature — this error is exactly why override exists.
📋 Quick Reference
| Concept | Syntax | Result |
|---|---|---|
| Static data member | static int count; | One copy shared by all objects |
| Define static member | int C::count = 0; | Required once, outside the class |
| Friend function | friend void f(const C&); | f may read private members |
| Virtual inheritance | class D : virtual public B | One shared copy of B (diamond fix) |
| Safe downcast | dynamic_cast<Dog*>(a) | Dog* if it is one, else nullptr |
| Checked override | void f() const override; | Compiler verifies it overrides |
| Seal a class/method | class Cat final | No further subclassing/overriding |
| Rule of five | ~C(); C(const C&); C& operator=(…); C(C&&); … | Manage copy + move + destroy |
Pro Tips
- 💡 Prefer the rule of zero: reach for std::vector, std::string, and std::unique_ptr so you don't have to write any of the five special functions yourself.
- 💡 Always add override: it costs nothing and turns a silent "new method" bug into a compile error.
- 💡 Prefer composition over multiple inheritance: "has-a" is usually clearer than juggling two base classes and a diamond.
- 💡 Reach for dynamic_cast sparingly: if you're testing the type a lot, a virtual method on the base is often the cleaner design.
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
- ✅ static members and methods give a class one shared copy of data (define static data once outside the class)
- ✅ friend grants one function or class controlled access to private members
- ✅ Multiple inheritance can hit the diamond problem; virtual public Base keeps a single shared base
- ✅ dynamic_cast + RTTI safely test an object's real type (polymorphic types only; nullptr on failure)
- ✅ override catches signature typos; final seals classes and methods
- ✅ The rule of three/five keeps resource-owning classes from double-freeing — or use the rule of zero
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
- Previous: Modern C++ Memory Model & Smart Pointer Internals
- Next: Templates Deep Dive: Type Deduction, Variadic Templates & Fold Expressions — Template argument deduction, variadic templates, and constexpr if
- Quick reference: C++ cheat sheet
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.