Scope-bound resource management and about writing classes

Written by:

Last updated: 20 June 2022


1 Destructors and object lifetimes

A destructor is a special member function that is called when the lifetime of an object ends. Roughly speaking, the lifetime of an object begins when its constructor is called and ends when its destructor is called.

1.1 How do I write a destructor for my class?

If defined inline (aka inside the struct definition), it looks like this:

// in my_struct.h
struct MyStruct {
  ~MyStruct() {
    // destructor stuff
  }
};
Snippet 1: inline definition of destructor

If defined out-of-line (aka separating the definition into a cpp file and keeping the declaration in the header file), it looks like this:

// in my_struct.h
struct MyStruct {
  ~MyStruct();
};

// in my_struct.{cc|cpp}
MyStruct::~MyStruct() {
  // destructor stuff
}
Snippet 2: out-of-line definition of destructor

1.2 Destructors in action (basic)

Godbolt link

#include <iostream>

struct S {
  int m_id;
  S(int id) : m_id(id) {
    std::cout << "S(id=" << m_id << ") constructor\n";
  }

  ~S() {
    std::cout << "S(id=" << m_id << ") destructor\n";
  }
};

int main() {
  S s1(1);  // lifetime of s1 starts here
  {
    std::cout << "\n-- Immediately AFTER inner scope START --\n";
    S s2(2);  // lifetime of s2 starts here

    // lifetime of s2 ends here,
    // right before the closing brace
  }
  std::cout << "-- Immediately AFTER inner scope END --\n\n";
  // lifetime of s1 ends here,
  // right before the closing brace
}
Snippet 3: Simple destructor example

Output:

$ ./dtor_example_1.out
S(id=1) constructor
-- Immediately AFTER inner scope START --
S(id=2) constructor
S(id=2) destructor
-- Immediately AFTER inner scope END --
S(id=1) destructor
Snippet 4: Lifetimes of locals start at construction and end when they go out of scope

1.3 Destructors in action (less basic)

If the object is a class with non-static data members, their lifetimes begin and end following class initialization order (aka the order you declare them in). This is reflected in their constructors being called following class initialization order and their destructors being called in the reverse order of their constructor calls.

Let’s see an example:

#include <iostream>

struct ChildOne {
  int m_parent_id;
  ChildOne(int parent_id) : m_parent_id(parent_id) {
    std::cout << "ChildOne(parent_id=" << m_parent_id << ") is BORN!\n";
  }
  ~ChildOne() {
    std::cout << "ChildOne(parent_id=" << m_parent_id << ") is KILL!\n";
  }
};

struct ChildTwo {
  int m_parent_id;
  ChildTwo(int parent_id) : m_parent_id(parent_id) {
    std::cout << "ChildTwo(parent_id=" << m_parent_id << ") is BORN!\n";
  }
  ~ChildTwo() {
    std::cout << "ChildTwo(parent_id=" << m_parent_id << ") is KILL!\n";
  }
};

struct Parent {
  int m_id;
  ChildOne m_c1;
  ChildTwo m_c2;

  Parent(int id) : m_id(id), m_c1(id), m_c2(id) {
    std::cout << "Parent(id=" << m_id << ") has ARRIVED!\n";
  }

  ~Parent() {
    std::cout << "Parent(id=" << m_id << ") has LEFT!\n";
  }
};

int main() {
  Parent p1(1);
  {
    std::cout << "\n-- Immediately AFTER inner scope START --\n";
    Parent p2(2);
  }
  std::cout << "-- Immediately AFTER inner scope END --\n\n";
}
Snippet 5: Destructor example with non-static data members

Output:

$ ./dtor_example_2.out
ChildOne(parent_id=1) is BORN!
ChildTwo(parent_id=1) is BORN!
Parent(id=1) has ARRIVED!
-- Immediately AFTER inner scope START --
ChildOne(parent_id=2) is BORN!
ChildTwo(parent_id=2) is BORN!
Parent(id=2) has ARRIVED!
Parent(id=2) has LEFT!
ChildTwo(parent_id=2) is KILL!
ChildOne(parent_id=2) is KILL!
-- Immediately AFTER inner scope END --
Parent(id=1) has LEFT!
ChildTwo(parent_id=1) is KILL!
ChildOne(parent_id=1) is KILL!
Snippet 6: Lifetime of member variables begin right before construction and end right after destruction

You probably understand why id=1 objects get constructed before id=2 objects and id=1 objects get destructed after id=2 objects.

But you might be wondering (I certainly was):

One way to think about it is with this example:

#include <iostream>

struct Child {
  int m_parent_id;
  std::string m_name;
  Child(int parent_id, const std::string& name)
      : m_parent_id(parent_id), m_name(name) {
    std::cout << "Child(parent_id=" << m_parent_id << ", name=" << m_name
              << ") is BORN!\n";
  }
  ~Child() {
    std::cout << "Child(parent_id=" << m_parent_id << ", name=" << m_name
              << ") is KILL!\n";
  }
};

struct Parent {
  int m_id;
  Child m_child;

  Parent(int id) : m_id(id), m_child(id, "bob") {
    std::cout << "Parent(id=" << m_id << ") has ARRIVED!\n";
    std::cout << "Parent(id=" << m_id
              << ") has child of name=" << m_child.m_name << "!\n";
  }

  ~Parent() {
    std::cout << "Parent(id=" << m_id << ") has LEFT!\n";
    std::cout << "Parent(id=" << m_id
              << ") has child of name=" << m_child.m_name << "!\n";
  }
};

int main() {
  Parent p(1);
}
Snippet 7: Example of accessing non-static data members in ctor and dtor

Output:

$ ./dtor_example_3.out
Child(parent_id=1, name=bob) is BORN!
Parent(id=1) has ARRIVED!
Parent(id=1) has child of name=bob!
Parent(id=1) has LEFT!
Parent(id=1) has child of name=bob!
Child(parent_id=1, name=bob) is KILL!
Snippet 8

Since we can access non-static data member objects of the enclosing class in its constructor and destructor, they need to be initialized within the bodies of the enclosing class’ constructor and destructor; if not, you might be accessing uninitialized data, which could lead to nasty bugs and explosions. It follows that you should be convinced of the 2 statements above that you might’ve been wondering about.

1.4 Simple timer class

We can also take advantage of destructors being called at the end of scopes to do other things like timing or benchmarking a given scope, something you might find useful in your C++ assignments.

#pragma once

#include <chrono>
#include <iomanip>
#include <iostream>
#include <sstream>

using Clock = std::chrono::system_clock;
using Timestamp = std::chrono::time_point<Clock>;

namespace detail {
inline int64_t milliseconds_between(const Timestamp& start,
                                    const Timestamp& end) {
  return std::chrono::duration_cast<std::chrono::milliseconds>(end -
                                                               start)
      .count();
}

}  // namespace detail

class Timer {
public:
  Timer() : m_start_ts(Clock::now()) {
    std::time_t t = Clock::to_time_t(m_start_ts);
    std::cout << "[Timer] start at: "
              << std::put_time(std::localtime(&t), "%F %T") << '\n';
  }
  ~Timer() {
    Timestamp end_ts = Clock::now();
    std::time_t t = Clock::to_time_t(end_ts);
    std::cout << "[Timer] end at: "
              << std::put_time(std::localtime(&t), "%F %T") << '\n';
    std::cout << "[Timer] milliseconds elapsed: "
              << detail::milliseconds_between(m_start_ts, end_ts) << '\n';
  }

private:
  Timestamp m_start_ts;
};
Snippet 9: timer.h
#include <iostream>

#include "timer.h"

int main() {
  std::cout << "Let's count to 2B. I wonder how long it'll take.\n";
  {
    Timer timer;
    for (uint32_t i = 0; i < 2'000'000'000; ++i) {
      /* counting... */
    }
  }
  std::cout << '\n';  // For better visual separation of timer output

  std::cout << "Now, let's count to 4B. I wonder how long it'll take.\n";
  {
    Timer timer;
    for (uint32_t i = 0; i < 4'000'000'000; ++i) {
      /* counting... */
    }
  }
}
Snippet 10: main.cpp

Output:

$ ./main.out
Let's count to 2B. I wonder how long it'll take.
[Timer] start at: 2022-07-15 15:29:51
[Timer] end at: 2022-07-15 15:30:04
[Timer] milliseconds elapsed: 12878

Now, let's count to 4B. I wonder how long it'll take.
[Timer] start at: 2022-07-15 15:30:04
[Timer] end at: 2022-07-15 15:30:21
[Timer] milliseconds elapsed: 17452

2 Lock ownership using RAII

Since the compiler makes many guarantees about object lifetime and makes sure that destructors are called at the end of an object’s lifetime, we can abuse this feature to reason about things other than the objects themselves.

We’ve seen this already with the timer class, where we made use of the lifetime of a stack-allocated object to run code at the start and end of a lexical scope.

With RAII, we tie an object’s lifetime to the setup and cleanup of resources in general. The guarantees provided to us by the language will allow us to make sure that these resources are always cleaned up.

We’ll go into more detail about what exactly RAII is after showing an example.


#include <pthread.h>

#include <exception>
#include <iostream>
#include <sstream>
#include <stdexcept>

#include "random_sleep.h"
#include "synchronised_print.h"

pthread_mutex_t count_mutex = PTHREAD_MUTEX_INITIALIZER;
uint64_t count = 0;

void increment_count_sus(pthread_t tid) {
  pthread_mutex_lock(&count_mutex);
  ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << std::hex
                      << tid << ", Old Count=" << count << '\n';
  ++count;
  ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << std::hex
                      << tid << ", New Count=" << count << '\n';
  if (5 == count) {
    // Simulate error thrown
    throw std::runtime_error("wtf?!");
  }
  pthread_mutex_unlock(&count_mutex);
}

uint64_t get_count() {
  uint64_t ret;
  pthread_mutex_lock(&count_mutex);
  ret = count;
  pthread_mutex_unlock(&count_mutex);
  return ret;
}

void* f(void* arg) {
  pthread_t tid = *((pthread_t*) arg);
  for (int i = 0; i < 5; ++i) {
    try {
      increment_count_sus(tid);
      random_sleep();  // sleep for random duration so one thread doesn't
                       // dominate
    } catch (const std::exception& e) {
      ThreadsafePrinter{} << "In catch block of Caller=" << std::hex
                          << tid << ", Error=" << e.what() << '\n';
    }
  }
  return nullptr;
}

int main() {
  const size_t num_threads = 2;
  pthread_t tids[num_threads];

  for (size_t i = 0; i < num_threads; ++i) {
    pthread_create(&tids[i], NULL, &f, &tids[i]);
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(1ms);
  }

  for (size_t i = 0; i < num_threads; ++i) {
    pthread_join(tids[i], NULL);
  }
}
Snippet 11: Exposed pthread_mutex_t non-RAII example

But what happens if increment_count_sus() throws? The thread which incremented count to 5 will never be able to call pthread_mutex_unlock(&count_mutex) to unlock the count_mutex! This causes all other threads (including itself, after it goes into the catch block) to block on pthread_mutex_lock(&count_mutex) in increment_count_sus(). Oh no!

Output:

