Lesson 5: Classes

Lesson 0005 — A Tour of C++, Chapter 5 (§5.1–§5.6)

Chapter 2 introduced user-defined types and Chapter 4 showed how exceptions and RAII keep constructors honest. This chapter is where those threads meet: classes are the central language feature of C++, the tool we use to define our own types that behave "as real as int and float." Stroustrup organizes class design into three kinds — concrete types (represented directly, like built-ins), abstract types (pure interfaces), and class hierarchies (families of related types built on virtual functions). Along the way you'll meet the two resource-management ideas that make C++ memory-safe in practice: constructors/destructors pairs (RAII) and unique_ptr. By the end, the Vector from Chapter 4 and the Shape hierarchy below are the same pattern seen from two sides: acquisition in the constructor, release in the destructor, interface up top, implementation below.

Why classes matter now: everything you've seen so far — complex, Vector, string, vector — is a class. This chapter gives you the vocabulary to read and write them: concrete vs. abstract types, virtual functions and the vtbl, hierarchies and dynamic_cast. The resource management half (RAII, unique_ptr) is what makes exceptions safe, and it's the single most important C++ idiom you'll carry forward.

5.1 Introduction — Classes

The central language feature of C++ is the class — a user-defined type. Classes provide "a structure for creating objects that can hold data and define operations on it," and they let us represent concepts directly in our code rather than through ad-hoc combinations of built-in types. This is the heart of the idea that a user-defined type should be "as real as int and float" — the quote is from Doug McIlroy, and it's the standard by which Stroustrup judges every class design.

Class design has a small set of recurring questions: where does the representation live (inside the object, or hidden behind an interface)? Is there a family of related types that should share a common interface? The answers produce the three kinds of classes covered in this chapter:

Kind of class Representation lives Used via Typical example
Concrete type (§5.2) in the object itself directly (stack, members) complex, Vector
Abstract type (§5.3) behind a pure interface pointers and references Container
Class hierarchy (§5.5) spread across a family of derived classes pointers and references ShapeCircleSmiley

5.2 Concrete Types

The simplest kind of class is a concrete type: one whose representation is part of its definition. That makes them behave much like built-in types — you can place them on the stack, refer to them directly (not through pointers or references), copy and move them freely, and initialize them immediately and completely. The price of this simplicity is that if the representation changes in any significant way, code using it must be recompiled. But for small, hot, performance-critical types that trade is exactly right.

5.2.1 An Arithmetic Type

The classic example is a complex number type. Because it's small and its operations are fundamental, it must behave like a built-in — no indirection, no heap allocation, no surprises:

class complex {
    double re, im;              // representation: two doubles
public:
    complex(double r = 0, double i = 0) : re{r}, im{i} {}   // build two doubles
    complex(double r) : re{r}, im{0} {}                     // build from a double
    complex& operator+=(const complex& z) { re += z.re; im += z.im; return *this; }
    double real() const { return re; }                      // read out
    double imag() const { return im; }
    // ...
};

Three design details are worth noticing:

complex operator+(complex a, complex b) { return a += b; }  // symmetric: 2+z works
bool operator==(complex a, complex b) { return a.real()==b.real() && a.imag()==b.imag(); }

5.2.2 A Container

A container holds a collection of elements — and unlike complex, its representation lives partly on the free store. That introduces the resource-management problem this chapter is really about: who acquires the memory, and who releases it? The answer, as Chapter 4 promised, is the constructor/destructor pair:

class Vector {
public:
    Vector(int s) : elem{new double[s]}, sz{s} {}   // constructor: acquire resources
    ~Vector() { delete[] elem; }                     // destructor: release resources
    double& operator[](int i) { return elem[i]; }
    int size() const { return sz; }
private:
    double* elem;   // pointer to the elements
    int sz;         // the number of elements
};

