Unreal对C++做了什么 · Part2根基 · 第 6 章 · UInterface:Unreal 的多继承替代
类角色继承关系反射元数据载体→UInterface→UObject接口方法载体独立(不继承 UObject)一个提供"身份"(让反射系统认识这个接口),一个提供"能力"(实际的方法签名)。两者缺一不可。维度C++ 纯虚类Java/C#GoRust声明方式纯虚函数interface关键字隐式trait关键字双类(U + I)运行时查询仅instanceofis类型断言Anytrait反射 + Cas
第 6 章 · UInterface:Unreal 的多继承替代
C++ 支持多继承。一个类可以同时继承多个基类,获得所有基类的成员和方法。这在理论上很灵活,在实践中却臭名昭著——菱形继承、虚继承、内存布局的不可预测性,这些问题让大多数现代语言都选择了单继承加接口的路线。
Java 有 interface,C# 有 interface,Go 有隐式接口,Rust 有 trait。Unreal 也做了同样的选择——但它的方案有一个独特之处:每个接口需要声明两个类。
这看起来很奇怪。本章解释为什么需要两个类,以及反射如何让接口查询在运行时成为可能。
6.1 C++ 多继承与 Unreal 的冲突
先看看标准 C++ 的多继承为什么和 Unreal 的体系不兼容。
菱形继承
class A { public: int Value; };
class B : public A {};
class C : public A {};
class D : public B, public C {};
D d;
d.Value = 42; // 编译错误:歧义——是 B::Value 还是 C::Value?
经典的菱形继承问题。C++ 的解决方案是虚继承(virtual 关键字),但虚继承会改变对象的内存布局,引入额外的指针开销,并让构造顺序变得复杂。
与 UObject 体系的冲突
Unreal 的 UObject 体系有三个基本假设:
- 单一的 UClass:每个 UObject 实例只有一个 UClass 描述它的类型。如果一个类同时继承了两个 UCLASS 标记的类,它该有几个 UClass?
- 确定的内存布局:反射系统依赖属性的内存偏移量来读写数据。多继承导致的内存布局不确定性会让偏移量计算出错。
- CDO 和序列化:CDO 依赖单一继承链来确定默认值的覆盖顺序。多继承会让这个链条分叉。
所以 Unreal 的规则是明确的:UCLASS 标记的类只能单继承。 你可以让 AMyCharacter 继承 ACharacter,但不能让它同时继承 ACharacter 和另一个 UCLASS 类。
但游戏开发中确实需要"一个对象具备多种能力"的表达方式。一个角色可能同时是"可交互的"、“可存档的”、“可受伤的”。在单继承体系中,你不可能把这些都塞进继承链——这就是接口的用武之地。
6.2 双类机制:UInterface + IInterface
让我们先看一个完整的接口声明,然后解释每一部分:
// Damageable.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "Damageable.generated.h"
// 第一个类:UInterface 子类(反射用的空壳)
UINTERFACE(MinimalAPI, Blueprintable)
class UDamageable : public UInterface
{
GENERATED_BODY()
};
// 第二个类:实际的接口定义(方法在这里)
class IDamageable
{
GENERATED_BODY()
public:
// 纯虚函数——实现类必须覆盖
virtual float GetCurrentHealth() const = 0;
// 带默认实现的虚函数——实现类可以覆盖也可以不覆盖
virtual void ApplyDamage(float Amount)
{
// 默认实现:什么都不做
}
// 蓝图可实现的事件
UFUNCTION(BlueprintImplementableEvent)
void OnDamageReceived(float Amount);
};
两个类,名字几乎一样,只有前缀不同:UDamageable 和 IDamageable。这看起来很冗余,但每个类都有不可替代的作用。
UDamageable:反射的锚点
UDamageable 继承自 UInterface(而 UInterface 继承自 UObject)。它存在的唯一目的是让 UHT 能为这个接口生成反射数据——一个 UClass。
这个 UClass 里不包含任何方法实现,但它让以下操作成为可能:
// 运行时检查一个对象是否实现了某个接口
bool bIsDamageable = MyActor->GetClass()->ImplementsInterface(UDamageable::StaticClass());
没有 UDamageable 就没有 StaticClass(),没有 StaticClass() 就没有运行时的接口查询能力。这个空壳类是反射系统的入场券。
IDamageable:方法的载体
IDamageable 是一个普通的 C++ 类(注意没有 U 前缀),不继承自 UObject。你的接口方法——GetCurrentHealth()、ApplyDamage() 等——都声明在这里。
为什么方法不能放在 UDamageable 里?因为 UDamageable 继承自 UObject,而实现接口的类(比如 AMyCharacter)已经继承了 UObject(通过 AActor → UObject)。如果方法在 UDamageable 上,实现类就必须多继承两个 UObject 后代——这正是 Unreal 要避免的。
IDamageable 不在 UObject 继承链上,所以"继承" IDamageable 不会导致菱形继承问题。在 C++ 层面,这是安全的多继承——因为 IDamageable 只有虚函数,没有数据成员,内存布局是确定的。
总结双类的分工
| 类 | 角色 | 继承关系 |
|---|---|---|
UDamageable |
反射元数据载体 | UDamageable → UInterface → UObject |
IDamageable |
接口方法载体 | 独立(不继承 UObject) |
一个提供"身份"(让反射系统认识这个接口),一个提供"能力"(实际的方法签名)。两者缺一不可。
命名规则是 UHT 的硬性要求
U 和 I 前缀互换不是风格建议,是 UHT 的强制要求。当 UHT 遇到 UINTERFACE() 标记的 UDamageable 时,它会在同一头文件中查找将 U 前缀替换为 I 的类 IDamageable——名字对不上会报编译错误。
两者的绑定发生在编译期:IDamageable 里的 GENERATED_BODY() 展开后,生成的代码会显式引用 UDamageable::StaticClass(),把两个类明确挂钩。这不是运行时靠名字去搜索配对类,而是 UHT 已经在生成代码里写死了关联。
验证方式:把 IDamageable 改名为 IMyDamageable,UHT 立刻报错,因为它找不到 UDamageable 的对应 I 类来生成绑定代码。
6.3 实现接口
一个类要声明自己实现了某个接口,需要在继承列表中加上 IDamageable:
UCLASS()
class AMyCharacter : public ACharacter, public IDamageable
{
GENERATED_BODY()
public:
// 实现接口方法
virtual float GetCurrentHealth() const override
{
return CurrentHealth;
}
virtual void ApplyDamage(float Amount) override
{
CurrentHealth -= Amount;
if (CurrentHealth <= 0.0f)
{
Die();
}
}
private:
UPROPERTY()
float CurrentHealth = 100.0f;
};
注意继承列表:public ACharacter, public IDamageable。这在 C++ 层面确实是多继承——但因为 IDamageable 是一个纯接口(只有虚函数),不会引起 UObject 体系的问题。
UHT 会识别出 AMyCharacter 实现了 IDamageable,并在反射数据中记录这个关系。
一个类可以实现多个接口
UCLASS()
class AMyCharacter : public ACharacter,
public IDamageable,
public IInteractable,
public ISaveable
{
GENERATED_BODY()
// ...
};
完全合法。这就是接口的意义——在单继承体系中获得多能力组合的灵活性。
6.4 使用接口
接口转换
拿到一个 AActor* 或 UObject* 指针后,如何检查它是否实现了某个接口,并调用接口方法?
方式一:Cast
if (IDamageable* Damageable = Cast<IDamageable>(SomeActor))
{
float Health = Damageable->GetCurrentHealth();
Damageable->ApplyDamage(25.0f);
}
Cast<IDamageable>() 在底层会检查目标对象的 UClass 是否实现了 UDamageable 接口。如果是,返回 IDamageable* 指针;如果不是,返回 nullptr。
这里 Cast 面对的是一个接口类型(I 前缀)而不是 UObject 类型(U/A 前缀),但语法完全一致。反射系统让 Cast 可以透明地处理接口转换。
方式二:TScriptInterface
UPROPERTY()
TScriptInterface<IDamageable> DamageTarget;
TScriptInterface 是一个包装器,同时持有 UObject*(保证 GC 安全)和 IDamageable*(用于调用接口方法)。它可以作为 UPROPERTY 暴露给蓝图。
方式三:直接检查
if (SomeActor->GetClass()->ImplementsInterface(UDamageable::StaticClass()))
{
IDamageable::Execute_ApplyDamage(SomeActor, 25.0f);
}
ImplementsInterface() 是纯反射查询。Execute_ 前缀的静态函数是 UHT 为蓝图可实现的接口方法生成的,它通过反射调用方法,对 C++ 实现和蓝图实现都有效。
蓝图中的接口
接口在蓝图中同样可用——前提是声明了 Blueprintable 和适当的 UFUNCTION 标记。蓝图类可以实现 C++ 定义的接口,蓝图节点可以调用接口方法而不需要知道具体的实现类。
这正是接口与反射结合的威力:调用方不需要知道被调用方的具体类型,甚至不需要知道它是 C++ 还是蓝图实现的。
6.5 跨语言对比
Unreal 的接口方案与其他语言的对比,有助于理解其设计取舍:
C++ 纯虚基类
class IDamageable
{
public:
virtual float GetCurrentHealth() const = 0;
virtual ~IDamageable() = default;
};
标准 C++ 也可以定义接口——用纯虚类。但它只有编译时的类型约束,没有运行时查询能力。你不能"拿到一个 void*,问它是否实现了 IDamageable"。Unreal 的方案通过反射添加了这个能力。
Java / C# interface
interface IDamageable {
float getCurrentHealth();
void applyDamage(float amount);
}
Java/C# 的接口有语言级反射支持(instanceof / is),不需要额外的空壳类。Unreal 需要 UDamageable 空壳类正是因为 C++ 没有语言级的接口反射——必须借助 UObject 体系来模拟。
Go interface
type Damageable interface {
GetCurrentHealth() float32
ApplyDamage(amount float32)
}
Go 的接口是隐式的——只要一个类型实现了接口中的所有方法,它就自动满足接口约束,不需要显式声明。Unreal 的接口是显式的——你必须在继承列表中声明 public IDamageable。显式声明的好处是意图明确,隐式的好处是更灵活。
Rust trait
trait Damageable {
fn get_current_health(&self) -> f32;
fn apply_damage(&mut self, amount: f32);
}
Rust 的 trait 提供了零成本抽象——编译器在编译时就确定了具体的方法调用目标(静态分派),不需要虚表查找。同时也支持动态分派(dyn Trait)。Unreal 的接口总是动态分派的(通过虚函数或反射)。
对比总结
| 维度 | C++ 纯虚类 | Java/C# | Go | Rust | Unreal UInterface |
|---|---|---|---|---|---|
| 声明方式 | 纯虚函数 | interface 关键字 |
隐式 | trait 关键字 |
双类(U + I) |
| 运行时查询 | 仅 dynamic_cast |
instanceof / is |
类型断言 | Any trait |
反射 + Cast |
| 蓝图兼容 | 否 | N/A | N/A | N/A | 是 |
| 额外开销 | 虚表 | 虚表 + 反射 | 接口表 | 零成本/虚表 | 虚表 + 反射 |
Unreal 的方案不是最优雅的——双类机制有明显的样板代码开销。但在"C++ 没有原生接口反射"这个约束下,它是一个务实而有效的解决方案。
6.6 何时用 UInterface,何时用其他方案
接口不是唯一的"多能力组合"方案。在 Unreal 中,你还有其他选择:
| 需求 | 推荐方案 |
|---|---|
| 定义一组行为契约,多个不相关的类共同遵守 | UInterface |
| 给 Actor 添加可复用的功能模块 | ActorComponent(第 21 章) |
| 简单的类型标签 / 能力标记 | GameplayTag(轻量标签系统) |
| 需要共享数据成员(不只是方法) | 组件 或 继承 |
UInterface 最适合的场景是:你需要一个"契约"——定义一组方法签名,让不相关的类(比如角色、道具、机关、载具)都能遵守,然后调用方通过接口与它们交互,不需要知道具体类型。
一句话总结
UInterface 通过双类机制(UInterface 提供反射身份 + IInterface 提供方法签名)在 C++ 的单继承 UObject 体系中实现了类似其他语言 interface 的能力——代价是多写一个空壳类,收益是获得了运行时接口查询和蓝图兼容性。
实验:声明并使用一个接口
-
创建接口。 新建一个头文件
Interactable.h,按照 6.2 节的模式声明UInteractable和IInteractable,包含一个纯虚方法void Interact(AActor* Caller)。 -
实现接口。 让你的 Actor 类继承
IInteractable,实现Interact()方法(比如打印一条日志)。 -
通过接口调用。 在另一个 Actor 中,拿到目标 Actor 的指针后:
if (IInteractable* Target = Cast<IInteractable>(HitActor))
{
Target->Interact(this);
}
- 验证反射查询。 用以下代码确认反射系统知道你的类实现了接口:
bool bImplements = MyActor->GetClass()->ImplementsInterface(UInteractable::StaticClass());
UE_LOG(LogTemp, Warning, TEXT("实现了 IInteractable: %s"),
bImplements ? TEXT("是") : TEXT("否"));
第二部分到此完结。你现在已经掌握了 Unreal C++ 方言的四块根基——UObject(统一基类)、反射(运行时类型信息)、GC(自动内存管理)、UInterface(多继承替代)。全景图的中心节点全部点亮。回头对照第 1 章的命名前缀:U(UObject 子类)、A(AActor 子类)、F(值类型)、I(接口纯虚基类)、T(模板)、E(枚举)——每一个前缀的含义,现在你都能从这四章找到出处。
翻到第三部分,我们进入日常开发中每天都在用的那些"不像标准 C++ 的工具"——从容器开始。
更多推荐

所有评论(0)