6. unique_ptr 详解(上):独占所有权、移动语义、make_unique、自定义删除器

unique_ptr 是 C++11 引入的智能指针中,我个人用得最多的一个。说白了,它就是「独占所有权」的代名词。你想想看,在项目里我们经常遇到这样的场景:一个资源只能被一个对象拥有,不能共享,也不能复制。这时候 unique_ptr 就是最合适的选择。

我记得刚接触 C++11 那会儿,团队里还在大量使用裸指针,手动 new/delete。有一次线上服务莫名其妙地内存泄漏,查了三天才发现是某个异常路径下忘了 delete。从那以后,我就强制团队全面改用 unique_ptr。嗯,效果立竿见影。

独占所有权:一个资源,一个主人

unique_ptr 的核心语义就是「独占」。它不允许拷贝构造,也不允许拷贝赋值。为什么呢?因为一旦拷贝了,两个 unique_ptr 指向同一块内存,那到底谁来负责释放?这就乱套了。

核心原则:unique_ptr 只能移动,不能拷贝。移动意味着所有权转移,源指针会变成空。

来看个简单的例子:

#include <memory>
#include <iostream>

class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; }
    void doWork() { std::cout << "Working...\n"; }
};

int main() {
    std::unique_ptr<Resource> ptr1(new Resource());
    // std::unique_ptr<Resource> ptr2 = ptr1;  // 编译错误!不能拷贝
    
    std::unique_ptr<Resource> ptr2 = std::move(ptr1);  // 所有权转移
    if (!ptr1) {
        std::cout << "ptr1 is now empty\n";
    }
    ptr2->doWork();
    // 离开作用域时,ptr2 自动释放资源
    return 0;
}

输出结果:

Resource acquired
ptr1 is now empty
Working...
Resource released

看到没?ptr1 把所有权交给了 ptr2,自己变成了空指针。资源只会在 ptr2 析构时释放一次。这就是独占所有权的精髓。

注意:移动后的 unique_ptr 处于「空」状态,对它解引用是未定义行为。我见过有人移动后忘了检查,直接崩溃了。所以移动后最好显式判断一下。

移动语义:所有权的高效流转

unique_ptr 的移动语义是它的灵魂。你想想看,如果我们要把一个资源从函数 A 传到函数 B,用裸指针你得小心翼翼地约定「谁负责释放」。用 unique_ptr 就简单了——移动过去,所有权自然跟着走。

我在项目中经常用 unique_ptr 作为函数返回值:

std::unique_ptr<Resource> createResource() {
    return std::unique_ptr<Resource>(new Resource());
}

void useResource(std::unique_ptr<Resource> res) {
    res->doWork();
    // 函数结束,资源自动释放
}

int main() {
    auto res = createResource();  // 从工厂函数获取所有权
    useResource(std::move(res));  // 转移所有权给 useResource
    // 此时 res 为空
    return 0;
}

这里有个细节:createResource 返回 unique_ptr 时,编译器会优先使用移动语义,甚至可能触发 RVO(返回值优化),效率非常高。我曾经在代码审查时看到有人写 return std::move(ptr),其实没必要——直接返回局部 unique_ptr 就行,编译器会自动处理。

小技巧:函数参数接收 unique_ptr 时,建议按值传递,而不是用右值引用。按值传递语义更清晰,调用方必须显式 std::move,一眼就能看出所有权在转移。

make_unique:更安全、更简洁的创建方式

C++14 引入了 std::make_unique,我个人强烈推荐用它来创建 unique_ptr。为什么?

  • 异常安全:避免裸 new 可能导致的资源泄漏
  • 代码简洁:不用写 new 和类型名两次
  • 语义清晰:一看就知道是创建独占资源

举个例子:

// 不推荐
std::unique_ptr<Resource> ptr1(new Resource("config"));

// 推荐
auto ptr2 = std::make_unique<Resource>("config");

你可能会问:「不就是少写几个字吗?」其实不止。考虑这个场景:

void process(std::unique_ptr<Resource> a, std::unique_ptr<Resource> b);

// 危险写法
process(std::unique_ptr<Resource>(new Resource("A")), 
        std::unique_ptr<Resource>(new Resource("B")));

