什么是指针?

指针是一个对象,它的值是某个对象或函数的地址。

Pointers in C Language

如上图所示,指针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 表示空指针,而不是使用 0NULL

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;
}

在上面的例子中,p1p2 共享这个整型对象的所有权,也就意味着它们都可以访问这个对象。

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;

firstsecond 离开作用域时,它们各自仍然被对方的 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)中释放资源。

当智能指针被创建时,它就拥有资源;当它被销毁时,资源会被自动释放。

智能指针具有以下几个优点:

它们可以减少内存泄漏。可以减少手动使用 newdelete 操作。并且有助于安全地处理异常,因为即使作用域退出,内存也会被释放。

智能指针 所有权模型 能否复制 是否增加引用计数 典型用途
std::unique_ptr 独占所有权 不能复制,只能移动 默认首选,表达单一所有者
std::shared_ptr 共享所有权 可以复制 多个对象确实需要共同拥有同一资源
std::weak_ptr 弱引用,不拥有对象 可以复制 观察 shared_ptr 管理的对象,避免循环所有权

智能指针是现代 C++ 的一项重要特性。在大多数情况下,应当首选 std::unique_ptr,因为它简单、高效,且能清晰地表达所有权。仅当程序中的多个部分需要共享同一个对象时,才应使用 std::shared_ptr。当某个对象需要引用另一个对象,但又不希望延长该被引用对象的生命周期时,std::weak_ptr 便显得尤为有用。