28. 智能指针与 C 库交互:自定义删除器调用 free、unique_ptr 管理 FILE*、跨语言边界

咱们做 C++ 开发的,几乎不可能完全活在纯 C++ 的世界里。你想想看,多少底层库、操作系统 API、遗留系统,都是用 C 写的。我这些年接手过的项目,十有八九都要跟 C 库打交道。这就带来一个很现实的问题:C 库分配的资源,怎么用 C++ 的智能指针来管理?

说白了,智能指针默认的删除器是 deletedelete[]。但 C 库用的是 malloc/free,或者 fopen/fclose。直接套用默认删除器,轻则资源泄漏,重则程序崩溃。今天我们就来彻底解决这个问题。

自定义删除器的本质

智能指针的删除器,其实就是一个可调用对象。它可以是函数、函数对象、lambda 表达式,甚至是 std::function。当智能指针析构时,会调用这个删除器来释放资源。

我个人的习惯是:只要资源不是用 new 分配的,就一定要自定义删除器。这个原则帮我避免过无数次踩坑。

核心要点:删除器的签名必须与资源释放方式匹配。对于 unique_ptr,删除器类型是模板参数的一部分;对于 shared_ptr,删除器类型会被擦除(type erasure)。

用 unique_ptr 管理 malloc 分配的内存

先看一个最常见的场景:C 库函数返回了 malloc 分配的内存。比如某些图像处理库、加密库,它们会返回 char*void*,要求调用者用 free 释放。

#include <memory>
#include <cstdlib>
#include <cstdio>

// 自定义删除器:调用 free
auto malloc_deleter = [](void* ptr) {
    if (ptr) {
        std::printf("自定义删除器:释放 malloc 内存\n");
        std::free(ptr);
    }
};

int main() {
    // 模拟 C 库返回的 malloc 内存
    char* raw = static_cast<char*>(std::malloc(100));
    if (!raw) return -1;

    // 用 unique_ptr 接管管理权
    std::unique_ptr<char, decltype(malloc_deleter)> ptr(raw, malloc_deleter);

    // 正常使用
    std::snprintf(ptr.get(), 100, "Hello from C library");

    // 离开作用域时自动调用 free
    return 0;
}

这里有个细节要注意:unique_ptr 的删除器类型是模板参数的一部分。所以 decltype(malloc_deleter) 必须写出来。如果你觉得麻烦,可以用 std::function 做删除器,但会有额外的性能开销。

我的建议:对于 unique_ptr,尽量用 lambda 或函数指针作为删除器。这样编译器可以内联调用,零开销抽象。

unique_ptr 管理 FILE*:RAII 封装文件操作

文件操作是另一个经典场景。C 标准库的 FILE*fopen 打开,用 fclose 关闭。如果忘记关闭,文件描述符会泄漏。我曾经在一个日志系统里见过,因为异常导致文件没关,最后进程的文件描述符用尽,整个服务挂了。

unique_ptr 管理 FILE*,可以完美解决这个问题:

#include <memory>
#include <cstdio>

// 文件删除器
struct FileDeleter {
    void operator()(std::FILE* fp) const {
        if (fp) {
            std::fclose(fp);
            std::printf("文件已关闭\n");
        }
    }
};

// 类型别名,方便使用
using unique_file = std::unique_ptr<std::FILE, FileDeleter>;

// 安全的文件打开函数
unique_file safe_fopen(const char* filename, const char* mode) {
    std::FILE* fp = std::fopen(filename, mode);
    if (!fp) {
        return nullptr;  // 打开失败返回空
    }
    return unique_file(fp);  // 移交管理权
}

int main() {
    auto file = safe_fopen("test.txt", "w");
    if (!file) {
        std::perror("文件打开失败");
        return -1;
    }

    // 写入数据
    std::fprintf(file.get(), "Hello, RAII!\n");

    // 离开作用域时自动 fclose
    return 0;
}

你想想看,有了这个封装,文件操作就再也不用担心忘记 fclose 了。即使函数中间抛出异常,unique_ptr 的析构函数也会确保文件被关闭。

注意:不要用 shared_ptr 管理 FILE*,除非你真的需要共享所有权。文件通常只有一个所有者,unique_ptr 更合适,而且性能更好。

shared_ptr 的自定义删除器

shared_ptr 也支持自定义删除器,但用法略有不同。删除器类型不会成为模板参数,而是在构造时传入:

#include <memory>
#include <cstdlib>

int main() {
    // 用 shared_ptr 管理 malloc 内存
    int* raw = static_cast<int*>(std::malloc(10 * sizeof(int)));
    if (!raw) return -1;

    // 删除器作为构造参数传入
    std::shared_ptr<int> ptr(raw, [](int* p) {
        std::printf("shared_ptr 删除器:释放内存\n");
        std::free(p);
    });

    // 可以正常使用
    for (int i = 0; i < 10; ++i) {
        ptr.get()[i] = i * i;
    }

    return 0;
}

