Phase 2: Object-Oriented Programming in Modern C++

Welcome to Phase 2
You've mastered C++ fundamentals—types, functions, references, and the compilation model. Now it's time to explore Object-Oriented Programming in C++. Unlike Java or Python where OOP is straightforward, C++ gives you fine-grained control over memory layout, object lifetime, and performance. This power comes with responsibility.
This phase covers everything you need to build robust, maintainable C++ classes: constructors, destructors, the Rule of Five (and when to use Rule of Zero), inheritance, polymorphism, and operator overloading. We'll emphasize RAII (Resource Acquisition Is Initialization)—the foundational C++ idiom that makes resource management safe and automatic.
Time commitment: 1-2 weeks, 1-2 hours daily Prerequisite: Phase 1: C++ Fundamentals
What You'll Learn
By the end of Phase 2, you'll be able to:
✅ Design classes with proper encapsulation
✅ Write all special member functions (constructors, destructors, copy/move)
✅ Understand RAII and apply it for resource management
✅ Use inheritance and virtual functions correctly
✅ Implement polymorphic hierarchies
✅ Overload operators for custom types
✅ Apply modern C++ idioms (= default, = delete, override, final)
✅ Avoid common OOP pitfalls in C++
1. Classes and Objects
Class Definition Syntax
class Rectangle {
private: // Access specifier (default for class)
double width_;
double height_;
public:
// Constructor
Rectangle(double width, double height)
: width_{width}, height_{height} {}
// Member functions
double area() const {
return width_ * height_;
}
double perimeter() const {
return 2 * (width_ + height_);
}
// Getters
double width() const { return width_; }
double height() const { return height_; }
// Setters
void set_width(double w) { width_ = w; }
void set_height(double h) { height_ = h; }
};Key observations:
- Access specifiers:
private,public,protected - Member initializer list:
: width_{width}, height_{height}(preferred over assignment) constmember functions: Promise not to modify object state- Trailing underscore convention:
width_distinguishes member from parameter
struct vs class
The only difference: default access.
class MyClass {
int x; // private by default
public:
int y; // public
};
struct MyStruct {
int x; // public by default
private:
int y; // private
};Convention:
- Use
structfor plain data aggregates (no invariants) - Use
classwhen you have invariants to maintain
Creating and Using Objects
#include <iostream>
int main() {
// Stack allocation (automatic storage)
Rectangle rect1{5.0, 3.0}; // Uniform initialization
Rectangle rect2(5.0, 3.0); // Traditional constructor syntax
std::cout << "Area: " << rect1.area() << '\n';
std::cout << "Perimeter: " << rect1.perimeter() << '\n';
// Heap allocation (dynamic storage) - prefer smart pointers!
Rectangle* rect3 = new Rectangle{10.0, 20.0};
std::cout << "Pointer area: " << rect3->area() << '\n';
delete rect3; // Manual cleanup required
return 0;
}Modern C++ advice: Prefer stack allocation. When heap allocation is needed, use smart pointers (std::unique_ptr, std::shared_ptr).
2. Constructors
Default Constructor
A constructor with no parameters (or all default parameters):
class Point {
private:
double x_;
double y_;
public:
// Default constructor
Point() : x_{0.0}, y_{0.0} {}
// Parameterized constructor
Point(double x, double y) : x_{x}, y_{y} {}
double x() const { return x_; }
double y() const { return y_; }
};
int main() {
Point origin; // Calls default constructor
Point p{3.0, 4.0}; // Calls parameterized constructor
}= default and = delete
Modern C++ lets you explicitly control special member functions:
class Widget {
public:
Widget() = default; // Compiler generates default constructor
Widget(int value); // Custom constructor
// Prevent copying
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
// Allow moving (will cover later)
Widget(Widget&&) = default;
Widget& operator=(Widget&&) = default;
};When = default helps:
- Documents intent ("yes, I want the default behavior")
- Required when you've declared other constructors but still want the default
- Can be more efficient than hand-written versions
Member Initializer Lists
Always prefer initializer lists over assignment in constructor body:
class BadExample {
private:
std::string name_;
int count_;
public:
BadExample(const std::string& name, int count) {
// BAD: default constructs, then assigns
name_ = name;
count_ = count;
}
};
class GoodExample {
private:
std::string name_;
int count_;
public:
GoodExample(const std::string& name, int count)
: name_{name}, count_{count} {} // GOOD: direct initialization
};Why initializer lists matter:
- Performance: Avoids default construction + assignment
- Required for:
constmembers, reference members, members without default constructors - Order: Members initialized in declaration order, not initializer list order
class Example {
private:
int a_; // Initialized first
int b_; // Initialized second
public:
// WARNING: b_ is initialized BEFORE a_ despite list order!
// This could cause bugs if b_ initialization depends on a_
Example(int x) : b_{x}, a_{b_ + 1} {} // UB: a_ uses uninitialized b_
};Delegating Constructors (C++11)
One constructor can call another:
class Circle {
private:
double radius_;
double x_, y_;
public:
// Primary constructor
Circle(double radius, double x, double y)
: radius_{radius}, x_{x}, y_{y} {}
// Delegates to primary constructor
Circle(double radius)
: Circle{radius, 0.0, 0.0} {}
// Default: unit circle at origin
Circle()
: Circle{1.0, 0.0, 0.0} {}
};explicit Keyword
Prevents implicit conversions:
class Meter {
private:
double value_;
public:
// Allows: Meter m = 5.0; (implicit conversion)
Meter(double v) : value_{v} {}
};
class Kilometer {
private:
double value_;
public:
// Prevents: Kilometer k = 5.0; (must use Kilometer k{5.0};)
explicit Kilometer(double v) : value_{v} {}
};
void processKm(Kilometer km);
int main() {
Meter m = 5.0; // OK: implicit conversion
// Kilometer k = 5.0; // ERROR: no implicit conversion
Kilometer k{5.0}; // OK: explicit construction
Kilometer k2(5.0); // OK: direct initialization
// processKm(5.0); // ERROR: can't implicitly convert
processKm(Kilometer{5.0}); // OK
}Best practice: Use explicit for single-argument constructors to prevent surprises.
3. Destructors and RAII
Destructor Basics
The destructor is called when an object's lifetime ends:
class Logger {
private:
std::string name_;
public:
Logger(const std::string& name) : name_{name} {
std::cout << "Logger '" << name_ << "' created\n";
}
~Logger() {
std::cout << "Logger '" << name_ << "' destroyed\n";
}
};
int main() {
Logger log1{"main"};
{
Logger log2{"inner"};
// log2 destroyed here (end of scope)
}
std::cout << "After inner scope\n";
// log1 destroyed here (end of main)
}Output:
Logger 'main' created
Logger 'inner' created
Logger 'inner' destroyed
After inner scope
Logger 'main' destroyedRAII: Resource Acquisition Is Initialization
RAII is the most important C++ idiom. The idea: tie resource lifetime to object lifetime.
#include <fstream>
#include <stdexcept>
class FileHandle {
private:
FILE* file_;
public:
explicit FileHandle(const char* filename, const char* mode) {
file_ = std::fopen(filename, mode);
if (!file_) {
throw std::runtime_error("Failed to open file");
}
}
~FileHandle() {
if (file_) {
std::fclose(file_); // Always cleaned up!
}
}
// Prevent copying (would cause double-close)
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FILE* get() const { return file_; }
};
void processFile(const char* filename) {
FileHandle file{filename, "r"}; // Opens file
// Do work with file.get()...
// Even if an exception is thrown, ~FileHandle() runs
// and the file is properly closed!
}Why RAII is powerful:
- No leaks: Destructor always runs (even with exceptions)
- No forget-to-close bugs: Cleanup is automatic
- Composable: RAII objects can contain other RAII objects
Standard library RAII types:
std::unique_ptr- Owns heap memorystd::shared_ptr- Shared heap memory ownershipstd::lock_guard- Holds mutex locksstd::fstream- Manages file handlesstd::vector,std::string- Manage their own memory
4. Copy Semantics
Copy Constructor
Creates a new object as a copy of an existing one:
class IntArray {
private:
int* data_;
size_t size_;
public:
explicit IntArray(size_t size)
: data_{new int[size]{}}, size_{size} {}
// Copy constructor
IntArray(const IntArray& other)
: data_{new int[other.size_]}, size_{other.size_} {
std::copy(other.data_, other.data_ + size_, data_);
}
~IntArray() {
delete[] data_;
}
int& operator[](size_t index) { return data_[index]; }
size_t size() const { return size_; }
};
int main() {
IntArray arr1{5};
arr1[0] = 42;
IntArray arr2 = arr1; // Copy constructor called
IntArray arr3{arr1}; // Also copy constructor
arr2[0] = 100;
// arr1[0] is still 42 (deep copy!)
}Copy Assignment Operator
Assigns one existing object to another:
class IntArray {
// ... (same as above)
// Copy assignment operator
IntArray& operator=(const IntArray& other) {
if (this != &other) { // Self-assignment check
delete[] data_; // Clean up existing resource
size_ = other.size_;
data_ = new int[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
};
int main() {
IntArray arr1{5};
IntArray arr2{3};
arr2 = arr1; // Copy assignment operator called
}Copy-and-Swap Idiom
A safer, exception-safe way to implement copy assignment:
#include <utility> // std::swap
class IntArray {
private:
int* data_;
size_t size_;
public:
explicit IntArray(size_t size)
: data_{new int[size]{}}, size_{size} {}
// Copy constructor (same as before)
IntArray(const IntArray& other)
: data_{new int[other.size_]}, size_{other.size_} {
std::copy(other.data_, other.data_ + size_, data_);
}
// Swap function
friend void swap(IntArray& a, IntArray& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
// Copy assignment using copy-and-swap
IntArray& operator=(IntArray other) { // Note: passed by VALUE
swap(*this, other);
return *this;
} // 'other' destructor cleans up old data
~IntArray() {
delete[] data_;
}
};Benefits:
- Exception-safe: If copy construction fails, original is unchanged
- Handles self-assignment: Naturally works correctly
- DRY: Reuses copy constructor logic
5. Move Semantics
The Problem with Copies
Sometimes copying is wasteful:
IntArray createArray() {
IntArray temp{1000000}; // Million integers
// ... fill temp ...
return temp; // Copy to caller's variable (expensive!)
}
IntArray arr = createArray(); // Copy constructor calledRvalue References and Move Constructor
C++11 introduced move semantics to "steal" resources from temporary objects:
class IntArray {
private:
int* data_;
size_t size_;
public:
explicit IntArray(size_t size)
: data_{new int[size]{}}, size_{size} {}
// Move constructor
IntArray(IntArray&& other) noexcept
: data_{other.data_}, size_{other.size_} {
other.data_ = nullptr; // Leave source in valid empty state
other.size_ = 0;
}
// Move assignment operator
IntArray& operator=(IntArray&& other) noexcept {
if (this != &other) {
delete[] data_; // Clean up existing
data_ = other.data_; // Steal resources
size_ = other.size_;
other.data_ = nullptr; // Leave source empty
other.size_ = 0;
}
return *this;
}
~IntArray() {
delete[] data_; // Safe: delete nullptr is no-op
}
};Key points:
IntArray&&is an rvalue reference (binds to temporaries)- Move operations should be
noexceptwhen possible - Moved-from object must be left in a valid but unspecified state
std::move
std::move doesn't move anything—it casts to rvalue reference:
#include <utility>
IntArray arr1{100};
IntArray arr2 = std::move(arr1); // Move, not copy
// arr1 is now empty (moved-from)
// arr2 owns the dataImportant: After std::move, don't use the moved-from object except to:
- Destroy it
- Assign to it
- Call methods that don't require a valid state
6. The Rule of Five (and Rule of Zero)
Rule of Five
If you define any of these, you probably need to define all five:
- Destructor
- Copy constructor
- Copy assignment operator
- Move constructor
- Move assignment operator
class Resource {
private:
int* data_;
public:
// Constructor
Resource() : data_{new int{42}} {}
// 1. Destructor
~Resource() { delete data_; }
// 2. Copy constructor
Resource(const Resource& other)
: data_{new int{*other.data_}} {}
// 3. Copy assignment
Resource& operator=(const Resource& other) {
if (this != &other) {
*data_ = *other.data_;
}
return *this;
}
// 4. Move constructor
Resource(Resource&& other) noexcept
: data_{other.data_} {
other.data_ = nullptr;
}
// 5. Move assignment
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete data_;
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
};Rule of Zero
Better approach: Don't manage resources directly. Use RAII types:
#include <memory>
#include <vector>
#include <string>
class Person {
private:
std::string name_; // Manages its own memory
std::vector<int> scores_; // Manages its own memory
std::unique_ptr<SomeResource> resource_; // Manages heap object
public:
Person(std::string name)
: name_{std::move(name)}
, resource_{std::make_unique<SomeResource>()} {}
// No destructor needed!
// No copy/move functions needed! (or = delete copy if unique_ptr)
// The compiler-generated defaults do the right thing.
};Rule of Zero: Design your classes so that compiler-generated special members are correct.
7. Inheritance
Basic Inheritance
class Animal {
protected:
std::string name_;
public:
Animal(std::string name) : name_{std::move(name)} {}
void eat() const {
std::cout << name_ << " is eating\n";
}
std::string name() const { return name_; }
};
class Dog : public Animal { // public inheritance
public:
Dog(std::string name) : Animal{std::move(name)} {}
void bark() const {
std::cout << name_ << " says: Woof!\n";
}
};
int main() {
Dog dog{"Buddy"};
dog.eat(); // Inherited from Animal
dog.bark(); // Dog's own method
}Access Specifiers in Inheritance
class Base {
public:
int pub;
protected:
int prot;
private:
int priv;
};
class PublicDerived : public Base {
// pub is public
// prot is protected
// priv is not accessible
};
class ProtectedDerived : protected Base {
// pub is protected
// prot is protected
// priv is not accessible
};
class PrivateDerived : private Base {
// pub is private
// prot is private
// priv is not accessible
};Almost always use public inheritance. Private/protected inheritance is rare.
Constructor/Destructor Order
class Base {
public:
Base() { std::cout << "Base()\n"; }
~Base() { std::cout << "~Base()\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << "Derived()\n"; }
~Derived() { std::cout << "~Derived()\n"; }
};
int main() {
Derived d;
}Output:
Base()
Derived()
~Derived()
~Base()Order: Base constructors first (top-down), then destructors in reverse order (bottom-up).
8. Polymorphism
Virtual Functions
Enable runtime polymorphism:
class Shape {
public:
virtual double area() const = 0; // Pure virtual (abstract)
virtual void draw() const {
std::cout << "Drawing a shape\n";
}
virtual ~Shape() = default; // CRITICAL: virtual destructor
};
class Circle : public Shape {
private:
double radius_;
public:
explicit Circle(double r) : radius_{r} {}
double area() const override { // 'override' ensures we're overriding
return 3.14159 * radius_ * radius_;
}
void draw() const override {
std::cout << "Drawing a circle with radius " << radius_ << '\n';
}
};
class Rectangle : public Shape {
private:
double width_, height_;
public:
Rectangle(double w, double h) : width_{w}, height_{h} {}
double area() const override {
return width_ * height_;
}
void draw() const override {
std::cout << "Drawing a " << width_ << "x" << height_ << " rectangle\n";
}
};Using Polymorphism
#include <memory>
#include <vector>
void printArea(const Shape& shape) {
std::cout << "Area: " << shape.area() << '\n';
}
int main() {
Circle circle{5.0};
Rectangle rect{4.0, 3.0};
printArea(circle); // Uses Circle::area()
printArea(rect); // Uses Rectangle::area()
// Polymorphism with pointers/smart pointers
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
shapes.push_back(std::make_unique<Circle>(1.5));
for (const auto& shape : shapes) {
shape->draw(); // Calls correct override
std::cout << "Area: " << shape->area() << '\n';
}
}Virtual Destructors
Critical rule: If a class has virtual functions, it needs a virtual destructor.
class Base {
public:
virtual void foo() {}
// ~Base() {} // BUG: non-virtual destructor
virtual ~Base() = default; // CORRECT
};
class Derived : public Base {
int* data_;
public:
Derived() : data_{new int[100]} {}
~Derived() { delete[] data_; }
};
int main() {
Base* ptr = new Derived{};
delete ptr; // Without virtual destructor, only Base::~Base() runs!
// Derived::~Derived() never runs = MEMORY LEAK
}override and final
class Base {
public:
virtual void foo() const;
virtual void bar();
};
class Derived : public Base {
public:
void foo() const override; // OK: matches Base::foo
// void foo() override; // ERROR: const mismatch caught by 'override'
void bar() final; // Can't be overridden further
};
class MoreDerived : public Derived {
public:
// void bar() override; // ERROR: bar is final in Derived
};
class FinalClass final : public Base {
// Cannot be inherited from
};Best practice: Always use override when overriding virtual functions.
9. Operator Overloading
Common Operators
class Fraction {
private:
int numerator_;
int denominator_;
public:
Fraction(int num, int den = 1)
: numerator_{num}, denominator_{den} {}
// Arithmetic operators (as member functions)
Fraction operator+(const Fraction& other) const {
return Fraction{
numerator_ * other.denominator_ + other.numerator_ * denominator_,
denominator_ * other.denominator_
};
}
Fraction operator*(const Fraction& other) const {
return Fraction{
numerator_ * other.numerator_,
denominator_ * other.denominator_
};
}
// Unary minus
Fraction operator-() const {
return Fraction{-numerator_, denominator_};
}
// Comparison (C++20 spaceship operator)
auto operator<=>(const Fraction& other) const {
return (numerator_ * other.denominator_) <=>
(other.numerator_ * denominator_);
}
bool operator==(const Fraction& other) const = default;
// Conversion operator
explicit operator double() const {
return static_cast<double>(numerator_) / denominator_;
}
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
};
// Stream operator (non-member, usually friend)
std::ostream& operator<<(std::ostream& os, const Fraction& f) {
return os << f.numerator() << '/' << f.denominator();
}Usage
int main() {
Fraction a{1, 2}; // 1/2
Fraction b{2, 3}; // 2/3
Fraction c = a + b; // 7/6
Fraction d = a * b; // 2/6
std::cout << a << " + " << b << " = " << c << '\n';
std::cout << "-a = " << -a << '\n';
if (a < b) {
std::cout << a << " < " << b << '\n';
}
double val = static_cast<double>(a); // 0.5
}Operator Overloading Guidelines
| Operator | Member vs Non-member |
|---|---|
= (assignment) | Must be member |
[], (), ->, ->* | Must be member |
+=, -=, *=, etc. | Usually member |
+, -, *, / | Prefer non-member |
<<, >> (streams) | Must be non-member |
<, <=, >, >=, ==, != | Prefer non-member |
<=> (C++20) | Either, but member is common |
10. Multiple Inheritance
Basic Multiple Inheritance
class Printable {
public:
virtual void print() const = 0;
virtual ~Printable() = default;
};
class Serializable {
public:
virtual std::string serialize() const = 0;
virtual ~Serializable() = default;
};
class Document : public Printable, public Serializable {
private:
std::string content_;
public:
explicit Document(std::string content) : content_{std::move(content)} {}
void print() const override {
std::cout << content_ << '\n';
}
std::string serialize() const override {
return "{\"content\": \"" + content_ + "\"}";
}
};The Diamond Problem
class Animal {
public:
std::string name;
};
class Mammal : public Animal {};
class Bird : public Animal {};
class Bat : public Mammal, public Bird {
// Problem: Bat has TWO copies of Animal!
// bat.name is ambiguous
};Virtual Inheritance
Solves the diamond problem:
class Animal {
public:
std::string name;
Animal(std::string n) : name{std::move(n)} {}
};
class Mammal : virtual public Animal {
public:
Mammal(std::string n) : Animal{std::move(n)} {}
};
class Bird : virtual public Animal {
public:
Bird(std::string n) : Animal{std::move(n)} {}
};
class Bat : public Mammal, public Bird {
public:
// Must call Animal constructor directly
Bat(std::string n)
: Animal{std::move(n)}, Mammal{""}, Bird{""} {}
// Now there's only ONE Animal subobject
};Advice: Avoid multiple inheritance of classes. Prefer:
- Single inheritance with interfaces (pure abstract classes)
- Composition over inheritance
11. Modern OOP Patterns
Composition Over Inheritance
Instead of:
class Car : public Engine, public Transmission { }; // BadPrefer:
class Car {
private:
Engine engine_;
Transmission transmission_;
public:
void start() {
engine_.start();
transmission_.engage();
}
};Interface Pattern
// Pure abstract class = Interface
class Drawable {
public:
virtual void draw() const = 0;
virtual ~Drawable() = default;
};
class Clickable {
public:
virtual void onClick() = 0;
virtual ~Clickable() = default;
};
// Implement multiple interfaces
class Button : public Drawable, public Clickable {
public:
void draw() const override { /* ... */ }
void onClick() override { /* ... */ }
};Factory Pattern
#include <memory>
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};
class Circle : public Shape {
public:
void draw() const override { std::cout << "Circle\n"; }
};
class Square : public Shape {
public:
void draw() const override { std::cout << "Square\n"; }
};
// Factory function
std::unique_ptr<Shape> createShape(const std::string& type) {
if (type == "circle") return std::make_unique<Circle>();
if (type == "square") return std::make_unique<Square>();
return nullptr;
}Common Pitfalls
1. Slicing
class Base {
public:
int x = 1;
virtual void foo() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
int y = 2;
void foo() override { std::cout << "Derived\n"; }
};
void badFunction(Base b) { // Pass by VALUE = slicing!
b.foo(); // Always calls Base::foo()
}
int main() {
Derived d;
badFunction(d); // Derived part is SLICED OFF
}Fix: Pass polymorphic types by reference or pointer.
2. Forgetting Virtual Destructor
Already covered—always make destructors virtual in base classes.
3. Calling Virtual Functions in Constructor/Destructor
class Base {
public:
Base() {
init(); // Calls Base::init(), even in Derived construction!
}
virtual void init() { std::cout << "Base init\n"; }
};
class Derived : public Base {
public:
void init() override { std::cout << "Derived init\n"; }
};
int main() {
Derived d; // Prints "Base init", not "Derived init"!
}Why? During Base constructor, the object is still a Base. Virtual dispatch doesn't work as expected.
4. Raw Pointer Ownership Confusion
class Container {
Widget* widget_; // Who owns this? Unclear!
public:
Container(Widget* w) : widget_{w} {}
~Container() { delete widget_; } // Maybe double-delete?
};Fix: Use smart pointers to express ownership.
Summary
✅ Classes: Use access specifiers, prefer struct for data, class for behavior
✅ Constructors: Use initializer lists, explicit for single-arg, = default/= delete
✅ RAII: Tie resource lifetime to object lifetime
✅ Rule of Five/Zero: Define all five, or use RAII types and define none
✅ Inheritance: Use public inheritance, always virtual destructors
✅ Polymorphism: Use override, pass by reference/pointer
✅ Operators: Follow conventions (member vs non-member)
✅ Composition: Prefer composition over inheritance
What's Next?
- Phase 3: Modern C++ Features → — C++11 through C++23 features
- Deep Dive: Memory Management & Smart Pointers → — RAII in depth
Additional Resources
Series: Modern C++ Learning Roadmap
Previous: Phase 1: C++ Fundamentals
Next: Phase 3: Modern C++ Features
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.