C++ Rule of Zero: The Evolution from Rule of Three to Rule of Zero

Resource Management and Ownership

In C++, the destructor of an object with automatic storage duration is called when its scope ends. This feature is often used to implement the RAII (Resource Acquisition Is Initialization) pattern to automatically handle resource cleanup.

The core of RAII is the concept of resource ownership: an object responsible for cleaning up resources in its destructor owns that resource. An appropriate ownership strategy is key to avoiding resource leaks.

Value Semantics and Ownership Conflict

In C++, when we pass objects by value, these objects are copied. Value semantics are crucial for RAII because references do not affect the lifecycle of the referenced object. However, the default value semantics conflict with the concept of ownership:

  • Pass by value: operates on copies of parameters
  • Pass by reference: operates on the same object without copying

Rule of Three

What happens when an object that owns resources is copied? By default, member-wise copying occurs, leading to two objects owning the same resource, which can ultimately result in catastrophic consequences of double deletion.

C++ allows defining a class’s value semantics through user-defined copy constructors and copy assignment operators. To achieve consistent and correct behavior, one must pay close attention to how these user-defined functions work together, which is the Rule of Three:

If a class defines a destructor, copy constructor, or copy assignment operator, it must explicitly define all three and not use the default implementations.

Ownership Strategy Selection

To resolve the conflict between value semantics and ownership, several approaches can be adopted:

1. Prohibit Copying

  • Assign exclusive ownership of the resource to a single object, disallowing ownership transfer
  • Limitation: prevents passing or returning resources by value
  • Example: <span>boost::scoped_ptr</span>

2. Deep Copy

  • Exclusive ownership, but simulates “normal” value semantics for the resource
  • Applicable to polymorphic classes to avoid slicing issues
  • Example: POSIX file descriptors (using <span>dup</span> to copy)

3. Shared Ownership

  • Shares ownership between two objects
  • Implementation: reference counting
  • Example: <span>std::shared_ptr</span>

4. Transfer Ownership

  • Exclusive ownership, allowing ownership transfer
  • Issue: implement move semantics through copying special members
  • Example: <span>std::auto_ptr</span> (deprecated)

Rule of Five

With the introduction of move semantics in C++11, there are more options for ownership strategies:

Improvements of Each Strategy

  • Option 1: Allow ownership transfer by adding move constructors and move assignment operators
  • Option 2: Have move special members to avoid unnecessary copying
  • Option 3: Provide ownership transfer capability without updating reference counts
  • Option 4: Replaced by safer <span>std::unique_ptr</span>

The Rule of Five extends the Rule of Three:

If a class requires a custom destructor, copy constructor, copy assignment operator, move constructor, or move assignment operator, then all five special member functions should be explicitly defined.

Rule of Zero

The Rule of Three and Rule of Five are crucial for writing well-behaved classes, but being overly fixated on these rules may cause one to miss the forest for the trees.

Core Idea

C++ allows encapsulating ownership strategies into generic reusable classes. In most cases, the need for ownership can be satisfied by classes that “encapsulate ownership”.

The standard library provides common ownership encapsulation classes:

  • <span>std::unique_ptr</span>: exclusive ownership
  • <span>std::shared_ptr</span>: shared ownership

By using custom deleters, these classes can manage almost any type of resource.

Practical Comparison

Traditional Approach (Following the Rule of Five)

#include<windows.h>
#include<string>

class module {
public:
    explicit module(std::wstring const & name)
        : handle { ::LoadLibrary(name.c_str()) } {}

    // Move constructor
    module(module&& that) noexcept
        : handle { that.handle } {
        that.handle = nullptr;
    }

    // Move assignment operator
    module& operator=(module&& that) noexcept {
        if (this != &that) {
            if (handle) ::FreeLibrary(handle);
            handle = that.handle;
            that.handle = nullptr;
        }
        return *this;
    }

    // Destructor
    ~module() {
        if (handle) ::FreeLibrary(handle);
    }

    // Disable copy
    module(const module&) = delete;
    module& operator=(const module&) = delete;

    // Other module-related functions
    HMODULE get() const { return handle; }

private:
    HMODULE handle;
};

Rule of Zero Approach

#include<windows.h>
#include<memory>
#include<string>

class module {
public:
    explicit module(std::wstring const & name)
        : handle { ::LoadLibrary(name.c_str()), &::FreeLibrary } {}

    // Other module-related functions
    HMODULE get() const { return handle.get(); }

private:
    using module_handle = std::unique_ptr;
    module_handle handle;
};

Advantages of Rule of Zero

  1. No Duplication of Ownership Logic
  2. Separation of Concerns: Do not conflate ownership with other issues
  3. Simpler Code: Reduced likelihood of errors
  4. Better Maintainability: No need to review/modify ownership code when class changes
  5. Exception Safety: Automatically gains exception safety guarantees

Exception Safety Example

Traditional Approach (Risk of Resource Leak)

struct something {
    something()
        : resource_one { new int(42) }
        , resource_two { new int(100) } // What if an exception is thrown here?
    {}

    ~something() {
        delete resource_one;
        delete resource_two;
    }

    int* resource_one;
    int* resource_two;
};

Rule of Zero Approach (Automatic Exception Safety)

#include<memory>

struct something {
    something()
        : resource_one { std::make_unique<int>(42) }
        , resource_two { std::make_unique<int>(100) } // Exception safe
    {}

    // No need for a custom destructor!
    std::unique_ptr<int> resource_one;
    std::unique_ptr<int> resource_two;
};

Conclusion

The Rule of Zero is a concrete embodiment of the Single Responsibility Principle in C++ resource management:

Classes that have custom destructors, copy/move constructors, or copy/move assignment operators should exclusively handle ownership issues. Other classes should not have custom destructors, copy/move constructors, or copy/move assignment operators.

Core Recommendations

  1. Prefer Standard Library Smart Pointers: <span>std::unique_ptr</span>, <span>std::shared_ptr</span>
  2. Encapsulate Ownership Logic: Isolate resource management into dedicated classes
  3. Keep Business Classes Simple: Let business classes focus on their core responsibilities
  4. Leverage Compiler-Generated Functions: Rely on default special member functions whenever possible

By following the Rule of Zero, we can write safer, simpler, and more maintainable C++ code.

References

[1] https://flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html[2] C++ Core Guidelines: R.11: Avoid calling new and delete explicitly[3] Effective Modern C++ by Scott Meyers

Leave a Comment