下面是一个典型的拥有动态资源的类,它必须自定义拷贝构造、拷贝赋值运算符和析构函数。代码中附有详细注释,后面会讲解为什么不能使用默认版本,并总结这类函数的一般格式。
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 类中,这会带来两个严重问题:
- 重复释放(double free)
两个MyString对象内部的m_data会指向同一块堆内存。当两个对象先后析构时,第一个对象的析构函数释放了这块内存,第二个对象析构时又会再次尝试释放同一块内存,导致未定义行为(通常程序崩溃)。 - 数据互相干扰
由于两个对象共享同一份字符串数据,通过其中一个对象修改内容(如果有修改接口的话)会影响到另一个对象,这违背了“值语义”的独立性。
因此,只要类中拥有指向动态分配资源的指针(或文件句柄、网络连接等),就必须显式实现“三法则”:
如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任意一个,那么它几乎一定需要自定义另外两个。
一般格式总结
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就默认取值为""(即一个空字符串的指针)。
为什么这样写?
- 支持无参构造(默认构造)
有了默认参数,我们就可以像这样创建一个“空的MyString”对象:cppMyString s; // 等价于 MyString s(“”);如果没有= "",而只写MyString(const char* str),那么创建对象时就必须显式传入一个字符串:cppMyString s; // 编译错误:缺少参数 MyString s(“Hello”); // 正确可见,= ""让构造函数兼具了默认构造函数的功能。 - 让内部逻辑统一、安全
构造函数内部第一条就是:cppif (str == nullptr) str = “”;这是防御性处理:即使调用者不小心传入了nullptr,也会被纠正为安全的空字符串指针。
现在,当完全不传参数时,形参str直接取默认值""(一个合法的非空指针),连nullptr都不可能出现。这样,后续的strlen(str)和strcpy都能安全执行。 - 为什么默认值选
""而不是nullptr?
如果写成= nullptr,那么无参构造时内部仍要依赖if (str == nullptr)来修正,这虽然也能工作,但不如直接给""来得干净:- 语义上,“空的 MyString” 就应该对应空字符串,而不是空指针。
- 避免了额外的 nullptr 检查依赖。
总结
= "" 就是让这个单参数构造函数同时能充当默认构造函数,使 MyString s; 合法,并自然地创建一个内容为空的字符串对象,同时保持内部实现的简洁与安全。