序列化与反序列化:把 C++ 对象可靠地保存下来

适用环境:原理适用于一般程序;完整示例使用 C++17 与 Qt 6 Core,不依赖 Widgets。

学习目标:分清内存布局与存储格式,能够实现带验证的 JSON 保存和读取,并理解二进制格式与版本兼容的基本要求。Qt 示例未在本机编译,输出为预期行为。

1. 对象为什么不能直接跨进程保存

程序运行时,对象可能包含数字、字符串、容器、指针和缓存。它们共同组成运行状态,但不是所有状态都适合原样保存。

例如,一个指针的值只表示当前进程中的某个地址。把这个地址写入文件,下次启动时那个位置不一定仍然是原来的对象。

序列化是按照明确规则,把需要保留的信息转换成可存储或传输的数据。反序列化是按照对应规则检查并恢复信息。

cpp 复制代码
运行时对象 → 编码 → 字节数据 → 文件或数据库或网络
运行时对象 ← 解码 ← 字节数据 ← 文件或数据库或网络

编码规则、保存介质和运行时对象,是三件不同的事。同一段 JSON 可以保存到文件,也可以通过网络发送;同一份二进制数据既可以写文件,也可以放进数据库 BLOB。

2. 需要保存的是信息而不是所有成员

假设学生对象包含这些内容:

cpp 复制代码
// 示意结构,不是直接落盘格式。
struct Student
{
    int id;
    std::string name;
    double averageScoreCache;
    DatabaseConnection* connection;
};

ID 和姓名可能需要持久化;平均分缓存可能由成绩重新计算;数据库连接是运行资源,需要重新建立,不能靠保存指针恢复。

设计格式前,应逐个问:这个成员是原始业务数据、可重算的派生值,还是临时资源?

成员增加不等于存储格式自动改变。是否需要落盘,先由业务需要决定,再由编码函数决定如何写入。

3. 为什么直接写结构体内存通常不可靠

下面是故意展示的反例片段:

cpp 复制代码
// 反例:Student 含有动态成员或指针时,不能这样持久化对象。
file.write(reinterpret_cast<const char*>(&student), sizeof(student));

问题包括:

  • 字符串和容器可能包含指针、长度等管理状态,原始字节不是完整的可移植内容。
  • 编译器可能为对齐插入填充字节。
  • 整数宽度、字节序和对象布局可能随平台或构建条件变化。
  • 多态对象还可能包含与具体实现相关的隐藏状态。
  • 读取字节并不等价于正确构造一个含资源的 C++ 对象。

"可平凡复制"也不自动意味着一种跨平台、跨版本的文件协议。它描述的是语言层面的对象操作条件,不能替代格式设计。

在严格控制平台和布局的低层场景可以使用特定内存表示,但约束、版本和兼容责任必须明确。通用持久化更适合逐字段编码。

4. JSON 与二进制格式怎样选择

格式 适合场景 需要注意
JSON 配置、接口交换、需要人工查看的数据 类型校验、大整数精度、文本大小
自定义二进制 数据量较大、固定协议、需要明确布局 字节序、长度、版本、边界检查
带 schema 的序列化方案 多语言协作、长期演进的协议 工具链、兼容规则和未知字段策略
CSV 规则明确的二维记录交换 嵌套结构弱,分隔符、转义和缺失值需约定

文本并不天然安全,二进制也不天然更快。数据规模、解析成本、压缩、随机访问方式和调试需求都影响选择。

下面先用 JSON 实现完整流程。Qt 的 QJsonDocument 可以在 JSON 对象与 UTF-8 JSON 字节之间转换,但业务字段是否齐全、数值范围是否正确,仍需要应用检查。Qt 官方文档:QJsonDocument

5. 先定义数据契约

本文的学生文件采用以下协议:

cpp 复制代码
{
    "kind": "student",
    "schemaVersion": 1,
    "id": 7,
    "name": "Alice"
}

