ECS: 实体 id 和二进制边界

整理 EnTT 类型标识符从运行时顺序生成到编译时哈希的演进,以及跨动态库 ABI 边界时的稳定性问题。

EnTT 在早期使用的标识符生成方法是完全运行时顺序生成的,这种方法适用于自包含应用程序,无法跨越二进制边界,也就是说无法跨共享库使用

这主要是因为没有良好通用且跨平台的方法来为类型生成 唯一标识符,在某些场景下,程序需要在运行时动态处理类型,例如

  • 实体组件系统(ECS):需要根据类型动态存取特定组件
  • 反射:运行时获取类型信息(例如字段名称、方法签名)
  • 序列化/反序列化:将类型对象保存为文件或从文件重建
  • 插件系统:动态加载模块时区分不同类型的定义

此时,为每个类型生成一个 唯一标识符,可以帮助程序正确识别和管理这些类型

在 C++ 中,无法直接可靠地为类型生成唯一标识符,虽然 std::type_info 可以为某个类型提供信息,但存在一些问题

#include <iostream>
#include <typeinfo>
 
int main() {
    int x = 0;
    std::cout << typeid(x).name() << std::endl;  // 输出类型名(如 "int")
    return 0;
}

typeid 可以在运行时返回一个 std::type_info 对象,用于获取类型名称或比较类型,例如通过 typeid(A) == typeid(B) 比较两个对象是否为相同类型

虽然 std::type_info 能够在一定程度上充当类型的标识符,但它有以下局限性

  • 实现依赖
    type_info::name() 的输出取决于编译器或平台,可能不一致(例如,GCC 和 MSVC 的类型名称格式不同)
  • 运行时不稳定
    在不同的程序运行过程中,std::type_info::name() 或内部的标识可能会改变,尤其是动态链接的模块,它不能保证跨模块或网络环境的一致性
  • 潜在冲突
    标准并未严格规定 type_info::hash_code() 必须唯一(虽然通常不会冲突),因此可能存在标识符重复的风险
  • 无法定制
    无法扩展 std::type_info 的行为,例如生成更高效的哈希值,或与用户自定义的 ID 生成方案结合

简单实现

一种简单类型标识符生成器的基本实现是借助模板特化,代码的核心是一个模板类,通过静态变量 counter 为每种类型生成一个唯一的顺序标识符

class generator {
    // 静态计数器,用于生成递增的标识符
    inline static std::size_t counter{};
 
public:
    template<typename Type>
    inline static const std::size_t type = counter++;
    // 模板变量,每种类型对应一个静态实例,关联到同一个计数器
};

每次访问 generator::type<T> 时,模板特化会触发 counter 自增,从而生成一个新的标识符,如果某个类型的标识符已经初始化过,再次访问不会改变 counter 的值

const auto type = generator::type<my_class>;

对于单一应用的场景,这没什么问题,生成的标识符是连续的,简单易用,但是它在在不同运行时环境中不稳定(例如不同的运行顺序可能导致不同的标识符),并且在跨边界(如动态链接库或插件)时,可能会因为 counter 的不一致性导致同一类型生成不同的标识符

运行时问题

以下代码展示了如何因运行时的条件影响标识符的生成

if (condition) {
    const auto it = generator::type<int>;
    const auto ct = generator::type<char>;
} else {
    const auto ct = generator::type<char>;
    const auto it = generator::type<int>;
}

在这种情况下

  • 如果 conditiontrueint 的标识符为 Nchar 的标识符为 N+1
  • 如果 conditionfalse,顺序相反,char 的标识符为 Nint 的标识符为 N+1

这种不稳定性在复杂代码中可能会导致不可预测的行为

跨边界问题

在跨动态链接库(DLL 或共享库)时,类型标识符可能不一致

  • Linux 默认 visibilitypublic,因而通常能正常工作
  • Windows 使用默认 visibility 时可能导致不同模块中生成的标识符不一致
struct GENERATOR_API generator {
    static std::size_t next() {
        static std::size_t value{};
        return value++;
    }
};
 