$ ./not_raii_1.out
In increment_count_sus(), Caller=7ffff7a59700, Old Count=0
In increment_count_sus(), Caller=7ffff7a59700, New Count=1
In increment_count_sus(), Caller=7ffff6a58700, Old Count=1
In increment_count_sus(), Caller=7ffff6a58700, New Count=2
In increment_count_sus(), Caller=7ffff7a59700, Old Count=2
In increment_count_sus(), Caller=7ffff7a59700, New Count=3
In increment_count_sus(), Caller=7ffff6a58700, Old Count=3
In increment_count_sus(), Caller=7ffff6a58700, New Count=4
In increment_count_sus(), Caller=7ffff7a59700, Old Count=4
In increment_count_sus(), Caller=7ffff7a59700, New Count=5
In catch block of Caller=7ffff7a59700, Error=wtf?!
^C
$ # The program hung
Snippet 12: Throwing code paths can result in missed unlock

As you can see from the last line, we get no more calls of increment_count_sus() and the program is blocked. What can we do?

Well, one way is to add pthread_mutex_unlock(&count_mutex) to the catch block.

    } catch (const std::exception& e) {
      ThreadsafePrinter{} << "In catch block of Caller=" << std::hex
                          << tid << ", Error=" << e.what() << '\n';
      pthread_mutex_unlock(&count_mutex);  // added
    }
Snippet 13: Making the caller handle the missed unlock

But, what if we don’t have access to the count_mutex object e.g. if counter was a class which owns count_mutex as a private data member?

class SynchronisedCounter {
  pthread_mutex_t count_mutex;
  uint64_t count;

public:
  SynchronisedCounter()
      : count_mutex(PTHREAD_MUTEX_INITIALIZER), count(0) {}

  void increment_count_sus(pthread_t tid) {
    pthread_mutex_lock(&count_mutex);
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << std::hex
                        << tid << ", Old Count=" << count << '\n';
    ++count;
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << std::hex
                        << tid << ", New Count=" << count << '\n';
    if (5 == count) {
      // Simulate error thrown
      throw std::runtime_error("wtf?!");
    }
    pthread_mutex_unlock(&count_mutex);
  }

  uint64_t get_count() {
    uint64_t ret;
    pthread_mutex_lock(&count_mutex);
    ret = count;
    pthread_mutex_unlock(&count_mutex);
    return ret;
  }
};

SynchronisedCounter counter;

void* f(void* arg) {
  // ...
    } catch (const std::exception& e) {
      ThreadsafePrinter{} << "In catch block of Caller=" << std::hex
                          << tid << ", Error=" << e.what() << '\n';
      // Doesn't work because it's private!
      // pthread_mutex_unlock(counter.count_mutex);
    }
Snippet 14: Unexposed pthread_mutex_t non-RAII example

Destructors to the rescue!

#include <pthread.h>

#include <exception>
#include <iostream>
#include <sstream>
#include <stdexcept>

#include "random_sleep.h"
#include "synchronised_print.h"

class Mutex {
  pthread_mutex_t m_mutex;

public:
  struct ScopedLock {
    pthread_mutex_t* m_mutex_ptr;
    // ...
    ScopedLock(pthread_mutex_t* mutex_ptr) : m_mutex_ptr(mutex_ptr) {
      pthread_mutex_lock(m_mutex_ptr);  // acquire
    }
    ~ScopedLock() {
      pthread_mutex_unlock(m_mutex_ptr);  // release
    }
    // ...

    // Delete copy constructor and copy assignment operator
    // as this class should not be copyable. How do you "copy"
    // a lock?
    ScopedLock(const ScopedLock&) = delete;
    ScopedLock& operator=(const ScopedLock&) = delete;
  };

  Mutex() : m_mutex(PTHREAD_MUTEX_INITIALIZER) {}

  const ScopedLock get_lock() {
    return ScopedLock(&m_mutex);
  }
};

class SynchronisedCounter {
  Mutex mutex;
  uint64_t count;

public:
  SynchronisedCounter() : count(0) {}

  void increment_count_sus(pthread_t tid) {
    Mutex::ScopedLock lock = mutex.get_lock();
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << tid
                        << ", Old Count=" << count << '\n';
    ++count;
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << tid
                        << ", New Count=" << count << '\n';
    if (5 == count) {
      // Simulate error thrown
      throw std::runtime_error("wtf?!");
    }
  }

  uint64_t get_count() {
    Mutex::ScopedLock lock = mutex.get_lock();
    return count;
  }
};

SynchronisedCounter counter;

void* f(void* arg) {
  pthread_t tid = *((pthread_t*) arg);
  for (int i = 0; i < 5; ++i) {
    try {
      counter.increment_count_sus(tid);  // ------- (1)
      random_sleep();
    } catch (const std::exception& e) {
      ThreadsafePrinter{} << "In catch block of Caller=" << tid
                          << ", Error=" << e.what() << '\n';
    }
  }
  return nullptr;
}

int main() {
  const size_t num_threads = 2;
  pthread_t tids[num_threads];

  for (size_t i = 0; i < num_threads; ++i) {
    pthread_create(&tids[i], NULL, &f, &tids[i]);
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(1ms);
  }

  for (size_t i = 0; i < num_threads; ++i) {
    pthread_join(tids[i], NULL);
  }
}
Snippet 15: pthread_mutex_t RAII example

Output:

