C++ · lesson 8 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Classes 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
-
Fix
init_order.cppso 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.
-
Fix
shallow.cppby 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 tooa = hello, b = Jello, thenleaving main, and exit code 0. Each object frees its own buffer. Deleting copy assignment (or implementing it) matters, orb = awould reintroduce the bug. -
Remove
noexceptfromBuffer’s move constructor inrule_of_five.cpp. How do thepush_backlines change?Solution
The reallocation now copies: the output shows
move ctor,move ctor, thencopy ctor (1 bytes)for the element relocated during growth.std::vectorusesstd::move_if_noexceptso that a throwing move can’t leave it half-moved. -
Give
Queryastd::stringmemberorder_and anorder_bysetter that chains, then buildSELECT * 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. ReturningQuery&(notQuery) is what keeps every call working on the same object. -
Make
Documentcopyable without writing a copy constructor by hand.Solution
Replace
std::unique_ptr<int[]>withstd::vector<int>(orstd::shared_ptr<int[]>if sharing is intended). Every member is then copyable, so the compiler generates the copy constructor — still Rule of Zero.