构造函数中使用 new 的典型场景与责任边界
在 C++ 中,构造函数承担初始化对象成员的职责。当类需要保存一段长度在运行期才能确定的数据时,开发者常常会在构造函数中使用 new 申请堆内存。例如一个自定义字符串类,需要在构造时根据传入的 C 风格字符串长度分配字符数组,并把内容复制进去。这种写法可以让对象在创建完成后立即拥有独立可用的资源,也符合对象生命周期开始即资源可用的设计目标。

一旦构造函数中出现 new,类的设计复杂度就会明显上升。因为对象不仅拥有普通成员变量,还持有了动态资源的所有权。此时必须回答几个问题:对象销毁时谁来释放内存,对象被复制时如何处理指针,对象被赋值时旧资源如何处理,以及申请内存失败时是否会让对象处于不一致状态。这些问题都直接关系到程序是否稳定。
#include <cstring>
class MyString {
private:
char* data;
int length;
public:
// 构造函数中使用 new 分配字符数组
MyString(const char* str = nullptr) {
if (str == nullptr) {
length = 0;
data = new char[1];
data[0] = ' ';
} else {
length = static_cast<int>(std::strlen(str));
data = new char[length + 1];
std::strcpy(data, str);
}
}
};
上面的示例展示了一个最基础的场景。成员指针 data 指向通过 new 分配的字符数组,length 保存有效字符数量。当传入空指针时,类仍然分配一个字符并写入空字符,这样可以让后续逻辑不必反复判断指针是否为空。这种处理方式虽然简单,却已经把动态资源管理的责任交给了类本身。
因此,构造函数中使用 new 并不可怕,可怕的是只写了申请逻辑,却没有同步设计释放、复制、赋值和异常处理策略。否则,程序可能出现内存泄漏、重复释放、悬空指针等难以排查的问题。
释放、复制与赋值:必须补齐的类成员函数
最基础的要求是析构函数。构造函数中通过 new 或 new[] 获得的内存,必须在对象生命周期结束时释放。对于数组形式分配的资源,要使用 delete[];对于单个对象分配的资源,要使用 delete。两者不能混用,否则会引发未定义行为。忘记释放会导致内存泄漏,而重复释放或错误释放则可能直接导致程序崩溃。
#include <cstring>
class MyString {
private:
char* data;
int length;
public:
MyString(const char* str = nullptr) {
if (str == nullptr) {
length = 0;
data = new char[1];
data[0] = ' ';
} else {
length = static_cast<int>(std::strlen(str));
data = new char[length + 1];
std::strcpy(data, str);
}
}
// 析构函数必须释放构造函数中申请的数组
~MyString() {
delete[] data;
data = nullptr;
}
};
仅有析构函数还不够。如果类没有显式定义拷贝构造函数,编译器会生成默认的逐成员拷贝。对于普通数值成员,这种拷贝没有问题;但对于指针成员,默认拷贝只会复制指针本身的值,不会复制指针指向的内容。结果就是两个对象的指针指向同一块堆内存,当两个对象先后销毁时,同一块内存会被释放两次。
#include <cstring>
class BrokenString {
private:
char* data;
public:
BrokenString(const char* str) {
data = new char[std::strlen(str) + 1];
std::strcpy(data, str);
}
~BrokenString() {
delete[] data;
}
};
void shallow_copy_problem() {
BrokenString first("hello");
BrokenString second = first; // 隐式拷贝构造只复制指针值
}
为了解决这个问题,必须实现深拷贝语义的拷贝构造函数。所谓深拷贝,就是为新对象重新分配一块足够大的内存,然后把原对象的数据复制过去。这样每个对象都拥有独立资源,互不影响。同样的问题也存在于赋值运算符中。默认赋值同样只是复制指针,而且赋值时当前对象可能已经持有旧资源,如果不先释放旧资源,就会造成内存泄漏。
#include <cstring>
class MyString {
private:
char* data;
int length;
public:
MyString(const char* str = nullptr) {
if (str == nullptr) {
length = 0;
data = new char[1];
data[0] = ' ';
} else {
length = static_cast<int>(std::strlen(str));
data = new char[length + 1];
std::strcpy(data, str);
}
}
// 深拷贝构造函数:为新对象分配独立内存
MyString(const MyString& other) {
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
}
~MyString() {
delete[] data;
}
};
自定义赋值运算符时,需要特别注意自赋值场景。如果对象赋值给自己,直接释放当前资源再去读取另一个对象,就会访问已经释放的内存。常见做法是先判断 this 与参数地址是否相同,如果相同则直接返回。随后释放旧资源,再分配新资源并复制内容。虽然这种写法足够经典,但每一步都需要谨慎处理,才能保证逻辑正确。
#include <cstring>
class MyString {
private:
char* data;
int length;
public:
MyString(const char* str = nullptr) {
if (str == nullptr) {
length = 0;
data = new char[1];
data[0] = ' ';
} else {
length = static_cast<int>(std::strlen(str));
data = new char[length + 1];
std::strcpy(data, str);
}
}
MyString(const MyString& other) {
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
}
// 赋值运算符需要处理自赋值、旧资源释放和深拷贝
MyString& operator=(const MyString& other) {
if (this == &other) {
return *this;
}
delete[] data;
length = other.length;
data = new char[length + 1];
std::strcpy(data, other.data);
return *this;
}
~MyString() {
delete[] data;
}
};
从资源管理角度看,析构函数、拷贝构造函数和赋值运算符往往是一组整体。只要类中出现了裸指针和 new,就需要认真考虑这几个函数是否应该显式实现。这也是 C++ 资源管理中最经典的规则之一。
异常安全与更稳妥的资源管理方式
除了复制和释放,异常也是构造函数中使用 new 时必须考虑的问题。new 在无法获得足够内存时会抛出 std::bad_alloc 异常。如果构造函数只申请一次资源,失败时对象没有构造完成,通常不会调用析构函数;如果构造函数中连续申请多个资源,前面的资源已经申请成功,后面的资源申请失败,就可能造成已申请资源无法释放。因此,需要在构造函数内部对可能失败的操作进行异常处理,并在重新抛出异常前清理已经获得的资源。
class ResourceHolder {
private:
int* res1;
int* res2;
public:
ResourceHolder() : res1(nullptr), res2(nullptr) {
res1 = new int(10);
try {
res2 = new int(20);
} catch (...) {
delete res1;
res1 = nullptr;
throw;
}
}
~ResourceHolder() {
delete res1;
delete res2;
}
};
这种写法的核心目标,是让构造函数在失败路径上也能保持资源安全。即使第二个 new 抛出异常,第一个 new 分配的资源也会被释放,不会遗留在程序中。对于更复杂的类,还可以先初始化指针为空,再逐步申请资源,或者使用局部智能指针临时接管资源,等全部初始化成功后再转移到成员变量中。
如果希望进一步降低手写资源管理的复杂度,可以使用智能指针。std::unique_ptr 可以表达独占资源,离开作用域时自动释放所管理的内存;std::shared_ptr 则适合共享所有权的场景。对于数组资源,可以使用 std::unique_ptr<char[]>,它会在销毁时自动调用数组删除器。这样就不必再手写析构函数,也能避免许多由于复制和赋值带来的问题。
#include <memory>
#include <cstring>
class SafeString {
private:
std::unique_ptr<char[]> data;
int length;
public:
SafeString(const char* str = nullptr) {
if (str == nullptr) {
length = 0;
data.reset(new char[1]);
data[0] = ' ';
} else {
length = static_cast<int>(std::strlen(str));
data.reset(new char[length + 1]);
std::strcpy(data.get(), str);
}
}
// unique_ptr 会在对象销毁时自动释放数组资源
};
当然,智能指针并不是万能替代品。使用 std::unique_ptr 后,类默认不再支持复制;如果确实需要复制,仍然要显式设计拷贝语义。对于字符串、容器这类常见需求,标准库中的 std::string、std::vector 往往比手写动态数组更安全。但在理解底层机制、维护遗留代码或实现特殊数据结构时,掌握构造函数中使用 new 的注意事项仍然非常必要。
- 构造函数中申请的资源,必须在析构函数中释放,并且保证
new与delete、new[]与delete[]成对匹配。 - 包含动态资源的类,应显式实现深拷贝构造函数和赋值运算符,避免默认浅拷贝导致重复释放。
- 赋值运算符要处理自赋值,并先释放旧资源再复制新资源。
- 构造函数中多次申请资源时,要考虑异常路径,确保已经申请的资源不会泄漏。
- 在适合的场景中,优先使用智能指针或标准容器,把资源管理交给 RAII 机制。
总体来看,在构造函数中使用 new 的关键,不是记住某一条语法规则,而是建立完整的资源所有权意识。对象何时获得资源,何时释放资源,复制时是否共享资源,失败时如何回退,这些问题都需要在类设计之初想清楚。只要把这些边界处理完整,动态内存就可以成为可控而强大的工具,而不是程序稳定性的隐患。