这里有个重要的区别:shared_ptr 的删除器是类型擦除的。这意味着你可以用不同的删除器创建多个 shared_ptr,它们可以指向同一个控制块。但这也意味着每次删除器调用都会有一次间接函数调用。

跨语言边界的资源管理

跨语言边界,比如 C++ 调用 C 库,或者 C++ 和 C 混合编程,资源管理是最容易出问题的地方。我见过太多因为「谁分配谁释放」规则没遵守而导致的崩溃。

基本原则是:谁分配的资源,谁负责释放。但智能指针可以帮我们自动化这个过程。

来看一个更复杂的例子:假设有一个 C 库提供了这样的接口:

// C 库头文件 (c_library.h)
#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    char* data;
    int   length;
} Buffer;

Buffer* create_buffer(int size);
void    destroy_buffer(Buffer* buf);
int     process_buffer(Buffer* buf);

#ifdef __cplusplus
}
#endif

在 C++ 中,我们可以这样安全地使用它:

#include <memory>
#include <cstdio>

// 假设这些函数来自 C 库
// Buffer* create_buffer(int size);
// void destroy_buffer(Buffer* buf);

// 自定义删除器
struct BufferDeleter {
    void operator()(Buffer* buf) const {
        if (buf) {
            destroy_buffer(buf);  // 调用 C 库的释放函数
            std::printf("C 库缓冲区已释放\n");
        }
    }
};

using unique_buffer = std::unique_ptr<Buffer, BufferDeleter>;

class SafeBuffer {
public:
    SafeBuffer(int size) 
        : buf_(create_buffer(size)) 
    {
        if (!buf_) {
            throw std::runtime_error("创建缓冲区失败");
        }
    }

    // 禁止拷贝,允许移动
    SafeBuffer(const SafeBuffer&) = delete;
    SafeBuffer& operator=(const SafeBuffer&) = delete;
    SafeBuffer(SafeBuffer&&) = default;
    SafeBuffer& operator=(SafeBuffer&&) = default;

    int process() {
        return process_buffer(buf_.get());
    }

    Buffer* get() const { return buf_.get(); }

private:
    unique_buffer buf_;
};

int main() {
    try {
        SafeBuffer buf(1024);
        int result = buf.process();
        std::printf("处理结果: %d\n", result);
    } catch (const std::exception& e) {
        std::fprintf(stderr, "错误: %s\n", e.what());
        return -1;
    }
    return 0;
}

这个封装的好处是:

  • 资源管理完全自动化,不需要手动调用 destroy_buffer
  • 异常安全,即使构造函数抛出异常也不会泄漏资源
  • 类型安全,SafeBuffer 提供了清晰的接口

知识体系总览

下面这张图总结了智能指针与 C 库交互的核心逻辑:

智能指针与 C 库交互核心逻辑 C 库资源类型 malloc / calloc 内存 FILE* 文件句柄 自定义 C 结构体 智能指针类型 unique_ptr<T, Deleter> shared_ptr<T> + 删除器 unique_ptr / shared_ptr 自定义删除器 free() 函数 fclose() 函数 destroy_xxx() 函数 核心原则:谁分配,谁释放 → 用自定义删除器桥接 C/C++ 资源管理 unique_ptr 删除器是类型的一部分(零开销) | shared_ptr 删除器类型擦除(灵活但有间接调用)

常见陷阱与最佳实践

嗯,这里我要分享几个我亲身踩过的坑:

陷阱一:不要用 delete 释放 malloc 的内存。这会导致未定义行为。一定要用 free

陷阱二:不要用 free 释放 new 分配的内存。同样会导致未定义行为。

最佳实践:为每个 C 库资源类型定义一个专用的 RAII 包装类。这样可以在一个地方集中管理删除逻辑,避免重复代码。

我曾经在一个跨平台项目中,需要同时管理 Windows 的 HANDLE 和 Linux 的文件描述符。我为每个平台都写了专门的删除器,然后用 unique_ptr 统一管理。这样上层代码完全不用关心底层资源释放的细节,代码清晰多了。

总结

智能指针与 C 库交互,说白了就是两件事:

  1. 选择合适的智能指针unique_ptr 适合独占所有权,shared_ptr 适合共享所有权
  2. 提供正确的删除器:确保删除器调用的是 C 库对应的释放函数

记住这个原则,你就能在 C++ 项目中安全地使用任何 C 库资源。不用再担心资源泄漏,也不用再手动配对 malloc/free 或 fopen/fclose 了。


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