Chapter 4: Object-Oriented Programming in C++

Classes and Structs

In C++, struct and class are nearly identical. The only difference is default access level.

struct Point {    // Members are public by default
    double x;
    double y;
};

class Point2 {    // Members are private by default
    double x;
    double y;
public:
    double getX() const { return x; }
};

Convention: Use struct for plain data, class for objects with behavior.

Class Anatomy

class BankAccount {
private:    // Only accessible within the class
    std::string owner;
    double balance;
    static int accountCount;  // Shared across all instances

public:     // Accessible from anywhere
    // Constructor
    BankAccount(const std::string& ownerName, double initialBalance)
        : owner(ownerName), balance(initialBalance) {  // Initializer list
        accountCount++;
    }

    // Destructor
    ~BankAccount() {
        accountCount--;
    }

    // Member function
    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    // Const member function (doesn't modify object)
    double getBalance() const {
        return balance;
    }

    // Static member function
    static int getAccountCount() {
        return accountCount;
    }

protected:  // Accessible to this class and derived classes
    void setBalance(double newBalance) {
        balance = newBalance;
    }
};

// Initialize static member outside class
int BankAccount::accountCount = 0;

Constructors

Types of Constructors

class Widget {
    int id;
    std::string name;
    int* data;

public:
    // Default constructor
    Widget() : id(0), name("unnamed"), data(nullptr) {}

    // Parameterized constructor
    Widget(int i, const std::string& n)
        : id(i), name(n), data(new int[100]) {}

    // Copy constructor
    Widget(const Widget& other)
        : id(other.id), name(other.name), data(new int[100]) {
        std::copy(other.data, other.data + 100, data);
    }

    // Move constructor
    Widget(Widget&& other) noexcept
        : id(other.id), name(std::move(other.name)), data(other.data) {
        other.data = nullptr;
    }

    // Destructor
    ~Widget() {
        delete[] data;
    }
};

Initializer List vs Assignment

class Example {
    const int id;           // Must use initializer list
    std::string& ref;       // Must use initializer list
    std::string name;

public:
    // WRONG: Assignment in body
    Example(int i, std::string& r, const std::string& n) {
        // id = i;     // ERROR: can't assign to const
        // ref = r;    // ERROR: can't assign to reference
        name = n;      // Works but inefficient (default construct then assign)
    }

    // CORRECT: Initializer list
    Example(int i, std::string& r, const std::string& n)
        : id(i), ref(r), name(n) {  // Direct initialization
    }
};

Rule: Always use initializer lists for member initialization.

Delegating Constructors (C++11)

class Rectangle {
    int width, height;

public:
    Rectangle() : Rectangle(0, 0) {}  // Delegates to below

    Rectangle(int size) : Rectangle(size, size) {}  // Delegates to below

    Rectangle(int w, int h) : width(w), height(h) {}  // Main constructor
};

Explicit Constructors

class Wrapper {
    int value;

public:
    // Without explicit: allows implicit conversion
    Wrapper(int v) : value(v) {}
};

void takeWrapper(Wrapper w);
takeWrapper(42);  // OK: implicitly converts int to Wrapper

class SafeWrapper {
    int value;

public:
    // With explicit: prevents implicit conversion
    explicit SafeWrapper(int v) : value(v) {}
};

void takeSafeWrapper(SafeWrapper w);
// takeSafeWrapper(42);  // ERROR: no implicit conversion
takeSafeWrapper(SafeWrapper(42));  // OK: explicit construction

Rule: Use explicit for single-argument constructors to prevent accidental conversions.

The Rule of Five (and Zero)

If you define any of these, you should define all of them:

  1. Destructor
  2. Copy constructor
  3. Copy assignment operator
  4. Move constructor
  5. Move assignment operator
class Resource {
    int* data;
    size_t size;

public:
    // Constructor
    Resource(size_t s) : data(new int[s]), size(s) {}

    // 1. Destructor
    ~Resource() {
        delete[] data;
    }

    // 2. Copy constructor
    Resource(const Resource& other) : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }

    // 3. Copy assignment
    Resource& operator=(const Resource& other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            std::copy(other.data, other.data + size, data);
        }
        return *this;
    }

    // 4. Move constructor
    Resource(Resource&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // 5. Move assignment
    Resource& operator=(Resource&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }
};

Rule of Zero

If you don't manage resources directly, you don't need to define any special members:

class Person {
    std::string name;           // Manages its own memory
    std::vector<int> scores;    // Manages its own memory
    std::unique_ptr<Data> data; // Manages its own memory

public:
    Person(const std::string& n) : name(n) {}
    // No destructor, copy, move needed - defaults work correctly
};

