第 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 体系有三个基本假设:

  1. 单一的 UClass:每个 UObject 实例只有一个 UClass 描述它的类型。如果一个类同时继承了两个 UCLASS 标记的类,它该有几个 UClass?
  2. 确定的内存布局:反射系统依赖属性的内存偏移量来读写数据。多继承导致的内存布局不确定性会让偏移量计算出错。
  3. 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);
};

两个类,名字几乎一样,只有前缀不同:UDamageableIDamageable。这看起来很冗余,但每个类都有不可替代的作用。

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(通过 AActorUObject)。如果方法在 UDamageable 上,实现类就必须多继承两个 UObject 后代——这正是 Unreal 要避免的。

IDamageable 不在 UObject 继承链上,所以"继承" IDamageable 不会导致菱形继承问题。在 C++ 层面,这是安全的多继承——因为 IDamageable 只有虚函数,没有数据成员,内存布局是确定的。

总结双类的分工

角色 继承关系
UDamageable 反射元数据载体 UDamageableUInterfaceUObject
IDamageable 接口方法载体 独立(不继承 UObject)

一个提供"身份"(让反射系统认识这个接口),一个提供"能力"(实际的方法签名)。两者缺一不可。

命名规则是 UHT 的硬性要求

UI 前缀互换不是风格建议,是 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 的能力——代价是多写一个空壳类,收益是获得了运行时接口查询和蓝图兼容性。


实验:声明并使用一个接口

  1. 创建接口。 新建一个头文件 Interactable.h,按照 6.2 节的模式声明 UInteractableIInteractable,包含一个纯虚方法 void Interact(AActor* Caller)

  2. 实现接口。 让你的 Actor 类继承 IInteractable,实现 Interact() 方法(比如打印一条日志)。

  3. 通过接口调用。 在另一个 Actor 中,拿到目标 Actor 的指针后:

if (IInteractable* Target = Cast<IInteractable>(HitActor))
{
    Target->Interact(this);
}
  1. 验证反射查询。 用以下代码确认反射系统知道你的类实现了接口:
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++ 的工具"——从容器开始。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