The constructor acquires the array (new double[s]) and the destructor releases it (delete[] elem). Because the destructor is called automatically when a Vector goes out of scope — and, crucially, even when an exception unwinds through it — the memory is released exactly once, on every path:

void fct(int n) {
    Vector v(n);   // constructor allocates
    // ... use v ...
}                  // destructor deallocates — even if an exception was thrown above

This is RAII (Resource Acquisition Is Initialization): acquire in the constructor, release in the destructor, and the language guarantees the rest. The Vector object itself is a small handle (two words: pointer + size); the actual data lives on the free store. That split — handle on the stack, data on the heap — is the pattern behind every standard container. The design rule to carry away: if a constructor acquires a resource, its class needs a destructor to release it, and you should avoid "naked" new and delete in application code entirely.

5.2.3 Initializing Containers

Raw new double[s] is awkward for everyday use. We want to write Vector v = {1, 2, 3, 4}; — so the class gets an initializer-list constructor that takes a std::initializer_list<double>:

Vector::Vector(std::initializer_list<double> lst)   // initialize from a list
    : elem{new double[lst.size()]}, sz{static_cast<int>(lst.size())} {
    std::copy(lst.begin(), lst.end(), elem);   // copy the elements in
}

Note the static_cast<int>(lst.size()): the list's size() returns a size_t (unsigned), and converting to the signed int we chose for sz needs an explicit cast — implicit narrowing would be a compile error inside a brace initializer. Casts are a code smell at high levels of abstraction, but here the conversion is legitimate and checked.

Once a Vector supports range-for (Chapter 2 style) and push_back(), it becomes genuinely convenient:

Vector read(istream& is) {
    Vector v;              // empty Vector
    for (double d; is >> d; )   // read floating-point values
        v.push_back(d);    // grow the Vector
    return v;              // return by value
}

For that return-by-value to be cheap (no copying of the whole array), Vector needs a move constructor, which transfers the handle — pointer and size — rather than copying elements:

Vector::Vector(Vector&& a)               // move constructor
    : elem{a.elem}, sz{a.sz} {            // steal the representation
    a.elem = nullptr;                     // leave a in an empty state
    a.sz = 0;
}

5.3 Abstract Types

A abstract type is a type that completely insulates a user from the implementation details. The representation is not part of the definition — in fact, an abstract type has no data members at all. It specifies an interface as a set of pure virtual functions, and it is the job of derived classes to supply the implementations:

class Container {
public:
    virtual double& operator[](int) = 0;   // pure virtual function
    virtual int size() const = 0;          // const member function
    virtual ~Container() {}                // virtual destructor
};

The = 0 marks a function as pure virtual — there is no definition in Container itself, and a class with any pure virtual function cannot be instantiated. You cannot write Container c;. What you can do is implement the interface in a concrete class:

class Vector_container : public Container {   // implements Container
    Vector v;                                  // Vector from §5.2.2 as the data
public:
    Vector_container(int s) : v(s) {}
    Vector_container(std::initializer_list<double> lst) : v(lst) {}
    ~Vector_container() {}                     // implicitly releases v's memory
    double& operator[](int i) override { return v[i]; }
    int size() const override { return v.size(); }
};

Now a user can write a function against the interface, and any implementation of Container will work — without the compiler knowing anything about Vector_container or the alternative List_container:

void use(Container& c) {
    const int sz = c.size();
    for (int i = 0; i != sz; ++i)
        cout << c[i] << '\n';   // works for any Container implementation
}

The cost of this flexibility: you can only access abstract types through pointers or references, and objects must be allocated on the free store. The override keyword on the derived functions makes the intent explicit and lets the compiler catch spelling or signature mistakes. And note the virtual destructor — it's essential, because deleting a Container* must run the most-derived destructor, and only a virtual destructor guarantees that.

Container is a polymorphic type: it has at least one virtual function, so objects of different derived classes can be treated uniformly through the interface — the same use() call dispatches to different implementations at run time.

5.4 Virtual Functions