Prefer Rule of Zero: Use smart pointers and RAII containers to avoid manual memory management.

Operator Overloading

class Vector2D {
public:
    double x, y;

    Vector2D(double x = 0, double y = 0) : x(x), y(y) {}

    // Arithmetic operators
    Vector2D operator+(const Vector2D& other) const {
        return Vector2D(x + other.x, y + other.y);
    }

    Vector2D operator-(const Vector2D& other) const {
        return Vector2D(x - other.x, y - other.y);
    }

    Vector2D operator*(double scalar) const {
        return Vector2D(x * scalar, y * scalar);
    }

    // Compound assignment
    Vector2D& operator+=(const Vector2D& other) {
        x += other.x;
        y += other.y;
        return *this;
    }

    // Comparison operators
    bool operator==(const Vector2D& other) const {
        return x == other.x && y == other.y;
    }

    bool operator!=(const Vector2D& other) const {
        return !(*this == other);
    }

    // Subscript operator
    double& operator[](size_t index) {
        return index == 0 ? x : y;
    }

    // Function call operator (functor)
    double operator()() const {
        return std::sqrt(x*x + y*y);  // Returns magnitude
    }
};

// Non-member operator (for scalar * vector)
Vector2D operator*(double scalar, const Vector2D& v) {
    return v * scalar;
}

// Stream insertion
std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
    return os << "(" << v.x << ", " << v.y << ")";
}

int main() {
    Vector2D a(3, 4), b(1, 2);

    Vector2D c = a + b;           // (4, 6)
    Vector2D d = a * 2;           // (6, 8)
    Vector2D e = 2 * a;           // (6, 8)
    double mag = a();             // 5.0 (magnitude)
    std::cout << a << "\n";       // (3, 4)
}

Spaceship Operator (C++20)

#include <compare>

class Version {
public:
    int major, minor, patch;

    // One operator generates all comparisons
    auto operator<=>(const Version&) const = default;
};

Version v1{1, 2, 3}, v2{1, 3, 0};
bool less = v1 < v2;     // true
bool greater = v1 > v2;  // false
bool equal = v1 == v2;   // false

Inheritance

// Base class
class Shape {
protected:
    double x, y;  // Position

public:
    Shape(double x, double y) : x(x), y(y) {}

    virtual double area() const = 0;  // Pure virtual (abstract)

    virtual void draw() const {
        std::cout << "Drawing shape at (" << x << ", " << y << ")\n";
    }

    virtual ~Shape() = default;  // Virtual destructor for polymorphism
};

// Derived class
class Circle : public Shape {
    double radius;

public:
    Circle(double x, double y, double r) : Shape(x, y), radius(r) {}

    double area() const override {  // override keyword catches errors
        return 3.14159 * radius * radius;
    }

    void draw() const override {
        Shape::draw();  // Call base implementation
        std::cout << "  -> It's a circle with radius " << radius << "\n";
    }
};

class Rectangle : public Shape {
    double width, height;

public:
    Rectangle(double x, double y, double w, double h)
        : Shape(x, y), width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

Inheritance Types

class Base {
public:
    int pub;
protected:
    int prot;
private:
    int priv;
};

// Public inheritance: maintains access levels
class PubDerived : public Base {
    // pub remains public
    // prot remains protected
    // priv is inaccessible
};

// Protected inheritance: public becomes protected
class ProtDerived : protected Base {
    // pub becomes protected
    // prot remains protected
    // priv is inaccessible
};

// Private inheritance: everything becomes private
class PrivDerived : private Base {
    // pub becomes private
    // prot becomes private
    // priv is inaccessible
};

Rule: Almost always use public inheritance. Private/protected inheritance is rare.

Polymorphism

#include <vector>
#include <memory>

void processShapes(const std::vector<std::unique_ptr<Shape>>& shapes) {
    for (const auto& shape : shapes) {
        shape->draw();  // Calls appropriate derived class method
        std::cout << "Area: " << shape->area() << "\n";
    }
}

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;

    shapes.push_back(std::make_unique<Circle>(0, 0, 5));
    shapes.push_back(std::make_unique<Rectangle>(10, 10, 4, 3));

    processShapes(shapes);

    return 0;
}

Virtual Functions and vtable

