引擎内部:反射

在本文中,我将分解引擎中的反射系统的工作方式。 该系统相对较新,我们承认仍然缺少一些重要功能,例如版本控制。 但是,它已经在积极使用中,我们已经建立了许多依赖反射功能的工具。 不用说,这极大地提高了我们的速度,特别是在构建工具时。

介绍

正如已经暗示的那样,构建反射系统的主要动机是使用工具,在该工具中拥有提供对数据的通用访问的系统是极其有益的。 这使得构建实用程序(如检查器和撤消堆栈)的过程更加简单。

在开发引擎时,我们始终专注于对我们很重要的功能。 我们一直在仔细评估它们的潜在用例,而不是仅仅为了拥有它们而添加新的。 这些并非一开始就很清楚,但是作为一个小团队,我们必须尽最大努力评估每个功能的投资回报率。

当开始在反射系统上工作时,我们首先概述了它将处理的数据类型。 我们的引擎非常面向数据(有关面向数据的设计的更多信息,请参见[1]),因此我们不需要复杂的依赖关系,而依赖关系则需要适当的垃圾收集。 我们想要表示数据树而不是图形。 依赖关系将通过对象ID进行定义。 这使我们能够保持系统的精简和清洁。

注解

在导致反射系统开发的研究阶段,我研究了直接基于C ++语言构建的不同方法。 以Clang为例,它支持反射(请参阅[2]),但是有一个主要警告要直接反映C ++语言。 没有很好的方法为语言提供诸如变量和方法之类的构造的附加信息。 您必须使用预处理程序定义和/或注释来执行此操作。 从技术上讲,在宏扩展之前不可能进行反射,因为该过程可能会生成大量新代码,而通用系统无法忽略这些代码。

我们的要求很简单:我们的数据结构相对平坦,我们需要提供有关如何访问和验证数据的信息。 我们可以生活而无需考虑宏观扩张。 但是,我们还有许多其他需求,例如对象池,轻量级运行时类型确定和验证规则。

考虑到这一点,我们最终开发了一个定制系统,并使用宏定义了类型反射数据。 在下面,您可以看到在TransformComponentTemplateData结构的情况下如何定义反射数据的示例。

如您在此代码段中所见,我们有一个单独的部分,在其中定义类型继承和组成。 所有反射信息都添加在REFLECTION_BEGIN_REGISTER_TYPE和REFLECTION_END_REGISTER_TYPE宏之间,它们实际上是空的,不会生成任何代码。

展望未来,我想对其进行重构,以使我们不必使用单个部分,而不必使用单个部分来注释每个字段,而不必使用REFLECTION_FIELD或类似名称的宏。 但是,当前方法的好处是解析速度快,并且很好地封装了所有反射信息,而不会污染其余的类型定义。

架构图

在“抓取”阶段,使用自定义解析器将上述类型定义转换为架构。 在此阶段,解析器浏览代码以查找反射信息部分。 当它最终遇到这样的部分时,它将创建该类型的内存表示。 可以选择将此模式存储在磁盘上以供以后使用。

当开始在反射系统上工作时,我最初计划使用宏扩展来定义反射和继承,而不是通过预处理程序来提取类型元数据。 这是我在以前使用过的某些引擎中所做的事情,但是这种方法的主要问题是反射信息只能在代码本身中访问。 但是,我们的两步方法的主要好处是能够以多种方式使用类型定义,例如以另一种编程语言为工具生成数据类型。

代码生成

在代码生成过程中,我们读取了在抓取阶段生成的模式。 然后,生成器遍历此定义,并输出所有不同用例的代码。 我们还将校验和存储在生成的文件中作为注释,以避免触摸内容未更改的文件。

类型注册

使用反射接口在运行时进行数据访问需要为每种数据类型生成注册代码。 在启动时调用此代码时,它将向反射系统描述类型,基本上向其解释如何访问其数据。 下面是该TransformComponentTemplateData类型的方法的示例。

该系统允许为每个字段定义自定义setter和getter。 如果未指定,则使用为字段类型指定的默认值。 默认情况下,在反射系统初始化期间定义基本数据类型,例如整数。

使用反射访问数据

现在,数据类型已在系统中注册,我们可以使用反射API来访问其数据。 这使得可以实施通用检查工具,例如对象检查器。

数据访问接口本身是相对简单的,并且类似于.NET反射接口,如下面的代码片段所示。

  int16_t fieldValue; 
Reflection :: GetValue(obj,field,valueIndex,1,0,&fieldValue);

接口的问题之一是严格来讲它不是类型安全的,因为我们传递了原始指针。 通常,危险在于手动使用界面并期望某些类型的数据。 由于数据类型可能会在某个时刻发生变化,并且由于类型定义和访问数据的代码之间只有松散的联系,因此我们实际上可能期望得到错误的数据类型。

对于通用工具,这通常不是问题,因为您使用反射接口来确定数据类型,然后才使用访问器接口来读取或写入数据。 下面的代码段显示了我们如何在检查器的上下文中使用上一段代码。

我们通常也避免在运行时使用动态反射接口,而是更喜欢代码生成。 这样,我们无需编写大量的样板代码即可获得类型安全的代码。

反序列化和序列化

反射API还使我们能够反序列化和序列化对象,而不必手动为每种类型编写代码。 但是,这种方法通常比手动编写的方法慢,因为我们必须遍历类型字段并使用通用接口来访问数据。

如前所述,首先生成模式的两步方法使我们可以对类型信息做更多的事情,而不仅仅是生成注册代码。 我们还使用该模式来输出可以作为脱机过程进行优化的序列化代码。 这样可以大大简化序列化过程,因为您可以在特定字段的解析中放置断点,例如,无需使用数据断点,因为这些断点通常会影响代码的性能。 也可以使生成的代码独立于运行时反射接口。 这样就可以在发行版本中禁用反射功能以节省运行时内存。

以下是为TransformComponentTemplateData类型生成的反序列化代码。

如您在上面的代码段中所见,我们正在使用通用接口来设置特定字段的值。 但是,我们对字段定义有静态引用,并将其传递给函数。 下面是方法Reflection :: ValueFromJSON的实现,该方法基本上只是调用与字段数据类型关联的JSON解串器。

当前,我们不生成序列化代码,而是具有使用运行时反射API的通用解决方案。

对象生命周期管理

由于我们不在引擎中进行垃圾收集,因此我们必须为数据所有权以及如何管理其生命周期定义明确的规则。 反射数据可以包含包含其他反射对象的列表。 我们声明容器必须使用Reflection :: DestroyObject销毁集合中的每个项目。 每个反射类型可以具有一个析构函数和一个默认构造函数,在创建和销毁过程中,反射系统将分别调用它们。 析构函数必须是虚拟的,以正确支持继承,单个基类允许使用该继承。

每个对象都带有对象的类型。 当前,它作为简单指针存储在容纳对象的存储块中。 在我们的案例中,反射类型的数量相对较低,这意味着我们可以在类型列表中使用16位甚至8位索引来优化内存消耗。

脏旗

除了类型信息外,我们还维护字段的脏标志,这些标志被嵌入对象存储块中。 这为我们提供了一个内置的解决方案,用于将字段标记为已修改(请参见上面的TransformComponentTemplateData的JSON反序列化过程中对Reflection :: SetIsFieldDirty函数的使用)。

我们通常使用此系统指示覆盖。 例如,我们有一个预制件,可从磁盘加载某些转换数据。 我们的场景格式引用了该预制件,并提供了对预制件中值的替代。 在编辑过程中,我们可以使用脏标志系统指示这些转换值已通过实例化实例进行了修改,并使用此信息将替代项存储在适当的位置,同时保存了场景。

摘要

我们着手在引擎中实现反射,作为在编辑器中修改数据的通用方法,并允许在运行时检查数据以进行故障排除。 我们的引擎遵循面向数据的设计原则,在这种情况下,这意味着我们的数据类型相对简单。 因此,我们不需要任何复杂的事情,而实施自定义系统可以为我们提供更多的自由,而复杂性则更少。

我们实现了一个两步系统,在该系统中,我们首先生成一个用于注册动态运行时数据访问类型以及脱机流程(例如生成优化的反序列化代码)的模式。

参考文献

[1]面向数据的设计:http://www.datadirectiondesign.com/dodbook/
[2] Clang LibTooling:https://clang.llvm.org/docs/LibASTMatchersTutorial.html