构造函数
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");
定义复制构造函数时,还应定义复制赋值运算符 (=)。可以通过将复制构造函数定义为已删除来阻止复制对象:
移动构造函数
移动构造函数是特殊成员函数,它将现有对象数据的所有权移交给新变量,而不复制原始数据。 它采用 右值引用(就是看着有些奇怪的&&
,普通引用(左值引用)是&
) 作为其第一个参数,以后的任何参数都必须具有默认值。 移动构造函数在传递大型对象时可以显著提高程序的效率。
[!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的人来说,可以这样类比:如果一个类没有实现trait
Copy
,那么赋值就是移动赋值,想要复制应该调用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
};
构造函数顺序
如果类有继承,那么就要仔细考虑构造函数的顺序。构造函数按此顺序执行工作:
- 调用基类和成员的构造函数:
- 当一个类继承自一个或多个基类时,这些基类的构造函数会在派生类的任何成员被初始化之前被调用。
- 基类的构造函数按照它们在类定义中的声明顺序被调用,不是在初始化列表中的顺序。
- 类的成员变量也会按照其在类定义中的声明顺序初始化,而不是初始化列表中的顺序。
- 如果有虚拟基类,其构造函数会在任何非虚拟基类之前初始化,以确保每个派生类都使用同一个虚拟基类实例。
- 初始化虚拟基类指针(如果有的话):
- 虚拟基类是通过虚继承来实现的,目的是解决多重继承中的菱形问题(即两个派生类继承自同一个基类)。
- 在构造过程中,如果类有虚拟基类,会设置一个指向虚拟基类的指针。这确保了无论继承结构如何复杂,虚拟基类实例只有一个。
- 初始化虚函数指针(如果有的话):
- 如果类定义了虚函数或继承了虚函数,它的每个实例都会有一个虚函数表(vtable)。vtable 是一个包含指向类的虚函数的指针的数组。
- 在运行时,对象的虚函数指针(vptr)会被初始化,指向相应的虚函数表。这个过程通常在构造函数执行之前发生,以确保即使在构造函数内部调用虚函数时,也能正确解析到派生类的覆盖实现。
- 执行构造函数体中的代码:
- 在所有的基类和成员变量初始化完成后,构造函数体中的代码才开始执行。
- 这意味着此时所有的成员变量和基类部分都已处于构造好的状态,可以在构造函数体中安全地使用。
析构函数
在对象超出范围或通过调用 delete
或 delete[]
显式销毁对象时,会自动调用析构函数。如果你未定义析构函数,编译器会提供一个默认的析构函数;对于某些类来说,这就足够了。 当类维护必须显式释放的资源(例如系统资源的句柄,或指向在类的实例被销毁时应释放的内存的指针)时,你需要定义一个自定义的析构函数。