C++ 中的三法则

2025年5月14日 | 阅读 15 分钟

在 C++ 编程中,有效地管理资源对于开发健壮且可维护的应用程序至关重要。实际上,涉及管理动态资源(如内存、文件描述符、网络套接字或任何其他系统级句柄)的类的 C++ 程序是典型情况。但是,如果没有适当的注意事项,这些资源可能会导致内存泄漏、悬空指针和未定义行为等错误。这时,“三法则”就作为一种设计指南,确保类能够正确地管理其资源。

三法则 指出,如果一个类需要自定义实现以下三个特殊成员函数中的任何一个,通常都应该实现所有三个:析构函数、拷贝构造函数和拷贝赋值运算符。这三个函数是我们管理带有资源的对象的生命周期所需要的函数。当需要时,如果缺少所有这三个函数的实现,通常会导致 C++ 程序难以追踪错误,非常烦人且耗时。

关键概念

在 C++ 编程领域,“三法则”是一条基本规则,它阐述了类中资源的管理。在这种情况下,当一个类显式处理动态分配的内存、文件描述符或某些系统句柄等资源时,该法则就适用。如果一个类定义了三个特殊成员函数中的任何一个——析构函数、拷贝构造函数和拷贝赋值运算符——那么它必须定义所有三个。生命周期函数(例如,life)处理资源的生命周期,确保资源被正确清理,并避免浅拷贝或内存泄漏。

三法则旨在避免资源管理不当的常见陷阱,以及避免双重删除、意外共享资源和未定义行为。它确保一个类为其实例赋予复制、赋值和销毁的明确语义。

动机

使用三法则的主要原因是资源的安全性与可靠性。首先,C++ 编译器提供的特殊成员函数的默认行为通常对于管理动态资源的 C++ 类来说过于弱。例如:

浅拷贝发生在编译器生成的拷贝构造函数或赋值运算符仅复制指针值,而不是复制底层资源时。这可能导致诸如双重删除等问题,如果两个对象最终同时管理同一资源。

内存泄漏是由于编译器生成的析构函数没有清理动态分配的内存和其他资源而导致的。

通过实现所有三个函数,开发人员可以确保:

  • 他们独立地处理自己的资源。
  • 在不需要时,会正确地清理资源。
  • 对象的副本会产生预期的行为,不会产生副作用。
  • 这就是资源所有权平衡和类行为一致性的实际三法则指导原则。

用例

如果一个类管理着任何外部资源,那么三法则就适用。常见的使用场景包括:

1. 动态内存管理

为了避免内存泄漏和崩溃,使用 new 或类似机制分配内存的类必须确保对分配的内存进行正确清理。这些类应具有明确定义的析构函数、拷贝构造函数和拷贝赋值运算符。

2. 文件处理

为了有效地管理文件操作,一个类应该确保在对象销毁时正确关闭文件句柄。此外,这类对象应该能够被复制或赋值,以便正确地复制文件访问语义。

3. 网络和套接字

不再需要时,处理网络套接字或连接的类不能持有资源,例如打开的套接字或流。但是,它们还必须确保复制的对象不会意外共享其连接句柄。

4. 自定义容器

元素通常存储在用户定义容器的动态内存中,包括实现为动态数组或链表的容器。只有当它绝对确保这些容器的复制、赋值和销毁是安全的时候,三法则才会被正确实现。

5. 低级系统资源

从管理低级资源(如互斥锁、线程句柄或 GPU 内存)的资源类开始,资源清理和复制语义在各个方面都至关重要。

方法 1:手动实现

手动方法意味着您必须显式定义三个基本特殊成员函数,如析构函数、拷贝构造函数和拷贝赋值运算符来正确管理资源。如果一个类实际处理实时动态资源,如原始指针、文件描述符或其他无法由编译器提供的默认值自动处理的系统句柄,那么这种方法也很有用。

程序

让我们举一个例子来说明 C++ 中的三法则。

输出

 
Creating array1 with size 5.
Default constructor: Array of size 5 created.
Setting values in array1.
Printing array1:
0 10 20 30 40 
Using copy constructor to create array2 from array1.
Copy constructor: Array of size 5 copied.
Printing array2:
0 10 20 30 40 
Modifying array2's elements.
Printing array1 (should remain unchanged):
0 10 20 30 40 
Printing array2 (modified values):
0 20 40 60 80 
Using copy assignment operator to assign array1 to array3.
Default constructor: Array of size 0 created.
Copy assignment: Array of size 5 assigned.
Printing array3:
0 10 20 30 40 
Demonstrating self-assignment.
Self-assignment detected. Skipping operation.
Printing array3 after self-assignment:
0 10 20 30 40 
End of main function. All destructors will now be called.
Destructor: Array of size 5 destroyed.
Destructor: Array of size 5 destroyed.
Destructor: Array of size 5 destroyed.   

