Skip to main content
C++ intermediate Lesson 8 of 23

Classes and Object-Oriented Programming in C++

Learn constructors, destructors, copy and move semantics, the Rule of Five, and the this pointer.

Classes and Object-Oriented Programming in C++

Classes let you bundle data and the operations that act on it into a single type with a well-defined interface. C++ goes further than many OOP languages by giving you precise control over how objects are constructed, copied, moved, and destroyed. This control is essential when managing resources like heap memory, file handles, or network sockets — the compiler can generate the right cleanup code automatically if you set things up correctly.

class vs struct

In C++ the only real difference between class and struct is the default access level: class defaults to private, struct defaults to public. By convention, use struct for simple data aggregates where everything is meant to be accessible, and class when you have invariants to protect through encapsulation.

struct Point { double x, y; };   // All public — fine for POD, no invariants to protect

class BankAccount {               // private by default — balance must go through deposit/withdraw
    double balance_;
public:
    explicit BankAccount(double initial) : balance_(initial) {}
    void deposit(double amount)   { balance_ += amount; }
    double balance() const        { return balance_; }
};

Access Specifiers

Access specifiers control who can see and use which members. This is how you enforce invariants: clients can call your public interface but cannot directly corrupt internal state.

  • public — accessible from anywhere
  • private — accessible only within the class (and friends)
  • protected — accessible within the class and derived classes

Constructors and Member Initializer Lists

The member initializer list (the : member(value) syntax before the constructor body) is the correct place to initialize members. It is more efficient than assignment in the body because members are constructed directly rather than default-constructed and then assigned. More importantly, it is the only way to initialize const members and reference members.

class Rectangle {
    double width_;
    double height_;
public:
    // Member initializer list — preferred over assigning inside the body
    Rectangle(double w, double h) : width_(w), height_(h) {}

    // Delegating constructor (C++11) — reuse another constructor to avoid duplication
    Rectangle() : Rectangle(1.0, 1.0) {}

    double area() const { return width_ * height_; }
};

const Member Functions and mutable

A member function marked const promises not to modify the observable state of the object. This is a contract: callers holding a const reference can only call const functions. Use mutable for members that need to change even in a logically-const context — the canonical examples are a cached computed value or a mutex that protects the real state.

class Circle {
    double radius_;
    mutable double cachedArea_ = -1.0; // cache is not part of logical state

public:
    explicit Circle(double r) : radius_(r) {}

    // const: doesn't change radius_ — the object is logically unchanged
    // but it does update the cache, so cachedArea_ must be mutable
    double area() const {
        if (cachedArea_ < 0)
            cachedArea_ = 3.14159265 * radius_ * radius_;
        return cachedArea_;
    }
};

The Rule of Five — A Resource-Managing Class

When your class directly owns a resource (raw heap memory, a file descriptor, a mutex, a socket), the compiler-generated special members do the wrong thing — they copy the pointer, not the resource, leading to double-frees or leaks. You must define all five: destructor, copy constructor, copy assignment, move constructor, and move assignment.

#include <cstring>
#include <utility>
#include <stdexcept>

class Buffer {
    char*  data_;
    size_t size_;

public:
    // 1. Constructor — acquire the resource
    explicit Buffer(size_t size)
        : data_(new char[size]), size_(size) {
        std::memset(data_, 0, size_);
    }

    // 2. Destructor — release the resource
    ~Buffer() {
        delete[] data_;
    }

    // 3. Copy constructor — deep copy: allocate new storage and copy contents
    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        std::memcpy(data_, other.data_, size_);
    }

    // 4. Copy assignment — deep copy with self-assignment guard
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;    // guard against self-assignment
        char* newData = new char[other.size_];
        std::memcpy(newData, other.data_, other.size_);
        delete[] data_;      // release old resource only after successful allocation
        data_ = newData;
        size_ = other.size_;
        return *this;
    }

    // 5. Move constructor — steal the resource, leave source in valid-but-empty state
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;  // source no longer owns the memory
        other.size_ = 0;
    }

    // 6. Move assignment — steal with cleanup
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;
        data_       = other.data_;
        size_       = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }

    size_t size() const { return size_; }
    char*  data()       { return data_; }
};

