跳转至

构造函数

C或者Rust都没有构造函数的概念,而构造函数在C++中又十分重要,值得研究。

构造函数最基本的用途就是用来创建一个新的类的对象,初始化字段,申请资源。这里借用来自Microsoft的例子:

class Box {
public:
    // Default constructor
    Box() {}

    // Initialize a Box with equal dimensions (i.e. a cube)
    explicit Box(int i) : m_width(i), m_length(i), m_height(i) // member init list
    {}

    // Initialize a Box with custom dimensions
    Box(int width, int length, int height)
        : m_width(width), m_length(length), m_height(height)
    {}

    int Volume() { return m_width * m_length * m_height; }

private:
    // Will have value of 0 when default constructor is called.
    // If we didn't zero-init here, default constructor would
    // leave them uninitialized with garbage values.
    int m_width{ 0 };
    int m_length{ 0 };
    int m_height{ 0 };
};

成员初始化列表(Member Initializer List)并不只是为类成员赋初值的语法糖。当使用成员初始化列表初始化类的数据成员时,成员被直接初始化,而不是先默认初始化后再赋值(在上面的例子中就是先初始化成0再赋值)。另外,const 成员和引用成员必须在构造函数体执行前被初始化,因此只能通过成员初始化列表来初始化。

声明类的实例时,编译器会基于重载决策选择要调用的构造函数,如下。对于Rust程序员来说,Box b看着总像是写了一半没初始化的变量,事实上仍然有构造函数被调用了。默认构造函数除非显式声明,否则只有在类没有任何其他构造函数的时候被编译器生成。

int main()
{
    Box b; // Calls Box()

    // Using uniform initialization (preferred):
    Box b2 {5}; // Calls Box(int)
    Box b3 {5, 8, 12}; // Calls Box(int, int, int)

    // Using function-style notation:
    Box b4(2, 4, 6); // Calls Box(int, int, int)
}

复制构造函数

复制构造函数通过从相同类型的对象复制成员值来初始化对象。 如果类成员都是简单类型(如标量值),则编译器生成的复制构造函数已够用,你无需定义自己的函数。如果类需要更复杂的复制语义(如成员是指针,需要深拷贝),仍需要自定义复制构造函数。

复制构造函数可能具有以下签名之一:

Box(Box& other); // Avoid if possible--allows modification of other.
Box(const Box& other);
Box(volatile Box& other);
Box(volatile const Box& other);

// Additional parameters OK if they have default values
Box(Box& other, int i = 42, string label = "Box");

定义复制构造函数时,还应定义复制赋值运算符 (=)。可以通过将复制构造函数定义为已删除来阻止复制对象:

Box (const Box& other) = delete;

移动构造函数

移动构造函数是特殊成员函数,它将现有对象数据的所有权移交给新变量,而不复制原始数据。 它采用 右值引用(就是看着有些奇怪的&&,普通引用(左值引用)是&) 作为其第一个参数,以后的任何参数都必须具有默认值。 移动构造函数在传递大型对象时可以显著提高程序的效率。

Box(Box&& other);

[!NOTE] 移动构造函数 | Microsoft Learn 我在尝试这里给的实例代码的时候,发现移动构造函数并没有被调用,因为编译器使用Return Value Optimization把它优化掉了。可以禁用这个优化以达到预期的效果g++ -fno-elide-constructors test.cpp

从 C++11 中开始,该语言支持两类赋值:复制赋值移动赋值。介绍完了复制构造函数和移动构造函数了,我们必须对他们也进行区分。声明上他们的主要区别是,复制赋值的参数是左值引用,移动赋值的参数是右值引用,如下:

Box &operator=(const Box &other) {
    std::cout << "copy assign" << endl;
    if (this != &other) {
      m_height = other.m_height;
      m_width = other.m_width;
      m_length = other.m_length;
      m_contents = other.m_contents; // 完成复制
    }
    return *this; // 返回当前对象
}

