Skip to main content
C++ · lesson 8 of 24
C++ intermediate Lesson 8 of 24

Classes and Object-Oriented Programming in C++

C++ classes run for real: initializer-list order, const and mutable, a double free from a shallow copy, the Rule of Five logged, and the Rule of Zero.

By SkillByExample · Updated

A class bundles data with the operations on it, and C++ lets you control exactly how its objects are constructed, copied, moved and destroyed. That control is what makes owning memory, files or sockets safe — when you set it up right.

class vs struct and Access

struct members are public by default, class members private; that is the only difference. public members are for everyone, private ones for the class itself (and friends), protected ones for the class and its subclasses.

Constructors, const and mutable

The member initializer list (: width_(w)) constructs members directly, and it’s the only way to initialize const and reference members. A const member function promises not to change the object’s observable state.

#include <iostream>

class Rectangle {
    double width_, height_;
public:
    Rectangle(double w, double h) : width_(w), height_(h) {}   // initializer list
    Rectangle() : Rectangle(1.0, 1.0) {}                       // delegating constructor
    double area() const { return width_ * height_; }
};

class Circle {
    double radius_;
    mutable double cached_ = -1;              // not part of the logical state
public:
    explicit Circle(double r) : radius_(r) {}
    double area() const {                     // const, yet it may fill the cache
        if (cached_ < 0) {
            std::cout << "(computing) ";
            cached_ = 3.14159265 * radius_ * radius_;
        }
        return cached_;
    }
};

int main() {
    const Rectangle r(3, 4), unit;
    std::cout << r.area() << " " << unit.area() << "\n";
    const Circle c(2);
    std::cout << c.area() << "\n";
    std::cout << c.area() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o shapes shapes.cpp
$ ./shapes
12 1
(computing) 12.5664
12.5664

Rectangle() delegates to the two-argument constructor, so unit is 1×1. Both rectangles are const objects, and calling area() on them compiles only because area is marked const. Circle::area is const too, yet it writes cached_ — allowed because that member is mutable, and the second call skips the computation. Use mutable only for state that isn’t part of what the object means, like caches and mutexes.

Initialization Order Follows Declaration Order

The initializer list reads left to right, but members are initialized in the order they are declared. Get this wrong and one member reads another before it exists:

#include <cstddef>
#include <cstring>

class String {
    char*       data_;     // declared first, so initialized first...
    std::size_t len_;
public:
    explicit String(const char* s)
        : len_(std::strlen(s)), data_(new char[len_ + 1]) {   // ...len_ not set yet
        std::strcpy(data_, s);
    }
    ~String() { delete[] data_; }
};

int main() { String s("hello"); }
$ g++ -std=c++20 -Wall -Wextra -o init_order init_order.cpp 2>&1 | grep warning
init_order.cpp:6:17: warning: 'String::len_' will be initialized after [-Wreorder]
init_order.cpp:5:17: warning:   'char* String::data_' [-Wreorder]
init_order.cpp:8:14: warning:   when initialized here [-Wreorder]
init_order.cpp:9:48: warning: '*this.String::len_' is used uninitialized [-Wuninitialized]
$ ./init_order
terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc
Aborted

data_ is declared first, so new char[len_ + 1] runs while len_ is still garbage. GCC warns twice — about the reordering and about the uninitialized read — and on this run the garbage length was so large that new threw std::bad_alloc. It’s undefined behavior, so another build could just as well corrupt memory silently. Fix it by declaring len_ before data_, or by not having one initializer depend on another.

Why Copying Needs Care

A class that owns heap memory through a raw pointer gets a compiler-generated copy constructor that copies the pointer, not the memory:

#include <cstring>
#include <iostream>

class Buffer {                         // owns heap memory, but no copy operations
    char* data_;
public:
    explicit Buffer(const char* s) : data_(new char[std::strlen(s) + 1]) {
        std::strcpy(data_, s);
    }
    ~Buffer() { delete[] data_; }
    void set(char c) { data_[0] = c; }
    const char* get() const { return data_; }
};

int main() {
    Buffer a("hello");
    {
        Buffer b = a;                  // compiler-generated copy: copies the pointer
        b.set('J');
        std::cout << "a = " << a.get() << ", b = " << b.get() << std::endl;
    }                                  // b's destructor frees the shared memory
    std::cout << "leaving main" << std::endl;
}                                      // a's destructor frees it again
$ g++ -std=c++20 -Wall -Wextra -o shallow shallow.cpp
$ ./shallow; echo "exit code: $?"
a = Jello, b = Jello
leaving main
free(): double free detected in tcache 2
Aborted
exit code: 134

Changing b changed a — they share one buffer. When b goes out of scope its destructor frees that buffer; when main ends, a’s destructor frees it again. glibc detects the double free and aborts (exit code 134 = 128 + SIGABRT). std::endl flushes each line; with "\n" the output sat in a buffer when the process aborted and was lost when piped. Other allocators may not detect it at all and corrupt memory instead.

The Rule of Five

A class that owns a raw resource must define the destructor, both copy operations and both move operations. This Buffer logs which one runs:

#include <cstring>
#include <iostream>
#include <utility>
#include <vector>

class Buffer {
    char* data_;
    std::size_t size_;
public:
    explicit Buffer(std::size_t n) : data_(new char[n]()), size_(n) {}
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer& o) : data_(new char[o.size_]), size_(o.size_) {
        std::memcpy(data_, o.data_, size_);
        std::cout << "  copy ctor (" << size_ << " bytes)\n";
    }
    Buffer& operator=(const Buffer& o) {
        std::cout << "  copy assign\n";
        Buffer tmp(o);                              // copy first: may throw
        std::swap(data_, tmp.data_);                // then swap; tmp frees old data
        std::swap(size_, tmp.size_);
        return *this;
    }
    Buffer(Buffer&& o) noexcept
        : data_(std::exchange(o.data_, nullptr)), size_(std::exchange(o.size_, 0)) {
        std::cout << "  move ctor\n";
    }
    Buffer& operator=(Buffer&& o) noexcept {
        std::cout << "  move assign\n";
        std::swap(data_, o.data_);                  // o frees our old data later
        std::swap(size_, o.size_);
        return *this;
    }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a(64);
    std::cout << "Buffer b = a;\n";            Buffer b = a;
    std::cout << "Buffer c = std::move(a);\n"; Buffer c = std::move(a);
    std::cout << "b = c;\n";                   b = c;
    std::cout << "b = Buffer(8);\n";           b = Buffer(8);
    std::cout << "sizes: a=" << a.size() << " b=" << b.size() << " c=" << c.size() << "\n";
    std::vector<Buffer> v;
    std::cout << "push_back x2 (vector grows):\n";
    v.push_back(Buffer(1));
    v.push_back(Buffer(2));
}
$ g++ -std=c++20 -Wall -Wextra -o rule_of_five rule_of_five.cpp
$ ./rule_of_five
Buffer b = a;
  copy ctor (64 bytes)