说明

类设计

成员变量

  • 数据:动态数组,动态分配的内存,其中包含一个数组。
  • 大小:它保存数组中元素的数量。

构造函数

  • 默认构造函数:如果提供了非零大小,它会为数组分配内存。它会将元素初始化为零。例如,考虑创建 DynamicArray array1(5) 的情况,它会创建一个包含 [0, 0, 0, 0, 0] 的 5 个元素的数组。

析构函数

  • 当对象被销毁时,它会释放为数组分配的内存。它避免了内存泄漏。例如,当 array1 超出作用域时,array1 的析构函数会释放内存。

复制构造函数

  • 拷贝构造函数管理对象复制。例如,当执行 DynamicArray array2 = array1 时,它确保为 array2 正确地复制了所有资源。
  • 它为 array2 分配新内存。
  • 它将 array1 的内容放入新内存。
  • 这确保了 array2 中的所有数据都被复制到 array2,与 array1 分开。

拷贝赋值运算符

它处理对象之间的赋值。例如,array3 = array1。

  • 它还会释放 array3 中现有的内存以防止内存泄漏。
  • 复制 array1 的数据并分配新内存。
  • 自我赋值(array3 = array3)检查以避免不必要的操作。

实用函数

设置和获取

它允许在进行边界检查的情况下修改和检索数组元素。

打印

它在调试模式下显示数组的内容。

主函数

  • 它展示了如何创建数组、使用拷贝构造函数复制以及使用拷贝赋值运算符赋值。
  • 它通过更改 array2 并证明 array1 未受影响,进一步突出了深拷贝行为。
  • 它展示了如何处理自我赋值以确保安全。

复杂度分析

时间复杂度

默认构造函数

标准构造函数会为数组分配内存量,并在数组中填充零。但是,我们需要遍历数组并将每个元素设置为零。for 循环迭代 n 次。这里 n 是数组的大小。因此,默认构造函数的时间复杂度为 O(n)。

析构函数

所有析构函数都使用 delete[] 解分配动态分配的内存。析构函数以恒定时间(O(1))运行,因为它只是释放分配的内存块,而无需迭代元素。

复制构造函数

其中一个拷贝构造函数会分配一个新数组,并复制源数组的每个元素。进行复制操作意味着要遍历数组中的所有元素。时间复杂度为 O(n)。

拷贝赋值运算符

这就是为什么拷贝赋值运算符会检查自我赋值(恒定时间),解分配当前数组(恒定时间),分配一个新数组,然后复制元素。我们需要 n 次操作来复制元素,因此拷贝赋值运算符的时间复杂度为 O(n)。

这意味着涉及构造、复制和赋值的操作的总体时间复杂度均为 O(n)。

空间复杂度

默认构造函数

大小为 n 的数组由构造函数分配,用于存储整数。它需要 O(n) 空间。

析构函数

请注意,析构函数除了释放之前的数组外,不占用任何额外空间,因此其空间复杂度为 O(1)。

复制构造函数

对于拷贝构造函数,由于它为数组的副本分配了新内存,因此空间复杂度为 O(n)。

拷贝赋值运算符

拷贝赋值运算符使用新内存分配来存储复制的数组到目标对象中。由此产生的空间复杂度为 O(n)。

基于以上,类操作的空间复杂度为 O(n)。

方法 2:使用 std::vector 或其他 STL 容器

当使用 std::vector 等标准库容器时,在以下情况下(例如,、 或 )通常不需要三法则,因为这些容器通常会处理内部的资源管理。

  • 析构函数:当容器超出作用域时,容器会自动释放其所有内存。
  • 拷贝构造函数:由于容器,它们的元素是深拷贝的,您无需手动操作。
  • 拷贝赋值运算符:元素的复制、赋值和内存由容器自动处理。

如果您的类主要存储数据,并且不需要控制内存分配、复制或清理,那么 STL 容器可以消除非常特殊的内存管理问题。

程序

让我们举一个使用 std::vector 说明 C++ 中三法则的例子。

输出

 
ResourceHandler constructor called: Vector size = 5
Vector contents: 10 10 10 10 10 
Element at index 2 set to 20
Vector contents: 10 10 20 10 10 
ResourceHandler copy constructor called
Vector contents: 10 10 20 10 10 
Element at index 4 set to 30
Vector contents: 10 10 20 10 30 
Vector contents: 10 10 20 10 10 
ResourceHandler constructor called: Vector size = 3
Vector contents: 50 50 50 
ResourceHandler copy assignment operator called
Vector contents: 50 50 50 
Resized vector to new size: 6
Vector contents: 50 50 50 100 100 100 
ResourceHandler destructor called
ResourceHandler destructor called
ResourceHandler destructor called   

说明

构造函数

