C#到C++迁移要点3:从 Unity 的多态到 Unreal 的运行时类型系统
在上一篇文章中,我们征服了指针、异常处理和模板。今天,我们将继续深入,探讨 C++ 的多态实现、底层的编译模型,并对比两个引擎的容器和标准库。这三个主题,是理解任何大型 C++ 项目,尤其是像 Unreal 这样的庞然大物的关键。
准备好了吗?让我们开始第三次技术“升级”。
引言:从 Unity 的多态到 Unreal 的运行时类型系统
在 Unity 中,我们对多态(Polymorphism)并不陌生。通过继承和接口,我们可以用基类引用来处理不同的派生类实例,从而实现灵活的代码设计。
C#
// C# 中的多态
public abstract class Weapon
{
public abstract void Attack();
}
public class Sword : Weapon
{
public override void Attack() { /* 挥剑逻辑 */ }
}
public class Bow : Weapon
{
public override void Attack() { /* 射箭逻辑 */ }
}
Weapon myWeapon = new Sword();
myWeapon.Attack(); // 在运行时调用 Sword 的 Attack 方法
C++ 同样提供了多态机制,但它的实现方式和 C# 有着本质的区别,这直接影响了性能和设计哲学。而 Unreal 在此基础上,又构建了一套独特的运行时类型系统(Runtime Type System)。
继承与接口:C# 的单继承与 C++ 的多重继承
C# 的单继承 + 接口
C# 遵循单一继承原则,一个类只能继承自一个基类。如果需要实现多功能性,则可以通过接口(Interface)来实现。一个类可以实现任意数量的接口,这是一种“合约”式的多态。这种设计简单、清晰,有效避免了 C++ 复杂的菱形继承问题(Diamond Problem)。
C++ 的多重继承与 Unreal 的实践
C++ 则允许多重继承(Multiple Inheritance),一个类可以同时继承多个基类。
C++
// C++ 的多重继承示例
class IWeapon { /* 武器接口 */ };
class IConsumable { /* 消耗品接口 */ };
class MagicSword : public IWeapon, public IConsumable
{
// 既是武器,又是消耗品
};
这种机制非常灵活,但也带来了著名的“菱形继承”问题:当 D 类同时继承自 B 和 C,而 B 和 C 又都继承自同一个基类 A 时,D 中将包含两份 A 的成员,从而导致歧义。
Unreal 引擎在实践中,通常会避免使用多重继承来继承功能类。相反,它倾向于使用**组合(Composition)**而非继承,这是一种更安全、更灵活的设计模式。例如,一个角色可能有一个 UCharacterMovementComponent 组件来处理移动,而不是直接继承一个庞大的 Movement 基类。这种组件化设计与 Unity 的 GameObject-Component 模式有着异曲同工之妙。
纯虚类与接口
在 C++ 中,我们通常通过纯虚函数(Pure Virtual Function)来实现接口的概念。一个包含一个或多个纯虚函数的类,被称为纯虚类(Abstract Class)。它的子类必须实现所有纯虚函数,否则它自己也会成为一个纯虚类。
C++
// C++ 中的接口(纯虚类)
class IInteractable
{
public:
// = 0 表示这是一个纯虚函数,没有实现,派生类必须实现它
virtual void Interact() = 0;
// 虚析构函数,保证多态删除的安全性
virtual ~IInteractable() {}
};
在 Unreal 中,你经常会看到这样的接口类,它们通常以 I 作为前缀,用于定义一系列行为契约,例如 IInterface_PostProcessVolume。
编译模型:从 JIT 到 AOT
C# 的即时编译(JIT)与域重载
在 Unity 中,你修改一个 .cs 文件并保存,几秒钟后就可以在编辑器中看到变化。这得益于 Unity 的**即时编译(Just-in-Time, JIT)和域重载(Domain Reload)**机制。
当你修改脚本时,Unity 只会重新编译被修改的 Assembly(通常是 Assembly-CSharp),然后重新加载整个应用程序域(AppDomain)。这个过程很快,但它会丢失所有非序列化的运行时状态。这也是为什么你需要在 OnEnable 或 OnDisable 中保存和恢复一些状态的原因。虽然快速,但在大型项目中频繁的域重载也会变得耗时。
C++ 的编译与链接
C++ 的编译流程要复杂得多,它是一个**AOT(Ahead-of-Time)**过程,所有代码在运行前就已完全编译。一个典型的 C++ 项目的编译流程包括四个步骤:
-
预处理(Preprocessing):处理
#include、#define等预处理指令。 -
编译(Compiling):将 C++ 代码翻译成汇编语言。
-
汇编(Assembling):将汇编语言翻译成机器码,生成目标文件(.obj 或 .o)。
-
链接(Linking):将所有目标文件以及引用的库文件链接在一起,生成最终的可执行文件(.exe)或动态库(.dll)。
Unreal 的编译系统(Unreal Build Tool, UBT):Unreal 使用自己的一套编译工具 UBT 来管理这个复杂的过程。UBT 会解析每个模块的 Build.cs 文件,自动生成编译命令和依赖关系。这使得开发者无需手动管理庞大的 Makefile 或 CMakeLists 文件。
Live Coding 热编译:为了解决 C++ 漫长的编译等待时间,Unreal 引入了 Live Coding。它可以在编辑器运行时,只重新编译你修改的那部分代码,然后将更新后的代码热加载到正在运行的进程中。这极大地提升了 C++ 的迭代效率,使得 C++ 开发也能像 C# 一样实现“所见即所得”的快速迭代。
标准库与容器:从 .NET BCL 到 Unreal 容器
C# 的 .NET BCL
在 C# 中,我们离不开 .NET 基础类库(Base Class Library, BCL)。List<T>、Dictionary<T>、string 等容器和类型是我们日常开发的得力助手。它们功能强大,提供了丰富的 API,并且经过了高度优化。
C++ 的 STL
C++ 也有自己的标准库 STL(Standard Template Library),它提供了 std::vector、std::map、std::string 等通用的容器和算法。这些容器同样经过了精心设计和优化,但它们是通用目的的,不一定总是能满足游戏引擎的特殊需求。
Unreal 的容器:引擎级别的优化与封装
Unreal 引擎没有直接使用 STL,而是提供了自己的一套封装容器,例如 TArray、TMap、FString。这些容器的背后有几个重要原因:
-
内存分配器(Allocator):Unreal 容器使用引擎定制的内存分配器。这使得引擎可以更好地控制内存分配,减少碎片,并与引擎的内存调试工具集成。
-
调试和工具:Unreal 容器在调试模式下提供了额外的检查,可以帮助你发现越界访问等问题。
-
性能优化:Unreal 的容器针对游戏引擎的特殊场景进行了优化。例如,
TArray在UObject的UPROPERTY中可以实现自动的引用跟踪和序列化,这是 STL 容器做不到的。 -
跨平台兼容性:确保在所有目标平台上,容器的行为和性能都是一致的。
所以,在 Unreal C++ 的世界里,你应该优先使用 TArray 而不是 std::vector,优先使用 TMap 而不是 std::map。掌握这些 Unreal 特有的容器,是高效进行 Unreal 开发的必要条件。
核心总结:掌握 Unreal 的“特殊规则”
从本篇文章中,我们看到 C++ 提供了更灵活的继承方式和更底层的编译控制,但 Unreal 并没有盲目照搬 C++ 的所有特性。相反,它在 C++ 的基础上,构建了一套自己独特的规则和工具集。
-
继承:Unreal 倾向于使用组合和组件,而非多重继承。
-
编译:Unreal 用 UBT 简化了编译流程,并用 Live Coding 解决了迭代效率问题。
-
容器:Unreal 提供了自己的封装容器,它们为引擎的内存管理、序列化和调试提供了额外的功能。
理解这些“特殊规则”,并把它们当作 C++ 到 Unreal 的“方言”来学习,将帮助你更好地融入这个生态系统,并构建出高效、健壮的游戏项目。
在下一篇文章中,我们将继续探讨现代 C++ 的语言特性,并深入研究两个引擎的模块化组织方式,这将帮助你从宏观上理解大型项目的代码结构。如果你对某个话题有更深的疑问,随时可以提出来,我们随时可以调整方向。
更多推荐

所有评论(0)