Buffer c = std::move(a);
  move ctor
b = c;
  copy assign
  copy ctor (64 bytes)
b = Buffer(8);
  move assign
sizes: a=0 b=8 c=64
push_back x2 (vector grows):
  move ctor
  move ctor
  move ctor

Every line matches a rule. Buffer b = a is a copy construction, std::move(a) turns the next one into a move, and a is left empty (size 0). b = c is a copy assignment, which here is implemented as copy-then-swap, hence the extra copy ctor line — if that copy throws, b is untouched. b = Buffer(8) assigns from a temporary, so it moves. The vector lines show why noexcept matters: the second push_back reallocates, and the vector moves the existing element into the new storage only because the move constructor is noexcept; without it, it would copy.

The Rule of Zero

Most classes shouldn’t manage raw resources at all. Hold them in members that already know how, and write none of the five.

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class Document {
    std::vector<std::string> lines_;        // owns its strings
    std::unique_ptr<int[]> scratch_;        // owns a heap array, move-only
public:
    Document() : scratch_(std::make_unique<int[]>(16)) {}
    void add(std::string line) { lines_.push_back(std::move(line)); }
    std::size_t size() const { return lines_.size(); }
    bool has_scratch() const { return scratch_ != nullptr; }
    // no destructor, no copy/move members: the compiler writes them all
};

