核心现象※
类中保存
FILE*、socket fd、Windows HANDLE这类独占系统资源,析构函数做释放(fclose、closesocket、CloseHandle),但不处理拷贝控制,例如:// 错误写法 struct BadFile { FILE* fp = nullptr; BadFile(const char* path) { fp = fopen(path, "r"); } ~BadFile() { if(fp) fclose(fp); } // 编译器自动生成浅拷贝构造、浅拷贝赋值 };
错误点※
- 编译器在编译期自动生成拷贝构造、拷贝赋值,做逐成员浅拷贝。拷贝之后两个对象共同持有同一个句柄:
- 第一个对象析构:调用资源释放
- 第二个对象析构:再次释放资源,直接产生未定义行为
触发拷贝的场景:对象赋值
b=a、T 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 智能封装,尽量不要裸存独占句柄指针