// 移动操作通常应标记为 `noexcept`,因为它们不应抛出异常,这有助于某些容器在需要重新分配时优化性能。
Box &operator=(Box &&other) noexcept {
    std::cout << "move assign" << endl;
    if (this != &other) {
      m_height = other.m_height;
      m_width = other.m_width;
      m_length = other.m_length;
      m_contents = std::move(other.m_contents); // 移动内容
      // 清空源对象的内容
      other.m_height = 0;
      other.m_width = 0;
      other.m_length = 0;
    }
    return *this; // 返回当前对象
}

int main() {
  Box b;          

  Box b3;
  b3 = b;

  Box b4;
  b4 = std::move(b);

  return 0;
}

看到这里,我想你大概猜到了std::move本身没有什么神奇之处,只是将参数的类型转化为了右值引用。所以你要是想要手动完成一下可以将上面的std::move换成static_cast<Box &&>

namespace std {
    template<typename T>
    typename remove_reference<T>::type&& move(T&& arg) noexcept {
        return static_cast<typename remove_reference<T>::type&&>(arg);
    }
}

[!NOTE] 对于熟悉Rust的人来说,可以这样类比:如果一个类没有实现traitCopy,那么赋值就是移动赋值,想要复制应该调用clone,如果实现了Copy就是复制赋值,一般只对基础类型实现Copy

委托构造函数

委托构造函数调用同一类中的其他构造函数,以完成部分初始化工作。 在具有多个全都必须执行类似工作的构造函数时,此功能非常有用。 可以在一个构造函数中编写主逻辑,并从其他构造函数调用它。 在以下简单示例中,Box(int) 将其工作委托给 Box(int,int,int)

class Box {
public:
    // Default constructor
    Box() {}

    // Initialize a Box with equal dimensions (i.e. a cube)
    Box(int i) :  Box(i, i, i)  // delegating constructor
    {}

    // Initialize a Box with custom dimensions
    Box(int width, int length, int height)
        : m_width(width), m_length(length), m_height(height)
    {}
    //... rest of class as before
};

构造函数顺序

如果类有继承,那么就要仔细考虑构造函数的顺序。构造函数按此顺序执行工作:

  1. 调用基类和成员的构造函数
    • 当一个类继承自一个或多个基类时,这些基类的构造函数会在派生类的任何成员被初始化之前被调用。
    • 基类的构造函数按照它们在类定义中的声明顺序被调用,不是在初始化列表中的顺序。
    • 类的成员变量也会按照其在类定义中的声明顺序初始化,而不是初始化列表中的顺序。
    • 如果有虚拟基类,其构造函数会在任何非虚拟基类之前初始化,以确保每个派生类都使用同一个虚拟基类实例。
  2. 初始化虚拟基类指针(如果有的话)
    • 虚拟基类是通过虚继承来实现的,目的是解决多重继承中的菱形问题(即两个派生类继承自同一个基类)。
    • 在构造过程中,如果类有虚拟基类,会设置一个指向虚拟基类的指针。这确保了无论继承结构如何复杂,虚拟基类实例只有一个。
  3. 初始化虚函数指针(如果有的话)
    • 如果类定义了虚函数或继承了虚函数,它的每个实例都会有一个虚函数表(vtable)。vtable 是一个包含指向类的虚函数的指针的数组。
    • 在运行时,对象的虚函数指针(vptr)会被初始化,指向相应的虚函数表。这个过程通常在构造函数执行之前发生,以确保即使在构造函数内部调用虚函数时,也能正确解析到派生类的覆盖实现。
  4. 执行构造函数体中的代码
    • 在所有的基类和成员变量初始化完成后,构造函数体中的代码才开始执行。
    • 这意味着此时所有的成员变量和基类部分都已处于构造好的状态,可以在构造函数体中安全地使用。

析构函数

在对象超出范围或通过调用 deletedelete[] 显式销毁对象时,会自动调用析构函数。如果你未定义析构函数,编译器会提供一个默认的析构函数;对于某些类来说,这就足够了。 当类维护必须显式释放的资源(例如系统资源的句柄,或指向在类的实例被销毁时应释放的内存的指针)时,你需要定义一个自定义的析构函数。

参考链接