int main() {
    Document d;
    d.add("first");
    d.add("second");
    Document moved = std::move(d);         // generated move constructor
    std::cout << std::boolalpha << "moved: " << moved.size() << " lines, scratch "
              << moved.has_scratch() << "; d: " << d.size() << " lines, scratch "
              << d.has_scratch() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o rule_of_zero rule_of_zero.cpp
$ ./rule_of_zero
moved: 2 lines, scratch true; d: 0 lines, scratch false

Document declares no destructor or copy/move operations, yet moving it works: the generated move constructor moves lines_ and scratch_, leaving d empty. Copying is a different story:

#include <memory>
#include <vector>

class Document {
    std::vector<int> lines_;
    std::unique_ptr<int[]> scratch_;
};

int main() {
    Document d;
    Document copy = d;      // unique_ptr can't be copied, so neither can Document
}
$ g++ -std=c++20 -Wall -Wextra -o zero_copy_fail zero_copy_fail.cpp 2>&1 | grep -m1 error
zero_copy_fail.cpp:11:21: error: use of deleted function 'Document::Document(const Document&)'

Because std::unique_ptr can’t be copied, the compiler deletes Document’s copy constructor instead of generating a wrong one. That’s the Rule of Zero’s payoff: the class is exactly as copyable as its members.

The this Pointer and friend

Inside a member function, this points at the current object. Returning *this by reference lets calls chain; a friend function gets private access without being a member.

#include <iostream>
#include <string>

class Query {
    std::string table_, where_;
    int limit_ = -1;
public:
    Query& from(const std::string& t)  { table_ = t; return *this; }
    Query& where(const std::string& w) { where_ = w; return *this; }
    Query& limit(int n)                { this->limit_ = n; return *this; }
    std::string build() const {
        std::string sql = "SELECT * FROM " + table_;
        if (!where_.empty()) sql += " WHERE " + where_;
        if (limit_ >= 0) sql += " LIMIT " + std::to_string(limit_);
        return sql;
    }
    friend std::ostream& operator<<(std::ostream& os, const Query& q) {
        return os << "Query{" << q.table_ << "}";          // private access via friend
    }
};

int main() {
    Query q;
    std::cout << q.from("users").where("age > 30").limit(10).build() << "\n";
    std::cout << Query{}.from("orders").build() << "\n";
    std::cout << q << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o builder builder.cpp
$ ./builder
SELECT * FROM users WHERE age > 30 LIMIT 10
SELECT * FROM orders
Query{users}

Each setter returns Query&, so q.from(...).where(...).limit(...) modifies the same object step by step — this->limit_ and limit_ are the same thing, the arrow is just explicit. Query{}.from("orders") chains on a temporary. operator<< must be a non-member (the stream is on its left), and friend lets it read table_ without a public getter.

Practice

  1. Fix init_order.cpp so it compiles with no warnings and stores the string correctly.

    Solution
    class String {
        std::size_t len_;      // declared first now
        char*       data_;
    public:
        explicit String(const char* s)
            : len_(std::strlen(s)), data_(new char[len_ + 1]) {
            std::strcpy(data_, s);
        }
        ~String() { delete[] data_; }
    };

    The initializer list didn’t change; the declaration order did, and that’s what the compiler follows.

  2. Fix shallow.cpp by adding a copy constructor that allocates its own buffer. What does the program print now?

    Solution
    Buffer(const Buffer& o) : data_(new char[std::strlen(o.data_) + 1]) {
        std::strcpy(data_, o.data_);
    }
    Buffer& operator=(const Buffer&) = delete;   // or implement it too

    a = hello, b = Jello, then leaving main, and exit code 0. Each object frees its own buffer. Deleting copy assignment (or implementing it) matters, or b = a would reintroduce the bug.

  3. Remove noexcept from Buffer’s move constructor in rule_of_five.cpp. How do the push_back lines change?

    Solution

    The reallocation now copies: the output shows move ctor, move ctor, then copy ctor (1 bytes) for the element relocated during growth. std::vector uses std::move_if_noexcept so that a throwing move can’t leave it half-moved.

  4. Give Query a std::string member order_ and an order_by setter that chains, then build SELECT * FROM users ORDER BY name.

    Solution
    Query& order_by(const std::string& col) { order_ = col; return *this; }
    // in build(), after WHERE:
    if (!order_.empty()) sql += " ORDER BY " + order_;

    Query{}.from("users").order_by("name").build() prints the expected SQL. Returning Query& (not Query) is what keeps every call working on the same object.

  5. Make Document copyable without writing a copy constructor by hand.

    Solution

    Replace std::unique_ptr<int[]> with std::vector<int> (or std::shared_ptr<int[]> if sharing is intended). Every member is then copyable, so the compiler generates the copy constructor — still Rule of Zero.

Frequently Asked Questions

What is the Rule of Five?
If a class needs a custom destructor, copy constructor, copy assignment, move constructor or move assignment, it almost certainly needs all five, because it is managing a resource by hand. Defining only some leaves the compiler-generated rest copying raw pointers, which leads to double frees or leaks.
What is the Rule of Zero?
Let members that already manage their resources, such as std::vector, std::string and std::unique_ptr, do the work, and write none of the five special members. The compiler then generates correct copy and move behaviour, or deletes copying when a member is move-only.
When should I use struct vs class?
The only language difference is default access: public for struct, private for class. By convention, use struct for plain data with no invariants and class when the type protects invariants behind member functions.
In what order are class members initialized?
Always in the order the members are declared in the class, regardless of the order written in the constructor's initializer list. If one member's initializer reads another, declare them in that order; GCC's -Wreorder (part of -Wall) warns when the list order differs.
Why mark move constructors noexcept?
std::vector only moves elements during reallocation if the move constructor cannot throw; otherwise it copies them to keep its strong exception guarantee. Marking moves noexcept turns those copies into cheap pointer swaps.