一、虚函数与序列化:多态对象的序列化挑战

序列化,说白了就是把内存里的对象变成一串字节流。存到文件也好,发到网络也罢,总之得能再变回来。

但问题来了——当对象带有虚函数时,事情就没那么简单了。

我刚开始做序列化时,天真地以为只要把成员变量挨个存进去就行。结果反序列化回来,调用虚函数直接崩了。为什么?因为虚函数表指针(vptr)丢了。

1.1 多态对象的序列化困境

你想想看,一个 Base* 指针,实际指向的是 Derived 对象。序列化时,我们只知道它是 Base*,但实际类型是 Derived。如果只存 Base 部分,反序列化回来就变成 Base 对象了——多态性彻底丢失。

核心问题: 序列化时丢失了对象的实际类型信息,导致反序列化后无法恢复正确的派生类对象。

举个例子:

class Animal {
public:
    virtual void speak() { cout << "Animal speaks\n"; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() override { cout << "Dog barks\n"; }
};

// 序列化时只存了 Animal 部分
// 反序列化回来,speak() 调用的是 Animal::speak(),不是 Dog::speak()

我在项目中遇到过类似问题。当时做一个游戏引擎,需要保存场景中的所有 GameObject。每个对象可能是 PlayerEnemyItem。序列化时没记录类型,结果读回来全是 GameObject,所有角色都变成了一模一样的默认模型。嗯,那场面挺尴尬的。

1.2 解决方案:记录类型标识

解决思路其实很直接——序列化时额外存一个类型ID,反序列化时根据这个ID创建正确的对象。

enum class TypeID {
    ANIMAL,
    DOG,
    CAT
};

class Animal {
public:
    virtual TypeID getTypeID() const = 0;
    virtual void serialize(ostream& os) const {
        TypeID id = getTypeID();
        os.write(reinterpret_cast<const char*>(&id), sizeof(id));
    }
};

class Dog : public Animal {
public:
    TypeID getTypeID() const override { return TypeID::DOG; }
    void serialize(ostream& os) const override {
        Animal::serialize(os);
        // 序列化 Dog 特有的成员
    }
};

这样反序列化时,先读类型ID,再根据ID创建对应对象。但这里有个新问题——怎么创建?你不能直接 new Dog(),因为反序列化代码不知道具体类型。

二、虚函数与对象持久化

持久化,就是把对象存到磁盘上,下次程序启动还能用。虚函数的存在让持久化变得棘手——因为虚函数表是编译器生成的,不同编译器、不同版本都可能不一样。

我记得有一次升级编译器版本,之前序列化的数据全读不出来了。查了半天,发现是虚函数表布局变了,vptr指向的位置不同了。从那以后,我养成了一个习惯:永远不要直接序列化内存布局

2.1 持久化的正确姿势

正确的做法是:只序列化数据成员,不碰虚函数表。反序列化时,通过构造函数或工厂方法重建对象,虚函数表自然就正确了。

经验之谈: 序列化时只存数据,不存代码相关的任何东西(vptr、函数指针等)。代码是编译期决定的,数据才是运行时需要保存的。
class Shape {
public:
    virtual double area() const = 0;
    virtual void serialize(ostream& os) const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius_;
public:
    Circle(double r = 0) : radius_(r) {}
    double area() const override { return 3.14159 * radius_ * radius_; }
    void serialize(ostream& os) const override {
        os.write(reinterpret_cast<const char*>(&radius_), sizeof(radius_));
    }
};

反序列化时,先读类型ID,再调用对应的构造函数:

unique_ptr<Shape> deserialize(istream& is) {
    TypeID id;
    is.read(reinterpret_cast<char*>(&id), sizeof(id));
    switch (id) {
        case TypeID::CIRCLE: {
            double r;
            is.read(reinterpret_cast<char*>(&r), sizeof(r));
            return make_unique<Circle>(r);
        }
        // 其他类型...
    }
}

你看,这里的关键是:反序列化时通过构造函数重建对象,虚函数表由编译器自动设置,完全不需要我们操心。

三、工厂模式在反序列化中的应用

上面那个 switch-case 写法,说实话,很丑。每加一个新类型,就得改 switch 语句。这违反了开闭原则——对扩展开放,对修改关闭。

我个人的习惯是用工厂模式来解决这个问题。工厂模式说白了就是一个对象创建中心,你给它一个类型ID,它返回一个对应类型的对象。

3.1 简单工厂模式

class ShapeFactory {
public:
    static unique_ptr<Shape> create(TypeID id) {
        switch (id) {
            case TypeID::CIRCLE:  return make_unique<Circle>();
            case TypeID::RECT:    return make_unique<Rect>();
            case TypeID::TRIANGLE: return make_unique<Triangle>();
            default: throw runtime_error("Unknown type");
        }
    }
};

这样反序列化代码就变成了:

unique_ptr<Shape> deserialize(istream& is) {
    TypeID id;
    is.read(reinterpret_cast<char*>(&id), sizeof(id));
    auto obj = ShapeFactory::create(id);
    obj->deserialize(is);  // 虚函数调用,读取具体数据
    return obj;
}

嗯,比之前清爽多了。但 switch-case 还是得改,对吧?

3.2 注册式工厂模式

我曾经在一个大型项目中维护过几十种消息类型的序列化。每次加新消息都要改工厂函数,烦不胜烦。后来我改用注册式工厂——类型自己注册到工厂里,工厂不需要知道具体有哪些类型。

class ShapeFactory {
    using Creator = function<unique_ptr<Shape>()>;
    static map<TypeID, Creator>& registry() {
        static map<TypeID, Creator> reg;
        return reg;
    }
public:
    static void registerType(TypeID id, Creator creator) {
        registry()[id] = move(creator);
    }
    static unique_ptr<Shape> create(TypeID id) {
        return registry().at(id)();
    }
};

// 在全局或静态初始化时注册
// Circle.cpp
bool circleRegistered = []{
    ShapeFactory::registerType(TypeID::CIRCLE, 
        []{ return make_unique<Circle>(); });
    return true;
}();
关键点: 注册式工厂实现了「类型即插即用」。加一个新类型,只需要在它的源文件里注册一下,工厂代码完全不用动。

3.3 工厂模式与虚函数序列化的完整流程

把上面所有东西串起来,一个完整的多态对象序列化/反序列化流程是这样的:

// 序列化
void serialize(ostream& os, const Shape& shape) {
    TypeID id = shape.getTypeID();  // 虚函数调用
    os.write(reinterpret_cast<const char*>(&id), sizeof(id));
    shape.serialize(os);  // 虚函数调用,序列化具体数据
}

// 反序列化
unique_ptr<Shape> deserialize(istream& is) {
    TypeID id;
    is.read(reinterpret_cast<char*>(&id), sizeof(id));
    auto shape = ShapeFactory::create(id);  // 工厂创建
    shape->deserialize(is);  // 虚函数调用,读取具体数据
    return shape;
}
注意: 类型ID必须跨版本稳定。我见过有人用枚举值,结果枚举顺序一变,旧数据全废了。建议用字符串作为类型ID,或者维护一个版本号。

四、知识体系总览

下面这张图总结了虚函数与序列化的核心逻辑:

虚函数与序列化核心流程 序列化 1. 获取类型ID(虚函数) 2. 写入类型ID 3. 序列化数据成员(虚函数) 输出字节流 字节流 类型ID + 数据 反序列化 1. 读取类型ID 2. 工厂模式创建对象 3. 反序列化数据成员(虚函数) 恢复完整多态对象 核心:类型ID + 工厂模式 + 虚函数序列化/反序列化

说白了,整个流程就三件事:存类型、建对象、读数据。类型ID解决「是什么」的问题,工厂模式解决「怎么建」的问题,虚函数解决「怎么读写」的问题。三者缺一不可。

我个人觉得,工厂模式是这里面最巧妙的一环。它把「创建对象」这个职责从反序列化代码中剥离出来,让代码更干净、更可扩展。如果你在项目中遇到多态对象的序列化问题,不妨试试这个组合拳。

避坑指南: 我曾经在序列化中直接保存了 type_info 的 name() 返回值。结果不同编译器返回的字符串格式不一样,导致跨平台反序列化失败。后来改用自定义的整数ID,配合注册表,才彻底解决这个问题。

好了,关于虚函数与序列化的内容就聊到这里。记住:序列化存的是数据,不是代码。只要把握住这个原则,多态对象的持久化其实没那么可怕。