智能指针与移动语义简介

设想一个函数,我们在其中动态分配一个对象:

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_ptrstd::auto_ptr 在 C++98 引入,C++17 移除,是 C++ 首次尝试标准化智能指针,同样采用与 Auto_ptr2 类似的移动语义实现。

然而,std::auto_ptr(以及我们的 Auto_ptr2)存在若干致命缺陷,使其使用危险:

  1. 由于 std::auto_ptr 通过拷贝构造函数与赋值运算符实现移动语义,按值传递 std::auto_ptr 会导致资源被移至函数形参(函数结束时形参被销毁)。随后若再从调用方访问原 auto_ptr,将解引用空指针,导致崩溃!
  2. std::auto_ptr 始终使用非数组形式的 delete 释放资源,因此无法正确管理动态分配的数组;更糟糕的是,它不会阻止你传入动态数组,从而错误释放,造成内存泄漏。
  3. 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_ptrstd::weak_ptrstd::shared_ptr。我们亦将探讨其中最常用的两种:unique_ptrauto_ptr 的直接替代)与 shared_ptr

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"