ResourceHandler 类的构造函数使用大小 (size) 和初始值 (initial_value) 初始化 std::vector<int>。std::resize() 方法用于调整向量大小并用给定值填充。在这种情况下,要分配内存并初始化元素的尺寸是动态确定的。

析构函数

析构函数由 std::vector 自动管理。当 ResourceHandler 对象超出作用域时,std::vector 会在后台调用 delete[] 自动释放分配的内存(例如,内部数组)。这样,类就不需要进行手动内存管理了。

复制构造函数

拷贝构造函数由 std::vector 自动处理。当 ResourceHandler 对象被复制时,vector 会对元素进行深拷贝。这意味着每个新向量(通过复制)都有自己的内存空间,并且不涉及浅拷贝,否则会导致有人拥有所有这些。

拷贝赋值运算符

同样,拷贝赋值运算符也由 std::vector 处理。当涉及到赋值给另一个对象时,vector 会对数据进行深拷贝,并负责正确的资源管理。自我赋值检查 (if (this != &other)) 确保对象不会尝试将自身赋值给自己,这可能导致未定义行为。

其他方法

  • 在 setData() 中,我们可以修改向量中特定索引处的元素。
  • 在 getData() 中,我们只是从向量中检索值。
  • resize() 函数将调整向量大小,从而更改其大小并终止值。

复杂度分析

时间复杂度

构造函数 (ResourceHandler(int, int))

std::vector 的 resize() 函数的时间复杂度为 O(n),因为 n 是 std::vector 的长度。它分配内存并用指定值初始化每个元素,确保数据已准备好使用。

析构函数

std::vector 的析构函数在对象超出作用域时会自动调用,确保资源得到正确清理。其时间复杂度为 O(n),因为这是对 vector 元素动态分配内存的解分配。

复制构造函数

此外,拷贝构造函数是深拷贝,因为它复制了向量中的每个元素。因此,其复杂度为 O(n)。

拷贝赋值运算符

首先检查是否为自我赋值(恒定时间),然后进行向量的深拷贝(O(n))。

空间复杂度

向量存储

它包含 n 个元素,因此空间复杂度为 O(n)。std::vector<int> 存储 n 个元素,其存储空间为 O(n)。

方法 3:使用智能指针(例如 std::unique_ptr 和 std::shared_ptr)

在这种方法中,我们使用标准库中的智能指针来自动处理这些资源,这样我们就不会遇到内存泄漏、悬空指针和双重删除等问题。RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种技术,它确保资源在对象构造期间被获取,并在对象超出作用域时自动释放,通常使用智能指针来管理内存。

  • 析构函数:std::unique_ptr 和 std::shared_ptr 会自动处理内存解分配,确保在对象超出作用域时调用析构函数。
  • 拷贝构造函数:由于 std::unique_ptr 函数提供独占所有权,因此不允许对 std::unique_ptr 进行深拷贝。std::shared_ptr 函数的拷贝构造函数会增加引用计数,从而在资源共享所有权下工作。
  • 拷贝赋值运算符:与 std::unique_ptr 函数一样,不允许对 std::unique_ptr 进行赋值,因为所有权不能转移。但是 std::shared_ptr 允许赋值,并且会相应地更新引用计数。

程序

让我们举一个使用智能指针说明 C++ 中三法则的例子。

输出

 
Constructor called. Value: 10
Value: 10
Move constructor called. Value moved: 10
Value: 10
Constructor called. Value: 20
Value: 20
Move assignment called. Value moved: 10
Value: 10
Destructor called. Memory automatically freed.
Destructor called. Memory automatically freed.
Destructor called. Memory automatically freed.   

说明

拷贝构造函数和拷贝赋值运算符

这将确保我们不复制资源或对其进行赋值。它阻止了双重删除或多人拥有本应只有一个所有者的实体。

移动构造函数

移动构造函数使用 std::move 函数,该函数将 std::unique_ptr 的所有权从一个对象转换到另一个对象,因此可以在不复制底层数据的情况下“移动”资源。

移动赋值运算符

同样,移动赋值运算符使用 std::move 转移所有权,并在对象被赋值给另一个对象时,在不泄漏内存的情况下保持资源同步。

复杂度分析

时间复杂度

  • 构造函数:使用 std::make_unique 函数的构造函数的时间复杂度为 O(1),因为它只分配一个整数的内存并对其进行初始化。
  • 移动构造函数和移动赋值:这两种操作都使用 std::move,它以 O(1) 的时间转移所有权。
  • 析构函数:由于只解分配一个整数,因此析构函数需要 O(1) 的时间来释放动态分配的内存。

空间复杂度

  • 空间复杂度:std::unique_ptr 函数只处理一个整数,因此空间复杂度是固定的。即 O(n)。此方法的时间和空间复杂度均为 O(1)。