4. 第4例
为什么需要 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,析构时不会重复关闭。
测试验证:异常路径下资源不泄漏
验证分两个层面:一是用 /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 | 支持显式关闭后仍安全析构 |