How does use(Container&) call the right operator[]() when it doesn't know the object's concrete type? The answer is the vtbl (virtual function table). Each object of a polymorphic class carries a hidden pointer to a table of function pointers for its class, and a call through a virtual function becomes an indirection through that table:

// Conceptual layout:
//   Vector_container object:  [ vptr ] --> [ Vector_container::vtbl ]
//                                             |-- operator[] : &Vector_container::operator[]
//                                             |-- size()     : &Vector_container::size()
//                                             `-- ~Container : &Vector_container::~Vector_container

The price of run-time dispatch is small and bounded: one pointer per object, one table per class, and a call that is roughly within 25% of a direct call on most hardware. That overhead is the deliberate trade — you pay a little for the ability to extend the program with new Container implementations without recompiling the code that uses the interface.

5.5 Class Hierarchies

When several classes share a common interface and a common structure, we organize them into a class hierarchy — a family of classes ordered by derivation. The classic example: a Shape base class with derived classes like Circle and Smiley:

class Shape {
public:
    virtual Point center() const = 0;       // pure virtuals: interface only
    virtual void move(Point to) = 0;
    virtual void draw() const = 0;
    virtual void rotate(int angle) = 0;
    virtual ~Shape() {}                     // virtual destructor
};

class Circle : public Shape {
public:
    Circle(Point p, int r);
    Point center() const override { return x; }
    void move(Point to) override { x = to; }
    void draw() const override;             // draw on some Canvas
    void rotate(int) override {}            // nice simple algorithm
private:
    Point x;    // center
    int r;      // radius
};

class Smiley : public Circle {              // uses Circle's data and operations
public:
    Smiley(Point p, int r) : Circle{p, r}, mouth{nullptr} {}
    void draw() const override;             // draw the face, then the eyes and mouth
    void rotate(int) override;              // rotate the eyes and mouth too
    void add_eye(Shape* s) { eyes.push_back(s); }
    void set_mouth(Shape* s);
    virtual void wink(int i);               // "feel free to override"
private:
    std::vector<Shape*> eyes;               // usually two
    Shape* mouth;
};

Then a general function can treat every shape uniformly — this is exactly the interface-inheritance idea from §5.3, applied to a family:

void rotate_all(std::vector<Shape*>& v, int angle) {
    for (auto p : v)
        p->rotate(angle);   // virtual call: the right rotate() runs for each shape
}

Objects are constructed bottom-up (base first, then derived) and destroyed top-down (derived first, then base). A virtual destructor is what makes top-down destruction reach Smiley's destructor — and thereby delete the eyes and mouth it owns — when you delete through a Shape*.

5.5.1 Benefits from Hierarchies

Hierarchies give us two distinct forms of inheritance, and Stroustrup's advice is to keep them separate in your mind:

The design question is always: is the base class something users will program against (interface), or a helper for the classes above it (implementation)? Keeping the two distinct prevents the classic hierarchy smell where a "base class" is really just shared code, and users end up depending on implementation details.

5.5.2 Hierarchy Navigation

Sometimes you need to get from a base pointer back to a derived object — for example, to call wink() on a shape you know is a Smiley. That's what dynamic_cast is for: a run-time check of "is this object of type X?"

Shape* ps {read_shape(cin)};
if (Smiley* p = dynamic_cast<Smiley*>(ps)) {   // does ps point to a Smiley?
    p->wink(1);                                 // yes: wink
} else {
    // not a Smiley: do nothing
}

The pointer form returns nullptr when the cast fails, so the condition fails and the else handles the "not a Smiley" case. The reference form dynamic_cast<Smiley&& has no null value to return, so it throws std::bad_cast instead. The advice: use pointer casts when failure is a valid alternative (test it), reference casts when failure is an error (let the exception propagate). And use dynamic_cast with restraint — code that type-tests its way down a hierarchy is often better redesigned to use virtual functions, which are simpler, faster, and checked at compile time.