// 安全写法
process(std::make_unique<Resource>("A"), 
        std::make_unique<Resource>("B"));

第一种写法存在潜在的异常安全问题。C++ 对函数参数的求值顺序没有严格规定,如果先 new 了 A,再 new B 时抛出异常,A 的资源就泄漏了。用 make_unique 就不会有这个问题——资源在 unique_ptr 构造完成后才暴露。

最佳实践:除非你需要自定义删除器,否则一律使用 std::make_unique。这是 C++ 核心指南的建议,也是我团队的铁律。

自定义删除器:掌控资源释放的细节

unique_ptr 默认用 delete 释放资源。但现实世界没那么简单——你可能需要关闭文件句柄、释放网络连接、或者调用某个特定的清理函数。这时候就需要自定义删除器了。

我曾经接手过一个遗留系统,里面用 malloc 分配内存,但代码里到处是 free 遗漏的 bug。后来我用 unique_ptr 配合自定义删除器,一次性解决了:

// 自定义删除器:用 free 释放
auto malloc_deleter = [](void* p) {
    std::cout << "Freeing memory\n";
    std::free(p);
};

int main() {
    std::unique_ptr<int, decltype(malloc_deleter)> ptr(
        static_cast<int*>(std::malloc(sizeof(int))),
        malloc_deleter
    );
    *ptr = 42;
    std::cout << *ptr << std::endl;
    // 离开作用域时自动调用 free
    return 0;
}

注意这里删除器的类型是模板参数的一部分。如果你用 lambda,类型是匿名的,所以要用 decltype 推导。当然,你也可以用函数指针或函数对象:

// 函数指针作为删除器
void file_closer(FILE* fp) {
    if (fp) {
        std::cout << "Closing file\n";
        std::fclose(fp);
    }
}

int main() {
    std::unique_ptr<FILE, decltype(&file_closer)> fp(
        std::fopen("test.txt", "w"),
        file_closer
    );
    // 使用文件...
    // 离开作用域时自动关闭
    return 0;
}

注意:自定义删除器会影响 unique_ptr 的大小。无状态的函数对象(如 lambda 捕获为空)可以通过空基类优化(EBO)不增加大小,但函数指针会额外占用一个指针的大小。如果你的删除器有状态(比如捕获了变量),那 unique_ptr 会更大。

知识体系总览

下面这张图总结了 unique_ptr 的核心知识点,方便你快速回顾:

unique_ptr 核心知识体系 独占所有权 • 一个资源只能被一个 unique_ptr 拥有 • 禁止拷贝构造和拷贝赋值 • 析构时自动释放资源 移动语义 • 通过 std::move 转移所有权 • 源指针变为空(nullptr) • 适合作为函数参数和返回值 make_unique • C++14 引入,推荐创建方式 • 异常安全,避免资源泄漏 • 代码简洁,语义清晰 自定义删除器 • 支持 lambda、函数指针、函数对象 • 用于管理非 new 分配的资源 • 注意删除器类型影响 unique_ptr 大小

避坑指南

最后,分享几个我踩过的坑:

  • 不要用 unique_ptr 管理数组:虽然 unique_ptr 支持数组特化(unique_ptr<T[]>),但更推荐用 std::vectorstd::array。除非你明确需要延迟初始化或与 C 接口交互。
  • 小心删除器类型不匹配:如果你用 lambda 做删除器,两个 lambda 即使代码相同,类型也不同。这会导致编译错误。解决办法是用 std::function 包装,或者用函数指针。
  • 不要混用 unique_ptr 和裸指针:一旦把裸指针传给 unique_ptr,就别再用裸指针操作了。我曾经见过有人 ptr.get() 拿到裸指针后自己 delete 了,结果 unique_ptr 析构时 double free。

个人习惯:我一般在类成员变量里用 unique_ptr 表示「这个资源归我管,但我不想暴露拷贝语义」。配合工厂函数和移动语义,代码既安全又灵活。

好了,unique_ptr 的上半部分就讲到这里。记住它的核心:独占所有权、移动语义、make_unique、自定义删除器。这四个点掌握了,你就能在大部分场景下安全地管理独占资源。


公众号:蓝海资料掘金营,微信 deep3321