约定如下:

  1. 根节点必须是对象,kind 必须为 student。
  2. 仅支持格式版本 1。
  3. id 必须是 1 到 1000000000 之间的整数。
  4. name 必须是非空白字符串,长度最多 80 个 QString UTF-16 码元。
  5. 文件不超过 64 KiB。
  6. 当前版本允许额外未知字段,但不会将它们保存进 Student 对象。

这些限制是本文示例的协议选择,不是 JSON 的统一规则。显式限制 ID 范围,也避免在这个示例中引入跨语言大整数表示问题。

6. 完整的 Qt 保存和读取示例

下面保存为 main.cpp。运行会在当前工作目录创建或替换 student_demo.json,请在独立的演示目录中使用。

cpp 复制代码
#include <QCoreApplication>
#include <QDebug>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QSaveFile>
#include <QString>
#include <cmath>

struct Student
{
    int id = 0;
    QString name;
};

bool validStudent(const Student& value)
{
    return value.id >= 1 && value.id <= 1000000000
        && !value.name.trimmed().isEmpty()
        && value.name.size() <= 80;
}

QByteArray encodeStudent(const Student& value)
{
    QJsonObject root;
    root.insert(QStringLiteral("kind"), QStringLiteral("student"));
    root.insert(QStringLiteral("schemaVersion"), 1);
    root.insert(QStringLiteral("id"), value.id);
    root.insert(QStringLiteral("name"), value.name);
    return QJsonDocument(root).toJson(QJsonDocument::Indented);
}

bool decodeStudent(const QByteArray& bytes, Student& output,
                   QString& error)
{
    error.clear();
    constexpr qint64 maxBytes = 64 * 1024;
    if (bytes.size() > maxBytes)
    {
        error = QStringLiteral("File is too large");
        return false;
    }

    QJsonParseError parseError;
    const auto document = QJsonDocument::fromJson(bytes, &parseError);
    if (parseError.error != QJsonParseError::NoError)
    {
        error = parseError.errorString();
        return false;
    }
    if (!document.isObject())
    {
        error = QStringLiteral("Root must be an object");
        return false;
    }

    const auto root = document.object();
    const auto kind = root.value(QStringLiteral("kind"));
    const auto version = root.value(QStringLiteral("schemaVersion"));
    const auto id = root.value(QStringLiteral("id"));
    const auto name = root.value(QStringLiteral("name"));

    if (!kind.isString() || kind.toString() != QStringLiteral("student")
        || !version.isDouble() || version.toDouble() != 1.0)
    {
        error = QStringLiteral("Unsupported document type or version");
        return false;
    }
    if (!id.isDouble() || !name.isString())
    {
        error = QStringLiteral("Missing field or invalid field type");
        return false;
    }

    const double number = id.toDouble();
    if (!std::isfinite(number) || number < 1.0 || number > 1000000000.0
        || std::floor(number) != number)
    {
        error = QStringLiteral("Invalid student ID");
        return false;
    }

    Student parsed{static_cast<int>(number), name.toString()};
    if (!validStudent(parsed))
    {
        error = QStringLiteral("Invalid student data");
        return false;
    }

    output = parsed;
    return true;
}

int main(int argc, char* argv[])
{
    QCoreApplication app(argc, argv);
    const QString path = QStringLiteral("student_demo.json");
    const Student original{7, QStringLiteral("Alice")};

    if (!validStudent(original))
    {
        qCritical() << "Invalid input";
        return 1;
    }

    const QByteArray bytes = encodeStudent(original);
    QSaveFile writer(path);
    if (!writer.open(QIODevice::WriteOnly))
    {
        qCritical() << writer.errorString();
        return 1;
    }
    if (writer.write(bytes) != bytes.size())
    {
        qCritical() << writer.errorString();
        writer.cancelWriting();
        return 1;
    }
    if (!writer.commit())
    {
        qCritical() << writer.errorString();
        return 1;
    }

    QFile reader(path);
    if (!reader.open(QIODevice::ReadOnly))
    {
        qCritical() << reader.errorString();
        return 1;
    }

    // 多读一个字节,用于识别超过协议上限的文件。
    constexpr qint64 maxBytes = 64 * 1024;
    const QByteArray loaded = reader.read(maxBytes + 1);
    if (reader.error() != QFileDevice::NoError)
    {
        qCritical() << reader.errorString();
        return 1;
    }

    Student restored;
    QString error;
    if (!decodeStudent(loaded, restored, error))
    {
        qCritical() << error;
        return 1;
    }

    qInfo().noquote() << restored.id << restored.name;
    return 0;
}