5.5.3 Avoiding Resource Leaks

Look at Smiley again: it owns eyes (a vector<Shape*>) and mouth (a Shape*), so its destructor must delete them:

Smiley::~Smiley() {
    delete mouth;
    for (auto p : eyes)
        delete p;
}

This is exactly the "naked new/delete" pattern §5.2.2 told you to avoid. It works, but it puts a burden on every owner: remember to delete, delete exactly once, and make sure no exception can leak the objects in between. The modern alternative is to use a smart pointer for owned resources — unique_ptr deletes its object automatically when it goes out of scope:

class Smiley : public Circle {
    // ...
    std::unique_ptr<Shape> mouth;                  // owns the mouth
    std::vector<std::unique_ptr<Shape>> eyes;     // owns the eyes
    // no destructor needed: unique_ptr members clean up automatically
};

Now Smiley needs no user-declared destructor at all — the compiler generates one that destroys the members, which destroys the mouth and each eye. No hand-written delete, no way to forget one, and if an exception is thrown mid-construction, the members that were already built are still cleaned up. This is the RAII philosophy applied to members: own resources with objects that know how to release them, and let the language do the rest.

5.6 Advice

Here is a summary of the guidance from this chapter. All 24 items, with the section where each is introduced. The C++ Core Guidelines link each item to its recommended practice.

# Guideline §
1 Express ideas directly in code. 5.1
2 A concrete type is the simplest kind of class. Where applicable, prefer a concrete type over more complicated classes and over plain data structures. 5.2
3 Use concrete classes to represent simple concepts. 5.2
4 Prefer concrete classes over class hierarchies for performance-critical components. 5.2
5 Define constructors to handle initialization of objects. 5.2.1, 6.1.1
6 Make a function a member only if it needs direct access to the representation of a class. 5.2.1
7 Define operators primarily to mimic conventional usage. 5.2.1
8 Use nonmember functions for symmetric operators. 5.2.1
9 Declare a member function that does not modify the state of its object const. 5.2.1
10 If a constructor acquires a resource, its class needs a destructor to release the resource. 5.2.2
11 Avoid "naked" new and delete operations. 5.2.2
12 Use resource handles and RAII to manage resources. 5.2.2
13 If a class is a container, give it an initializer-list constructor. 5.2.3
14 Use abstract classes as interfaces when complete separation of interface and implementation is needed. 5.3
15 Access polymorphic objects through pointers and references. 5.3
16 An abstract class typically doesn't need a constructor. 5.3
17 Use class hierarchies to represent concepts with inherent hierarchical structure. 5.5
18 A class with a virtual function should have a virtual destructor. 5.5
19 Use override to make overriding explicit in large class hierarchies. 5.3
20 When designing a class hierarchy, distinguish between implementation inheritance and interface inheritance. 5.5.1
21 Use dynamic_cast where class hierarchy navigation is unavoidable. 5.5.2
22 Use dynamic_cast to a reference type when failure to find the required class is considered a failure. 5.5.2
23 Use dynamic_cast to a pointer type when failure to find the required class is considered a valid alternative. 5.5.2
24 Use unique_ptr or shared_ptr to avoid forgetting to delete objects created using new. 5.5.3

Retrieval Quiz

Concrete vs. abstract type

What is the defining property of a concrete type like complex or Vector?

Member vs. nonmember operators

Why does Stroustrup define operator+ for complex as a nonmember function?

const member functions

What does the const on double real() const promise?

RAII: who releases the memory

A Vector constructor does elem{new double[s]}. When is delete[] elem guaranteed to run?

Pure virtual functions

Why can't you write Container c;?

Virtual destructor

What goes wrong if Shape's destructor is NOT virtual, and you delete a Smiley through a Shape*?

dynamic_cast: pointer vs. reference

You write dynamic_cast<Smiley*>(ps) and ps actually points to a Circle that is not a Smiley. What happens?

