什么是指针?
指针是一个对象,它的值是某个对象或函数的地址。
如上图所示,指针ptr里存着的内容是变量num的地址。于是我们说指针ptr指向变量num。
如果我们有一个指针和一个变量,怎么让指针指向这个变量呢?可以使用取地址符 &(address-of operator),这个符号的名字很直白,即取该变量的地址。代码如下:
int num{26};
int* ptr{&num};
std::cout << num << '\n'; // 26
std::cout << &num << '\n'; // num 的地址
std::cout << ptr << '\n'; // ptr 保存的地址,也就是 &num
std::cout << &ptr << '\n'; // ptr 这个指针变量自己的地址
std::cout << *ptr << '\n'; // 26
如果我们想要取出指针指向的地址上的值,也就是 num,我们需要使用解引用符号 *(dereference operator)。
std::cout << *ptr << '\n'; // 26
空指针 nullptr
指针也可以不指向任何对象。现代 C++ 中推荐使用 nullptr 表示空指针,而不是使用 0 或 NULL。
int* ptr {nullptr};
空指针没有指向有效对象,因此不能被解引用。解引用空指针会造成未定义行为(undefined behavior)。
int* ptr {nullptr};
// 错误:ptr 没有指向有效对象
std::cout << *ptr << '\n';
所以在不确定一个指针是否有效时,可以先判断它是否为空:
int* ptr {nullptr};
if (ptr != nullptr) {
std::cout << *ptr << '\n';
}
裸指针是否拥有资源?
裸指针(raw pointer)本身只是一个地址,它并不会告诉我们“谁负责释放这块资源”。也就是说,int* 这个类型既可以表示拥有资源,也可以只是观察某个对象。
比如下面这个指针只是观察 num,它不拥有 num,所以不能对它使用 delete:
int num {26};
int* ptr {&num};
std::cout << *ptr << '\n';
而下面这个指针指向由 new 创建的对象,它负责释放这个对象:
int* ptr {new int {26}};
std::cout << *ptr << '\n';
delete ptr;
这就是裸指针容易出错的原因之一:单看 int* ptr 并不能判断它是否拥有资源。现代 C++ 中通常遵循这样的习惯:
- 如果需要表达“独占拥有”,优先使用
std::unique_ptr。 - 如果需要表达“共享拥有”,再使用
std::shared_ptr。 - 如果只是临时访问或观察一个对象,可以使用裸指针、引用,或者在配合
shared_ptr时使用std::weak_ptr。
资源释放问题
当我们用 new 动态创建对象时,对象位于自由存储区(free store,通常可以简单理解为堆 heap)。这类资源不会随着指针变量离开作用域而自动释放,因此需要使用 delete 手动释放(deallocate):
int* p {new int};
delete p;
如果我们忘记释放,程序运行期间就可能造成内存泄漏(memory leak)。虽然程序结束时操作系统通常会回收进程占用的内存,但这并不能替代正确释放资源:对象的析构函数可能不会被调用,长时间运行的程序也会不断占用更多内存。而当我们不小心对同一块内存 delete 两次时,又会造成未定义行为(undefined behavior)。
悬空指针(dangling pointer)
delete 会释放指针指向的对象,但不会自动修改指针变量本身。也就是说,释放之后,指针里仍然保存着原来的地址,只是这个地址已经不再属于我们了。这样的指针叫做悬空指针。
int* p {new int {10}};
delete p;
// 错误:p 指向的对象已经被释放
std::cout << *p << '\n';
访问悬空指针同样会造成未定义行为。为了降低误用风险,释放之后可以把指针设为 nullptr:
int* p {new int {10}};
delete p;
p = nullptr;
不过更推荐的做法是避免手动管理这类资源,直接使用智能指针,让对象的释放交给 RAII 机制处理。
智能指针(smart pointer)
为了解决这些问题,智能指针应运而生。智能指针会在合适的时机自动释放内存,从而减少上述问题。
智能指针相关类型定义在头文件 <memory> 里,使用之前需要包含它。
std::unique_ptr(独占指针)
std::unique_ptr 独占所指向的对象。意味着同一时间只有一个 unique_ptr 拥有该对象的所有权(ownership)。
#include <iostream>
#include <memory>
int main() {
std::unique_ptr<int> p = std::make_unique<int>(10);
std::cout << *p << '\n';
return 0;
}
unique_ptr 最重要的特性就是无法被复制(copy),以下代码将会报错:
std::unique_ptr<int> p1 = std::make_unique<int>(10);
// Error
std::unique_ptr<int> p2 = p1;
我们说过 unique_ptr 独占该对象,所以不能同时有两个 unique_ptr 拥有同一个对象。
不过所有权可以被转移:
#include <utility>
std::unique_ptr<int> p1 = std::make_unique<int>(10);
std::unique_ptr<int> p2 = std::move(p1);
std::move 会把传入的左值(lvalue)转换成右值引用,让 unique_ptr 的移动构造函数可以接管所有权。移动之后,p2 拥有对象,p1 会变成空指针。
std::shared_ptr(共享指针)
std::shared_ptr 表示共享所有权。这就意味着可以有很多个指针指向并拥有同一对象。
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> p1 = std::make_shared<int>(20);
std::shared_ptr<int> p2 = p1;
std::cout << *p1 << '\n';
std::cout << *p2 << '\n';
return 0;
}
在上面的例子中,p1 和 p2 共享这个整型对象的所有权,也就意味着它们都可以访问这个对象。
shared_ptr 还有一大特性:引用计数(reference count)。这个值会记录当前有多少个 shared_ptr 拥有该对象。
std::cout << p1.use_count() << '\n';
当该值为零时,意味着没有 shared_ptr 再拥有这个对象,对象就会被安全地自动释放。
但是这样引出了一个新的问题:循环所有权(circular ownership)。循环所有权指的是两个对象通过 shared_ptr 彼此拥有对方,导致引用计数永远无法归零:
class Node {
public:
std::shared_ptr<Node> next {};
~Node() {
std::cout << "Node destroyed\n";
}
};
auto first { std::make_shared<Node>() };
auto second { std::make_shared<Node>() };
first->next = second;
second->next = first;
当 first 和 second 离开作用域时,它们各自仍然被对方的 next 成员拥有,所以引用计数不会变成零,析构函数也不会被调用。
为了解决这个问题,std::weak_ptr出现了。
std::weak_ptr(弱指针)
weak_ptr 是一种智能指针,用于观察由 shared_ptr 管理的对象,但它不会增加引用计数,也不会延长对象的生命周期。
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> sp = std::make_shared<int>(30);
std::weak_ptr<int> wp = sp;
std::cout << sp.use_count() << '\n'; // 1
return 0;
}
这里的引用计数是 1 而不是 2。
当我们想通过 weak_ptr 访问对象时,必须先使用 lock() 函数把它临时转换为一个 shared_ptr。
if (std::shared_ptr<int> temp = wp.lock()) {
std::cout << *temp << '\n';
} else {
std::cout << "Object no longer exists\n";
}
如果对象还存在,lock() 会返回一个有效的 shared_ptr;如果对象已经被释放,则会返回一个空的 shared_ptr。
在循环所有权的场景中,通常把“不拥有对方,只是观察对方”的那一边改成 weak_ptr:
class Node {
public:
std::weak_ptr<Node> next {};
};
总结
智能指针是基于 RAII 提出的。
RAII(Resource Acquisition Is Initialization)即“资源获取即初始化”。简单来说,就是把资源的生命周期绑定到对象的生命周期。
它的核心思想是,在对象的构造函数(constructor)中获取资源,并在对象的析构函数(destructor)中释放资源。
当智能指针被创建时,它就拥有资源;当它被销毁时,资源会被自动释放。
智能指针具有以下几个优点:
它们可以减少内存泄漏。可以减少手动使用 new 和 delete 操作。并且有助于安全地处理异常,因为即使作用域退出,内存也会被释放。
| 智能指针 | 所有权模型 | 能否复制 | 是否增加引用计数 | 典型用途 |
|---|---|---|---|---|
std::unique_ptr |
独占所有权 | 不能复制,只能移动 | 否 | 默认首选,表达单一所有者 |
std::shared_ptr |
共享所有权 | 可以复制 | 是 | 多个对象确实需要共同拥有同一资源 |
std::weak_ptr |
弱引用,不拥有对象 | 可以复制 | 否 | 观察 shared_ptr 管理的对象,避免循环所有权 |
智能指针是现代 C++ 的一项重要特性。在大多数情况下,应当首选 std::unique_ptr,因为它简单、高效,且能清晰地表达所有权。仅当程序中的多个部分需要共享同一个对象时,才应使用 std::shared_ptr。当某个对象需要引用另一个对象,但又不希望延长该被引用对象的生命周期时,std::weak_ptr 便显得尤为有用。