自定义拷贝构造、拷贝赋值运算符和析构函数

下面是一个典型的拥有动态资源的类,它必须自定义拷贝构造、拷贝赋值运算符和析构函数。代码中附有详细注释,后面会讲解为什么不能使用默认版本,并总结这类函数的一般格式。

cpp

#include <cstring>   // std::strlen, std::strcpy
#include <iostream>

// 只引入需要的名字,避免命名空间污染
using std::strlen;
using std::strcpy;
using std::cout;
using std::endl;

class MyString {
private:
char* m_data; // 指向堆上分配的字符串

public:
// ---------- 普通构造函数 ----------
// 从 C 风格字符串创建 MyString 对象
MyString(const char* str = "") {
if (str == nullptr) str = "";
// 分配足够容纳字符串 + 结尾 '\0' 的内存
m_data = new char[strlen(str) + 1];
// 将传入的字符串内容复制到自己的内存中
strcpy(m_data, str);
}

// ---------- 拷贝构造函数(深拷贝) ----------
// 从另一个 MyString 对象创建一个新的独立副本
MyString(const MyString& other) {
// 为新对象分配独立的内存
m_data = new char[strlen(other.m_data) + 1];
// 将 other 的字符串内容复制过来
strcpy(m_data, other.m_data);
// 注意:这里没有直接赋值指针,而是复制了数据,因此两个对象互相独立
}

// ---------- 拷贝赋值运算符(深拷贝) ----------
// 将一个已经存在的 MyString 对象赋值给另一个已经存在的 MyString 对象
MyString& operator=(const MyString& other) {
// 1. 检查自赋值:如果自己赋值给自己,直接返回,避免释放自身资源
if (this != &other) {
// 2. 释放当前对象原有的内存资源
delete[] m_data;

// 3. 像拷贝构造函数一样分配新内存并复制数据
m_data = new char[strlen(other.m_data) + 1];
strcpy(m_data, other.m_data);
}
// 4. 返回当前对象的引用,以支持链式赋值(如 a = b = c)
return *this;
}

// ---------- 析构函数 ----------
// 对象生命周期结束时自动调用,用于释放分配的资源
~MyString() {
delete[] m_data; // 释放在构造函数中分配的内存
}

// 辅助方法:获取 C 风格字符串(便于测试输出)
const char* c_str() const {
return m_data;
}
};

// 简单测试
int main() {
MyString s1("Hello");
MyString s2 = s1; // 调用拷贝构造函数
MyString s3("World");
s3 = s1; // 调用拷贝赋值运算符

cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
cout << s3.c_str() << endl;
return 0;
}

这里是程序的运行输出:

text

Hello
Hello
Hello

输出解释

  • s1 的内容是 "Hello",直接输出 Hello
  • s2 通过拷贝构造函数从 s1 创建,拥有独立的字符串数据,值也是 "Hello",因此输出 Hello
  • s3 最初是 "World",但随后通过拷贝赋值运算符 s3 = s1; 被赋予 s1 的内容,旧的 "World" 内存被正确释放,新的独立字符串 "Hello" 被复制过来,所以最终也输出 Hello

因此三行输出都是 Hello,证明了深拷贝的正确性:三个对象各自拥有独立的内存,但内容都是从 s1 复制过来的。

为什么要自定义,而不能使用默认的?

如果完全不写拷贝构造函数、拷贝赋值运算符和析构函数,编译器会自动生成默认版本。默认版本的行为是逐成员拷贝(浅拷贝)

  • 默认拷贝构造函数会将 other.m_data(一个指针)直接赋值给新对象的 m_data
  • 默认拷贝赋值运算符同样直接复制指针。
  • 默认析构函数什么也不做(对于 char* 这种基本类型成员,不会调用 delete)。

在我们的 MyString 类中,这会带来两个严重问题:

  1. 重复释放(double free)
    两个 MyString 对象内部的 m_data 会指向同一块堆内存。当两个对象先后析构时,第一个对象的析构函数释放了这块内存,第二个对象析构时又会再次尝试释放同一块内存,导致未定义行为(通常程序崩溃)。
  2. 数据互相干扰
    由于两个对象共享同一份字符串数据,通过其中一个对象修改内容(如果有修改接口的话)会影响到另一个对象,这违背了“值语义”的独立性。

因此,只要类中拥有指向动态分配资源的指针(或文件句柄、网络连接等),就必须显式实现“三法则”
如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任意一个,那么它几乎一定需要自定义另外两个。


一般格式总结

1. 拷贝构造函数

cpp

ClassName(const ClassName& other)
    : /* 按需初始化成员变量 */ 
{
    // 执行“深拷贝”:
    // 为指针成员分配新内存,并将 other 对应内存的内容复制过来。
}
  • 参数必须是 const 引用,避免无意修改源对象且避免无限递归。
  • 任务是创建并初始化一个独立的新对象

2. 拷贝赋值运算符

cpp

ClassName& operator=(const ClassName& other) {
    if (this != &other) {          // 1. 检查自赋值
        // 2. 释放当前对象持有的旧资源
        // 3. 深拷贝 other 的资源到当前对象
    }
    return *this;                  // 4. 返回自身引用
}
  • 自赋值检查是必需的,否则 delete 之后 other 的数据可能已经无效。
  • 返回 *this 的引用是为了支持链式赋值(a = b = c)。
  • 更现代、更安全的写法是 copy-and-swap 惯用法,它能同时解决自赋值和异常安全问题,但经典形式对理解原理更重要。

3. 析构函数

cpp

~ClassName() {
    // 释放对象持有的所有动态资源(delete 指针、关闭文件等)
}
  • 永远不会被显式调用,对象离开作用域或 delete 时自动执行。
  • 职责是清理构造函数或其它成员函数申请的资源,确保无泄漏。

掌握并正确实现这三个函数,是 C++ 中安全地管理动态资源的基础。


MyString(const char* str = "")

括号里写 = "" 是 C++ 的默认参数(default argument)语法。它表达的意思是:

如果调用构造函数时没有提供任何实参,那么形参 str 就默认取值为 ""(即一个空字符串的指针)

为什么这样写?

  1. 支持无参构造(默认构造)
    有了默认参数,我们就可以像这样创建一个“空的 MyString”对象:cppMyString s; // 等价于 MyString s(“”);如果没有 = "",而只写 MyString(const char* str),那么创建对象时就必须显式传入一个字符串:cppMyString s; // 编译错误:缺少参数 MyString s(“Hello”); // 正确可见,= "" 让构造函数兼具了默认构造函数的功能。
  2. 让内部逻辑统一、安全
    构造函数内部第一条就是:cppif (str == nullptr) str = “”;这是防御性处理:即使调用者不小心传入了 nullptr,也会被纠正为安全的空字符串指针。
    现在,当完全不传参数时,形参 str 直接取默认值 ""(一个合法的非空指针),连 nullptr 都不可能出现。这样,后续的 strlen(str) 和 strcpy 都能安全执行。
  3. 为什么默认值选 "" 而不是 nullptr
    如果写成 = nullptr,那么无参构造时内部仍要依赖 if (str == nullptr) 来修正,这虽然也能工作,但不如直接给 "" 来得干净:
    • 语义上,“空的 MyString” 就应该对应空字符串,而不是空指针。
    • 避免了额外的 nullptr 检查依赖。

总结

= "" 就是让这个单参数构造函数同时能充当默认构造函数,使 MyString s; 合法,并自然地创建一个内容为空的字符串对象,同时保持内部实现的简洁与安全。

分享您的喜爱
WebsiteExpert
WebsiteExpert
文章: 12