<< Home

Chapter 10: Classes and Object Semantics

This chapter develops a practical model for C++ classes. You will learn how objects are laid out, how access control works, when to use friends, how special member functions define the rule of zero, five, and six, and how to design constructors and destructors that preserve invariants. You will also practice copy, move, and assignment, before building robust RAII wrappers for resources such as files and mutexes.

Class layout, access control, and friends

A class bundles state and behavior. Within the object, data members appear in declaration order with padding for alignment. Access control guards the interface; use public for the stable surface, private for internal details, and protected for inheritance cases where derived classes need a hook. A friend grants a specific function or class access to private members, which is a narrow tool for tightly coupled helpers.

Object representation basics

Standard layout types have predictable member order and address properties. Most everyday classes do not need to be standard layout; prefer clarity and rely on the language to manage padding. When binary wire formats are required, use explicit packing only at the boundary and keep the core in normal classes for safety.

struct rgba {
  std::uint8_t r, g, b, a;  // declaration order is the storage order
};
static_assert(std::is_standard_layout_v<rgba>);

Access specifiers in practice

Start with everything private and then promote stable operations to public. Avoid protected data; prefer protected virtual functions or private data with public non-virtual functions that call private helpers. This keeps invariants centralized and testable.

class counter {
  long value_ = 0; // private state
public:
  void increment() noexcept { ++value_; }
  long get() const noexcept { return value_; }
};

When a friend makes sense

Use friendship for symmetric operators that must inspect internals or for factory helpers that need to build valid states. Prefer free functions that use the public interface when possible; friendship is a targeted escape hatch, not a default.

class box {
  int w_, h_;
public:
  box(int w, int h): w_(w), h_(h) {}
  friend bool operator==(const box& a, const box& b) { return a.w_ == b.w_ && a.h_ == b.h_; }
};
⚠️ A friend declaration grants access but does not couple linkage or lifetime. Keep the set of friends minimal to reduce accidental tight coupling.

The rules of zero, five, and six

Special member functions manage ownership and lifetime. When a type does not own a resource, the compiler generated defaults usually do the right thing; this is the rule of zero. When a type owns a resource, you may need to provide specific operations; historically this was the rule of five, and with =default and =delete plus the swap pattern, many designs compress to fewer custom members in practice.

The rule of zero

If your class only stores values that already manage themselves (for example std::string, std::vector), do not write any special members. Let the compiler synthesize copy, move, destructor, and default constructor.

struct person {
  std::string name;
  std::vector<std::string> tags;
  // no custom special members; rule of zero
};

The classic rule of five

When manual ownership is involved, consider defining destructor, copy constructor, copy assignment, move constructor, and move assignment. Mark the ones you do not want as =delete to make intent explicit.

class file_handle {
  FILE* f_ = nullptr;
public:
  file_handle() = default;
  explicit file_handle(const char* path, const char* mode) : f_(std::fopen(path, mode)) {}
  ~file_handle() { if (f_) std::fclose(f_); }

  file_handle(const file_handle&) = delete;
  file_handle& operator=(const file_handle&) = delete;

  file_handle(file_handle&& other) noexcept : f_(std::exchange(other.f_, nullptr)) {}
  file_handle& operator=(file_handle&& other) noexcept {
    if (this != &other) {
      if (f_) std::fclose(f_);
      f_ = std::exchange(other.f_, nullptr);
    }
    return *this;
  }

  FILE* get() const noexcept { return f_; }
};

The proposed rule of six

Some teams count the default constructor as a sixth when it carries meaningful work. Another view counts a custom swap as part of the set. Regardless of counting, the design idea remains the same; own exactly the members that represent ownership and make the rest defaults.

💡 Try zero first. If you add one special member, check whether you need the matching set to keep the class coherent; asymmetry often hints at a missing piece.

Constructors, destructors, and invariants

Constructors establish a valid state and destructors release owned resources. An invariant is a truth that holds between public calls. Keep invariants simple and make them true after construction, before and after each public function, and during destruction until the last step.

Member initialization order and safety

Members are initialized in the order of declaration, not the order in the initializer list. Declare members in dependency order and use the initializer list to construct them. Avoid complex work in destructors; release resources and keep logic minimal to prevent exceptions escaping.

class range {
  int lo_;
  int hi_;
public:
  range(int lo, int hi) : lo_(lo), hi_(hi) { if (lo_ > hi_) throw std::invalid_argument("bad range"); }
  bool contains(int x) const noexcept { return lo_ <= x && x <= hi_; }
};