┌───────────────────────────────────────────────────────────────┐
│  How Virtual Functions Work (simplified)                      │
├───────────────────────────────────────────────────────────────┤
│                                                               │
│  Each object with virtual functions has a hidden vptr         │
│                                                               │
│  ┌─────────────┐          ┌─────────────────────────────────┐│
│  │ Circle obj  │          │         Circle vtable            ││
│  ├─────────────┤          ├─────────────────────────────────┤│
│  │ vptr ───────┼─────────▶│ area()  -> Circle::area()       ││
│  │ x, y        │          │ draw()  -> Circle::draw()       ││
│  │ radius      │          │ ~Shape  -> Circle::~Circle()    ││
│  └─────────────┘          └─────────────────────────────────┘│
│                                                               │
│  ┌─────────────┐          ┌─────────────────────────────────┐│
│  │ Rectangle   │          │       Rectangle vtable           ││
│  ├─────────────┤          ├─────────────────────────────────┤│
│  │ vptr ───────┼─────────▶│ area()  -> Rectangle::area()    ││
│  │ x, y        │          │ draw()  -> Shape::draw()        ││
│  │ width,height│          │ ~Shape  -> Rectangle::~Rectangle││
│  └─────────────┘          └─────────────────────────────────┘│
│                                                               │
│  Cost: One pointer per object + one table per class           │
└───────────────────────────────────────────────────────────────┘

Virtual Destructor Rule

class Base {
public:
    ~Base() { std::cout << "Base destructor\n"; }  // Non-virtual
};

class Derived : public Base {
    int* data;
public:
    Derived() : data(new int[100]) {}
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor\n";
    }
};

Base* ptr = new Derived();
delete ptr;  // ONLY calls Base destructor - MEMORY LEAK!

// Fix: make Base destructor virtual
class Base {
public:
    virtual ~Base() = default;
};

Rule: If a class has any virtual functions, make its destructor virtual.

Abstract Classes and Interfaces

// Abstract class (has at least one pure virtual function)
class Drawable {
public:
    virtual void draw() const = 0;  // Pure virtual
    virtual ~Drawable() = default;
};

// Interface (all functions are pure virtual)
class Serializable {
public:
    virtual std::string serialize() const = 0;
    virtual void deserialize(const std::string& data) = 0;
    virtual ~Serializable() = default;
};

// Multiple inheritance of interfaces
class Button : public Drawable, public Serializable {
    std::string label;

public:
    Button(const std::string& l) : label(l) {}

    void draw() const override {
        std::cout << "[" << label << "]\n";
    }

    std::string serialize() const override {
        return "Button:" + label;
    }

    void deserialize(const std::string& data) override {
        label = data.substr(7);
    }
};

Composition vs Inheritance

Prefer composition over inheritance when you don't need polymorphism.

// INHERITANCE: "is-a" relationship
class Car : public Vehicle {
    // Car IS A Vehicle
};

// COMPOSITION: "has-a" relationship
class Car {
    Engine engine;     // Car HAS AN Engine
    Transmission trans;
    std::vector<Wheel> wheels;

public:
    void start() {
        engine.start();
        trans.engage();
    }
};

Benefits of Composition

  • More flexible (can change at runtime)
  • No fragile base class problem
  • Clearer ownership semantics
  • Easier to test

Final and Override Keywords

class Base {
public:
    virtual void foo();
    virtual void bar() final;  // Cannot be overridden
};

class Derived final : public Base {  // Cannot be inherited from
    void foo() override;  // Explicitly marks override (catches typos)
    // void bar() override;  // ERROR: bar is final
    // void fo() override;   // ERROR: no Base::fo() to override
};

// class MoreDerived : public Derived {};  // ERROR: Derived is final

Rule: Always use override when overriding virtual functions.

Friend Functions and Classes

class SecretKeeper {
    int secret = 42;

    // Friend function can access private members
    friend void reveal(const SecretKeeper& sk);

    // Friend class can access private members
    friend class Inspector;
};

void reveal(const SecretKeeper& sk) {
    std::cout << sk.secret << "\n";  // OK: friend
}

class Inspector {
public:
    void inspect(const SecretKeeper& sk) {
        std::cout << sk.secret << "\n";  // OK: friend class
    }
};

Use sparingly: Friend breaks encapsulation. Only use when truly necessary.

Exercises

  1. Bank Account System

    • Create classes for Account, SavingsAccount, CheckingAccount
    • Implement proper inheritance hierarchy
    • Add transactions with polymorphic behavior
  2. Shape Hierarchy

    • Create abstract Shape with Circle, Rectangle, Triangle
    • Implement area() and perimeter() methods
    • Create a function to process a vector of shapes
  3. Rule of Five Practice

    • Create a DynamicArray class that manages a raw array
    • Implement all five special member functions
    • Verify correct behavior with copies and moves
  4. Operator Overloading

    • Create a Fraction class (numerator/denominator)
    • Overload +, -, *, /, ==, <, <<
    • Implement automatic simplification

Previous: 03 - Memory Management Next: 05 - Modern C++ Features