设想一个函数,我们在其中动态分配一个对象:
void someFunction()
{
Resource* ptr = new Resource(); // Resource 为结构体或类
// 在这里使用 ptr
delete ptr;
}
尽管上述代码看似简单,却极易忘记释放 ptr。即便你在函数末尾记得 delete ptr
,若函数提前返回,ptr 仍可能未被释放。这种情况可能由早期返回引发:
#include <iostream>
void someFunction()
{
Resource* ptr = new Resource();
int x;
std::cout << "Enter an integer: ";
std::cin >> x;
if (x == 0)
return; // 函数提前返回,ptr 不会被释放!
// 在这里使用 ptr
delete ptr;
}
也可能由抛出的异常导致:
#include <iostream>
void someFunction()
{
Resource* ptr = new Resource();
int x;
std::cout << "Enter an integer: ";
std::cin >> x;
if (x == 0)
throw 0; // 函数因异常退出,ptr 不会被释放!
// 在这里使用 ptr
delete ptr;
}
在上述两段程序中,提前返回或抛出的异常致使变量 ptr 未被释放,结果所分配的内存泄漏(每次调用并提前返回时都会再次泄漏)。
问题的根源在于:指针变量本身不具备自我清理的机制。
智能指针类:RAII 的实践
类的一大优势在于其析构函数会在对象离开作用域时自动执行。因此,若你在构造函数中分配(或获取)内存,可在析构函数中释放,从而确保类对象销毁时内存必然被释放(无论因作用域结束、显式删除等)。这正是我们在“析构函数”中提到的 RAII(资源获取即初始化)范式的核心。
我们能否利用类来管理和清理裸指针?答案是肯定的!
设想一个类,其唯一职责是持有并“拥有”传入的指针,并在类对象离开作用域时释放该指针。只要该类的对象以局部变量形式创建,就能保证其作用域结束时被销毁,进而确保其持有的指针被释放。
以下是一个初版设计:
#include <iostream>
template <typename T>
class Auto_ptr1
{
T* m_ptr {};
public:
// 构造函数接收并“拥有”指针
Auto_ptr1(T* ptr = nullptr)
: m_ptr(ptr)
{
}
// 析构函数确保释放
~Auto_ptr1()
{
delete m_ptr;
}
// 重载 * 和 ->,使 Auto_ptr1 可像 m_ptr 一样使用
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
};
// 示例类,验证上述实现
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main()
{
Auto_ptr1<Resource> res(new Resource()); // 注意此处分配内存
// … 无需显式 delete
// 注意使用 <Resource> 而非 <Resource*>,因为 m_ptr 定义为 T*(而非 T)
return 0;
} // res 在此离开作用域,自动销毁已分配 Resource
程序输出:
Resource acquired
Resource destroyed
观察程序与类的行为:首先动态创建 Resource,并以之为参数构造模板类 Auto_ptr1。此后,Auto_ptr1 变量 res 拥有该 Resource 对象(Auto_ptr1 与 m_ptr 构成组合关系)。由于 res 是局部变量,块结束时销毁,触发 Auto_ptr1 析构函数,从而确保释放其持有的指针!
只要 Auto_ptr1 以局部变量(自动生存期,故类名带“Auto”)方式定义,就能保证块结束时 Resource 被销毁,无论函数如何终止(即使提前返回)。
此类称为智能指针。智能指针是一种组合类,用于管理动态分配的内存,并确保智能指针对象离开作用域时释放该内存。(与之对应,裸指针常被称为“哑指针”,因无法自我清理。)
让我们回到前述 someFunction()
示例,展示智能指针如何解决难题:
#include <iostream>
template <typename T>
class Auto_ptr1
{
T* m_ptr {};
public:
Auto_ptr1(T* ptr = nullptr)
: m_ptr(ptr)
{
}
~Auto_ptr1()
{
delete m_ptr;
}
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
};
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
void sayHi() { std::cout << "Hi!\n"; }
};
void someFunction()
{
Auto_ptr1<Resource> ptr(new Resource()); // ptr 现在拥有 Resource
int x;
std::cout << "Enter an integer: ";
std::cin >> x;
if (x == 0)
return; // 函数提前返回
// 使用 ptr
ptr->sayHi();
}
int main()
{
someFunction();
return 0;
}
若用户输入非零整数,程序输出:
Resource acquired
Hi!
Resource destroyed
若用户输入零,程序提前终止,输出:
Resource acquired
Resource destroyed
注意即使用户输入零导致函数提前返回,Resource 仍被正确释放。
由于 ptr 是局部变量,函数结束时 ptr 被销毁(无论以何种方式终止)。Auto_ptr1 析构函数确保释放 Resource,从而杜绝内存泄漏。
std::auto_ptr
的致命缺陷
Auto_ptr1 类存在一个由编译器自动生成代码引发的致命缺陷。在继续阅读前,请尝试找出问题所在。
(提示:回顾若未显式提供,哪些成员函数会由编译器自动生成。)
(等待思考音乐)
时间到。
与其直接告诉你,不如用示例展示:
#include <iostream>
// 同上
template <typename T>
class Auto_ptr1
{
T* m_ptr {};
public:
Auto_ptr1(T* ptr = nullptr)
: m_ptr(ptr)
{
}
~Auto_ptr1()
{
delete m_ptr;
}
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
};
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main()
{
Auto_ptr1<Resource> res1(new Resource());
Auto_ptr1<Resource> res2(res1); // 或先不初始化 res2,再写 res2 = res1;
return 0;
} // res1 和 res2 在此离开作用域
程序输出:
Resource acquired
Resource destroyed
Resource destroyed
程序极可能崩溃。发现问题了吗?由于未提供拷贝构造函数与赋值运算符,C++ 会生成默认版本,执行浅拷贝。因此,用 res1 初始化 res2 后,两 Auto_ptr1 指向同一 Resource。res2 先离开作用域,释放资源,res1 成为悬空指针。随后 res1 再次 delete,导致未定义行为(通常是崩溃)!
以下函数亦会引发同样问题:
void passByValue(Auto_ptr1<Resource> res)
{
}
int main()
{
Auto_ptr1<Resource> res1(new Resource());
passByValue(res1);
return 0;
}
res1 按值传递给形参 res,二者 m_ptr 指向同一地址。函数结束时形参 res 被销毁,res1.m_ptr 悬空,后续 delete 触发未定义行为。
显然,这不可接受。如何解决?
一种做法是显式定义并删除拷贝构造函数与赋值运算符,从而禁止拷贝。这样可避免按值传递问题(本身也不应如此传参)。
但如何返回 Auto_ptr1?
??? generateResource()
{
Resource* r{ new Resource() };
return Auto_ptr1(r);
}
不能引用返回,因为局部 Auto_ptr1 会在函数结束时销毁,调用方得到悬空引用。返回裸指针 Resource* 又可能忘记 delete,违背使用智能指针的初衷。唯一合理的按值返回,却又导致浅拷贝、重复指针与崩溃。
另一种方案是重载拷贝构造与赋值运算符,实现深拷贝。这样可避免重复指针,但拷贝代价高昂(有时甚至不可行),且我们不希望为返回 Auto_ptr1 而盲目拷贝对象。此外,裸指针赋值不会复制所指对象,智能指针同样不应如此。
移动语义的引入
若拷贝构造与赋值运算符不再复制指针(“拷贝语义”),而是转移/移动指针所有权(“移动语义”),如何?这正是移动语义的核心思想。移动语义意味着类将转移对象所有权,而非复制。
让我们更新 Auto_ptr1 为 Auto_ptr2,演示实现:
#include <iostream>
template <typename T>
class Auto_ptr2
{
T* m_ptr {};
public:
Auto_ptr2(T* ptr = nullptr)
: m_ptr(ptr)
{
}
~Auto_ptr2()
{
delete m_ptr;
}
// 实现移动语义的拷贝构造函数
Auto_ptr2(Auto_ptr2& a) // 注意:非 const
{
// 此处无需 delete m_ptr,仅用于新建对象,m_ptr 尚未指向任何资源
m_ptr = a.m_ptr; // 将源裸指针转移给本对象
a.m_ptr = nullptr; // 确保源不再拥有该指针
}
// 实现移动语义的赋值运算符
Auto_ptr2& operator=(Auto_ptr2& a) // 注意:非 const
{
if (&a == this)
return *this;
delete m_ptr; // 先释放目标对象已持有的指针
m_ptr = a.m_ptr; // 转移源裸指针
a.m_ptr = nullptr; // 源放弃所有权
return *this;
}
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main()
{
Auto_ptr2<Resource> res1(new Resource());
Auto_ptr2<Resource> res2; // 初始为空
std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");
res2 = res1; // 所有权转移,res1 置空
std::cout << "Ownership transferred\n";
std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");
return 0;
}
程序输出:
Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed
注意重载的 operator= 实现了 m_ptr 从 res1 到 res2 的所有权转移!因此不会出现重复指针,且资源得到妥善清理。
友情提示:delete nullptr
的安全性
delete nullptr;
是安全的,什么也不做。
std::auto_ptr
及其历史缺陷
现在适宜谈谈 std::auto_ptr
。std::auto_ptr
在 C++98 引入,C++17 移除,是 C++ 首次尝试标准化智能指针,同样采用与 Auto_ptr2 类似的移动语义实现。
然而,std::auto_ptr
(以及我们的 Auto_ptr2)存在若干致命缺陷,使其使用危险:
- 由于
std::auto_ptr
通过拷贝构造函数与赋值运算符实现移动语义,按值传递std::auto_ptr
会导致资源被移至函数形参(函数结束时形参被销毁)。随后若再从调用方访问原auto_ptr
,将解引用空指针,导致崩溃! std::auto_ptr
始终使用非数组形式的delete
释放资源,因此无法正确管理动态分配的数组;更糟糕的是,它不会阻止你传入动态数组,从而错误释放,造成内存泄漏。auto_ptr
与标准库中大多数容器和算法相处不睦,因为这些组件默认“拷贝”应产生副本,而非移动。
基于以上缺陷,std::auto_ptr
在 C++11 被弃用,C++17 正式移除。
展望:C++11 后的智能指针
std::auto_ptr
的设计核心问题在于:C++11 之前,语言机制无法区分“拷贝语义”与“移动语义”。用拷贝语义实现移动语义会导致诡异边界案例与隐蔽 Bug。例如,写下 res1 = res2
却无法预知 res2
是否被修改!
因此,C++11 正式定义“移动”概念,引入语言级移动语义,以正确区分拷贝与移动。本章余下内容将深入探讨移动语义,并使用其改进 Auto_ptr2。
C++11 中,std::auto_ptr
已被多种“感知移动”的智能指针取代:std::unique_ptr
、std::weak_ptr
与 std::shared_ptr
。我们亦将探讨其中最常用的两种:unique_ptr
(auto_ptr
的直接替代)与 shared_ptr
。