C++独占资源句柄(FILE*/socket/HANDLE)裸成员陷阱

-
-
2026-09-17 15:03

核心现象

  • 类中保存FILE*socket fdWindows HANDLE这类独占系统资源,析构函数做释放(fcloseclosesocketCloseHandle),但不处理拷贝控制,例如:

    // 错误写法
    struct BadFile {
        FILE* fp = nullptr;
        BadFile(const char* path) { fp = fopen(path, "r"); }
        ~BadFile() { if(fp) fclose(fp); }
        // 编译器自动生成浅拷贝构造、浅拷贝赋值
    };

错误点

  • 编译器在编译期自动生成拷贝构造、拷贝赋值,做逐成员浅拷贝。拷贝之后两个对象共同持有同一个句柄:
    • 第一个对象析构:调用资源释放
    • 第二个对象析构:再次释放资源,直接产生未定义行为

触发拷贝的场景:对象赋值b=aT b(a)初始化、函数值传递、函数按值返回对象。

Debug与Release行为差异

  • Debug模式

    库开启调试校验;fclose之后改写 FILE 内存魔数。二次关闭直接断言崩溃,问题容易复现。

  • Release模式

    关闭调试魔数检查,不一定崩溃

    • 运气好:已经释放的 FILE 内存读取出来无效 fd,系统调用直接返回错误,程序继续跑;
    • 运气差:内存被其它对象复用覆盖,读出一个合法 fd,错误关闭别的正在使用的文件 / 套接字,业务逻辑错乱,无直接 crash,故障很难定位。

解决方法

三/五法则:析构做资源释放,不放任默认拷贝

  • 方法一:实现拷贝构造+拷贝赋值,但针对文件句柄的情况下极少使用
  • 方法二:禁止拷贝,=delete拷贝构造、拷贝赋值,根据需要保留或者删除移动构造、移动赋值。

    struct FileWrapper {
        FILE* fp = nullptr;
        explicit FileWrapper(const char* path) { fp = fopen(path, "r"); }
        ~FileWrapper() { if(fp) fclose(fp); }
    
        // 禁止拷贝,拷贝直接编译报错
        FileWrapper(const FileWrapper&) = delete;
        FileWrapper& operator=(const FileWrapper&) = delete;
    
        // 需要所有权转移就开启移动,不需要就把移动也delete
        FileWrapper(FileWrapper&& other) noexcept {
            fp = other.fp;
            other.fp = nullptr;
        }
        FileWrapper& operator=(FileWrapper&& other) noexcept {
            if(this == &other) return *this;
            if(fp) fclose(fp);
            fp = other.fp;
            other.fp = nullptr;
            return *this;
        }
    };

零法则:工程首选:unique_ptr + 自定义删除器(推荐)

  • 无需手写析构、拷贝、移动。

    struct FileCloser {
        void operator()(FILE* fp) const noexcept {
            if(fp) fclose(fp);
        }
    };
    using FileHandle = std::unique_ptr<FILE, FileCloser>;
    
    struct FileWrapper {
        FileHandle fp;
        explicit FileWrapper(const char* path) {
            fp.reset(fopen(path, "r"));
        }
        // 无需手写析构、拷贝、移动,全部由unique_ptr接管
    };
    

写资源类时注意点

  • 析构函数是否做资源释放:delete / fclose / closesocket / CloseHandle
  • 资源是否属于独占资源?
  • 是否处理拷贝控制:要么实现深拷贝,要么delete拷贝成员
  • 禁止拷贝后,如果要转移对象,补充移动语义
  • 优先 RAII 智能封装,尽量不要裸存独占句柄指针


目录