预期打印的有效内容为:

cpp 复制代码
7 Alice

Qt 日志输出的前缀可能受运行环境配置影响。文件中的键顺序不应当作为业务契约,读取器按键名查找字段。

最小 CMakeLists.txt 如下,需要先安装 Qt 6 开发环境,并让 CMake 能找到 Qt:

cpp 复制代码
cmake_minimum_required(VERSION 3.16)
project(StudentCodec LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Core)
add_executable(StudentCodec main.cpp)
target_link_libraries(StudentCodec PRIVATE Qt6::Core)

示例没有声明 Q_OBJECT,因此不需要为自定义类运行 moc,也不需要启动 GUI 事件循环。

7. 这段代码有哪些值得保留的习惯

7.1 编码与文件写入分开

encodeStudent() 负责生成字节,不负责路径。这样可以把同样的字节写入不同介质,也可以单独检查编码结果。

这里的编码函数约定输入已经合法,所以调用方先调用 validStudent()。如果接口面向不受控调用者,也可以把验证和失败返回放进编码接口内部。

7.2 转换前先检查类型

不能把 toInt()、toDouble() 或 toString() 返回了某个值,直接理解成字段有效。缺失和类型不匹配时,转换函数可能返回默认值。先检查 JSON 类型,再检查取值范围,才能避免把错误数据伪装成合法的 0 或空字符串。Qt 官方文档:QJsonValue

7.3 先解析临时对象再提交结果

解码函数先建立 parsed,全部验证成功后才执行 output = parsed。对于返回 false 的正常校验失败路径,调用方原有结果不会被修改一半。

这是解析配置、导入数据和恢复状态时都可以使用的设计方式。若还要提供对内存分配异常的更强保证,需要结合类型的移动、交换和异常语义进一步设计。

7.4 写完不等于保存成功

示例检查了打开、写入和提交三个阶段。QSaveFile 默认采用临时文件写入后提交替换的机制,未成功提交时不会把半写入内容当作完整目标文件;不要随意开启会改变该保障的直接写入回退模式。Qt 官方文档:QSaveFile

这种保存机制也不等于数据库事务,更不是备份机制。多文件一致性、并发写入和断电持久性要求仍需单独设计。

8. 如果改成二进制格式需要约定什么

一个简单格式可以按逻辑字段描述为:

cpp 复制代码
魔数 4 字节 | 格式版本 uint16 | ID uint32
名称字节长度 uint32 | UTF-8 名称字节

"魔数"是用于识别文件类型的固定字节,例如 ASCII 的 STUD。除此之外,还应明确:

  • 所有整数的字节序,例如统一小端。
  • 长度单位是字节还是字符。
  • 字符串编码与最大长度。
  • 文件允许哪些版本,尾部额外数据如何处理。
  • 字段是否可选,缺失怎样表达。

整数 0x01020304 在大端顺序中为 01 02 03 04,在小端顺序中为 04 03 02 01。因此不能仅说"这里保存一个整数",还需要约定具体编码。

读取长度字段时,应先确认剩余输入足够保存长度值,再检查长度是否超过剩余数据和应用上限,最后才分配或复制。不要先按文件给出的巨大长度申请内存,再期待后续读取失败帮你处理。

