0%

Rule of Five and Rule of Zero in C++

This is my learning notes from the course - Mastering C++ Standard Library Features.

Rule of Five and Rule of Zero

  • How to make sure that your classes properly expose move operations
  • Rule of three (before C++11)
  • Rule of five and rule of zero (C++11 and later)

Rule of Three

  • Before C++11, the rule of three claims that:
    • If you have a class explicitly defining one of the following:
      • Destructor
      • Copy constructor
      • Copy assignment operator
    • Then, it should probably define all three
  • This prevents mistake and oversights when implementing classes that manage resources

To show an example of the “rule of three” in action, imagine some sort of file_handle pointer type that is a “resource” which can be acquired, released, and shared.

1
2
3
4
struct file_handle;
void acquire(file_handle*);
void release(file_handle*);
file_handle* share(file_handle*);

Imagine that we want to create a RAII wrapper around the C-like file_handle that will automatically deal with resource acquisition/release and with ownership sharing.

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 file
{
public:
// destructor
~file()
{
release(_fh);
}

// copy constructor
file(const file& rhs) : _fh{share(rhs._fh)}, flags{rhs._flags}
{
}

// copy assignment operator
file& operator=(const file& rhs)
{
_fh = share(rhs._fh);
_flags = rhs._flags;
return *this;
}

private:
file_handle* _fh;
int _flags{0};
};

As soon as you have a user-defined destructor, you should probably define copy operations, such as copy constructor and copy assignment operator, as well (following the rule of three).

Explicitly defining copy operations prevents move operations from being automatically generated by the compiler. Later we’ll see how to explicitly define them using the “rule of five“.

Rule of Five

After C++11, the rule of three was extended to the rule of five because of move operations.

  • The rule of five claims that:
    • If you have a class explicitly defining one of the following:
      • Destructor
      • Copy constructor
      • Copy assignment operator
      • Move constructor
      • Move assignment operator
    • Then, it should probably define all five

Using the same example as before, let’s now follow the “rule of five“ to implement move operations.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <utility>

struct file_handle;
void acquire(file_handle*);
void release(file_handle*);
file_handle* share(file_handle*);

class file
{
public:
// destructor
~file()
{
release(_fh);
}

// == copy operations ==

// copy constructor
file(const file& rhs) : _fh{share(rhs._fh)}, flags{rhs._flags}
{
}

// copy assignment operator
file& operator=(const file& rhs)
{
_fh = share(rhs._fh);
_flags = rhs._flags;
return *this;
}

// == move operations ==

// This is a good opportunity to use `std::exchange`

// move constructor
file(file&& rhs) : _fh{std::exchange(rhs._fh, nullptr)},
_flags{rhs._flags}
{
}

// move assignment operator
file& operator=(file&& rhs)
{
_fh = std::exchange(rhs._fh, nullptr);
_flags = rhs._flags;
return *this;
}

private:
file_handle* _fh;
int _flags{0};
};

Rule of Zero

  • The rule of zero encourages you to avoid implementing any copy/move operations for your classes, and to rely on the value semantics of existing types
  • The idea is that custom copy/move/destructor are usually implemented to deal with resource management and ownership - if that’s the case, then the Single Responsibility Principle (SRP) encourages to extract this functionality to a separate class

In the previous code examples, the file class was dealing with multiple tasks: managing the file_handle resource and providing the _flags member variable.

Let’s apply the “rule of zero“ to the file class: the resource management logic can be extract to a separate shared_file_handle class that follows the “rule of five“, while file becomes very simple (following the SRP).

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <utility>

struct file_handle;
void acquire(file_handle*);
void release(file_handle*);
file_handle* share(file_handle*);

class shared_file_handle
{
public:
~shared_file_handle()
{
release(_fh);
}

shared_file_handle(const shared_file_handle& rhs) : _fh{share(rhs._fh)}
{
}

shared_file_handle& operator=(const shared_file_handle& rhs)
{
_fh = share(rhs._fh);
return *this;
}

shared_file_handle(shared_file_handle&& rhs)
: _fh{std::exchange(rhs._fh, nullptr)}
{
}

shared_file_handle& operator=(shared_file_handle&& rhs)
{
_fh = std::exchange(rhs._fh, nullptr);
return *this;
}

private:
file_handle* _fh;
};

class file
{
public:
~file() = default;

file(const file&) = default;
file& operator=(const file&) = default;

file(file&&) = default;
file& operator=(file&&) = default;

private:
shared_file_handle _fh;
int _flags{0};
};

Custom Deleters

References:
https://en.cppreference.com/w/cpp/memory/unique_ptr
https://en.cppreference.com/w/cpp/memory/unique_ptr/unique_ptr
https://en.cppreference.com/w/cpp/memory/shared_ptr/shared_ptr

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
27
28
29
30
31
32
#include <memory>
#include <utility>

struct file_handle;
void acquire(file_handle*);
void release(file_handle*);

class unique_file
{
public:
unique_file(file_handle* fh) : _fh{fh, &release}
{
acquire(fh);
}

private:
std::unique_ptr<file_handle, decltype(&release)> _fh;
int _flags{0};
};

class shared_file
{
public:
shared_file(file_handle* fh) : _fh{fh, &release}
{
acquire(fh);
}

private:
std::shared_ptr<file_handle> _fh;
int _flags{0};
};