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 anywhereprivate— accessible only within the class (andfriends)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.