$ ./raii.out
In increment_count_sus(), Caller=140737348212480, Old Count=0
In increment_count_sus(), Caller=140737348212480, New Count=1
In increment_count_sus(), Caller=140737331431168, Old Count=1
In increment_count_sus(), Caller=140737331431168, New Count=2
In increment_count_sus(), Caller=140737348212480, Old Count=2
In increment_count_sus(), Caller=140737348212480, New Count=3
In increment_count_sus(), Caller=140737331431168, Old Count=3
In increment_count_sus(), Caller=140737331431168, New Count=4
In increment_count_sus(), Caller=140737348212480, Old Count=4
In increment_count_sus(), Caller=140737348212480, New Count=5
In catch block of Caller=140737348212480, Error=wtf?!
In increment_count_sus(), Caller=140737348212480, Old Count=5
In increment_count_sus(), Caller=140737348212480, New Count=6
In increment_count_sus(), Caller=140737331431168, Old Count=6
In increment_count_sus(), Caller=140737331431168, New Count=7
In increment_count_sus(), Caller=140737348212480, Old Count=7
In increment_count_sus(), Caller=140737348212480, New Count=8
In increment_count_sus(), Caller=140737331431168, Old Count=8
In increment_count_sus(), Caller=140737331431168, New Count=9
In increment_count_sus(), Caller=140737331431168, Old Count=9
In increment_count_sus(), Caller=140737331431168, New Count=10

Wow! The program did not block and we get count=10 in the end, why’s that?

    try {
      counter.increment_count_sus(tid);  // ------- (1)
      random_sleep();
    } catch (const std::exception& e) {
      ThreadsafePrinter{} << "In catch block of Caller=" << tid
                          << ", Error=" << e.what() << '\n';
    }

At (1), exception is thrown and control is passed into the catch block. In the catch block, the C++ run time calls destructors for all objects constructed on the stack since the start of the try block. Note that this process is automatic due to stack unwinding (roughly speaking, stack frames getting popped off the stack).

What object was constructed on the stack since the start of the try block?

  void increment_count_sus(pthread_t tid) {
    Mutex::ScopedLock lock = mutex.get_lock();
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << tid
                        << ", Old Count=" << count << '\n';
    ++count;
    ThreadsafePrinter{} << "In increment_count_sus(), Caller=" << tid
                        << ", New Count=" << count << '\n';
    if (5 == count) {
      // Simulate error thrown
      throw std::runtime_error("wtf?!");
    }
  }

It’s Mutex::ScopedLock, which has a destructor

    ~ScopedLock() { pthread_mutex_unlock(&m_mutex); }

that unlocks the mutex. Subarashii!

From this, we see two benefits of designing classes / interfaces with destructors:

  1. It allows us to better reason about the lifetime of objects e.g. the pthread_mutex_t object being tied to lifetime of Mutex::ScopedLock
  2. It lets us have better abstraction e.g. SynchronisedCounter does not have to expose a getter for the pthread_mutex_t to be unlocked in the catch block, which also does not need to know about the internals of SynchronisedCounter
Note on API Design

Godbolt link

We should mark Mutex::get_lock() as [[nodiscard]] so clients of our interface don’t accidentally call get_lock() and discard the return value (compiler throws warning if so):

  [[nodiscard]] const ScopedLock get_lock() {
    return ScopedLock(&m_mutex);
  }
  uint64_t get_count() {
    // Compiler throws warning if users do:
    mutex.get_lock();  // without assigning the return value to anything

    // Operations that need to be synchronised
    return count;
  }
Snippet 16: Using [[nodiscard]] to prevent misuse of get_lock api

Doing this does not lock the scope as the return value of Mutex::get_lock() is a temporary whose lifetime begins (ctor) and ends (dtor) on that expression. So, it both acquires and releases the lock in that expression alone, not extending the locking effect to the scope which is a bug. By marking the function [[nodiscard]], we can help prevent potentially unintended interface misuse by clients by warning them to assign the return value to something.

Helper code for ThreadsafePrinter
#include <pthread.h>

#include <iostream>
#include <sstream>

struct ThreadsafePrinter {
  static inline pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  std::ostringstream oss;

  ThreadsafePrinter& operator<<(const auto& x) {
    oss << x;
    return *this;
  }

  ~ThreadsafePrinter() {
    pthread_mutex_lock(&mutex);
    std::cout << oss.str() << std::flush;
    pthread_mutex_unlock(&mutex);
  }
};
Snippet 17: Helper code for ThreadsafePrinter

2.1 Introducing the idea of RAII

The above section demonstrated one purpose of the destructor – to free the resources (pthread_mutex_t) that the object may have acquired during its lifetime by tying the lifetime of the resources it owns to the lifetime of itself. This is the very idea of “Resource Acquisition is initialization”, also known as RAII, in which an object’s constructor is seen to “acquire” resources the object owns, and its destructor is seen to “release” them.

    // ...
    ScopedLock(pthread_mutex_t* mutex_ptr) : m_mutex_ptr(mutex_ptr) {
      pthread_mutex_lock(m_mutex_ptr);  // acquire
    }
    ~ScopedLock() {
      pthread_mutex_unlock(m_mutex_ptr);  // release
    }
    // ...

Mutex::ScopedLock owns the “resource” of the “lock on the pthread_mutex_t* m_mutex_ptr” which it “acquires” and “releases” in its constructor and destructor respectively. Actually, this pattern is so common that we have std::lock_guard, std::scoped_lock, and std::unique_lock for scope-based RAII-style locking.

// Global scope accessible to the wholoe program
std::mutex mut;

void f() {
  // Operations that don't need synchronization

  // Scope-based RAII-style locking and unlocking
  {
    std::lock_guard lk(mut);
    // `lk`'s ctor called in above line, acquiring the lock on `mut`

    // Operations that need synchronization

    // `lk`'s dtor called here, releasing the lock on `mut`
  }

  // Operations that don't need synchronization
}

Another term used to describe RAII is scope-bound resource management. Some feel more accurately describes the state of affairs, as the emphasis is not on the acquisition of the resource, nor is it on the cleanup.

Rather, it is the pairing of both acquiring and releasing the resource to constructors and destructors that describes the pattern. These objects are then tied to some stack frame by being stack-allocated. Thus, the management of the resource becomes scope-bound.

3 Memory ownership using RAII

The most common use of the RAII technique is to implement memory ownership.

Memory ownership is a pattern of organising heap allocations to ensure that they are not leaked or doubly freed, both of which are generally undesirable.

In this lecture, we focus on the most basic type of ownership, where we define the owner of a heap allocation to be the unique “thing” in charge of freeing the memory. For example, std::unique_ptr and all of the STL containers use this form of ownership.

However, note that there are more nuanced takes on memory ownership, such as allowing the memory allocator itself to own the memory (arena allocation), shared ownership (std::shared_ptr), garbage collection (e.g. in Java), and so on. The point is that we just don’t want to do bad things to our poor memory allocators.

3.1 Memory ownership

Godbolt link for this section

We define the owner of a heap allocation to be the unique “thing” in charge of freeing the memory.

We start with a more C-style interpretation of ownership, where ownership is purely documentation based and there are no tools to help us.

Firstly, whenever we create a heap allocation in a function, the function becomes the owner of the memory. It becomes the function’s responsibility to free it before the function returns.

void example1() {
  int* x = new int;
  // If following memory ownership patterns, `main` is the owner of `*x`.

  // So we gotta free it before we finish
  delete x;
}
Snippet 18: The function example1 owns the allocation *x

We can transfer ownership, in which case the original owner no longer owns the heap allocation.

// Somewhere in the documentation...
//   Prints the value of *x.
//   Ownership of *x is transferred to this function.
void print_and_destroy_int(int* x) {
  std::cout << "x = " << *x << '\n';
  delete x;
}

void example2() {
  int* x = new int;
  // If following memory ownership patterns, `main` is the owner of `*x`.

  // Ownership of *x is transferred to `print_and_destroy_int`, as
  // documented.
  print_and_destroy_int(x);

  // We *must not* free it, because we don't own `*x`!
  // delete x;  // will explode
}
Snippet 19: The function example2 transfers ownership of *x to print_and_destroy_int

Since this relies entirely on the programmer to write the delete calls at the right places, it is extremely error prone, which is a big reason why C programs are so full of heap memory related bugs.

A more C++-like interpretation of memory ownership is to avoid letting raw functions have ownership, and instead use objects.

In the following example, we write a (buggy) class that owns an int heap allocation like before.

struct OwnedIntBuggy {
  int* x;

  // The heap allocations here are owned by the `OwnedIntBuggy` object.
  OwnedIntBuggy() : x{new int{0}} {}
  OwnedIntBuggy(int x) : x{new int{x}} {}

  // Since `OwnedIntBuggy` objects own `*x`, we must ensure that `*x` is
  // freed before the objects end. One good place to guarantee that is to
  // simply have the destructor free `*x`.
  ~OwnedIntBuggy() {
    delete x;
  }
};

void example3() {
  // `ox` owns `*(ox.x)`.
  OwnedIntBuggy ox;

  // When `ox` goes out of scope, its destructor is called, which frees
  // `*(ox.x)` automagically.
}
Snippet 20: The object ox owns the heap allocation *(ox.x)

That’s great! Unfortunately, it’s still very easy to misuse this class. For example, we might want to make a (deep) copy of the int, but forget how OwnedIntBuggy works, and do the following:

void example4() {
  // `ox` owns `*(ox.x)`.
  OwnedIntBuggy ox;

  OwnedIntBuggy oy = ox;  // copy ox
  // Unfortunately, this is a "shallow" copy.
  // By default, the copy constructor copies the contents member-wise,
  // but since pointers simply copy by copying the address,
  // we now have `oy.x == ox.x`.

  // Now who owns the heap allocation `*(ox.x)`?
  // Both `ox` and `oy` "think" they own it.
  // We are now in a situation where we've broken the fundamental
  // invariant of ownership, which is that exactly one "thing" owns any
  // particular allocation.

  // When `oy` goes out of scope, its destructor is called, which frees
  // `*(oy.x)` (the same as `*(ox.x)`). When `ox` goes out of scope, its
  // destructor is called, which tries to free `*(ox.x)`, and this
  // explodes.
}
Snippet 21: Incorrectly implementing a class can break ownership invariants with innocent-looking code

In order to fix this, we have 2 options.

  1. Yeet the copy constructor
  2. Implement a “correct” copy constructor

Whatever copy constructor we choose to implement, we need to make sure that when the user simply uses our class in “innocent” ways, it does not blow up.

The ownership model tells us exactly what we need, which is to ensure that at any point in time, every heap allocation has exactly one owner.

If we copy construct an oy with ox, since ox already owns *(ox.x), and since ox is not modifiable, we need to make sure oy does not point to *(ox.x) no matter what.

So the most intuitive behaviour is that oy points to a new heap allocation whose value is the same as *(ox.x).

struct OwnedIntBuggy2 {
  int* x;

  OwnedIntBuggy2() : x{new int{0}} {}
  OwnedIntBuggy2(int x) : x{new int{x}} {}

  // Copy constructor
  OwnedIntBuggy2(const OwnedIntBuggy2& other) : x{new int{*other.x}} {}

  ~OwnedIntBuggy2() {
    delete x;
  }
};

void example5() {
  OwnedIntBuggy2 ox;

  OwnedIntBuggy2 oy = ox;
  // A new heap allocation is created, so `oy` owns `*(oy.x)`,
  // and `*(oy.x)` is a different allocation from `*(ox.x)`.
}
Snippet 22: Implementing a custom copy constructor to fix the earlier example

There is still one more gotcha, which is the copy assignment operator.

void example6() {
  // `ox` owns `*(ox.x)`.
  OwnedIntBuggy2 ox;

  // `oy` owns `*(oy.x)`.
  OwnedIntBuggy2 oy;

  oy = ox;
  // The members of ox are copy-assigned to the members of oy.
  // So now no one owns (what was previously called) `*(oy.x)`, and it is
  // leaked, and furthermore, two objects now own `*(ox.x)`, so the
  // program will explode when they go out of scope.
}
Snippet 23: Still broken as copy assignment operator is still on the default behaviour

We can fix that by also implementing the copy assignment operator in a similar way to the copy constructor.

struct OwnedIntBuggy3 {
  int* x;

  OwnedIntBuggy3() : x{new int{0}} {}
  OwnedIntBuggy3(int x) : x{new int{x}} {}

  // Copy constructor
  OwnedIntBuggy3(const OwnedIntBuggy3& other) : x{new int{*other.x}} {}
  // Copy assignment operator
  OwnedIntBuggy3& operator=(const OwnedIntBuggy3& other) {
    delete x;
    x = new int{*other.x};

    return *this;
  }

  ~OwnedIntBuggy3() {
    delete x;
  }
};

void example7() {
  OwnedIntBuggy3 ox;
  OwnedIntBuggy3 oy;

  oy = ox;
  // We made sure to free `*(oy.x)` before reassigning `oy.x`,  so we
  // don't leak memory. Furthermore, `*(oy.x)` is a new heap allocation,
  // so it's different from `*(ox.x)`.
}
Snippet 24: Also implementing a custom copy assignment operator to fix the earlier example

Why did I call the class OwnedIntBuggy3? Where’s the last bug? Beware of self-assign!

void example8() {
  // `ox` owns `*(ox.x)`.
  OwnedIntBuggy3 ox;

  // clang actually warns on obvious cases of self assign since this is
  // such a common bug!
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wself-assign-overloaded"
  ox = ox;
#pragma clang diagnostic pop
  // This will free `*(ox.x)` first, and then try to read from the
  // now-dangling pointer when creating the new heap allocation, so we
  // have a use after free bug.
}
Snippet 25: The last bug is not handling self-assign

In this case, the fix is simple, since we can simply check that we are not self-assigning.

struct OwnedIntBad {
  int* x;

  OwnedIntBad() : x{new int{0}} {}
  OwnedIntBad(int x) : x{new int{x}} {}

  // Copy constructor
  OwnedIntBad(const OwnedIntBad& other) : x{new int{*other.x}} {}
  // Copy assignment operator
  OwnedIntBad& operator=(const OwnedIntBad& other) {
    if (this == &other) return *this;

    delete x;
    x = new int{*other.x};

    return *this;
  }

  ~OwnedIntBad() {
    delete x;
  }
};

void example9() {
  OwnedIntBad ox;

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wself-assign-overloaded"
  // Now, this does nothing
  ox = ox;
#pragma clang diagnostic pop
}
Snippet 26: Fixing self-assignment
Aside: Memory ownership in Rust

Rust has a stricter definition of ownership. Rather than being a mere pattern, it is baked into the language semantics and checked at compile time.

With Rust style memory ownership, the (exclusive) owner or borrower of an object is the only thing allowed to mutate the object. In order to read from an object, one must either obtain ownership (move), or borrow it temporarily (borrows).

A borrow may either be exclusive (aka mutable borrow), in which case the borrower is temporarily allowed to mutate the object, or shared (aka immutable borrow), in which case the borrower may not mutate the object. In either case, the original owner no longer has exclusive access to the object, so it may not mutate it.

Furthermore, when an object is being borrowed, it cannot be destroyed. Attempting to destroy it (e.g. by letting it go out of scope) would be a compile error, and this is the source of many “borrow checker” frustrations. One must first either make sure that all borrows have finished, or keep the object around for longer than the owner is alive.

3.2 Rule of 0/3

You may have noticed a pattern with the last two code examples. We set out to write a class that made use of RAII in some way, i.e. it used a user defined destructor to implement some functionality or pattern.

In both cases, we needed to also implement the copy constructor and the copy assignment operator.

This is known as the rule of three:

If a class requires a user-defined destructor, a user-defined copy constructor, or a user-defined copy assignment operator, it almost certainly requires all three.

cppreference

But we also write plenty of classes that don’t require any custom behaviour in the destructor, copy constructor or copy assignment operator. When this is the case, we shouldn’t write them, as this introduces unnecessary complexity and makes classes more fragile when things change (e.g. member variables or the types of member variables).

This is known as the rule of zero, and we’ll show an example using this later.

3.3 const-correctness

While OwnedIntBad is no longer incorrect in the sense that it now properly implements memory ownership semantics with RAII, it still isn’t great when we only have a const reference to it.

void example10() {
  OwnedIntBad ox{10};
  const OwnedIntBad& cr_ox = ox;

  std::cout << "*(ox.x) = " << *(ox.x) << '\n';
  // *(ox.x) = 10
  *(cr_ox.x) = 5;
  std::cout << "*(ox.x) = " << *(ox.x) << '\n';
  // *(ox.x) = 5
}
Snippet 27: We can use a const reference to an OwnedInt to mutate the underlying int!

If we want to protect access to *x, we can make x private, and only expose *x if we have a non-const reference.

class OwnedIntWorse {
  int* x;

public:
  OwnedIntWorse() : x{new int{0}} {}
  OwnedIntWorse(int x) : x{new int{x}} {}

  OwnedIntWorse(const OwnedIntWorse& other) : x{new int{*other.x}} {}
  OwnedIntWorse& operator=(const OwnedIntWorse& other) {
    if (this == &other) return *this;

    delete x;
    x = new int{*other.x};

    return *this;
  }

  ~OwnedIntWorse() {
    delete x;
  }

  int& get() {
    return *x;
  }
};

void example11() {
  OwnedIntWorse ox{10};

  // We can mutate it since we have a non-`const` ref
  std::cout << "ox.get() = " << ox.get() << '\n';
  // *(ox.x) = 10
  ox.get() = 5;
  std::cout << "ox.get() = " << ox.get() << '\n';
  // *(ox.x) = 5

  // But a const ref now can't even read *x!
  [[maybe_unused]] const OwnedIntWorse& cr_ox = ox;
  // compile error:
  // std::cout << "cr_ox.get() = " << cr_ox.get() << '\n';
}
Snippet 28: Protecting the heap allocation and exposing it for non-const references

So this solution is not ideal, as we’ve broken the const version of OwnedInt. Marking get const would put us back to where we began, since it would allow const references to mutate the inner int, which we might consider unintuitive.

The proper solution is to have two overloads of get, one used when this is const, and the other when this is not const.

For our const overload, we instead return a const int& so that it may be used to read the inner value, but not to modify it.

class OwnedInt {
  int* x;

public:
  OwnedInt() : x{new int{0}} {}
  OwnedInt(int x) : x{new int{x}} {}

  OwnedInt(const OwnedInt& other) : x{new int{*other.x}} {}
  OwnedInt& operator=(const OwnedInt& other) {
    if (this == &other) return *this;

    delete x;
    x = new int{*other.x};

    return *this;
  }

  ~OwnedInt() {
    delete x;
  }

  int& get() {
    return *x;
  }

  const int& get() const {
    return *x;
  }
};

void example12() {
  OwnedInt ox{10};

  // We can mutate it since we have a non-`const` ref
  std::cout << "*(ox.x) = " << ox.get() << '\n';
  // *(ox.x) = 10
  ox.get() = 5;
  std::cout << "*(ox.x) = " << ox.get() << '\n';
  // *(ox.x) = 5

  // A const ref can still read it
  const OwnedInt& cr_ox = ox;
  std::cout << "*(cr_ox.x) = " << cr_ox.get() << '\n';
  // *(cr_ox.x) = 5

  // But cannot modify it, as intended!
  // compile error:
  // cr_ox.get() = 42;
}
Snippet 29: Having multiple overloads of get() so we expose different APIs to const and non-const users

3.4 Applying this to SimpleString

Godbolt link for this section

OwnedInt was a rather contrived example, but a more practical demonstration of this concept would be SimpleString. Unlike ints, (sufficiently long) strings must be stored on the heap as their length is arbitrary, and may depend on runtime values.

As SimpleString will need to use heap allocations, let’s describe the ownership model. A SimpleString will contain and own a char array that stores the actual contents of a null terminated string, and it will also contain the size of the string.

Unlike std::string, we won’t implement short string optimisation or amortise heap allocations by over-allocating with capacity. Instead, we will have every string on a heap allocation with exactly the size needed to contain the string. If the length of the string changes at any point, we will reallocate.

With that out of the way, we can now implement the ownership semantics with a custom destructor, copy constructor, and copy assignment operator.

class SimpleString {
  size_t m_size;
  char* m_buf;

public:
  // Create empty string
  SimpleString() : m_size{0}, m_buf{new char[1]{'\0'}} {}
  // Create string from string literal
  SimpleString(const char* str)
      : m_size{strlen(str)}, m_buf{new char[m_size + 1]} {
    memcpy(m_buf, str, m_size);
    m_buf[m_size] = '\0';
  }

  // Rule of three
  SimpleString(const SimpleString& other)
      : m_size{other.m_size}, m_buf{new char[other.m_size + 1]} {
    memcpy(m_buf, other.m_buf, m_size);
    m_buf[m_size] = '\0';
  }

  SimpleString& operator=(const SimpleString& other) {
    if (this == &other) {
      return *this;
    }

    delete[] m_buf;

    m_size = other.m_size;
    m_buf = new char[m_size + 1];
    memcpy(m_buf, other.m_buf, m_size);
    m_buf[m_size] = '\0';

    return *this;
  }

  ~SimpleString() {
    delete[] m_buf;
  }

  // Basic getters
  size_t size() const {
    return m_size;
  }

  const char* c_str() const {
    return m_buf;
  }

  // ...
};
Snippet 30: Implementing ownership pattern with rule of three

Now to implement the accessors, we will use the subscript operator operator[], and following advice from the const-correctness section, let’s also ensure that const strings can read the string buffer but not modify it.

class SimpleString {
  size_t m_size;
  char* m_buf;

public:
  // ...

  // Accessors
  char& operator[](size_t index) {
    return m_buf[index];
  }
  const char& operator[](size_t index) const {
    return m_buf[index];
  }

  // ...
};
Snippet 31: Implementing operator[]

With this base, we can now consider adding more functionality, such as operator+=, and know that the ownership pattern will protect us from funny business involving heap allocations.

class SimpleString {
  size_t m_size;
  char* m_buf;

public:
  // ...

  // Modifiers
  SimpleString& operator+=(const SimpleString& other) {
    size_t new_size = m_size + other.m_size;
    char* new_buf = new char[new_size + 1];
    memcpy(new_buf, m_buf, m_size);
    memcpy(new_buf + m_size, other.m_buf, other.m_size);
    new_buf[new_size] = '\0';

    delete[] m_buf;
    m_size = new_size;
    m_buf = new_buf;

    return *this;
  }

