3 Object-Oriented Programming in C++
Abstraction
Abstraction is one of the most fundamental concepts when we want to manage complex systems. It allows us to focus on the essential features of an object or a system while hiding the unnecessary implementation details.
In classic C a typical problem that often arises is that as the system grows in complexity, managing all the details of its implementation becomes increasingly difficult. This is where C++ comes into play, offering a powerful class system that supports abstraction while also providing the flexibility and performance of a low-level language. By using classes, we can define clear interfaces for our objects and hide the implementation details, making our code more modular, maintainable, and easier to understand.
Consider a Game Engine which has to manage various subsystems:

A lot of modern game engines are build in C++, leveraging its class system to manage the complexity of various subsystems while maintaining high performance. The class features of C++ allows us to recreate the structure of of the entire engine with each system being a struct/class that manages its own data and behavior, providing a clear and modular design.
The Idea of Object-Oriented Programming
The goal of software engineering is to make programs easier to write, understand and maintain. Object-oriented programming (OOP) introduces four big ideas that help with exactly that:
- Classes let you define your own data types — together with the valid range of values and the operations allowed on them.
- Encapsulation lets you hide the internal implementation of a class behind an interface that controls access to it.
- Inheritance lets you reuse source code without having to copy it.
- Subtype polymorphism lets you write generic code that operates on values without knowing their exact type.
Because of this, object-oriented programming operates on a higher level of abstraction than the purely imperative style (functions, loops, structs) we’ve used so far.
Why C fails in abstraction
Let’s motivate all of this with a concrete problem: write a C program that
- creates geometric shapes (circles, rectangles, …),
- stores them in an array and
- computes the area of every shape by iterating over the array.
To keep things simple, every shape is described by exactly two points:
- a circle by its center and a point on the circle,
- a rectangle by its top-left and bottom-right corner.
We’ll first solve this the way we already know how to — with plain structs and free functions:
#include <stdio.h> // for printf
#include <stdlib.h> // for abs
#include <math.h> // for M_PI
// the "kind" of shape a Shape currently represents
enum ShapeType { circle, rectangle };
typedef enum ShapeType ShapeType;
struct Point2D {
int x, y;
};
typedef struct Point2D Point2D;
// a shape is described by its type and two points, whose meaning depends on the type
struct Shape {
ShapeType type;
Point2D points[2];
};
typedef struct Shape Shape;
double area(Shape shape) {
switch (shape.type) {
case circle: {
int dx = shape.points[0].x - shape.points[1].x;
int dy = shape.points[0].y - shape.points[1].y;
return M_PI * (dx * dx + dy * dy);
}
case rectangle: {
int width = shape.points[1].x - shape.points[0].x;
int height = shape.points[1].y - shape.points[0].y;
return abs(width * height);
}
}
return 0.0; // unreachable, but keeps the compiler happy
}
int main() {
Shape shapes[] = {
{circle, {{8, 4}, {11, 4}}},
{rectangle, {{0, 0}, {5, 3}}},
};
int shapes_size = sizeof(shapes) / sizeof(shapes[0]);
for (int i = 0; i < shapes_size; ++i) {
printf("Area: %f\n", area(shapes[i]));
}
return 0;
}gcc shapes.c -o shapes
./shapes
> Area: 28.2743
> Area: 15What’s wrong with this?
The program is correct and does what it’s supposed to, but it doesn’t scale well:
- Poor reusability: anyone using
Shapeneeds to know the internal meaning ofpointsfor eachShapeType— that’s an implementation detail leaking into every caller. - Poor modularity: a small conceptual change (e.g. how a rectangle is represented) can force changes all over the codebase, not just in one place.
- Poor extensibility: the program can only be extended by editing its source code directly — there’s no way to add a new kind of shape without touching
Shapeitself.
Imagine we wanted to add triangles. We’d have to:
- add
triangletoType, - make
Shape::pointshold three points for every shape, even circles and rectangles, - add another
casetoarea.
Every single shape pays the price for a feature only triangles need. Object-oriented programming — classes, encapsulation, inheritance and polymorphism — gives us the tools to avoid exactly this. We’ll rebuild this example step by step over the rest of this chapter.
Object-Oriented Approach to Shapes in C++
Now we will rewrite the shapes example using C++ classes to take advantage of object-oriented programming principles. This will allow us to encapsulate the details of each shape, making the code more modular, reusable, and extensible.
- We use an abstract base struct
Shapeto define a common interface for all shapes.- we can make it purely abstract with the pure virtual destructor and pure virtual functions
- The derived classes like
CircleandRectangleimplement the specific details for each shape, such as calculating the area.
We will explain each technique we use in this example later a bit more in detail.
#include <iostream> // for std::cout, std::endl
#include <cmath> // for M_PI
#include <vector> // for std::vector
#include <memory> // for std::unique_ptr (optional, if using smart pointers)
using std::unique_ptr;
using std::make_unique;
// this is an abstract base class for all shapes
struct Shape {
// the virtual keyword indicates that this function can be overridden in
// derived classes
// a pure virtual function must be overridden in derived classes
virtual auto area() const -> double = 0; // pure virtual function
// pure virtual destructor ensures that shape is abstract and cannot be
// instantiated directly
virtual ~Shape() = 0; // pure virtual destructor
};
inline Shape::~Shape() = default; // definition of pure virtual destructor
// Point2D struct to represent a point in 2D space
struct Point2D {
double x, y;
};
// Circle class which is a derived class of Shape
struct Circle : Shape {
// private members are not accessible from outside the class
// this allows us to control access to the internal state of the circle
// as a convention we use "_" before member variable names to indicate that
// they are private
private:
Point2D _center;
double _radius;
// public members are accessible from outside the class
// its the interface through which developers interact with the circle
public:
// constructor to initialize the radius
Circle(double radius, Point2D center = {.x = 0.0, .y = 0.0}) :
_radius(radius), _center(center) {};
// override the area function to calculate the area of the circle
// override is an optional keyword that indicates this function
// overrides a virtual function in the base class
auto area() const -> double override {
// πr²
return M_PI * _radius * _radius;
}
// read only radius function
auto radius() const -> double { return _radius; }
};
// Rectangle class which is a derived class of Shape
struct Rectangle : Shape {
private:
// we can now add more properties like rotation
double _width, _height, _rotation;
Point2D _center;
public:
// constructor
Rectangle(double width, double height,
Point2D center = {.x = 0.0, .y = 0.0}, double rotation = 0.0)
: _width(width), _height(height), _center(center),
_rotation(rotation) {}
// area function
auto area() const -> double override {
return _width * _height;
}
// so called "getter" functions to get read access to private members
auto width() const -> double { return _width; }
auto height() const -> double { return _height; }
auto rotation() const -> double { return _rotation; }
auto center() const -> Point2D { return _center; }
};
auto main() -> int {
// initialize a vector of unique_ptr to Shape objects
std::vector<unique_ptr<Shape>> shapes;
// add a circle with radius 3.0
shapes.emplace_back(make_unique<Circle>(/*radius:*/3.0));
// add a rectangle with width 5.0 and height 3.0
shapes.emplace_back(make_unique<Rectangle>(/*width:*/5.0, /*height:*/3.0));
// iterate over the shapes and print their areas
for (const auto& shape : shapes) {
std::cout << "Area: " << shape->area() << std::endl;
}
// no need to manually delete shapes, unique_ptr will handle it
return 0;
}Now if we want to add more shapes like a triangle we can just do it without touching the code above:
struct Triangle : Shape {
private:
Point2D _base_point;
double _baselength, _height, _rotation;
public:
// constructor
Triangle(double baselength, double height,
Point2D base_point = {.x = 0.0, .y = 0.0},
double rotation = 0.0)
: _baselength(baselength), _height(height),
_base_point(base_point), _rotation(rotation) {}
// area
auto area() const -> double override {
return 0.5 * _base * _height;
}
// getter functions
auto base() const -> double { return _base; }
auto height() const -> double { return _height; }
};Objects in C++
Structs vs Classes
Classes and structs are almost the same in C++. There are only two real differences between them:
- Default member access: members of a
structarepublicby default, while members of aclassareprivateby default. - Default inheritance access: when deriving with
struct Derived : Base, the inheritance ispublicby default; when deriving withclass Derived : Base, it isprivateby default — regardless of whetherBaseitself is a struct or a class.
People argue all the time about whether to use struct or class. I personally prefer to use struct all the time, since making things private by default is usually not a good idea. But this is a matter of taste.
Member Variables and Methods
A class bundles data (member variables) with the operations that work on that data (member functions, or methods). This makes it easier to model real-world entities and their behaviors in a single cohesive unit.
One can access the members with:
- By using the dot operator (
.) on an instance of the class or struct. - Or by using the arrow operator (
->) on a pointer to an instance of the class or struct.
Encapsulation
We can modify the access level of class members using access specifiers: public, private, and protected.
| Modifier | Access from… |
|---|---|
public |
anywhere, via . |
private |
only from inside the class itself (any instance) |
protected |
from inside the class and its subclasses (see the Inheritance section) |
Why hide anything at all?
Encapsulation (or information hiding) is a core principle of OOP: expose only as much information and access as necessary, and hide as much of the implementation as possible.
- Member variables are almost always
private. Access happens through getter and setter methods instead:
Let’s take an example of a Car class. As a devoloper who wants to use the Car, we are: - Interested in driving the car for a certain amount of time and knowing the distance covered. - Interested in refueling the car when needed. - Not interested in the internal details of how the car manages its fuel.
struct Car {
private:
// Private member variables are not accessible from outside the class
// this allows us to control access to the internal state of the car
double _speed;
double _fuel_consumption;
double _fuel_left;
double _fuel_capacity;
public:
// Constructor to initialize the car with speed, fuel consumption,
// and fuel capacity
Car(double speed, double fuel_consumption, double fuel_capacity)
: _speed(speed), _fuel_consumption(fuel_consumption),
_fuel_capacity(fuel_capacity), _fuel_left(fuel_capacity) {}
// Public member functions provide controlled access to the
// private members
auto refuel() -> void {
_fuel_left = _fuel_capacity;
}
// drive the car for a certain amount of time and return the distance
// covered
auto drive(double time) -> double /*distance*/ {
double distance = _speed * time;
double fuel_needed = distance * _fuel_consumption;
if (fuel_needed > _fuel_left) {
// Not enough fuel to cover the desired distance,
// so we adjust the distance and fuel left accordingly
distance = _fuel_left / _fuel_consumption;
_fuel_left = 0;
// print a warning that we couldn't drive the full distance
std::cout <<
"Warning: Not enough fuel to drive the full distance."
<< std::endl;
} else {
_fuel_left -= fuel_needed;
}
return distance;
}
};Friend Functions
A friend function is a function that is not a member of a class but still has access to its private and protected members. This is useful when you need to allow an external function to access the internal state of a class without making the members public.
#include <iostream>
struct Car {
private:
double _speed;
double _fuel_consumption;
double _fuel_left;
double _fuel_capacity;
public:
Car(double speed, double fuel_consumption, double fuel_capacity)
: _speed(speed), _fuel_consumption(fuel_consumption),
_fuel_capacity(fuel_capacity), _fuel_left(fuel_capacity) {}
auto drive(double time) -> double /*distance*/ {
double distance = _speed * time;
double fuel_needed = distance * _fuel_consumption;
if (fuel_needed > _fuel_left) {
distance = _fuel_left / _fuel_consumption;
_fuel_left = 0;
std::cout <<
"Warning: Not enough fuel to drive the full distance."
<< std::endl;
} else {
_fuel_left -= fuel_needed;
}
return distance;
}
friend auto print_car_status(const Car& car) -> void;
};
auto print_car_status(const Car& car) -> void {
std::cout << "Speed: " << car._speed << std::endl;
std::cout << "Fuel consumption: " << car._fuel_consumption << std::endl;
std::cout << "Fuel left: " << car._fuel_left << std::endl;
std::cout << "Fuel capacity: " << car._fuel_capacity << std::endl;
}
auto main() -> int {
Car my_car(50, 0.1, 1000);
my_car.drive(100);
print_car_status(my_car);
}< Speed: 50
< Fuel consumption: 0.1
< Fuel left: 500
< Fuel capacity: 1000
# Declaration vs. Definition
In C++, on large projects, it's common to separate the **declaration** of a class from its **definition**. The **declaration** usually goes in a **header** file (`.hpp`), while the **definition** (implementation) goes in a **source** file (`.cpp`). This helps to reduce compile time as only the header files need to be included in multiple places, while the implementation is compiled separately.
Example:
The declaration of the `Dog` struct goes in the header file:
`Dog.hpp`:
```cpp
// allways use #pragma once in header files so it will only be included once
#pragma once
struct Dog {
std::string name;
int age;
auto bark() -> void;
};
auto pet(Dog& dog) -> void;
The definition of the Dog struct’s methods goes in the source file:
Dog.cpp:
#include "Dog.hpp"
auto Dog::bark() -> void {
std::cout << this->name << " says: Woof!" << std::endl;
}
auto pet(Dog& dog) -> void {
std::cout << "Petting " << dog.name << std::endl;
std::cout << dog.name << " is happy!" << std::endl;
}A few things to note:
- The struct name is part of the function’s name when it’s defined outside the struct’s body (
Dog::bark). - Methods can access member variables directly, without any extra syntax.
- Every object has an implicit pointer to itself called
this; if there’s no naming conflict,this->can be (and usually is) omitted.
For the rest of this course we’ll write declaration and definition together in a single code block for brevity — but keep the header/source split in mind for real projects.
Special Member Functions
There are 7 special member functions in C++ that the compiler can generate for a class if you don’t provide them yourself:
- Default constructor
Dog() - Destructor
~Dog() - Copy constructor
Dog spanky_clone(spanky) - Copy assignment operator
Dog spanky_clone = spanky - Move constructor
Dog new_spanky(move(spanky)) - Move assignment operator
Dog new_spanky = move(spanky) - Swap function (not always considered part of the “big six”, but often included for completeness)
Default Constructor
The default constructor is a special member function that is called when an object is created without any arguments. If you don’t provide one, the compiler will generate it for you.
Example:
struct Dog {
std::string name;
int age;
Dog() : name("Puppy"), age(0) {} // Default constructor
};
auto main() -> int {
Dog puppy;
std::cout << puppy.name << " is " << puppy.age << " years old." << std::endl;
}< Puppy is 0 years old.
Destructor
The destructor is a special member function that is called when an object goes out of scope or is explicitly deleted. If you don’t provide one, the compiler will generate it for you.
Example:
struct Dog {
std::string name;
int age;
~Dog() {
std::cout << name << " is being destroyed." << std::endl;
}
};
auto main() -> int {
Dog puppy;
}< Puppy is being destroyed.
Copy Constructor and Copy Assignment Operator
The copy constructor is a special member function that is called when a new object is created as a copy of an existing object. The copy assignment operator is called when an existing object is assigned the value of another existing object.
Example:
struct Dog {
std::string name = "default";
int age;
// Copy constructor
Dog(const Dog& other) : name(other.name), age(other.age) {
std::cout << other.name << " is being cloned." << std::endl;
}
// Copy assignment operator
auto operator=(const Dog& other) -> Dog& {
if (this != &other) {
std::cout << this->name << " is being replaced by a clone of "
<< other.name << std::endl;
this->name = other.name;
this->age = other.age;
}
return *this;
}
};
auto main() -> int {
Dog spanky{.name = "Spanky", .age = 3};
Dog spanky_clone(spanky); // Calls copy constructor
Dog nele{.name = "Nele", .age = 2};
// we replace Nele with a clone of Spanky
nele = spanky; // Calls copy assignment operator
}< Spanky is being cloned.
< Nele is being replaced by a clone of Spanky
Move Constructor and Move Assignment Operator
The move constructor is a special member function that is called when a new object is created by transferring resources from an existing object. The move assignment operator is called when an existing object is assigned the value of another object by transferring resources. The object that is moved from is typically then empty.
As an example, consider the following code with 3 Glasses of Beer of which only one has beer in it.
#include <iostream> // for input/output
#include <memory> // for unique_ptr
#include <utility> // for move
struct Beer {
float amount;
};
struct BeerGlass {
std::unique_ptr<Beer> beer = nullptr;
// Move constructor
BeerGlass(BeerGlass&& other) noexcept : beer(std::move(other.beer)) {}
// Move assignment operator
auto operator=(BeerGlass&& other) noexcept -> BeerGlass& {
if (this != &other) {
beer = std::move(other.beer);
}
return *this;
}
};
auto main() -> int {
// Create a BeerGlass with some beer in it
BeerGlass glass1{std::make_unique<Beer>(Beer{.amount = 0.5f})};
// Create a new BeerGlass by moving the contents of glass1 into it
BeerGlass glass2(std::move(glass1));
// Create an empty BeerGlass
BeerGlass glass3;
// Move the beer from glass2 into glass3
glass3 = std::move(glass2);
std::cout << "glass1 has beer: "
<< (glass1.beer ? "yes" : "no") << std::endl;
std::cout << "glass2 has beer: "
<< (glass2.beer ? "yes" : "no") << std::endl;
std::cout << "glass3 has beer: "
<< (glass3.beer ? "yes" : "no") << std::endl;
}< glass1 has beer: no
< glass2 has beer: no
< glass3 has beer: yes
Rule of Zero
The Rule of Zero is a design principle in C++ that suggests that structs should avoid explicitly declaring special member functions (destructor, copy/move constructors, copy/move assignment operators) whenever possible.
A good a example for this is a 3D Vector struct. We don’t need to explicitly define any special member functions because the compiler-generated ones are sufficient.
#include <iostream>
#include <cmath> // for std::sqrt
struct Vector3 {
float x, y, z;
auto magnitude() const -> float {
return std::sqrt(x * x + y * y + z * z);
}
};
auto main() -> int {
Vector3 v{.x = 3.0f, .y = 4.0f, .z = 5.0f};
std::cout << "Magnitude: " << v.magnitude() << std::endl;
}< Magnitude: 7.07107
Rule of Five
The Rule of Five is a design principle in C++ that suggests that if a class explicitly declares any of these special member functions:
- Destructor
- Copy constructor
- Copy assignment operator
- Move constructor
- Move assignment operator
it should explicitly declare all of them to ensure proper resource management. This is usually the case when the class manages resources such as dynamic memory, file handles, or network connections.
A good a example for this is a dynamic array class that manages its own memory.
#include <iostream>
struct DynamicArray {
private:
// pointer to the dynamically allocated array
int* _data = nullptr;
// current number of elements in the array
std::size_t _size = 0;
// actual size of the allocated array (capacity)
std::size_t _capacity = 0;
public:
// constructor with optional size parameter
DynamicArray(std::size_t size = 0) : _size(size) {
_capacity = 2*_size;
if (_size > 0) {
_data = new int[_capacity];
}
}
// push_back method to add elements to the array
void push_back(int value) {
if (_size >= _capacity) {
_capacity = _capacity == 0 ? 1 : 2 * _capacity;
int* new_data = new int[_capacity];
for (std::size_t i = 0; i < _size; ++i) {
new_data[i] = _data[i];
}
delete[] _data;
_data = new_data;
}
_data[_size++] = value;
}
// friend function to print the array
friend std::ostream& operator<<(std::ostream& os, const DynamicArray& arr) {
for (std::size_t i = 0; i < arr._size; ++i) {
os << arr._data[i] << " ";
}
return os;
}
// accessor operator to access elements by index
int& operator[](std::size_t index) {
return _data[index];
}
const int& operator[](std::size_t index) const {
if (index >= _size) {
throw std::out_of_range("Index out of range");
}
return _data[index];
}
// 1. Destructor
~DynamicArray() {
delete[] _data;
}
// 2. Copy constructor
DynamicArray(const DynamicArray& other) : _size(other._size),
_capacity(other._capacity) {
_data = new int[_capacity];
for (std::size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
}
// 3. Copy assignment operator
auto operator=(const DynamicArray& other) -> DynamicArray& {
if (this != &other) {
delete[] _data;
_size = other._size;
_capacity = other._capacity;
_data = new int[_capacity];
for (std::size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
}
return *this;
}
// 4. Move constructor
DynamicArray(DynamicArray&& other) noexcept : _data(other._data),
_size(other._size), _capacity(other._capacity) {
other._data = nullptr;
other._size = 0;
other._capacity = 0;
}
// 5. Move assignment operator
auto operator=(DynamicArray&& other) noexcept -> DynamicArray& {
if (this != &other) {
delete[] _data;
_data = other._data;
_size = other._size;
_capacity = other._capacity;
other._data = nullptr;
other._size = 0;
other._capacity = 0;
}
return *this;
}
};
auto main() -> int {
DynamicArray arr1(3);
arr1[0] = 1;
arr1[1] = 2;
arr1[2] = 3;
arr1.push_back(1);
arr1.push_back(2);
std::cout << arr1 << std::endl;
}< 1 2 3 1 2
The Rule of Seven
The Rule of Seven is not a strictly standardized C++ term, but rather a design philosophy used to enforce extreme architectural safety or performance optimizations.
| Feature | Rule of Five | Rule of Seven |
|---|---|---|
| Primary Intent | Enable performance optimizations (move semantics) for a class that owns a raw resource. | Enforce explicit architectural rules (like banning copies) and optimize container operations. |
| Functions Defined | Destructor, Copy Constructor, Copy Assignment, Move Constructor, Move Assignment. | The Rule of Five functions + Default Constructor + swap function. |
| Best Used For | Standard custom resource managers (e.g., custom arrays, database connections). | Move-only types (like thread wrappers, unique handles) and types heavily used in std::algorithm. |
Use the Rule of Five when your class manages a resource that can be logically copied and moved, and you want to ensure it works efficiently with modern C++ features.
For example:
- You are writing a custom data structure (like a flat map or a specialized ring buffer) that allocates heap memory.
- You class wraps a resource that is expensive to copy, but you still want to allow copying when explicitly requested.
- You want the compiler to automatically optimize your object when it is returned from functions (via RVO and move constructors).
Use the Rule of Seven when you need explicit control over how a class is initialized, or when you need to strictly forbid certain behaviors (like copying).
For example:
- Creating Move-Only Types: If your class wraps a system resource that cannot be duplicated (like a network socket, a file lock, or a thread), you use the Rule of Seven to explicitly = delete the copy constructor and copy assignment, while keeping the move operations.
- Standard Library Optimization: You are writing a type that will be stored in standard containers (like std::vector) and sorted frequently. Providing a custom, noexcept swap function prevents the container from doing expensive allocations during sorting algorithms.
- Disabling Default Construction: You want to force users to provide arguments when creating your object, requiring you to explicitly delete or manage the default constructor alongside your resource management.
A good example for the Rule of Seven is FileDescriptor, a class that manages a file handle and ensures proper resource management through explicit control over construction, destruction, and swapping:
#include <utility> // for std::swap
// System functions defined somewhere else
// they can be dangerous and should be used with care, which is why we wrap
// them in a struct like FileDescriptor
auto os_open(const string& path) -> int /*file_descriptor_id*/;
auto os_close(int file_descriptor_id) -> void;
struct FileDescriptor {
private:
int _file_descriptor_id;
public:
// getter for the file descriptor
auto get() const -> int {
return _file_descriptor_id;
}
// 1. Default Constructor
// ensures a default-initialized file descriptor is always empty ∼ -1
FileDescriptor() : _file_descriptor_id(-1) {}
// Constructor that opens a file
// explicit keyword prevents implicit conversions from string to
// FileDescriptor
explicit FileDescriptor(const string& path) :
_file_descriptor_id(os_open(path)) {}
// 2. Destructor
// ensures the file descriptor is properly closed when the object goes
// out of scope
~FileDescriptor() {
if (_file_descriptor_id != -1) {
os_close(_file_descriptor_id);
}
}
// 3. Copy Constructor
// deleted to prevent copying
FileDescriptor(const FileDescriptor&) = delete;
// 4. Copy Assignment
// deleted to prevent copying
FileDescriptor& operator=(const FileDescriptor&) = delete;
// 5. Move Constructor
// transfers ownership of the file descriptor
FileDescriptor(FileDescriptor&& other) noexcept :
_file_descriptor_id(other._file_descriptor_id) {
other._file_descriptor_id = -1;
}
// 6. Move Assignment
// transfers ownership of the file descriptor
FileDescriptor& operator=(FileDescriptor&& other) noexcept {
if (this != &other) {
if (_file_descriptor_id != -1) {
os_close(_file_descriptor_id);
}
_file_descriptor_id = other._file_descriptor_id;
other._file_descriptor_id = -1;
}
return *this;
}
// 7. swap function
// swaps the file descriptors of two objects
void swap(FileDescriptor& other) noexcept {
std::swap(_file_descriptor_id, other._file_descriptor_id);
}
};Inheritance
Inheritance lets object-oriented languages reuse source code: a subclass (or derived class) is derived from a superclass (or base class), inheriting its state (member variables) and behavior (methods). A class without any superclass is called a base class. A subclass can add its own members and methods, or override those of its superclass.
C++ supports several shapes of inheritance:
- Multiple inheritance: a subclass can be derived from more than one superclass.
- Multilevel inheritance: a subclass can itself be a superclass for another subclass.
- Hierarchical inheritance: several subclasses can be derived from the same superclass.

The standard library’s stream classes are a great real-world example: istream and ostream both derive from ios, and iostream derives from both of them — hierarchical, then multiple inheritance, in one hierarchy. We’ll come back to the tricky part of this diagram — the diamond shape — a bit later in this chapter.
Basic Syntax
Let’s make Circle a subclass of Shape:
Circle.hpp:
struct Circle : public Shape {
private:
Point2D center_;
double radius_;
public:
Circle(Point2D center, double radius);
auto area() -> double override;
};Circleis now a subclass ofShape.- It defines its own, more meaningful constructor.
- It overrides
area.ovverrideensures that the method actually overrides a base class method and produces a compile-time error if it doesn’t.
- Inheritance can also be private, protected, or public.
private:shapes.push_back(&circle);produces an error because the base class is inaccessible.protected: similar restrictions apply, though derived classes can access the base class.public:shapes.push_back(&circle);works as expected.
Constructor Chaining
A derived class’s constructor can (and usually should) initialize its base class by calling the base class’s constructor in the initializer list:
struct Animal {
std::string name;
Animal(std::string name) : name(name) {}
};
struct Dog : Animal {
std::string race;
// initialize base class Animal with name
Dog(std::string name, std::string race)
: Animal(name), race(race) {}
};Subtype Polymorphism
Subtyping is a relation between two types that lets objects of a subtype S be used wherever an object of a supertype T is expected. In C++, this relation exists exactly when inheritance is declared public.
With public inheritance, our code compiles again — but doesn’t behave the way we’d want:
struct Shape {
auto area() -> double;
};
struct Circle : Shape {
auto area() -> double;
};
auto main() -> int {
Circle circle;
Shape* shape = &circle;
shape->area(); // calls Shape::area, not Circle::area!
}- The static type of
shape— known at compile time — isShape*. - The dynamic type of
shape— only known at runtime — isCircle*.
By default, C++ picks the method to call based on the static type. To make it use the dynamic type instead, we need dynamic dispatching.
Dynamic Dispatching: Virtual Methods
Dynamic dispatching selects the method to call based on an object’s dynamic (i.e. actual) type rather than its static (i.e. declared) type. To enable it, declare the method virtual in the base class, and override it in derived classes:
struct Shape {
virtual auto area() -> double;
};
struct Circle : Shape {
auto area() -> double override;
};
auto main() -> int {
Circle circle;
Shape* shape = &circle;
shape->area(); // calls now Circle::area as one might expect
}virtualgoes at the very front of the declaration, in the base class.overridegoes at the very end, after the trailing return type, in derived classes.
Always prefer
overrideover re-declaring the method without it: if the base class method ever changes or disappears,overrideturns that into a compile error instead of a silent bug.
Now shape->area() correctly calls Circle::area().
Virtual Destructors
As soon as a class has virtual methods, it should also have a virtual destructor:
struct Shape {
virtual ~Shape() = default;
virtual auto area() -> double;
};
struct Circle : Shape {
~Circle() override = default;
auto area() -> double override;
};This ensures that deleting an object through a base class pointer (e.g. delete shape; where shape is a Shape* pointing at a Circle) runs every destructor in the inheritance chain. Without it, only Shape::~Shape would run, and Circle’s part of the object would leak.
Final Classes and Methods
final lets you forbid further overriding. On a method, it means no further subclass may override it; on a class, it means no further subclass may derive from it at all:
struct Rectangle : Shape {
auto area() -> double final; // no further subclass may override this
};
struct Square final : Rectangle { // no further subclass at all
Square(Point2D center, int length);
~Square() override = default;
};Multiple Inheritance and the Diamond Problem
Multiple inheritance can create ambiguous situations. The classic example is the diamond problem:

DonkeyandHorseboth derive fromAnimal, and both override a virtual methodAnimal::goes.Mulederives from bothDonkeyandHorse.- If
Muledoesn’t overridegoesitself — which definition does it inherit?
Calling mule.goes(); on an instance mule of Mule is a compile error:
error: request for member 'goes' is ambiguous
There are two ways to fix this:
- call one of the two versions explicitly:
mule.Donkey::goes(); - override
goesinMuleitself, resolving the ambiguity there.
Even if neither Donkey nor Horse overrides Animal::goes, mule.goes(); is still ambiguous. That’s because an instance of Mule physically contains one complete sub-object for each of its (transitive) base classes — and since both Donkey and Horse each contain their own Animal, Mule ends up containing two separate copies of Animal.
Virtual inheritance fixes this by making sure only a single, shared copy of the common base class is created:
struct Animal { auto goes() -> void; };
struct Donkey : virtual Animal { /* ... */ };
struct Horse : virtual Animal { /* ... */ };
struct Mule : Donkey, Horse { /* ... */ }; // only one Animal nowThis is exactly how the standard library avoids the same problem for std::iostream that we saw at the start of this section: ios is a virtual base class of both istream and ostream.
Abstract Classes
Sometimes it makes no sense to create instances of a class at all. Take another look at Shape:
struct Shape {
virtual ~Shape() = default;
virtual auto area() -> double;
};Shape::area can’t be meaningfully implemented — a bare Shape isn’t a circle, a rectangle, or anything with an actual area. Abstract classes are for exactly this situation: they can do everything a normal class can, plus declare pure virtual methods.
struct Shape {
virtual ~Shape() = default;
virtual auto area() -> double = 0; // pure virtual — declared, not defined
};The = 0 means the method is declared but doesn’t need a definition — so we can delete the (pointless) implementation of Shape::area entirely. A class with at least one pure virtual method is abstract: it can no longer be instantiated directly, and any concrete subclass must override and define every pure virtual method it inherits.
Pure Virtual Destructors
Even a destructor can be declared pure virtual:
struct Shape {
virtual ~Shape() = 0;
};This still needs a definition, though — usually:
Shape::~Shape() = default;That’s not a contradiction: constructors and destructors are special functions that get called automatically along the whole inheritance chain, and that automatism needs some definition to call. A pure virtual destructor is mainly useful when you want an abstract class that otherwise has no pure virtual methods of its own.
const Correctness
We’ve already seen const on variables, function parameters, pointers and references. As a quick refresher:
| Declaration | Meaning |
|---|---|
const double PI = 3.14159; |
PI cannot be reassigned after initialization |
auto f(const int a) -> void |
the local parameter a cannot be reassigned inside f |
const char* s |
the pointee is read-only; the pointer itself can be reassigned |
char* const p |
the pointer is read-only; the pointee can be modified |
const char* const s |
both the pointer and the pointee are read-only |
auto f(const std::string& s) -> void |
s refers to an object that cannot be modified through this reference |
Classes add one more place where const matters: methods.
const Methods
A const method promises not to modify any of the object’s member variables:
struct Shape {
virtual auto area() const -> double = 0;
};Breaking that promise is a compile error:
error: assignment of member in read-only object
Only const methods can be called on a const instance:
const Circle circle({0, 0}, 3);
circle.area(); // fine, area() is constmutable
Sometimes a method needs to change a “helper” variable — like a call counter or a cache — without changing the object’s actual, observable state. Declaring that variable mutable lets a const method modify it anyway:
struct Circle : Shape {
mutable int nof_calls_to_area = 0;
auto area() const -> double override;
};
auto Circle::area() const -> double {
nof_calls_to_area++; // fine, nof_calls_to_area is mutable
// ...
}const Overloading
Overloaded functions and methods can also differ purely by their “constness”:
struct IntList {
auto operator[](size_t i) -> int&; // (1) non-const
auto operator[](size_t i) const -> const int&; // (2) const
};The compiler picks (1) or (2) based on whether the object you’re calling it on is const:
IntList variable_list(2);
const IntList const_clist(2);
// left: (1) int&, right: (2) const int&
variable_list[0] = const_clist[0];
// error: left is (2) const int&, can't be assigned to
const_clist[1] = variable_list[1]; What Makes a Program const-Correct?
A program is const-correct when:
- every variable that shouldn’t change after its initial assignment is declared
const, - every method that shouldn’t modify its object is declared
const, and - every return value the caller shouldn’t be able to modify is declared
const.
Whether something should be immutable is a judgment call based on what the program is meant to do — the compiler won’t check this for you (except for the parts you did mark const, which it enforces strictly). One exception: basic types (int, float, double, …) passed by value don’t need to be const in function parameters, since the caller’s copy can’t be affected either way.