在C++开发中,把运行时的数据持久化到文件是最常见的需求之一。当我们需要保存一个类实例时,很多人第一反应是拿到对象的指针,然后调用fwrite或者ofstream.write把这段内存直接写下去。这种做法在表面上看似省事,实际上埋下了极难排查的隐患。真正安全的做法不是写指针,而是写对象所包含的真实数据,也就是完成一次明确的序列化过程。

为什么直接write类对象指针是致命错误
要理解这个坑,必须先弄清楚指针到底是什么。在64位程序里,一个指针变量占用8字节,它里面存储的值并不是对象本身,而是该对象在当前进程虚拟地址空间里的地址,例如0x00007ffd3c4a0010。当你执行write(fd, &objPtr, sizeof(objPtr))时,磁盘上多出来的只是这串数字。等到下次程序启动,操作系统会分配完全不同的虚拟地址布局,原来那个地址要么指向无效内存,要么属于别人的数据。
更麻烦的是,如果类里还包含指向堆内存的成员变量指针(比如char* name或者std::vector内部指针),即使你自作聪明把整个对象内存write(&obj, sizeof(obj)),写进去的也仅仅是对象栈部分里存的那些指针值。文件里根本没有字符串内容、没有vector元素,只有几个地址。读回时构造出的对象成了“悬空引用聚合体”,一访问就段错误。
下面这段代码就是典型的错误示范:把指针自身而非指向内容写文件,读回后解引用必崩。
#include <fstream>
#include <iostream>
class User {
public:
int id;
char* name;
User(int i, const char* n) : id(i) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
~User() { delete[] name; }
};
int main() {
User* u = new User(1, "Alice");
std::ofstream out("bad.dat", std::ios::binary);
// 错误:写的是指针变量地址,不是对象数据
out.write(reinterpret_cast<char*>(&u), sizeof(u));
out.close();
User* loaded = nullptr;
std::ifstream in("bad.dat", std::ios::binary);
in.read(reinterpret_cast<char*>(&loaded), sizeof(loaded));
// 灾难:loaded里的地址是上次进程的,访问loaded->id直接崩溃
std::cout << loaded->id;
return 0;
}
正确的序列化思路:只写原始数据不写地址
所谓序列化,就是约定一种与内存布局无关的字节表达形式,把对象的状态变成纯数据。对于 Plain Old Data(POD)类型且不含任何指针的简单结构,可以直接写对象内存,但前提是确保读回的程序有完全一致的编译环境。一旦类里有虚函数、有动态分配、有STL容器,就必须手写字段级的写出逻辑。
最基础的手段是重载operator<<或写两个成员函数save和load。写文件时把每个基本类型依次输出,字符串先写长度再写内容;读文件时按相同顺序还原。这样磁盘上存的是“1,5,Alice”这类真实信息,而不是0x开头的地址。下面给出一个安全的手写版本:
#include <fstream>
#include <cstring>
#include <iostream>
class User {
public:
int id;
char* name;
User() : id(0), name(nullptr) {}
User(int i, const char* n) : id(i) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
void save(std::ofstream& out) {
out.write(reinterpret_cast<char*>(&id), sizeof(id));
int len = strlen(name);
out.write(reinterpret_cast<char*>(&len), sizeof(len));
out.write(name, len);
}
void load(std::ifstream& in) {
in.read(reinterpret_cast<char*>(&id), sizeof(id));
int len = 0;
in.read(reinterpret_cast<char*>(&len), sizeof(len));
name = new char[len + 1];
in.read(name, len);
name[len] = '\0';
}
~User() { delete[] name; }
};
int main() {
User u(1, "Alice");
std::ofstream out("good.dat", std::ios::binary);
u.save(out);
out.close();
User loaded;
std::ifstream in("good.dat", std::ios::binary);
loaded.load(in);
std::cout << loaded.id << " " << loaded.name;
return 0;
}
这种写法虽然啰嗦,但跨进程、跨时间都安全。它的缺点是每加一个字段就要改save和load,维护成本高。在真实项目中,如果对象关系复杂,建议改用成熟的序列化库。
借助Boost.Serialization与标准库方案避开深坑
当类层级变深、包含容器和嵌套对象时,手写字段读写容易漏写指针指向的子对象。Boost.Serialization通过宏和模板自动遍历成员,并能正确处理指针所指向的真实对象,它会维护一个对象跟踪表,保证同一指针不被重复写、读回后关系不丢失。使用方式是在类里声明friend class boost::serialization::access,再写serialize方法。
如果不想引入第三方库,C++标准里也可以用文本化方式,例如把对象转成JSON再写文件,虽然体积大一点,但可读性和跨语言兼容性极好。对于只是临时缓存的场景,还可以用std::ostringstream配合std::string_view做内存映射文件的定制布局。无论选哪种,核心原则不变:落盘的是数据值,不是虚拟地址。下面的片段展示Boost风格的基本结构:
#include <boost/archive/binary_oarchive.hpp>
#include <boost/archive/binary_iarchive.hpp>
#include <fstream>
class User {
friend class boost::serialization::access;
int id;
std::string name;
template<class Archive>
void serialize(Archive& ar, const unsigned int version) {
ar & id;
ar & name;
}
public:
User() = default;
User(int i, std::string n) : id(i), name(n) {}
};
void demo() {
User u(2, "Bob");
std::ofstream ofs("user.bin", std::ios::binary);
boost::archive::binary_oarchive oa(ofs);
oa << u;
}
从工程角度看,直接write指针永远不该出现在生产代码里。它既不是序列化也不是持久化,只是把进程内部的临时地图坐标抄给了另一天完全不同的城市。养成“写数据不写地址”的习惯,才能从根上消灭这一类悬空指针崩溃。