template<typename Type>
struct GENERATOR_API type {
    static std::size_t id() {
        static const std::size_t value = generator::next();
        return value;
    }
};
  • GENERATOR_API 是一个宏,用于控制符号的导出和导入(如 __declspec(dllexport)
  • 通过静态变量 value 保持标识符生成的一致性

为了在不同平台上实现符号的导入和导出,定义了 GENERATOR_API

#if defined _WIN32 || defined __CYGWIN__ || defined _MSC_VER
#    define GENERATOR_EXPORT __declspec(dllexport)
#    define GENERATOR_IMPORT __declspec(dllimport)
#elif defined __GNUC__ && __GNUC__ >= 4
#    define GENERATOR_EXPORT __attribute__((visibility("default")))
#    define GENERATOR_IMPORT __attribute__((visibility("default")))
#else
#    define GENERATOR_EXPORT
#    define GENERATOR_IMPORT
#endif
 
#ifndef GENERATOR_API
#   if defined GENERATOR_API_EXPORT
#       define GENERATOR_API GENERATOR_EXPORT
#   elif defined GENERATOR_API_IMPORT
#       define GENERATOR_API GENERATOR_IMPORT
#   else
#       define GENERATOR_API
#   endif
#endif
  • Windows:使用 __declspec(dllexport)__declspec(dllimport) 来控制符号的导出和导入
  • Linux:使用 __attribute__((visibility("default"))) 确保符号可以被其他模块访问

假设我们有一个库模块和一个主程序,分别在 Windows 和 Linux 上运行

库模块(library)

在编译动态链接库时,设置 GENERATOR_API_EXPORT,确保符号被导出

// 定义 GENERATOR_API_EXPORT 表示导出符号
#define GENERATOR_API_EXPORT
#include "generator.h"
 
// 导出的类
struct GENERATOR_API generator {
    static int next();
};

主程序(application)

在使用动态链接库时,设置 GENERATOR_API_IMPORT,确保可以从库中导入符号

// 定义 GENERATOR_API_IMPORT 表示导入符号
#define GENERATOR_API_IMPORT
#include "generator.h"
 
// 使用导入的类
int main() {
    int value = generator::next();
    return 0;
}

跨边界

但上面的宏仅仅解决了 符号可见性 的跨平台一致,它并无法解决跨二进制边界的 行为一致性,因为在这里 static std::size_t value{}generator::nexttype<T>::id 中定义的静态局部变量,静态局部变量在不同的动态链接库或模块中是独立的

  • 如果 generatortype 被多个模块独立加载,每个模块都有自己独立的 value 实例,结果将导致不同模块可能会为同一种类型生成不同的标识符
// 动态库 A
const auto id_in_libA = type<int>::id();  // 生成 id = 0
 
// 动态库 B
const auto id_in_libB = type<int>::id();  // 生成 id = 0 (但与 libA 不共享静态变量)

问题在于,C++ 标准中没有一个完善的机制能够为模板函数或类型生成稳定的标识符

__func__ 可以获取函数名,但不能包含模板参数信息,因此对于模板特化无效

现代编译器提供了一些非标准但广泛支持的特性

  • MSVC: 提供 __FUNCSIG__ 宏,它返回完整的函数签名,包括模板参数
  • Clang 和 GCC: 提供 __PRETTY_FUNCTION__,功能类似,返回完整的函数或模板签名

通过 编译时哈希函数__FUNCSIG____PRETTY_FUNCTION__ 的值转化为类型的唯一标识符

#if defined _MSC_VER
#   define GENERATOR_PRETTY_FUNCTION __FUNCSIG__
#elif defined __clang__ || defined __GNUC__
#   define GENERATOR_PRETTY_FUNCTION __PRETTY_FUNCTION__
#endif
 
template<typename Type>
struct generator {
    static constexpr std::size_t id() {
        // 对 GENERATOR_PRETTY_FUNCTION 进行编译时哈希计算
        constexpr auto value = hash_fn(GENERATOR_PRETTY_FUNCTION);
        return value;
    }
};

这种解决方案的优势是

  • 稳定性:生成的标识符在跨模块和不同运行时中是稳定的(假设编译器行为一致)
  • 零运行时开销:标识符完全在编译时生成,不增加运行时负担
  • 适用于复杂类型:包括模板特化

当然它也不是完美的

  • 不完全标准化:依赖编译器特性,可能无法在较旧或不支持这些特性的编译器上使用
  • 不可移植性:在不同编译器或平台上,PRETTY_FUNCTIONFUNCSIG 的具体输出格式可能不同,可能需要特殊处理

所以我们可以在受支持时使用非标准解决方案在编译时生成类型唯一标识符,而在不受支持时使用运行时、完全受标准支持的回退方案

SFINAE

还可以利用 SFINAE(模板替换失败不算错误)机制扩展类型标识符生成系统的功能,以支持特定场景,比如通过外部库类型的特性动态生成标识符

通过为模板类 type 添加第二个模板参数,并默认设置为 void,实现基于 SFINAE 的扩展

template<typename Type, typename = void>
struct GENERATOR_API type {
    // 默认实现
};

这将允许对模板进行条件特化,默认情况下,typename = void 表示这个模板适用于所有类型,当需要特化时,可以通过为第二个参数提供具体的类型或条件(如 std::void_t<...>)来实现针对性特化

假设某些类型(如第三方库中的类型)已经有自己的 id() 方法,我们可以为这些类型提供特化

template<typename Type>
struct GENERATOR_API type<Type, std::void_t<decltype(Type::id())>> {
    static constexpr std::size_t id() {
        return Type::id();  // 使用外部类型自带的 id()
    }
};