C++ classes are how you bundle data + behaviour and enforce invariants. Unlike Python, C++ enforces access control at compile time (private/protected/public), supports multiple inheritance, distinguishes static and dynamic dispatch with virtual, and gives the compiler enough information to inline method calls aggressively. This post covers the OOP essentials.

Series map: 00 Setup → 01 Syntax & Types → 02 Pointers & Memory → 03 Classes & OOP (this post) → 04 Templates & STL → 05 Modern C++ → (future).


Learning Outcome

  • Define class / struct with constructors, destructors, member functions.
  • Use access specifiers public, private, protected.
  • Implement copy + move constructors and assignment (Rule of Five).
  • Use inheritance + virtual functions for runtime polymorphism.
  • Use override, final, and abstract classes (pure virtual).
  • Apply the Rule of Zero: design classes that need no explicit copy/move/destructor.

Chapter 1 — class vs struct

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point {
double x_, y_; // private by default
public:
Point(double x, double y) : x_(x), y_(y) {}
double x() const { return x_; }
double y() const { return y_; }
};

struct Pair {
int first; // public by default
int second;
};

class and struct are nearly identical — they differ only in default access: class defaults to private, struct to public. Convention:

  • Use struct for plain data aggregates (no invariants).
  • Use class when you have invariants to enforce or non-trivial behaviour.

Chapter 2 — Constructors and the initialiser list

1
2
3
4
5
6
7
8
9
10
class Person {
std::string name_;
int age_;
public:
Person(std::string name, int age)
: name_(std::move(name)), // initialiser list
age_(age)
{}
};

The : name_(...), age_(...) syntax is the member initialiser list. It runs before the body — and is the only way to initialise:

  • const members,
  • reference members,
  • base classes,
  • members with no default constructor.

Always prefer the initialiser list over assignments inside the body.

Default constructor

1
2
3
4
5
6
7
class Counter {
int n_;
public:
Counter() : n_(0) {} // explicit default
// OR: Counter() = default; // ask compiler to generate one
};

If you declare any constructor, the compiler stops generating the default. Add = default if you want both your custom one and a default.

Explicit

1
2
3
4
5
6
7
8
class Wrapper {
public:
explicit Wrapper(int x) {} // explicit blocks implicit conversion
};

Wrapper w1(5); // OK
Wrapper w2 = 5; // ERROR if explicit

Mark single-argument constructors explicit to prevent surprise conversions. Standard advice.


Chapter 3 — Destructor

1
2
3
4
5
6
7
class Buffer {
int* data_;
public:
Buffer(int n) : data_(new int[n]{}) {}
~Buffer() { delete[] data_; } // destructor
};

The destructor runs automatically when the object's lifetime ends:

  • For stack objects: end of scope.
  • For heap objects: when you call delete.
  • For class members: after the enclosing object's destructor body runs.

In modern code, most classes don't need an explicit destructor — the Rule of Zero (Chapter 7).


Chapter 4 — Member functions and const

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Account {
double balance_;
public:
Account(double opening) : balance_(opening) {}

// const method — doesn't modify the object
double balance() const { return balance_; }

// non-const — can modify
void deposit(double amount) {
balance_ += amount;
}
};

const Account a(100);
a.balance(); // OK — const method
a.deposit(50); // ERROR — non-const method on const object

Mark methods const whenever they don't mutate. Lets callers use the class with const references — better safety, more places usable.

this pointer

Inside a member function, this is a pointer to the current object.

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
std::string name_;
public:
Person& set_name(std::string n) {
name_ = std::move(n);
return *this; // enables chaining
}
};

Person p;
p.set_name("Ada").set_name("Lovelace"); // fluent style

Chapter 5 — Copy semantics

The compiler generates a copy constructor and copy assignment operator by default. They do member-wise copy. For most classes this is fine. For classes that own raw memory, you must define them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Buffer {
int* data_;
int size_;
public:
Buffer(int n) : data_(new int[n]{}), size_(n) {}
~Buffer() { delete[] data_; }

// copy constructor
Buffer(const Buffer& other)
: data_(new int[other.size_]),
size_(other.size_)
{
std::copy(other.data_, other.data_ + size_, data_);
}

// copy assignment
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this; // self-assignment check
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
};

The default member-wise copy would have copied the pointer — both objects would point to the same memory, and the second destructor would double-free. Classic bug.


Chapter 6 — Move semantics (C++11+)

Moving = transferring ownership of resources without copying. Crucial for performance when you want a new object to take over what an old one had.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Buffer {
int* data_;
int size_;
public:
// ... (existing constructor + dtor + copy from Chapter 5)

// move constructor
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr; // steal the pointer
other.size_ = 0;
}

// move assignment
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}
};

When does move happen?

  • Returning a local from a function.
  • std::move(x) cast.
  • Passing a temporary (rvalue) to a constructor.

Trick: if you implement copy + dtor by hand, also implement move. Otherwise the compiler won't generate move automatically, and your class falls back to copies in performance-critical places.

The Rule of Five

If you write any of: destructor, copy ctor, copy assign, move ctor, move assign — you should write (or = default / = delete) all five.

The Rule of Zero

If your class only owns members that already handle their own copy/move/destruction (smart pointers, standard containers), write none of the five. Let the compiler default everything.

1
2
3
4
5
6
7
class Buffer_zero {
std::vector<int> data_; // std::vector handles itself
public:
Buffer_zero(int n) : data_(n) {}
// no destructor, no copy/move declarations — all generated correctly
};

This is the modern way. Reach for explicit Rule-of-Five only when wrapping a C library or hand-managing memory.

