The Rule of Five in C++

Do this at first: Move Semantics in C++

1. Special Methods: Constructors and Destructor

假设我们有这样一个类,类内只有一个 std::string 类型的成员变量和一个负责打印的成员函数。我们将他们的访问权限都变为 public,这样,我们就可以对类内的成员变量进行赋值操作了。这时,我们对类内成员变量的构造简单而直接。如下:

#include <iostream>
#include <string>

class Name{
public:
	std::string m_name;
	void printName(){
		std::cout << m_name << std::endl;
	}
};

int main(){
	Name alice;
    alice.m_name = "Alice";
    alice.printName();
	return 0;
}

但为了保证类内数据的封装性,我们一般将成员变量的访问权限设置为 private。这个时候我们就不能对这些变量直接进行操作了,现在的规则是:“你只能通过类内一些特殊的函数初始化类内私有的成员变量”。在面向对象程序设计中,我们将这些特殊的函数称为构造函数。同时,我们还有一种特殊的函数叫析构函数,它会在对象的生命周期结束后自动调用用于清理对象(见RAII)。

1.1 Constructor and Destructor

在将成员变量设为私有后,我们需要一些特殊的函数来初始化和清理这些变量。构造函数会在类实例化对象时自动调用,用于初始化成员变量。而析构函数则会在对象的生命周期结束时自动调用,用于清理资源。通常而言,我们加入 const 是为了避免对原对象的修改。

#include <iostream>
#include <string>

class Name {
private:
    std::string m_name{"NO NAME"};
public:
    Name() = default;

    // Constructor that accepts std::string
    Name(const std::string& name) : m_name(name) {}
    void printName() const {
        std::cout << m_name << std::endl;
    }
};

int main() {
    Name alice("Alice");
    Name alice2(alice); // compiler provided for free
    Name alice3 = alice;// compiler provided for free
    alice.printName();
    alice2.printName();
    alice3.printName();
    return 0;
}

上面,我们添加了一个接受 std::string 类型的构造函数。但在下面的例子中,我们发现虽然我们没有显式添加接受 Name 类类型的拷贝构造函数和拷贝赋值运算符,但它们仍然能够正常工作。这是因为编译器为我们自动生成了默认的拷贝构造函数和赋值运算符。

    // Default copy constructor, copy assignment operator provided by compiler.
    Name(const Name& other_name) : m_name(other_name.m_name){}
    Name& operator=(const Name& other_name) : m_name(other_name.m_name){}

这些默认的拷贝构造函数和赋值运算符会逐个成员地复制对象中的每个成员,这种拷贝被称为浅拷贝。在含有成员指针的类中,这种浅拷贝可能会造成资源的二次释放。详见浅拷贝和深拷贝

2. The Rule of Three (prior to C++11)

在 C++11 前,C++ 没有移动语义的概念。因此,那时我们不需要考虑移动构造函数和移动赋值运算符。如果你要实现一个类,那你就应当实现所有的三个重要函数和一个析构函数,即构造函数、析构函数、拷贝构造函数和拷贝赋值运算符。这就是“三法则”(A best practice)。

如果类内资源不涉及内存分配,实际上你可以依赖编译器为你提供的默认的构造函数和赋值运算符重载。简化了类的设计和实现,这种原则叫做 the rule of zero 。

You may be curious about why we always pass an object by reference in C++, even in the copy constructor. You might know this is to avoid copying the object. While it's true that we need to copy the object in the copy constructor, the way we achieve this is different.

We use a reference to avoid creating a new copy of the object on the stack of the current function. By passing a reference (or a pointer) to the existing object, the function can operate directly on the original object without the overhead of making a copy. This method ensures that we're not creating unnecessary copies, which can be expensive in terms of CPU time and memory usage.

If you're still feeling confused, you're welcome to learn more here.

2.1 The Law of the Big Two

3. The Rule of Five (Since C++11)

自 C++11 引入移动语义之后,一个类可以通过移动构造函数和重写移动赋值运算符来构造对象。"The Rule of Five" 或 “五法则” 是 C++11 后定义构造函数的指导原则。五法则规定要实现一个类,你不仅需要实现三法则(即定义析构函数、拷贝构造函数和拷贝赋值运算符),还需要在类中添加移动构造函数和移动赋值运算符,以支持移动语义。

4. The Rule of Zero

Rule of Zero旨在简化类的设计和实现。它的核心思想是:如果一个类不需要自定义的析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数或移动赋值运算符,那么就不应该定义这些函数。相反,应该信任并依赖编译器生成的默认实现。

#include <string>
#include <memory>

class Widget {
private:
    int i{0};
    std::string s{};
    std::unique_ptr<int> pi{nullptr};

public:
    Widget() = default;
    ~Widget() = default;
    Widget(const Widget&) = default;
    Widget& operator=(const Widget&) = default;
    Widget(Widget&&) = default;
    Widget& operator=(Widget&&) = default;
};