  SimpleString& operator+=(const char* other) {
    size_t other_size = strlen(other);
    size_t new_size = m_size + other_size;
    char* new_buf = new char[new_size + 1];
    memcpy(new_buf, m_buf, m_size);
    memcpy(new_buf + m_size, other, other_size);
    new_buf[new_size] = '\0';

    delete[] m_buf;
    m_size = new_size;
    m_buf = new_buf;

    return *this;
  }

  // Extend std::ostream operator<<
  friend std::ostream& operator<<(std::ostream& os,
                                  const SimpleString& str) {
    // Inferior implementation since we're making iostream calculate the
    // size when we already know it:
    // return os << str.m_buf;

    // This is better:
    return os.write(str.m_buf, static_cast<std::streamsize>(str.m_size));
  }

  // Comparison operators
  friend auto operator<=>(const SimpleString& lhs,
                          const SimpleString& rhs) {
    size_t smaller_size = std::min(lhs.m_size, rhs.m_size);
    if (auto cmp = memcmp(lhs.m_buf, rhs.m_buf, smaller_size); cmp == 0) {
      return lhs.m_size <=> rhs.m_size;
    } else if (cmp < 0) {
      return std::strong_ordering::less;
    } else {
      return std::strong_ordering::greater;
    }
  }

  // When defining a custom operator<=>, always also define operator==!
  friend auto operator==(const SimpleString& lhs,
                         const SimpleString& rhs) {
    return lhs.m_size == rhs.m_size &&
           memcmp(lhs.m_buf, rhs.m_buf, lhs.m_size) == 0;
  }
};
Snippet 32: “Fearfully” implementing other features (but less scary than without an ownership model)
fearless

3.5 Some philosophy regarding const vs non-const APIs

We’ve introduced two ideas so far, memory ownership and const-correctness.

These two ideas are orthogonal. Just as how you might want some classes to use a more nuanced definition of memory ownership (e.g. std::shared_ptr), you might also want some classes to not be const-“correct” in the way we’ve implied here (e.g. pointer types in general, like std::unique_ptr).

However, just as how we want all classes to at least have a policy regarding memory ownership (who cleans up the heap allocations?), the same applies to const-ness, and you should always be aware of the fact that every class in C++ ships with not one, but (at least) TWO APIs, the non-const and const APIs.

In the case of OwnedInt, the APIs are really quite simple.

OwnedInt x;

// non-const API:
x = x;    // calls OwnedInt&::operator=(const OwnedInt&)
x.get();  // calls int& OwnedInt::get()

const OwnedInt& cr_x;

// const API:
// x = x;  // not allowed
x.get();   // calls int& OwnedInt::get() const

Notice how the APIs are not the same! Different functions are invoked when we call .get(), and we can only assign to a non-const OwnedInt.

Similarly, in the case of SimpleString, we have:

SimpleString x;

// non-const API:
x = x;           // SimpleString& SimpleString::operator=(const SimpleString&)
x.size();        // size_t& SimpleString::size() const
x.c_str();       // const char* SimpleString::c_str() const
x[0];            // char& operator[](size_t index)
x += x;          // SimpleString& SimpleString::operator+=(const SimpleString&)
x += "hi";       // SimpleString& SimpleString::operator+=(const char*)
std::cout << x;  // std::ostream& operator<<(std::ostream&, const SimpleString&)
x <=> x;         // std::strong_ordering operator<=>(const SimpleString&, const SimpleString&)
x == x;          // bool operator==(const SimpleString&, const SimpleString&)

const SimpleString& cr_x;

// const API:
// cr_x = cr_x;     // not allowed
cr_x.size();        // size_t& SimpleString::size() const
cr_x.c_str();       // const char* SimpleString::c_str() const
cr_x[0];            // const char& operator[](size_t index)
// cr_x += cr_x;    // not allowed
// cr_x += "hi";    // not allowed
std::cout << cr_x;  // std::ostream& operator<<(std::ostream&, const SimpleString&)
cr_x <=> cr_x;      // std::strong_ordering operator<=>(const SimpleString&, const SimpleString&)
cr_x == cr_x;       // bool operator==(const SimpleString&, const SimpleString&)

Here, most functions are the same (.size(), .c_str(), operator<=>, operator==, operator<<) but some are not allowed for const (operator=, operator+=) and some have subtly different behaviour between the two (operator[]).

So when designing your APIs, you want to make sure that the const and non-const APIs mean the same thing, and also that you are aware of what your class’s semantics for each API is.

3.6 Applying rule of zero

One thing that we’ve left out is how rule of zero shows up in practice. It’s quite simple, really. Use rule of zero when the default behaviour for copy and destruct is desired. That is, copy and destruct simply need to invoke the corresponding operations member-wise.

For example, we might want to create a class that contains information about a Person:

// Rule of zero
struct Person {
  SimpleString name;
  int age;

  // Defaulted operator<=> uses memberwise lexicographic <=>
  friend auto operator<=>(const Person&, const Person&) = default;
  // When operator<=> is defaulted, operator== is implicitly defaulted.
  // So the compiler will autogenerate a memberwise operator== as well.

  bool is_old() const {
    return age >= 17;
  }
};
Snippet 33: Using rule of zero for simple composite types

That’s all we have to do! No need for custom destructors.

We don’t need a custom destructor because all the types we are using follow the ownership pattern, so there is no additional cleanup effort required on our part. The automatically generated destructor which simply calls the destructor on each member will make sure that the char array containing the name will be freed appropriately.

We also don’t need a custom copy constructor or copy assignment operator because the default member-wise copy behaviour is exactly what we want.

© 20 June 2022, Georgie Lee, Tan Chee Kun Thomas, All Rights Reserved

^

dummy