Classes and Objects
14 min read
Chapter 5: Classes and Objects
A class groups related data and the functions that operate on it into a single unit. Before classes existed, you would pass a dozen related variables between functions separately — the balance, the owner name, the account number — and trust that callers passed them in the right order. A class puts them together and makes the relationship explicit.
This chapter builds a running example — a bank account — that starts simple and grows through inheritance and interfaces. By the end you will be comfortable writing classes with proper constructors, destructors, overloaded operators, and a hierarchy of derived types.
5.1 Why classes exist
Here is how you might manage a bank account without classes:
#include <iostream>
#include <string>
struct Account {
std::string owner;
double balance;
};
void deposit(Account& acc, double amount) {
acc.balance += amount;
}
bool withdraw(Account& acc, double amount) {
if (amount > acc.balance) return false;
acc.balance -= amount;
return true;
}
void printBalance(const Account& acc) {
std::cout << acc.owner << ": $" << acc.balance << "\n";
}
int main() {
Account a{"Alice", 1000.0};
deposit(a, 500.0);
withdraw(a, 200.0);
printBalance(a);
return 0;
}
This works. But there is nothing stopping a caller from writing a.balance = -999.0 directly and bypassing the withdraw function. The struct has no invariants — no guarantee that its data is always in a valid state.
A class solves this by controlling access to its data.
5.2 Members, methods, and access control
#include <iostream>
#include <string>
#include <stdexcept>
class BankAccount {
public:
BankAccount(const std::string& owner, double initialBalance)
: owner_{owner}, balance_{initialBalance}
{
if (initialBalance < 0.0) {
throw std::invalid_argument{"Initial balance cannot be negative"};
}
}
void deposit(double amount) {
if (amount <= 0.0) throw std::invalid_argument{"Deposit must be positive"};
balance_ += amount;
}
bool withdraw(double amount) {
if (amount <= 0.0) throw std::invalid_argument{"Withdrawal must be positive"};
if (amount > balance_) return false;
balance_ -= amount;
return true;
}
double balance() const { return balance_; }
const std::string& owner() const { return owner_; }
void print() const {
std::cout << owner_ << ": $" << balance_ << "\n";
}
private:
std::string owner_;
double balance_;
};
int main() {
BankAccount account{"Alice", 1000.0};
account.deposit(500.0);
if (!account.withdraw(200.0)) {
std::cout << "Insufficient funds\n";
}
account.print();
std::cout << "Balance: $" << account.balance() << "\n";
// account.balance_ = -999.0; // compile error: private member
return 0;
}
Public members are accessible from outside the class. Private members are accessible only from within the class's own methods. The public: and private: labels mark where each section begins.
By convention, private member variables use a trailing underscore (owner_, balance_). This distinguishes them from parameters and local variables at a glance.
The const at the end of balance() const means this method does not modify the object. Methods that only read data should always be marked const. Doing so allows calling them on const BankAccount& parameters and communicates the intent clearly.
5.3 Constructors and destructors
A constructor is called when an object is created. A destructor is called when it is destroyed. Together they define the object's lifetime.
The syntax : owner_{owner}, balance_{initialBalance} is a member initializer list. It initializes members before the body of the constructor runs. Always prefer the initializer list over assignment in the constructor body — it is more efficient (it constructs the member once, directly) and required for const members and references.
Here is a class that demonstrates a meaningful destructor — a timer that prints elapsed time when it goes out of scope:
#include <iostream>
#include <string>
#include <chrono>
class Timer {
public:
explicit Timer(const std::string& label)
: label_{label}, start_{std::chrono::steady_clock::now()}
{
std::cout << "[" << label_ << "] started\n";
}
~Timer() {
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start_).count();
std::cout << "[" << label_ << "] elapsed: " << ms << " ms\n";
}
// Prevent copying — a timer should not be duplicated
Timer(const Timer&) = delete;
Timer& operator=(const Timer&) = delete;
private:
std::string label_;
std::chrono::steady_clock::time_point start_;
};
int main() {
{
Timer outer{"outer"};
for (volatile int i = 0; i < 50'000'000; ++i) {} // busy loop
{
Timer inner{"inner"};
for (volatile int i = 0; i < 10'000'000; ++i) {}
} // inner destroyed here — prints elapsed
} // outer destroyed here — prints elapsed
return 0;
}
= delete disables a function. Here it prevents copying a Timer, which would make no sense (two timers with the same start time). The explicit keyword on the constructor prevents implicit conversions — Timer t = "label"; would be an error, which is usually what you want for constructors that take a single argument.
5.4 The Rule of Zero, Three, and Five
C++ automatically generates six "special member functions" for every class:
- Default constructor
- Copy constructor
- Copy assignment operator
- Destructor
- Move constructor (C++11)
- Move assignment operator (C++11)
The Rule of Zero: if your class does not manage raw resources directly (raw memory, file descriptors, mutexes), do not define any of these — the compiler's generated versions are correct. Use std::string, std::vector, and smart pointers instead of raw arrays and pointers, and the rule of zero applies automatically.
The Rule of Three: if you define any one of destructor, copy constructor, or copy assignment, define all three. Defining one typically means the others need custom behavior too.
The Rule of Five: in C++11 and later, the Rule of Three expands to Five to include the move constructor and move assignment operator.
Here is a class that genuinely needs all five — a Buffer that owns raw heap memory:
#include <iostream>
#include <cstring>
#include <stdexcept>
class Buffer {
public:
explicit Buffer(size_t size)
: data_{new char[size]}, size_{size}
{
std::memset(data_, 0, size_);
std::cout << "Buffer(" << size_ << ") constructed\n";
}
// Destructor
~Buffer() {
delete[] data_;
std::cout << "Buffer(" << size_ << ") destroyed\n";
}
// Copy constructor
Buffer(const Buffer& other)
: data_{new char[other.size_]}, size_{other.size_}
{
std::memcpy(data_, other.data_, size_);
std::cout << "Buffer(" << size_ << ") copy-constructed\n";
}
// Copy assignment
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this; // self-assignment guard
delete[] data_;
data_ = new char[other.size_];
size_ = other.size_;
std::memcpy(data_, other.data_, size_);
std::cout << "Buffer(" << size_ << ") copy-assigned\n";
return *this;
}
// Move constructor
Buffer(Buffer&& other) noexcept
: data_{other.data_}, size_{other.size_}
{
other.data_ = nullptr;
other.size_ = 0;
std::cout << "Buffer move-constructed\n";
}
// Move assignment
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
std::cout << "Buffer move-assigned\n";
return *this;
}
size_t size() const { return size_; }
private:
char* data_;
size_t size_;
};
int main() {
Buffer a{100};
Buffer b = a; // copy constructor
Buffer c = std::move(a); // move constructor (a is now empty)
Buffer d{200};
d = b; // copy assignment
Buffer e{50};
e = std::move(b); // move assignment (b is now empty)
return 0;
}
In practice, if you use std::vector<char> instead of char*, all five of these are generated correctly by the compiler, and you write zero of them. The Rule of Zero is the best rule.
The Buffer example is here to show you what is happening under the hood when move semantics work. You need to understand it to debug the occasional case where the generated versions are not correct.
5.5 Operator overloading
C++ lets you define what operators mean for your types. Here is a 2D vector that supports arithmetic:
#include <iostream>
#include <cmath>
class Vector2D {
public:
double x, y;
Vector2D(double x = 0.0, double y = 0.0) : x{x}, y{y} {}
Vector2D operator+(const Vector2D& other) const {
return {x + other.x, y + other.y};
}
Vector2D operator-(const Vector2D& other) const {
return {x - other.x, y - other.y};
}
Vector2D operator*(double scalar) const {
return {x * scalar, y * scalar};
}
bool operator==(const Vector2D& other) const {
return x == other.x && y == other.y;
}
bool operator!=(const Vector2D& other) const {
return !(*this == other);
}
double length() const {
return std::sqrt(x * x + y * y);
}
// operator<< must be a free function (or friend) to print with std::cout
friend std::ostream& operator<<(std::ostream& os, const Vector2D& v) {
return os << "(" << v.x << ", " << v.y << ")";
}
};
int main() {
Vector2D a{3.0, 4.0};
Vector2D b{1.0, 2.0};
Vector2D c = a + b;
Vector2D d = a * 2.0;
std::cout << "a = " << a << "\n";
std::cout << "b = " << b << "\n";
std::cout << "a + b = " << c << "\n";
std::cout << "a * 2 = " << d << "\n";
std::cout << "length of a = " << a.length() << "\n";
std::cout << "a == b: " << (a == b ? "yes" : "no") << "\n";
return 0;
}
Overload operators when the operation genuinely has a clear meaning for your type. + for a 2D vector is obvious. + for a BankAccount is not — do not overload it just because you can. When in doubt, use a named method.
operator<< is declared as a friend function, which gives it access to private members while still living outside the class. The signature std::ostream& operator<<(std::ostream& os, const Vector2D& v) is the standard pattern for making a type printable with std::cout.
5.6 Inheritance and virtual dispatch
Inheritance lets one class extend another. The derived class inherits all public and protected members of the base class and can add new members and override virtual functions.
#include <iostream>
#include <string>
#include <vector>
#include <memory>
class Account {
public:
Account(const std::string& owner, double balance)
: owner_{owner}, balance_{balance} {}
virtual ~Account() = default; // REQUIRED: virtual destructor in base class
void deposit(double amount) {
balance_ += amount;
}
double balance() const { return balance_; }
const std::string& owner() const { return owner_; }
virtual void print() const {
std::cout << owner_ << " [Account]: $" << balance_ << "\n";
}
virtual std::string accountType() const = 0; // pure virtual
protected:
std::string owner_;
double balance_;
};
class CheckingAccount : public Account {
public:
CheckingAccount(const std::string& owner, double balance, double overdraftLimit)
: Account{owner, balance}, overdraftLimit_{overdraftLimit} {}
bool withdraw(double amount) {
if (amount > balance_ + overdraftLimit_) return false;
balance_ -= amount;
return true;
}
std::string accountType() const override { return "Checking"; }
void print() const override {
std::cout << owner_ << " [Checking]: $" << balance_
<< " (overdraft: $" << overdraftLimit_ << ")\n";
}
private:
double overdraftLimit_;
};
class SavingsAccount : public Account {
public:
SavingsAccount(const std::string& owner, double balance, double interestRate)
: Account{owner, balance}, interestRate_{interestRate} {}
void applyInterest() {
balance_ *= (1.0 + interestRate_);
}
std::string accountType() const override { return "Savings"; }
void print() const override {
std::cout << owner_ << " [Savings]: $" << balance_
<< " (rate: " << interestRate_ * 100.0 << "%)\n";
}
private:
double interestRate_;
};
int main() {
std::vector<std::unique_ptr<Account>> accounts;
accounts.push_back(std::make_unique<CheckingAccount>("Alice", 1000.0, 500.0));
accounts.push_back(std::make_unique<SavingsAccount>("Bob", 5000.0, 0.03));
accounts.push_back(std::make_unique<SavingsAccount>("Carol", 2000.0, 0.025));
// Virtual dispatch: calls the right print() for each type
for (const auto& acc : accounts) {
acc->print();
std::cout << "Type: " << acc->accountType() << "\n";
}
// Apply interest to savings accounts using dynamic_cast
for (auto& acc : accounts) {
if (auto* savings = dynamic_cast<SavingsAccount*>(acc.get())) {
savings->applyInterest();
}
}
std::cout << "\nAfter interest:\n";
for (const auto& acc : accounts) {
acc->print();
}
return 0;
}
Key points:
virtual ~Account() = default — the base class destructor must be virtual. Without this, deleting a derived object through a base pointer calls only the base destructor and leaks the derived part.
virtual void print() const — declaring a function virtual means derived classes can override it. The overriding function is selected at runtime based on the actual type of the object, not the type of the pointer. This is called virtual dispatch or polymorphism.
= 0 (pure virtual) — makes a function that derived classes must implement. A class with any pure virtual function is abstract — you cannot create instances of it directly.
override — tells the compiler "I intend to override a virtual function." If the base class has no such virtual function (perhaps you misspelled it), the compiler gives an error. Always use override.
dynamic_cast<SavingsAccount*>(acc.get()) — attempts to cast a base pointer to a derived pointer. Returns nullptr if the object is not actually a SavingsAccount. Use this sparingly; if you find yourself using it frequently, the design may benefit from a virtual function instead.
5.7 Abstract classes and interfaces
An interface is a class with only pure virtual methods and a virtual destructor — no data, no non-virtual methods. It defines a contract that implementing classes must fulfill:
#include <iostream>
#include <string>
#include <vector>
#include <memory>
class Drawable {
public:
virtual ~Drawable() = default;
virtual void draw() const = 0;
virtual std::string description() const = 0;
};
class Circle : public Drawable {
public:
Circle(double radius) : radius_{radius} {}
void draw() const override {
std::cout << "Drawing circle with radius " << radius_ << "\n";
}
std::string description() const override {
return "Circle(r=" + std::to_string(radius_) + ")";
}
private:
double radius_;
};
class Rectangle : public Drawable {
public:
Rectangle(double w, double h) : width_{w}, height_{h} {}
void draw() const override {
std::cout << "Drawing rectangle " << width_ << "x" << height_ << "\n";
}
std::string description() const override {
return "Rectangle(" + std::to_string(width_) + "x" + std::to_string(height_) + ")";
}
private:
double width_, height_;
};
void drawAll(const std::vector<std::unique_ptr<Drawable>>& shapes) {
for (const auto& shape : shapes) {
std::cout << shape->description() << ": ";
shape->draw();
}
}
int main() {
std::vector<std::unique_ptr<Drawable>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
shapes.push_back(std::make_unique<Circle>(2.5));
drawAll(shapes);
return 0;
}
drawAll does not know or care what kind of shape each element is — it calls the virtual functions and gets the right behavior. Adding a new shape type (Triangle, Ellipse) requires no changes to drawAll. This is the power of interfaces.
5.8 Exercises
Easy
-
Add a
transfer(BankAccount& from, BankAccount& to, double amount)free function that withdraws from one account and deposits into another. Returntrueif successful,falseif the withdrawal failed. -
Add an
operator+=toVector2Dthat adds another vector in place, modifying*this.
Medium
- Create a
Rectangleclass with memberswidthandheight. Overload+(returns the bounding box of two rectangles),==, andoperator<<. Write amainthat demonstrates all three.
Stretch
- Design a
Shapehierarchy with a pure virtualarea()andperimeter()method. ImplementCircle,Rectangle, andTriangle. Write a free functiontotalArea(const std::vector<std::unique_ptr<Shape>>&)that returns the sum of all areas.
5.9 Common mistakes
-
Forgetting
virtualon the base class destructor. When you delete a derived object through a base pointer, only the base destructor runs. The derived part leaks. Always declare the base destructorvirtual. -
Not using
override. If you misspell or change the base class function signature, withoutoverrideyou silently create a new virtual function instead of overriding the one you intended. Always writeoverrideon overriding functions. -
Calling virtual functions in constructors or destructors. During construction, the derived part of the object does not yet exist. Virtual dispatch resolves to the base class version, not the derived one. This is almost never what you intend.
-
Object slicing. If you assign a derived object to a base object by value (
Account a = checking;), only theAccountpart is copied. The derived members are silently lost. This is "slicing." Use pointers or references when working polymorphically. -
Forgetting the self-assignment check in copy assignment.
if (this == &other) return *this;is not optional. Without it,buf = buf;deletes your own data before copying from it. -
Putting everything in
public. Private data with public accessor methods is not just style — it lets you maintain invariants (the balance can never go negative) and change the internal representation without breaking callers. -
Not marking methods
const. If a method does not modify*this, mark itconst. Failing to do so prevents calling it onconstobjects orconstreferences, which come up more often than you might expect. -
Using
dynamic_casteverywhere. Frequent use ofdynamic_castto pick behavior based on the concrete type suggests the virtual interface should be redesigned to handle the variation without the cast.
5.10 Summary
Classes group data and the functions that operate on it, controlling access through public and private. Constructors initialize the object; destructors clean it up. The member initializer list is the right place to initialize members.
The Rule of Zero: if your class uses smart pointers and standard containers, write no special members. The Rule of Five applies when you own raw resources.
Operator overloading lets your types work naturally with arithmetic and I/O operators. Overload only when the meaning is obvious.
Inheritance and virtual dispatch let you write code that works with any type in a hierarchy, with the correct behavior selected at runtime. The base class destructor must be virtual. Use override on every overriding function.
Interfaces — classes with only pure virtual methods — define contracts that multiple implementations can fulfill without the caller knowing the concrete type.
In Chapter 6 we go deeper into memory — the part of C++ that trips up most learners. Understanding ownership and lifetime is the key that unlocks the rest of the language.
All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter05