Posts C++ RAII Notes
Post
Cancel

C++ RAII Notes

What is a resource?

A resource is anything that requires manual management. For example:

  • Allocated memory
    • malloc/free, new/delete, new[]/delete[]
  • File handlers
    • open/close
  • Mutex locks
  • C++ threads
    • spawn/join

For example, if we have a pinter we made with new, we need to clean up after by calling delete.

Considering a naive implementation of a vector as an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
class NaiveVector {
    int *ptr;
    size_t size;
public:
    NaiveVector(): ptr(nullptr), size(0) {}
    void push_back(int new_value) {
        int *new_ptr = new int[size + 1];
        std::copy(ptr, ptr + size, new_ptr);
        delete [] ptr;
        ptr = new_ptr;
        ptr[size++] = new_value;
    }
};

The vector does not do any geometric resizing, it simply holds a pointer to a heap allocation and keeps track of how big the heap allocation was.

The constructor initializes ptr with a resource. The push_back method replaces the resource managed by ptr, so there is no resource leak.

But, there is still a bug in the NaiveVector implementation. Consider:

1
2
3
4
5
{
    NaiveVector vec;   // ptr initialized with 0 elements
    vec.push_back(1);  // ptr is updated with 1 element
    vec.push_back(2);  // ptr is updated with 2 elements
}

When the scope is left, the vector is destroyed, the compiler does not clean up the heap because the pointer is dropped. Hence, we have a leak.

The Destructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NaiveVector {
    int *ptr;
    size_t size;
public:
    NaiveVector(): ptr(nullptr), size(0) {}
    void push_back(int new_value) {
        int *new_ptr = new int[size + 1];
        std::copy(ptr, ptr + size, new_ptr);
        delete [] ptr;
        ptr = new_ptr;
        size += 1;
    }

    ~NaiveVector(){ delete [] ptr;}
};

The implementation no longer leaks memory on destruction.

However, there are still bugs. Considering:

1
2
3
4
5
6
7
8
{
    NaiveVector v;
    v.push_back(1);
    {
        NaiveVector w = v; // 1
    }
    std::cout << v[0] << "\n";
}

Line 1 invokes the implicitly generated (defaulted) copy constructor of NaiveVector. A defaulted copy constructor simply copies each member.

When we copy the memory address, the new pointer points to the same place as the old pointer. We hit the end of the scope, and invoke the destructor of w. We free the memory allocation of w, meaning that v is now accessing freed memory, this causes undefined behavior.

On top of that, when we delete v, we double delete the memory allocation.

This kind of bug is referred to as a “double free”.

Introducing the copy constructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
class NaiveVector{
    int *ptr;
    size_t size;
public:
    NaiveVector() : ptr(nullptr), size(0) {}
    ~NaiveVector() { delete [] ptr;}

    NaiveVector(const NaiveVector& rhs) {
        ptr = new int[rhs.size];
        size = rhs.size;
        std::copy(rhs.ptr, rhs.ptr + size, ptr);
    }
};

When we make a copy of the NaiveVector we duplicate the resource, avoiding a double free.

However, initialization != assignment:

1
2
3
4
NaiveVector w = v; // calls a copy constructor

NaiveVector w;
w = v; // calls assignment operator

So, considering:

1
2
3
4
5
6
7
8
9
{
    NaiveVector v;
    v.push_back(1);
    {
        NaiveVector w;
        w = v; // 1
    }
    std::cout << v[0] << "\n";
}

Line 1 invokes the implicitly generated (defaulted) operator= of NaiveVector. A defaulted copy assignment operator copy-assigns each member.

So, if we need a destructor, we need a copy constructor and if we need a copy constructor, we need a copy assignment operator.

1
2
3
4
5
6
7
8
9
10
11
12
13
class NaiveVector{
    int *ptr;
    size_t size;
public:
    NaiveVector() : ptr(nullptr), size(0) {}
    ~NaiveVector() { delete [] ptr;}
    NaiveVector(const NaiveVector& rhs){ ... }

    NaiveVector& operator=(const NaiveVector& rhs) {
        NaiveVector copy = rhs;
        copy.swap(*this);
        return *this;
    }

This is called the copy and swap idiom (need to write a swap). Here, the assignment operator is implemented in terms of the copy constructor and swap.

This is called “The Rule of Three”:

  • If a class directly manages some kind of resource, the we must almost certainly need to hand-write three special member functions:
    • A destructor - to free the resource
    • A copy constructor - to copy the resource
    • A copy assignment operator - to free the left-hand resource and copy the right-hand one
  • Use the copy-and-swap idiom to implement the assignment.

Why copy and swap?

Why not:

1
2
3
4
5
6
7
NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
    delete [] ptr;
    ptr = new int[rhs.size];
    size = rhs.size;
    std::copy(rhs.ptr, rhs.ptr + size, ptr);
    return *this;
}

Simply, this is not robust against self-assignment.

So, copy-and-swap:

1
2
3
4
5
NaiveVector& operator=(const NaiveVector& rhs) {
        NaiveVector copy = rhs;
        copy.swap(*this);
        return *this;
}

NOTE: Copy-and-swap will not make use of already acquired resources. For example allocated storage. So in the naive vector implementation, in the copy assignment operator you could check if the target instance already owns enough storage for holding all elements and just copy all elements. Copy-and-swap will always discard all already allocated storage and make a new one! Doing so will only give you the basic exception safety (instead of strong), but thats a tradeoff for performance.

RAII also helps with exception safety, for example:

1
2
3
4
5
6
7
8
9
int main(){
    try{
        int *arr = new int[4];
        throw std::runtime_error("Example with leak");
        delete [] arr; // cleanup is too late
    }catch{
        std::cout << "Caught an exception: " << ex.what() << "\n";
    }
}

This code will leak memory, this is bad code. So, let’s fix it:

1
2
3
4
5
struct FixedPtr {
    int *ptr;
    FixedPtr(int *p) : ptr(p){}
    ~FixedPtr(){ delete [] ptr; }
};

Now, with the same example:

1
2
3
4
5
6
7
8
int main(){
    try{
        int *arr = new int[4];
        throw std::runtime_error("Example with no leak");
    }catch{
        std::cout << "Caught an exception: " << ex.what() << "\n";
    }
}

The destructor is called and no leak happens. NOTE: this is still relatively bad code because FixedPtr has a defaulted copy constructor, however for the purpose of the example, it works. Let’s improve on it:

1
2
3
4
5
6
7
struct FixedPtr{
    int *ptr;
    FixedPtr(int *p) : ptr(p){}
    ~FixedPtr() { delete [] ptr }
    FixedPtr(const FixedPtr&) = delete;
    FixedPtr& operator=(const FixedPtr&) = delete;
}

Now, FixedPtr is non-copyable. When a function body is =delete instead of the normal statement, the compiler rejects calls to that function at compile time.

Similarly, when a member function has the body =default, instead of the normal statement, the compiler will create a defaulted version of that function, just as if it was implicitly generated. Explicitly defaulting special members helps code to be sel-documenting.

For example:

1
2
3
4
5
6
7
class Book{
    // ...
public:
    Book(const Book&) = default;
    Book& operator=(const Book&) = default;
    ~Book() = default;
}

This is like saying “I considered that I need these methods, and decided that the default ones are fine”.

Now, on the other hand, if our class doe not directly manage any resource, but is simply using library components such as vectors and strings, then we should strive to write no special member functions (The rule of zero).

  • Let the compiler implicitly generate a defaulted destructor
  • Let the compiler generate the copy constructor
  • Let the compiler generate the copy assignment operator
  • Note that your own swap may improve performance

Consider the two kinds of well-designed value-semantic C++ classes:

  • Domain business-logic classes do not manually manage any resources and strive to follow the rule of zero. They delegate the job of resource management to data members of types.
  • Resource-management classes - small and single-purpose classes that follow the rule of three. They acquire the resource in each constructor, free the resource in the destructor and use the copy-and-swap idiom in the assignment operator.

Now, considering the C++11 introduction of rvalue reference types (all references up to this point has been lvalue references).

The term “lvalue” and “rvalue” come from the syntax of assignment expressions. An lvalue can appear on the left-hand side of an assignment, an rvalue must appear on the right-hand side.

1
2
3
4
5
6
7
8
9
int x, *p, a[10];

x = 1;
*p = 1;
a[2] = 1;

// these will not compile
&x = 1;
x + 1 = 1;

x, *p and a[2] are lvalues, &x, x + 1 are rvalues.

  • int& is an lvalue reference to an int
  • int&& is an rvalue reference to an int
  • lvalue reference parameters do not bind to rvalues, and rvalue reference parameters do not bind to lvalues
  • Special case for backward compatibility: a const lvalue reference will bind to an rvalue
1
2
3
4
void foo(int&); foo(i); // OK
void foo(int&&); foo(i); // ERROR
void foo(const int&); foo(i); //OK

We can combine this with overload resolution. We can write a function that takes a const string&, which would bind to lvalues. We can also provide a second overload of this function that takes an rvalue reference, which would bind to rvalues.

1
2
3
4
5
6
7
8
void foo(const std::string&); // takes lvalues
void foo(std::string&&); //takes rvalues

std::string s = "Hello";
foo(s); // calls foo1
foo(s + " world!"); // calls foo2
foo("hi"); // calls foo2
foo(std::move(s)); // calls foo2

With this, we can create the move constructor:

1
2
3
4
5
6
7
8
9
10
11
class NaiveVector {
    NaiveVector(const NaiveVector& rhs) {
        ptr = new int[rhs.size];
        size = rhs.size;
        std::copy(rhs.ptr, rhs.ptr + size, ptr);
    }
    NaiveVector(NaiveVector&& rhs) {
        ptr = std::exchange(rhs.ptr, nullptr);
        size = std::exchange(rhs.size, 0);
    }
}

Each STL container type has a move constructor in addition to its copy constructor. Consider the example above, new int is slow, std::copy is slow, the move constructor does not need to do any of these things, hence it’s much faster, due to not caring about what happens to the rvalue. In this case, rhs won’t be missed, we take it’s heap allocation and size and replace them with nullptr and 0 respectively. This is a performance optimization.

So, the existence of this in C++11 leads to the rule of five:

If our class directly manages some kind of resource, we need to hand-write five special member functions for correctness and performance:

  • a destructor to free the resource
  • a copy constructor to copy the resource
  • a move constructor to transfer ownership of the resource
  • a copy assignment operator to free the left-hand resource and copy the right-hand one
  • a move assignment operator to free the left-hand resource and transfer ownership of the right-hand one

All this considered, copy-and-swap may lead to a lot of duplication:

1
2
3
4
5
6
7
8
9
10
11
NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
    NaiveVector copy(rhs);
    copy.swap(*this);
    return *this;
}

NaiveVector& NaiveVector::operator=(NaiveVector&& rhs) {
    NaiveVector copy(std::move(rhs));
    copy.swap(*this);
    return *this;
}

Move is in the same overload set as copy. So, NaiveVector copy is a new local variable initialized with std::move(rhs). It’s still a copy of the original rhs, but the way it’s made is by calling the move constructor of NaiveVector. NaiveVector could even be a move-only type, this would still be okay, even if we can’t copy it, as we are using the move constructor.

So, to avoid this duplication we could just write one assignment operator and leave the copy to the caller. For example:

1
2
3
4
NaiveVector& NaiveVector::operator=(NaiveVector copy) {
    copy.swap(*this);
    return *this;
}

This is relatively uncommon, writing copy assignment and move assignment separately is more frequently seen. In particular, the STL always writes them separately.

So, we could use the rule of four and a half:

If our class directly manages some kind of resource, then we could hand-write four special member functions and a swap function, for correctness and performance:

  • a destructor to free the resource
  • a copy constructor to copy the resource
  • a move constructor to transfer ownership of the resource
  • a by-value assignment operator to free the left-hand resource and transfer ownership of the right-hand one
  • a nonmember swap function, and ideally a member version too

And so we have:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Vec{
    Vec(const Vec& rhs) {
        ptr = new int[rhs.size];
        size = rhs.size;
        std::copy(rhs.ptr , rhs.ptr + size, ptr);
    }
    Vec(Vec&& rhs) noexcept {
        ptr = std::exchange(rhs.ptr, nullptr);
        sizee = std::exchange(rhs.size, 0);
    }
    friend void swap(Vec& a, Vec& b) noexcept {
        a.swap(b);
    }
    ~Vec() {
        delete [] ptr;
    }
    Vec& operator=(Vec copy) noexcept {
        copy.swap(*this);
        return *this;
    }
    void swap(Vec& rhs) noexcept {
        using std::swap;
        swap(ptr, rhs.ptr);
        swap(size, rhs.size);
    }
};

Or, we can use std::vector to make a rule-of-zero vector:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Vec {
    std::vector<int> vec;

    Vec(const Vec& rhs) = default;
    Vec(Vec&& rhs) noexcept = default;
    Vec& operator=(const Vec& rhs) = default;
    Vec& operator=(Vec&& rhs) = default;
    ~Vec() = default;

    void swap(Vec& rhs) noexcept {
        vec.swap(rhs.vec);
    }
    friend void swap(Vec& a, Vec& b) noexcept {
        a.swap(b);
    }
};

Swapping ownership is now only used for performance, and not for correctness, in the rule-of-zero case.

This post is licensed under CC BY 4.0 by the author.

Recent Update

    Contents