Delegating and explicit constructors

Delegating constructors reduce duplication by routing common work through a single constructor. Mark single argument converting constructors as explicit when implicit conversions would surprise callers.

class url {
  std::string spec_;
public:
  url() : url("about:blank") {}
  explicit url(std::string s) : spec_(std::move(s)) {}
  const std::string& str() const noexcept { return spec_; }
};
💡 Prefer constructor bodies that only validate and assign. Shift parsing or heavy work into private helpers that can report detailed errors with context.

Copy, move, and assignment

Copying duplicates a value; moving transfers resources and leaves the source valid but unspecified within its documented post-move state. Assignment reuses an existing object. Aim for strong exception safety where possible and document the post-move guarantees.

Defaulted operations when possible

When all members have correct copy and move semantics, default your operations. This keeps behavior aligned with member types and reduces bugs.

struct image {
  std::vector<std::byte> pixels;
  int width = 0, height = 0;
  image() = default;
  image(const image&) = default;
  image(image&&) noexcept = default;
  image& operator=(const image&) = default;
  image& operator=(image&&) noexcept = default;
};

Strong exception safety with copy-and-swap

Implement copy assignment via pass by value and swap. This provides a commit or roll back effect; either the operation succeeds and the new state is in place, or nothing changes.

class buffer {
  std::unique_ptr<std::byte[]> data_;
  std::size_t size_ = 0;
public:
  buffer() = default;
  explicit buffer(std::size_t n) : data_(new std::byte[n]{}), size_(n) {}

  friend void swap(buffer& a, buffer& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);
    swap(a.size_, b.size_);
  }

  buffer(const buffer& other) : buffer(other.size_) {
    std::memcpy(data_.get(), other.data_.get(), size_);
  }
  buffer(buffer&&) noexcept = default;

  buffer& operator=(buffer other) noexcept { // by value
    swap(*this, other);
    return *this;
  }
};
⚠️ After a move, document the valid operations on the moved-from object. A common guarantee is that it remains destructible and assignable; most accessors may be called but will observe an empty state.

Customizing operator== and ordering

Define equality in terms of observable state. For ordering, consider <=> which can synthesize comparisons from member-wise rules. Keep comparison free of hidden global state so that containers and algorithms behave predictably.

struct point3 {
  int x, y, z;
  auto operator<=>(const point3&) const = default; // compares in member order
  bool operator==(const point3&) const = default;
};

RAII handles and resource wrappers

RAII ties resource lifetime to object lifetime. Acquire the resource during construction and release it in the destructor. This pattern makes code exception safe and simpler to reason about, since the compiler runs destructors automatically on scope exit.

Single ownership with std::unique_ptr

std::unique_ptr models exclusive ownership and deletes the object with a custom deleter if needed. Use std::make_unique to construct safely. Prefer unique ownership unless you truly need sharing.

struct widget { /* … */ };

std::unique_ptr<widget> make_widget() {
  return std::make_unique<widget>();
}

Shared ownership with std::shared_ptr

std::shared_ptr counts references and deletes when the last owner goes away. Cycles leak, so use std::weak_ptr to break reference loops in graphs. Consider whether shared ownership reflects the real design or if a caller should own and pass views instead.

struct node {
  std::shared_ptr<node> next;
  std::weak_ptr<node> prev; // breaks cycles
};

Writing a minimal RAII wrapper

Model a handle that owns a resource with clear move semantics and disabled copy. Expose a small interface that represents the capability you want to give to callers.

class fd {
  int n_ = -1;
public:
  fd() = default;
  explicit fd(int n) : n_(n) {}
  ~fd() { if (n_ >= 0) ::close(n_); }

  fd(const fd&) = delete;
  fd& operator=(const fd&) = delete;

  fd(fd&& other) noexcept : n_(std::exchange(other.n_, -1)) {}
  fd& operator=(fd&& other) noexcept {
    if (this != &other) {
      if (n_ >= 0) ::close(n_);
      n_ = std::exchange(other.n_, -1);
    }
    return *this;
  }

  int get() const noexcept { return n_; }
  explicit operator bool() const noexcept { return n_ >= 0; }
};
💡 Keep the wrapper tiny; constructor acquires, destructor releases, copy disabled or well defined, move enabled, and a small set of queries that reveal state without breaking invariants.