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. 说明类设计成员变量
构造函数
析构函数
复制构造函数
拷贝赋值运算符 它处理对象之间的赋值。例如,array3 = 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)) 确保对象不会尝试将自身赋值给自己,这可能导致未定义行为。 其他方法
复杂度分析时间复杂度构造函数 (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,资源获取即初始化)是一种技术,它确保资源在对象构造期间被获取,并在对象超出作用域时自动释放,通常使用智能指针来管理内存。
程序让我们举一个使用智能指针说明 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 转移所有权,并在对象被赋值给另一个对象时,在不泄漏内存的情况下保持资源同步。 复杂度分析时间复杂度
空间复杂度
下一个主题C++ 中的乔列斯基分解 |
我们请求您订阅我们的新闻通讯以获取最新更新。