简体   繁体   中英

Is the Rule of 5 (for constructors and destructors) outdated?

The rule of 5 states that if a class has a user-declared destructor, copy constructor, copy assignment constructor, move constructor, or move assignment constructor, then it must have the other 4.

But today it dawned on me: when do you ever need a user-defined destructor, copy constructor, copy assignment constructor, move constructor, or move assignment constructor?

In my understanding, implicit constructors / destructors work just fine for aggregate data structures. However, classes which manage a resource need user-defined constructors / destructors.

However, can't all resource managing classes be converted into an aggregate data structure using a smart pointer?

Example:

// RAII Class which allocates memory on the heap.
class ResourceManager {
    Resource* resource;
    ResourceManager() {resource = new Resource;}
    // In this class you need all the destructors/ copy ctor/ move ctor etc...
    // I haven't written them as they are trivial to implement
};

vs

class ResourceManager {
    std::unique_ptr<Resource> resource;
};

Now example 2 behaves exactly the same as example 1, but all the implicit constructors work.

Of course, you can't copy ResourceManager , but if you want a different behavior, you can use a different smart pointer.

The point is that you don't need user-defined constructors when smart pointers already have those so implicit constructors work.

The only reason I would see to have user-defined constructors would be when:

  1. you can't use smart pointers in some low-level code (I highly doubt this is ever the case).

  2. you are implementing the smart pointers themselves.

However, in normal code I don't see any reason to use user-defined constructors.

Am I missing something here?

The full name of the rule is the rule of 3/5/0 .

It doesn't say "always provide all five". It says that you have to either provide the three, the five, or none of them.

Indeed, more often than not the smartest move is to not provide any of the five. But you can't do that if you're writing your own container, smart pointer, or a RAII wrapper around some resource.

However, in normal code I don't see any reason to use user-defined constructors.

User provided constructor allows also to maintain some invariant, so orthogonal with rule of 5.

As for example a

struct clampInt
{
    int min;
    int max;
    int value;
};

doesn't ensure that min < max . So encapsulate data might provide this guaranty. aggregate doesn't fit for all cases.

when do you ever need a user-defined destructor, copy constructor, copy assignment constructor, move constructor, or move assignment constructor?

Now about rule of 5/3/0.

Indeed rule of 0 should be preferred.

Available smart-pointers (I include container) are for pointers, collections or Lockables . But resources are not necessary pointers (might be handle hidden in an int , internal hidden static variables ( XXX_Init() / XXX_Close() )), or might requires more advanced treatment (as for database, an auto commit at end of scope or rollback in case of exceptions) so you have to write your own RAII object.

You might also want to write RAII object which doesn't really own resource, as a TimerLogger for example (write elapsed time used by a "scope").

Another moment when you generally have to write destructor is for abstract class, as you need virtual destructor (and possible polymorphic copy is done by a virtual clone ).

The full rule is, as noted, the Rule of 0/3/5; implement 0 of them usually, and if you implement any, implement 3 or 5 of them.

You have to implement the copy/move and destruction operations in a few cases.

  1. Self reference. Sometimes parts of an object refer to other parts of the object. When you copy them, they'll naively refer to the other object you copied from.

  2. Smart pointers. There are reasons to implement more smart pointers.

  3. More generally than smart pointers, resource owning types, like vector s or optional or variant s. All of these are vocabulary types that let their users not care about them.

  4. More general than 1, objects whose identity matters. Objects which have external registration, for example, have to reregister the new copy with the register store, and when destroyed have to deregister themselves.

  5. Cases where you have to be careful or fancy due to concurrency. As an example, if you have a mutex_guarded<T> template and you want them to be copyable, default copy doesn't work as the wrapper has a mutex, and mutexes cannot be copied. In other cases, you might need to guarantee the order of some operations, do compare and sets, or even track or record the "native thread" of the object to detect when it has crossed thread boundaries.

Having good encapsulated concepts that already follow the rule of five ensures indeed that you have to worry less about it. That said if you find yourselves in a situation where you have to write some custom logic, it still holds. Some things that come to mind:

  • Your own smart pointer types
  • Observers that have to unregister
  • Wrappers for C-libraries

Next to that, I find that once you have enough composition, it's no longer clear what the behavior of a class will be. Are assignment operators available? Can we copy construct the class? Therefore enforcing the rule of five, even with = default in it, in combination with -Wdefaulted-function-deleted as error helps in understanding the code.

To look closer at your examples:

// RAII Class which allocates memory on the heap.
class ResourceManager {
    Resource* resource;
    ResourceManager() {resource = new Resource;}
    // In this class you need all the destructors/ copy ctor/ move ctor etc...
    // I haven't written them as they are trivial to implement
};

This code could indeed nicely be converted to:

class ResourceManager {
    std::unique_ptr<Resource> resource;
};

However, now imagine:

class ResourceManager {
    ResourcePool &pool;
    Resource *resource;

    ResourceManager(ResourcePool &pool) : pool{pool}, resource{pool.createResource()} {}
    ~ResourceManager() { pool.destroyResource(resource);
};

Again, this could be done with a unique_ptr if you give it a custom destructor. Though, if your class now stores a lot of resources, are you willing to pay the extra cost in memory?

What if you first need to take a lock before you can return the resource to the pool to be recycled? Will you take this lock only once and return all resources or 1000 times when you return them 1-by-1?

I think your reasoning is correct, having good smart pointer types makes the rule of 5 less relevant. However, as indicated in this answer, there are always cases to be discovered where you'll need it. So calling it out-dated might be a bit too far, it's a bit like knowing how to iterate using for (auto it = v.begin(); it.= v;end(); ++it) instead of for (auto e: v) . You no longer use the first variant, up to the point, you need to call 'erase' where this suddenly does become relevant again.

The rule is often misunderstood because it is often found oversimplified.

The simplified version goes like this: if you need to write at least one of (3/5) special methods then you need to write all of the (3/5).

The actual, useful rule: A class that is responsible with manual ownership of a resource should: deal exclusively with managing the ownership/lifetime of the resource; in order to do this correctly it must implement all 3/5 special members. Else (if your class doesn't have manual ownership of a resource) you must leave all special members implicit or defaulted (rule of zero).

The simplified versions uses this rhetoric: if you find yourself in need to write one of the (3/5) then most likely your class manually manages the ownership of a resource so you need to implement all (3/5).

Example 1: if your class manages the acquisition/release of a system resource then it must implement all 3/5.

Example 2: if your class manages the lifetime of a memory region then it must implement all 3/5.

Example 3: in your destructor you do some logging. The reason you write a destructor is not to manage a resource you own so you don't need to write the other special members.

In conclusion: in user code you should follow the rule of zero: don't manual manage resources. Use RAII wrappers that already implement this for you (like smart pointers, standard containers, std::string , etc.)

However if you find yourself in need to manually manage a resource then write a RAII class that is responsible exclusively with the resource lifetime management. This class should implement all (3/5) special members.

A good read on this: https://en.cppreference.com/w/cpp/language/rule_of_three

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM