4. 第4例

RAII封装系统文件描述符——实现一个不可拷贝、可移动的文件描述符RAII包装类,在构造时接管fd、析构时关闭,并编写测试验证异常路径下资源不泄漏。 学习卡片

为什么需要 RAII 文件描述符包装类

裸 int fd 在 C++ 中是最容易泄漏的资源之一:任何一条提前 return、一次 throw、一个被忽略的错误分支,都会让 close(fd) 被跳过。RAII 的核心思路是把“资源生命周期”绑定到“对象生命周期”上——构造即接管,析构即释放,从而让编译器替我们保证清理路径。

本例实现一个 FileDescriptor 类,满足三条硬约束:

  • 不可拷贝:两个对象持有同一个 fd 会导致双重 close,必须用 = delete 显式禁用拷贝构造与拷贝赋值。
  • 可移动:移动后源对象必须进入“空”状态(fd = -1),否则源对象析构时会关闭已被转移的 fd。
  • 异常安全:构造失败(open 返回 -1)时抛异常,且不产生任何需要清理的中间状态。

核心实现

下面给出完整可编译的实现。关键点在于:析构函数检查 fd_ != -1 才调用 close;移动构造/移动赋值通过 std::exchange 把源对象的 fd 置为 -1,实现“所有权转移 + 源对象置空”的原子语义。

// file_descriptor.hpp
#pragma once
#include <unistd.h>
#include <fcntl.h>
#include <utility>
#include <stdexcept>
#include <string>
#include <system_error>

class FileDescriptor {
public:
    FileDescriptor() noexcept : fd_(-1) {}

    // 接管一个已打开的 fd(不负责打开)
    explicit FileDescriptor(int fd) noexcept : fd_(fd) {}

    // 打开路径并接管
    static FileDescriptor open(const std::string& path, int flags) {
        int fd = ::open(path.c_str(), flags);
        if (fd == -1) {
            throw std::system_error(errno, std::generic_category(),
                                    "open failed: " + path);
        }
        return FileDescriptor(fd);
    }

    ~FileDescriptor() {
        if (fd_ != -1) {
            ::close(fd_);
        }
    }

    // 不可拷贝
    FileDescriptor(const FileDescriptor&) = delete;
    FileDescriptor& operator=(const FileDescriptor&) = delete;

    // 可移动:转移所有权,源对象置空
    FileDescriptor(FileDescriptor&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) {
            if (fd_ != -1) ::close(fd_);          // 先释放自己持有的
            fd_ = std::exchange(other.fd_, -1);   // 再接管对方的
        }
        return *this;
    }

    int get() const noexcept { return fd_; }
    bool valid() const noexcept { return fd_ != -1; }

    // 显式提前释放
    void reset() noexcept {
        if (fd_ != -1) {
            ::close(fd_);
            fd_ = -1;
        }
    }

private:
    int fd_;
};
为什么移动赋值要先 close 自己的 fd? 如果 a = std::move(b) 时 a 已经持有一个 fd,直接覆盖会导致该 fd 泄漏。正确顺序是:先释放自身资源,再接管对方资源,最后把对方置空。

所有权转移与生命周期状态

下图展示了一个 fd 从“被 open 创建”到“被移动转移”再到“析构关闭”的完整所有权路径。注意移动后源对象的 fd 变为 -1,析构时不会重复关闭。

FileDescriptor 所有权转移与生命周期 阶段 1:创建 ::open() 返回 fd=3 FileDescriptor a(3) 阶段 2:移动构造 FileDescriptor b(std::move(a)) b.fd_=3, a.fd_=-1 阶段 3:移动赋值 c = std::move(b) 先 close(c.fd_), 再接管 对象状态变化: 对象 阶段1后 阶段2后 阶段3后 析构行为 a fd=3 fd=-1 fd=-1 不 close b — fd=3 fd=-1 不 close c — — fd=3 close(3) 异常路径验证: open 失败 → 抛 system_error → 无对象构造成功 → 无 fd 需要关闭 函数中途 throw → 栈展开 → 已构造的 FileDescriptor 析构 → close(fd) 自动执行

测试验证:异常路径下资源不泄漏

验证分两个层面:一是用 /proc/self/fd 统计当前进程打开的 fd 数量,确认反复创建/销毁后数量不增长;二是构造一个在持有 fd 后抛异常的函数,确认异常传播时 fd 被正确关闭。

// test_fd_raii.cpp
#include "file_descriptor.hpp"
#include <cassert>
#include <cstdio>
#include <dirent.h>
#include <vector>
#include <string>

// 统计当前进程打开的 fd 数量
static int count_open_fds() {
    int count = 0;
    DIR* d = ::opendir("/proc/self/fd");
    if (!d) return -1;
    while (dirent* e = ::readdir(d)) {
        if (e->d_name[0] != '.') ++count;
    }
    ::closedir(d);
    return count;
}

// 在持有 fd 后抛异常,验证栈展开时 fd 被关闭
static void throw_after_open() {
    FileDescriptor fd = FileDescriptor::open("/etc/hostname", O_RDONLY);
    assert(fd.valid());
    throw std::runtime_error("simulated failure");
}

int main() {
    int baseline = count_open_fds();

    // 测试1:正常作用域退出,fd 被关闭
    {
        FileDescriptor fd = FileDescriptor::open("/etc/hostname", O_RDONLY);
        assert(fd.valid());
        assert(count_open_fds() == baseline + 1);
    }
    assert(count_open_fds() == baseline);

    // 测试2:异常路径,fd 被关闭
    try {
        throw_after_open();
        assert(false && "should have thrown");
    } catch (const std::runtime_error&) {
        // 预期捕获
    }
    assert(count_open_fds() == baseline);

    // 测试3:移动后源对象析构不重复关闭
    {
        FileDescriptor a = FileDescriptor::open("/etc/hostname", O_RDONLY);
        int raw = a.get();
        FileDescriptor b(std::move(a));
        assert(!a.valid());
        assert(b.get() == raw);
        // a 析构时 fd_=-1,不会 close(raw)
    }
    assert(count_open_fds() == baseline);

    // 测试4:移动赋值释放目标原有 fd
    {
        FileDescriptor c = FileDescriptor::open("/etc/hostname", O_RDONLY);
        FileDescriptor d = FileDescriptor::open("/etc/hostname", O_RDONLY);
        int d_raw = d.get();
        c = std::move(d);
        assert(c.get() == d_raw);
        assert(!d.valid());
    }
    assert(count_open_fds() == baseline);

    std::puts("all tests passed");
    return 0;
}
编译注意: 需要 C++17 或更高(std::exchange 在 <utility> 中)。Linux 下用 g++ -std=c++17 -Wall -Wextra test_fd_raii.cpp -o test_fd_raii 编译。测试依赖 /proc/self/fd,仅适用于 Linux;其他平台可改用 fcntl(fd, F_GETFD) 探测特定 fd 是否仍有效。

关键设计决策速查

决策点做法原因
拷贝构造/赋值= delete防止双重 close
移动构造std::exchange(other.fd_, -1)转移所有权并置空源对象
移动赋值先 close 自身,再接管避免覆盖时泄漏自身 fd
析构检查 fd_ != -1空状态不调用 close
构造失败抛 std::system_error不产生半构造对象
提前释放reset() 置 -1支持显式关闭后仍安全析构