Default copy, deep copy and move of a Buffer, plus the Rule of Five and the Rule of Zero


Chapter 7 — Inheritance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Animal {
public:
Animal(std::string name) : name_(std::move(name)) {}
virtual ~Animal() = default;
virtual std::string speak() const = 0; // pure virtual = abstract
std::string name() const { return name_; }
protected:
std::string name_;
};

class Dog : public Animal {
public:
Dog(std::string name) : Animal(std::move(name)) {}
std::string speak() const override {
return name_ + " says woof!";
}
};

class Cat : public Animal {
public:
Cat(std::string name) : Animal(std::move(name)) {}
std::string speak() const override {
return name_ + " says meow";
}
};

Key parts:

  • class Dog : public Animal — public inheritance (the usual kind).
  • virtual — enables runtime dispatch through base-class pointers/references.
  • = 0 — pure virtual; makes Animal abstract (cannot be instantiated).
  • override — checks at compile time that you're actually overriding. Use it on every override.
  • virtual ~Animal() = default; — always virtual-destructor your polymorphic bases, or delete through a base pointer leaks the derived part.

Polymorphism in action

1
2
3
4
5
6
7
8
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Dog>("Rex"));
zoo.push_back(std::make_unique<Cat>("Mochi"));

for (const auto& a : zoo) {
std::cout << a->speak() << "\n"; // calls Dog or Cat version
}

The vector holds Animal* (via unique_ptr), but each speak() dispatches to the correct derived class via the virtual table.

final

1
2
3
4
5
class FinalDog final : public Animal { ... };    // can't be inherited from
class Animal {
virtual void roar() final; // can't be overridden further
};

Use final when you intend to lock down the hierarchy. Documents intent + lets compiler de-virtualise calls.


Chapter 8 — Access specifiers

Keyword Inside the class In derived classes Outside
public yes yes yes
protected yes yes no
private yes no no

Default to private for data members. Expose access via methods (getters/setters with validation). This lets you change the representation later without breaking callers.

friend

1
2
3
4
5
6
class Wallet {
int amount_;
friend class Auditor; // Auditor can access private members
friend bool same_wallet(const Wallet&, const Wallet&);
};

Use sparingly. Tight coupling.


Chapter 9 — Operator overloading (briefly)

1
2
3
4
5
6
7
8
class Vec2 {
double x_, y_;
public:
Vec2(double x, double y) : x_(x), y_(y) {}
Vec2 operator+(const Vec2& o) const { return {x_ + o.x_, y_ + o.y_}; }
bool operator==(const Vec2& o) const { return x_ == o.x_ && y_ == o.y_; }
};

C++ lets you overload most operators. Overload sparingly — only when the semantics are obvious (math types, smart-pointer-like wrappers, iterator types).

C++20 added the spaceship operator <=> which generates all comparison operators in one go:

1
2
3
4
5
#include <compare>
class Vec2 {
auto operator<=>(const Vec2&) const = default;
};

Practice Exercises

Exercise 1 — BankAccount

Build a class BankAccount with private balance_, public deposit(double), withdraw(double) (rejecting negative or over-balance), and balance() const. Use the initialiser list.

Solution sketch
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BankAccount {
double balance_;
public:
explicit BankAccount(double opening) : balance_(opening) {}
double balance() const { return balance_; }
void deposit(double a) {
if (a < 0) throw std::invalid_argument("negative");
balance_ += a;
}
void withdraw(double a) {
if (a < 0) throw std::invalid_argument("negative");
if (a > balance_) throw std::runtime_error("insufficient");
balance_ -= a;
}
};

Exercise 2 — Shape hierarchy

Shape (abstract, pure virtual double area() const), Circle, Rectangle. Compute total area of a vector<unique_ptr<Shape>>.

Exercise 3 — Rule of Zero

Write a class Inventory that holds std::vector<std::string> items. Add methods to add, list, and count items. Do not write a destructor, copy ctor, or assignment operator — Rule of Zero.

Exercise 4 — Rule of Five for a Buffer

Implement Chapter 5+6 Buffer with constructor, destructor, copy ctor, copy assign, move ctor, move assign. Test that copy works (independent buffers) and move leaves the source empty.

Exercise 5 — Operator overload

Extend Vec2 (Chapter 9) with operator-, operator* (scalar multiplication), and operator<< (stream output for printing).


Cheat-sheet

What How
Default access of class private
Default access of struct public
Initialiser list : a_(x), b_(y) {}
Const method T foo() const;
Default ctor (gen) MyClass() = default;
Block conversion explicit MyClass(int);
Inherit class D : public B
Pure virtual virtual void f() = 0;
Mark override void f() override;
Mark final void f() final; or class D final
Virtual destructor required for polymorphic base
Rule of Five dtor + 2 copy + 2 move
Rule of Zero write none — use members that manage themselves
Move construct T(T&& o) noexcept
Self-assign check if (this == &other) return *this;
Spaceship op auto operator<=>(const T&) const = default;

What's next

Post 04 covers templates and the STL — generic programming, std::vector, std::map, std::string, iterators, and the algorithms library that makes most loops unnecessary.


References

  • cppreference / language / classes
  • A Tour of C++ — Ch. 4 (Classes), Ch. 5 (Essential operations).
  • Effective Modern C++ — Items 11–17 (move semantics).
  • C++ Core Guidelines — C.20, C.21, C.30–C.66.

Note: this post is in progress. Planned additions: virtual table diagram; CRTP introduction; multiple-inheritance diamond + virtual base.