The move operations leave the source in a valid-but-empty state and are marked noexcept so standard containers like std::vector can use them during reallocation instead of falling back to slower copies.

Rule of Zero

The Rule of Five is for classes that directly manage raw resources. The Rule of Zero says: if you don’t manage raw resources directly, delegate ownership to a smart pointer or standard container and let the compiler generate all five special members correctly for free. Prefer this whenever possible.

#include <memory>
#include <vector>

class Document {
    std::vector<std::string> lines_;        // vector owns its memory
    std::unique_ptr<Buffer>  rawBuffer_;    // unique_ptr owns the Buffer

public:
    Document() = default;
    // Compiler generates correct destructor, move constructor, and move assignment.
    // Copy is deleted because unique_ptr isn't copyable — intentional and correct.
};

Prefer the Rule of Zero over the Rule of Five whenever possible.

The this Pointer

Inside a non-static member function, this is a pointer to the current object. Its most useful practical application is returning *this from a method, which enables method chaining — a pattern you see in builder APIs and stream operators.

class Builder {
    std::string result_;
public:
    // Return *this to enable chaining: builder.append("a").append("b")
    Builder& append(const std::string& s) {
        result_ += s;
        return *this;
    }
    std::string build() const { return result_; }
};

int main() {
    std::string s = Builder{}
        .append("Hello")
        .append(", ")
        .append("World!")
        .build();
}

friend Functions and Classes

A friend declaration grants a non-member function (or another class) access to private members. It breaks encapsulation intentionally, so use it sparingly. The most common legitimate use is operator<< for stream output, which must be a non-member (so cout << obj works) but needs access to private state.

#include <iostream>

class Vector2D {
    double x_, y_;
public:
    Vector2D(double x, double y) : x_(x), y_(y) {}

    // friend so it can access x_ and y_ directly without public getters
    friend std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
        return os << "(" << v.x_ << ", " << v.y_ << ")";
    }
};

int main() {
    Vector2D v{3.0, 4.0};
    std::cout << v << "\n";   // (3, 4)
}

Putting It Together

Here’s a minimal String class that demonstrates the complete picture — Rule of Five, copy-and-swap idiom, and friend operator:

#include <cstring>
#include <iostream>

class String {
    char*  data_;
    size_t len_;

public:
    String() : data_(new char[1]{'\0'}), len_(0) {}

    explicit String(const char* s)
        : len_(std::strlen(s)), data_(new char[len_ + 1]) {
        std::strcpy(data_, s);
    }

    ~String()                                  { delete[] data_; }
    String(const String& o)                    : String(o.data_) {}
    String(String&& o) noexcept                : data_(o.data_), len_(o.len_) { o.data_ = nullptr; o.len_ = 0; }

    // Copy-and-swap: takes by value (handles both copy and move), then swaps
    // This gives strong exception safety with minimal code
    String& operator=(String o) noexcept       { std::swap(data_, o.data_); std::swap(len_, o.len_); return *this; }

    size_t      length() const                 { return len_; }
    const char* c_str()  const                 { return data_; }

    friend std::ostream& operator<<(std::ostream& os, const String& s) {
        return os << s.data_;
    }
};

This uses the copy-and-swap idiom for the assignment operator, which handles both copy and move assignment in one function and provides strong exception safety.

Frequently Asked Questions

What is the Rule of Five?
If you define any of destructor/copy-ctor/copy-assign/move-ctor/move-assign, you should define all five, because the compiler won't generate correct defaults.
When should I use struct vs class?
Use struct for plain data aggregates (POD types). Use class when you have invariants to maintain through encapsulation.