3 Object-Oriented Programming in C++

Classes, Encapsulation, Inheritance, Polymorphism and Special Member Functions in C++
Author

Daniel Schwarzenbach

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: 15

What’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 Shape needs to know the internal meaning of points for each Type — 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 Shape itself.

Imagine we wanted to add triangles. We’d have to:

  • add triangle to Type,
  • make Shape::points hold three points for every shape, even circles and rectangles,
  • add another case to area.

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:

  1. Default member access: members of a struct are public by default, while members of a class are private by default.
  2. Default inheritance access: when deriving with struct Derived : Base, the inheritance is public by default; when deriving with class Derived : Base, it is private by default — regardless of whether Base itself 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 public getter 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 public or private too. Methods that are part of a class’s interface should be public (Shape::area, List::insert, …). Internal helper methods should be private (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.

Inheritance Diagram of std::iostream

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;
};
  • Circle is now a subclass of Shape.
  • 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_ protected so subclasses can see it, or
  • give Circle its own private member variables (center_, radius_) and remove points_ from Shape entirely.

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 — is Shape*.
  • The dynamic type of shape — only known at runtime — is Circle*.

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;
};
  • virtual goes at the very front of the declaration, in the base class.
  • override goes at the very end, after the trailing return type, in derived classes.

Always prefer override over re-declaring the method without it: if the base class method ever changes or disappears, override turns 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
  • B and C both derive from A, and both override a virtual method A::m.
  • D derives from both B and C.
  • If D doesn’t override m itself — 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 m in D itself, 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 now

This 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 fine

Move 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 again

In 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::move doesn’t move anything by itself — it’s just a cast that marks an object as “movable”, equivalent to static_cast<Circle&&>(c1). If the argument is const, std::move silently falls back to a copy, since a const object 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 (like std::vector or std::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 const

mutable

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 to

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.