unique_ptr: ownership without a destructor

Why does Smiley need no user-declared destructor when its members are std::unique_ptrs?


Notes

Classes :-

class user-defined type ; central feature of C++

3 kinds concrete | abstract | hierarchy

goal as real as int and float (eg McIlroy)

Concrete types :-

concrete representation part of definition behaves like built-in

stack / direct access / copy / move all allowed

price representation change recompile all users

complex :-

default args ctor no uninitialized variables

const member fn doesn't modify object callable on const objects

member fn only if it needs direct representation access

symmetric operators nonmember (eg +, ==) ; conversion on both sides

RAII :-

constructor acquires resource (eg new double[s])

destructor releases resource (eg delete[] elem)

RAII acquire in ctor, release in dtor safe on every path incl. exceptions

handle-to-data small object on stack, data on free store

Containers :-

initializer-list ctor {1,2,3,4} std::initializer_list

push_back append element at end

move ctor steals handle cheap return by value

static_cast explicit narrowing (eg size_t int) ; use sparingly

Abstract types :-

abstract interface only ; no representation

pure virtual = 0 ; no definition in base ; class cannot be instantiated

access pointers/refs only ; objects on free store

override explicit ; compiler catches signature mistakes

polymorphic same interface, many implementations (eg use(Container&))

Virtual functions :-

vtbl table of function pointers ; per class

object one hidden vptr call goes through the table

cost 1 ptr/object + 1 table/class ; ≈ within 25% of direct call

payoff new implementations without recompiling users

Hierarchies :-

interface inheritance base = interface any derived usable (eg Shape)

implementation inheritance base = shared code/data (eg Smiley : Circle)

construction bottom-up ; destruction top-down

virtual destructor delete via base ptr derived dtor runs

Navigation :-

dynamic_cast run-time "is it a kind of X?"

pointer form failure nullptr ; test it

reference form failure throws std::bad_cast

restraint prefer virtual fns over type-testing

Avoiding leaks :-

naked ptr + hand delete × easy to forget / double-delete

unique_ptr owns ; auto-deletes at scope end

members as unique_ptr no user destructor needed


Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed., Chapter 5: "Classes." Addison-Wesley.
Reference: Chapter 1–5 Quick Reference & Glossary — keep it beside you while you study.
Recommended supplement: cppreference on classes, virtual functions, dynamic_cast, and unique_ptr.

Questions? Ask your agent — your teacher — about anything unclear: when a class hierarchy is worth its overhead over a concrete type, why override catches real bugs, or how the move constructor makes push_back and return-by-value cheap. Follow-ups are expected, not optional.

Chapters

  1. Lesson 1: The Basics (Ch. 1)
  2. Lesson 2: User-Defined Types (Ch. 2)
  3. Lesson 3: Separate Compilation (Ch. 3)
  4. Lesson 4: Error Handling (Ch. 4)
  5. Lesson 5: Classes (Ch. 5 — this lesson)
  6. Lesson 6: Essential Operations (Ch. 6)
  7. Lesson 7: Templates (Ch. 7)
  8. Lesson 8: Concepts and Generic Programming (Ch. 8)
  9. Lesson 9: Library Overview (Ch. 9)
  10. Lesson 10: Strings and Regular Expressions (Ch. 10)
  11. Lesson 11: Input and Output (Ch. 11)
  12. Lesson 12: Containers (Ch. 12)
  13. Lesson 13: Algorithms (Ch. 13)
  14. Lesson 14: Ranges (Ch. 14)
  15. Lesson 15: Pointers and Containers (Ch. 15)
  16. Lesson 16: Utilities (Ch. 16)
  17. Lesson 17: Numerics (Ch. 17)
  18. Lesson 18: Concurrency (Ch. 18)
  19. Lesson 19: History and Compatibility (Ch. 19)
← Course Dashboard ← Ch. 4: Error Handling Ch. 6: Essential Operations → Quick Reference →