Qt 的 QDataStream 提供二进制读写、字节序和流版本设置。使用它时,读写两端仍要约定相同顺序与配置,并检查状态;它不能替你定义业务字段含义。Qt 官方文档:QDataStream

9. 三种版本不要混在一起

版本 管理的内容
应用版本 某次软件发布包含哪些功能和修复
业务格式版本 文件或数据包中字段如何组织与解释
库的编码版本 例如 QDataStream 对 Qt 数据类型采用的编码规则

应用升级,不一定改变文件格式。业务数据结构新增字段,也不必直接把库的流版本改成最新值。

假设格式 1 保存 ID 和姓名,格式 2 增加邮箱。新读取器可以识别版本 1,把邮箱补为空;识别版本 2 时按新规则解析。旧读取器能否读新文件,则取决于原本是否设计了可跳过字段等兼容策略,不能默认成立。

不要用新布局冒充旧版本。即使新旧字段恰好同样长,含义或单位改变也可能使旧程序得到看似正常却错误的数据。

10. 反序列化需要验证哪些边界

除了语法,至少考虑以下情况:

  • 文件被截断,或完全为空。
  • 根节点类型不正确,关键字段缺失。
  • 类型正确但取值越界,例如负数 ID。
  • 数组长度、字符串长度或嵌套层数超出限制。
  • 版本未知,或者数据包含有无法解释的尾部内容。
  • 对象之间引用的 ID 不存在。

校验和可以帮助发现某些意外损坏,但不能自动提供防篡改能力;需要真实性验证时,要考虑适合的认证或签名机制。

教学示例的边界限制应与真实用途重新核对,不能把一段最小示例当成所有不可信输入场景的通用安全解析器。

11. 自检题

  1. 为什么保存一个 std::string 对象的原始内存不等于保存字符串内容?
  2. 二进制协议的字符串长度为什么必须说明单位?
  3. 为什么先填充临时对象,再更新输出对象?
  4. 文件写进数据库 BLOB 后,数据库会自动理解其中的成员吗?
  5. 应用增加一个仅用于显示的缓存值,是否一定需要升级保存格式?

参考答案

  1. 对象可能包含实现相关的管理状态和地址,原始布局不是独立可恢复的字符串协议。
  2. UTF-8 中文等文本的字符数量与字节数量不同,单位不一致会使解析位置错误。
  3. 防止校验失败时留下部分更新的业务状态。
  4. 不会。BLOB 保存字节,内部编码仍由应用解释。
  5. 不一定。先确认是否需要持久化;可可靠重算的临时值可能无需保存。

建议练习:将文件中的 ID 改成字符串、将版本改成 2、删除姓名字段,分别检查错误路径;然后尝试增加一个可选字段,明确缺失时的默认行为。练习时应单独调用读取逻辑,避免示例 main 在开始时重新写入文件覆盖你的修改。

相关推荐
ragnwang2 小时前
Notes: Python pybind11 import C++ module
开发语言·c++·python
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 搜索附近的功能概述和分析
java·开发语言·交友
骑着蜗牛撵大象3272 小时前
大模型 Agent 的沙箱护栏:gVisor 隔离、出口白名单与审计闭环
java·开发语言·jvm·安全·agent沙箱·gvisor·microvm
Dxy12393102163 小时前
JS DES 加密详解
开发语言·javascript·arcgis
充值内卷3 小时前
我给桌面加了个鱼缸:C++ 写后端,WebView2 里养一座 Three.js 小岛
开发语言·javascript·c++
瀚高PG实验室4 小时前
发布端逻辑复制槽消失
数据库·postgresql·瀚高数据库
云和数据.ChenGuang4 小时前
深入解析 Python 的 dir():从用法到 CPython 源码实现
开发语言·人工智能·python·langchain
ss2734 小时前
PostgreSQL慢查询定位与优化
数据库·postgresql
香蕉大香蕉(06敬畏市场)4 小时前
MySQL 单点详解(07)
java·数据库·mysql·ai·博客·八股