3 Object-Oriented Programming in C++
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 do we need this?
Let’s motivate all of this with a concrete problem: write a C++ program that
- creates geometric shapes (circles, rectangles, …),
- stores them in a
std::vector<Shape>, and - computes the area of every shape by iterating over the vector.
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 <iostream>
#include <vector>
#include <cmath>
// the "kind" of shape a Shape currently represents
enum class Type { circle, rectangle };
struct XYPoint {
int x, y;
};
// a shape is described by its type and two points, whose meaning depends on the type
struct Shape {
Type type;
XYPoint points[2];
};
auto area(const Shape& shape) -> double {
switch (shape.type) {
case Type::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 Type::rectangle: {
int width = shape.points[1].x - shape.points[0].x;
int height = shape.points[1].y - shape.points[0].y;
return std::abs(width * height);
}
}
return 0.0; // unreachable, but keeps the compiler happy
}
auto main() -> int {
std::vector<Shape> shapes = {
{Type::circle, {{8, 4}, {11, 4}}},
{Type::rectangle, {{0, 0}, {5, 3}}},
};
for (const Shape& shape : shapes) {
std::cout << "Area: " << area(shape) << std::endl;
}
return 0;
}g++ -std=c++20 -o shapes shapes.cpp
./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 eachType— 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.
Classes in C++
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, and you should do what you feel is best. For the rest of this chapter we’ll stick with class, since that’s what you’ll see most often in other people’s code.
Member Variables and Methods
A class bundles data (member variables) with the operations that work on that data (member functions, or methods). Let’s rebuild Shape as a class:
class Shape {
Type type_;
XYPoint points_[2];
auto area() -> double;
};- The declaration of a class normally lives in a header file, usually named after the class, e.g.
Shape.h. - The semicolon at the end of the class body is required — forgetting it is a classic beginner’s mistake.
- The trailing underscore (
type_,points_) is a common convention to visually separate member variables from local variables and parameters. We’ll use it throughout this chapter. - Just like free functions, member functions use the trailing return type syntax (
auto ... -> Type) throughout this chapter.
Declaration vs. Definition
Just like free functions, member functions are usually declared in the header and defined (implemented) in a corresponding source file:
// Shape.h
class Shape {
Type type_;
XYPoint points_[2];
public:
auto area() -> double;
};// Shape.cpp
#include "Shape.h"
auto Shape::area() -> double {
switch (type_) { /* ... */ }
}A few things to note:
- The class name is part of the function’s name when it’s defined outside the class body (
Shape::area). - 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 notebook 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.
Creating Objects
The values of a class are called objects or instances. Creating one looks just like any other variable declaration:
Shape circle;Just like with structs, this is a completely ordinary declaration — the type on the left, the name on the right — and it already reserves storage for a Shape object named circle.
Member access works exactly like it does for structs, via the . operator:
circle.type_ = Type::circle;
std::cout << circle.area() << std::endl;This won’t compile yet, though — we haven’t made anything public. That’s next.
Encapsulation
By default, members of a class are not accessible from outside — that’s the one real difference between class and struct we mentioned earlier. Access modifiers control who can see what:
class Shape {
private:
Type type_;
XYPoint points_[2];
public:
auto area() -> double;
};| 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:
class Circle {
XYPoint center_;
int radius_;
public:
auto center() const -> XYPoint { return center_; }
auto set_center(XYPoint center) -> void { center_ = center; }
auto radius() const -> int { return radius_; }
auto set_radius(int radius) -> void { radius_ = radius; }
};This gives you two things you don’t get with a plain public member:
read and write access can be controlled independently — e.g. a
publicgetter with no setter creates a read-only property,the class can keep its internal state consistent, even if member variables depend on each other.
Methods can be
publicorprivatetoo. Methods that are part of a class’s interface should bepublic(Shape::area,List::insert, …). Internal helper methods should beprivate(Chess::init,Account::update_balance, …).
Friend Classes and Functions
Sometimes an external function or class legitimately needs access to your class’s internals — testing is a common example. A friend class is granted that access explicitly, by name:
class Shape {
friend class ShapeTest; // every instance of ShapeTest can access Shape's internals
};A friend function is identified by its name, argument types and return type, and is often used to implement operators that need access to internals from outside the class, such as a custom operator<<:
class Shape {
friend auto operator<<(std::ostream& os, const Shape& shape) -> std::ostream&;
};
auto operator<<(std::ostream& os, const Shape& shape) -> std::ostream& {
os << "Shape with area " << shape.area();
return os;
}Argument names can be omitted in declarations — that’s why we wrote
std::ostream&without a parameter name in the class body above.
Static Members
Besides regular member variables, a class can also have class variables (static members) — a single value shared by all instances of the class, rather than one value per instance.
Class Variables
// Canvas.h
class Canvas {
private:
static const XYPoint ORIGIN;
};// Canvas.cpp
const XYPoint Canvas::ORIGIN = { 0, 0 };Class variables are declared in the header, but still need to be defined exactly once in a source file. They don’t have to be const — a non-const static member is a common way to count how many instances of a class currently exist.
Class Methods
Analogous to class variables, there are class methods (static member functions), declared with static:
class Canvas {
private:
static const XYPoint ORIGIN;
public:
static auto is_origin(const XYPoint& p) -> bool;
};
auto Canvas::is_origin(const XYPoint& p) -> bool {
return ORIGIN.x == p.x && ORIGIN.y == p.y;
}Class methods can only access class variables (and their own arguments) — never member variables, since there’s no specific instance (no this) associated with the call. They’re invoked using the class name directly:
std::cout << (Canvas::is_origin({3, 4}) ? "yes" : "no") << std::endl;Constructors and Destructors
Once member variables are private, we can no longer initialize them directly from outside the class. Writing a separate init() method would work, but it’s error-prone — nothing forces callers to actually call it.
Constructors solve this: they’re guaranteed to run whenever an object is created.
Constructors
A constructor is a special member function with the same name as the class and no return type:
// Shape.h
class Shape {
Type type_;
XYPoint points_[2];
public:
Shape(Type type, XYPoint p1, XYPoint p2);
};// Shape.cpp
Shape::Shape(Type type, XYPoint p1, XYPoint p2) {
type_ = type;
points_[0] = p1;
points_[1] = p2;
}Shape circle(Type::circle, {8, 4}, {11, 4});A class can have multiple constructors with different parameters — the compiler picks the right one based on the arguments you pass, exactly like with overloaded functions.
Member Initializer Lists
Member variables can also be initialized using a member initializer list, which runs before the constructor body:
Shape::Shape(Type type, XYPoint p1, XYPoint p2)
: type_{type}, points_{p1, p2} {
// further initialization, if needed
}This is generally the preferred style, for two reasons:
- it’s typically faster than assigning inside the body, since it initializes members directly instead of default-constructing them and then overwriting them,
- for member variables that aren’t basic types, it calls the matching constructor directly.
Initialize member variables in the initializer list in the same order they’re declared in the class (top to bottom, left to right) — and initialize all of them.
The Default Constructor
The constructor with no arguments is called the default constructor. You get one automatically, even if you never write it yourself — it default-initializes every member as declared in the header.
Writing your own constructor with arguments removes the implicit default constructor. If you still need a no-argument constructor alongside your custom ones, bring it back explicitly:
Shape::Shape() = default;Destructor
The destructor runs automatically when an object is destroyed — when its scope ends, or when it’s deleted through a pointer:
// Shape.h
class Shape {
public:
~Shape();
};// Shape.cpp
Shape::~Shape() { /* ... */ }- The destructor’s name is a tilde (
~) followed by the class name. - There is only ever one destructor per class, and it never takes any arguments.
If you don’t write one, there’s an implicit default destructor: it does nothing for basic-typed members, and recursively calls the destructor of every other member (in reverse declaration order). An explicit destructor does the same thing automatically, after running its own body. You can also request the implicit version explicitly:
Shape::~Shape() = default;The destructor’s job is to clean up anything the object acquired during its lifetime — we’ll come back to this when we look at the Rule of Three/Five later in this chapter.
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:
class Circle : Shape {
public:
Circle(XYPoint center, int radius);
auto area() -> double;
};Circleis now a subclass ofShape.- It defines its own, more meaningful constructor.
- It overrides
area.
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:
Circle::Circle(XYPoint center, int radius)
: Shape(Type::circle, center, {center.x, center.y + radius}) {
}Rethinking Member Variables
If Shape::points_ stays private, Circle::area can’t access it at all — private members are only visible inside the class that declares them, not in subclasses. We have two options:
- make
points_protectedso subclasses can see it, or - give
Circleits ownprivatemember variables (center_,radius_) and removepoints_fromShapeentirely.
The second option is better: protected is nearly as dangerous as public (any subclass, now or in the future, gets full access), while each shape knows best what data it actually needs.
class Shape {
public:
auto area() -> double;
};
class Circle : Shape {
XYPoint center_;
int radius_;
public:
Circle(XYPoint center, int radius);
auto area() -> double;
};Rectangle can be declared and defined analogously.
Access Specifiers for Inheritance
Compiling code that stores a Circle* in a std::vector<Shape*> now fails:
std::vector<Shape*> shapes;
Circle circle({8, 4}, 3);
shapes.push_back(&circle); // error: 'Shape' is an inaccessible base of 'Circle'So far we’ve only used inheritance to reuse the base class’s code. For Circle* to also be usable as a Shape*, Circle needs to be a proper subtype of Shape — the base class needs to stay accessible. C++ gives you three choices for how : inheritance affects the base class’s access modifiers:
| Inheritance | Effect on public/protected members of the base |
|---|---|
private (default) |
become private in the derived class |
protected |
become protected in the derived class |
public |
stay exactly as they were |
class Circle : public Shape { /* ... */ };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:
Circle circle({0, 0}, 5);
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:
class Shape {
public:
virtual auto area() -> double;
};
class Circle : public Shape {
public:
auto area() -> double override;
};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:
class Shape {
public:
virtual ~Shape() = default;
virtual auto area() -> double;
};
class Circle : public Shape {
public:
~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.
Abstract Classes
Sometimes it makes no sense to create instances of a class at all. Take another look at Shape:
class Shape {
public:
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.
class Shape {
public:
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:
class Shape {
public:
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.
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:
class Rectangle : public Shape {
public:
auto area() -> double final; // no further subclass may override this
};
class Square final : public Rectangle { // no further subclass at all
public:
Square(XYPoint topLeft, int length);
~Square() override = default;
};Multiple Inheritance and the Diamond Problem
Multiple inheritance can create ambiguous situations. The classic example is the diamond problem:
A
/ \
B C
\ /
D
BandCboth derive fromA, and both override a virtual methodA::m.Dderives from bothBandC.- If
Ddoesn’t overridemitself — which definition does it inherit?
Calling d.m(); on an instance d of D is a compile error:
error: request for member 'm' is ambiguous
There are two ways to fix this:
- call one of the two versions explicitly:
d.B::m(); - override
minDitself, resolving the ambiguity there.
Even if neither B nor C overrides A::m, d.m() is still ambiguous. That’s because an instance of D physically contains one complete sub-object for each of its (transitive) base classes — and since both B and C each contain their own A, D ends up containing two separate copies of A.
Virtual inheritance fixes this by making sure only a single, shared copy of the common base class is created:
class A { auto m() -> void; };
class B : public virtual A { /* ... */ };
class C : public virtual A { /* ... */ };
class D : public B, public C { /* ... */ }; // only one A 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.
Special Member Functions
Every class actually has six member functions defined implicitly, whether you write them yourself or not:
- default constructor
- default destructor
- copy constructor
- copy assignment operator
- move constructor
- move assignment operator
We already covered the default constructor and destructor. Let’s look at the other four, starting with copying.
Copy Constructor
Like any constructor, the copy constructor shares the class’s name and has no return type. It takes exactly one argument: a constant reference to an instance of the same class.
class Circle : public Shape {
public:
Circle(const Circle& c); // copy constructor
};If you don’t define one, the compiler generates an implicit copy constructor that copies every member variable — recursively calling the copy constructor of any member that isn’t a basic type.
An object can be copied in two different ways:
- a shallow copy only copies references to the original object’s members,
- a deep copy recursively copies the members themselves.
The copy constructor’s contract requires a deep copy. This matters a lot for classes managing dynamic memory (raw pointers from new): the implicit copy constructor only copies the pointer value, not what it points to — which can cause subtle bugs (e.g. in a linked list) or crashes from double-freeing the same memory.
The copy constructor runs whenever the target object didn’t exist yet:
Circle c1({8, 4}, 3); // regular constructor
Circle c2(c1); // copy constructor, argument c1
Circle c3 = c1; // copy constructor, argument c1
c2 = c3; // NOT the copy constructor!Copy Assignment Operator
To copy into an object that already exists, you need the copy assignment operator:
class Circle : public Shape {
public:
auto operator=(const Circle& c) -> Circle&;
};Its implementation is typically similar to the copy constructor’s, but with a few extra things to watch out for:
- you may need to free memory the object (
this) had already reserved, - the assignment must still work correctly for self-assignment (
c = c;) and for chained assignment (a = b = c;), - every
operator=implementation must return a reference to the object itself (*this).
The Rule of Three
If you define any one of the following three special member functions, you should implement all three:
- destructor
- copy constructor
- copy assignment operator
This is because if you break the rule, the implicitly-generated versions almost certainly won’t match your explicit ones. For example, if your copy constructor allocates memory for a deep copy, the implicit destructor won’t know to free it.
Explicit Constructors
For “regular” constructors that take exactly one argument, it’s good practice to mark them explicit. The reason is C++’s automatic type conversion:
class Person {
public:
Person(int id); // "normal" constructor, no explicit
};
auto process(Person person) -> void;
process(42); // compiles! 42 is silently converted to Person(42)Without explicit, that call to process(42) silently converts 42 into a Person — probably not what you meant. Adding explicit forbids exactly this kind of implicit, one-argument conversion:
class Person {
public:
explicit Person(int id);
};
process(42); // now a compile error
process(Person(42)); // this still works fineMove Semantics
Why Move?
Often you “copy” a value that you don’t actually need afterwards — a classic example is swap:
Circle tmp = c1; // c1 is about to be overwritten anyway
c1 = c2; // c2 is about to be overwritten anyway
c2 = tmp; // tmp is never used againIn cases like this, we could just steal the dynamically-allocated memory from the right-hand side instead of copying it. That’s exactly what C++’s move operations let you do.
Move Constructor and Move Assignment Operator
class Circle : public Shape {
public:
Circle(Circle&& rhs);
auto operator=(Circle&& rhs) -> Circle&;
};Circle&& is a so-called rvalue reference — roughly, “a reference to an object you’re allowed to steal from”. After a move, rhs must still be a valid object, but it may have a different (typically empty) value than before. For classes managing dynamic memory, moving can be implemented much more cheaply than copying — no new allocation is needed at all.
Move operations are used automatically in two situations:
Circle c1;
c1 = Circle({0, 0}, 3); // temporary, unnamed object -> move assignment
Circle c2(std::move(c1)); // explicit std::move -> move constructor
std::movedoesn’t move anything by itself — it’s just a cast that marks an object as “movable”, equivalent tostatic_cast<Circle&&>(c1). If the argument isconst,std::movesilently falls back to a copy, since aconstobject can’t be modified (and thus can’t be moved from).
The Rule of Five
- Rule of Three: if you implement the destructor, copy constructor, or copy assignment operator, implement all three — for consistent behavior.
- Rule of Five: if you implement those three, also implement the move constructor and move assignment operator — for better performance.
- Rule of Zero: classes that don’t manually manage dynamic memory (with
new/delete) shouldn’t implement any of the five — for less error-prone code. Let member variables that do manage resources (likestd::vectororstd::unique_ptr) do the work for you.
Deleting Copy and Move Operations
Sometimes an object should never be copied at all — std::unique_ptr is a good example. You express that by delete-ing the corresponding special member functions:
class DoNotCopy {
public:
DoNotCopy(const DoNotCopy&) = delete;
auto operator=(const DoNotCopy&) -> DoNotCopy& = delete;
};This is exactly how std::unique_ptr prevents you from accidentally copying it instead of moving it.
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:
class Shape {
public:
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:
class Circle : public Shape {
mutable int numCallsToArea_ = 0;
public:
auto area() const -> double override;
};
auto Circle::area() const -> double {
numCallsToArea_++; // fine, numCallsToArea_ is mutable
// ...
}const Overloading
Overloaded functions and methods can also differ purely by their “constness”:
class IntList {
public:
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 vlist(2);
const IntList clist(2);
vlist[0] = clist[0]; // left: (1) int&, right: (2) const int&
clist[1] = vlist[1]; // error: left is (2) const int&, can't be assigned toWhat 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.