接口驱动设计:官方社区教程走读
正课学会了 interface 的语法,这篇回答更难的问题:什么时候该抽接口?我们走读 Epic 社区教程「Implementing an Interface in Verse Code」的设计思路,对照官方文档给出的三大价值,最后看看引擎 API 里那些你天天在用、却没意识到的接口。
一、教程在做什么:一份合同,多种履约
Epic 社区教程的套路可以浓缩成三步:定义契约 → 各自实现 → 统一持有。先用 interface 写下「签约者必须会做什么」;再让若干个彼此无关的类各自 <override> 履约;最后调用方用接口类型统一持有它们,根本不关心背后是谁。用一个「可受击物」的例子复刻这个骨架:
Epic 社区教程的套路可以浓缩成三步:定义合同 → 各自实现 → 统一持有。先用 interface(就是蓝图接口 BPI)写下「签约的人必须会做什么」;再让若干个彼此无关的蓝图各自用 <override> 把合同实现掉;最后用接口类型的一根引用统一拿着它们,根本不问背后是谁。用一个「可受击物」的例子复刻这个骨架:
using { /UnrealEngine.com/Temporary/Diagnostics }
# 第一步:契约——凡可受击物,必须会「吃伤害」
damageable := interface:
TakeHit(Amount:int):void
# 第二步:各自履约——木人桩和油桶毫无血缘关系
training_dummy := class(damageable):
TakeHit<override>(Amount:int):void =
Print("木人桩稳稳吃下 {Amount} 点伤害")
exploding_barrel := class(damageable):
TakeHit<override>(Amount:int):void =
Print("油桶被击中,轰然爆炸!")
# 第三步:统一持有——调用方只认合同,不认具体类型
HitAll(Targets:[]damageable, Amount:int):void =
for (T : Targets):
T.TakeHit(Amount)
读图翻译:三段对应三步:先建接口 damageable(合同:会 TakeHit 挨伤害);再让两个毫无血缘的蓝图 training_dummy(木人桩)和 exploding_barrel(油桶)各自签约、把 TakeHit 用 <override> 实现成自己的反应(一个稳稳吃下、一个当场爆炸);最后 HitAll 收一个 []damageable 数组,用 ForEach 挨个喊 TakeHit——它只认「你会挨打」这份合同,不管你具体是谁。
留意 HitAll 的参数类型:[]damageable。木人桩、油桶,以后再加的靶子、稻草人、镜像分身……只要签了约,就能进这个数组、被这个函数处理。新增一种受击物,HitAll 一个字都不用改——这就是接口驱动设计的核心收益:把「会变的部分」(各类的实现)和「不变的部分」(调用逻辑)干净地切开。
留意 HitAll 的参数类型:[]damageable——数组每一格的类型写的是接口。木人桩、油桶,以后再加的靶子、稻草人、镜像分身……只要签了这份约,就能进这个数组、被这个函数处理。新增一种受击物,HitAll 一个字都不用改——这就是接口驱动设计的核心收益:把「会变的部分」(各蓝图各自的实现)和「不变的部分」(调用逻辑)干净地切开。
二、官方文档划的重点:接口的三大价值
官方「Interface in Verse」文档为接口总结了三条价值,值得逐条翻译成人话:
▢ 安全性。接口只暴露契约,不暴露实现:拿着 damageable 的调用方,只能调 TakeHit,碰不到油桶内部的引信状态。暴露面越小,别人能误用的方式就越少。
▢ 安全性。接口只亮出合同、不亮出内部做法:手里拿着一根 damageable 引用的人,只能调合同答应的 TakeHit,碰不到油桶内部的引信状态。暴露面越小,别人能误用的方式就越少。
▢ 多继承。Verse 的类只能继承一个基类,但可以同时实现任意多个接口:class(base, iface1, iface2)。一个 boss 可以既是 damageable 又是 pickup(打死掉落自己?为什么不),两份能力互不干扰地拼在同一个类上——这是继承树给不了的自由。
▢ 多继承。Verse 的一个类只能有一个父类,却能同时实现任意多个接口:class(base, iface1, iface2)——就是蓝图 Class Settings 里那个能勾一长串的 Implemented Interfaces。一个 boss 可以既是 damageable 又是 pickup(打死掉落自己?为什么不),两份能力互不干扰地拼在同一个蓝图上——这是单纯靠父类继承给不了的自由。
▢ 复用性。针对接口写的每一段逻辑(HitAll、掉落结算、UI 列表……)自动适用于所有现在和未来的实现类。写一次,吃到饱。
▢ 复用性。针对接口写的每一段逻辑(HitAll、掉落结算、UI 列表……)自动适用于所有现在和将来签了这份约的蓝图。写一次,吃到饱。
三、什么时候该抽接口:一张检查清单
接口不是越多越好——为只有一个实现的东西抽接口,是给代码穿雨衣洗澡。对照这张清单再动手:
- 两个以上互不相关的类需要同一种能力(金币和药水都能被拾取)→ 抽接口。
- 调用方只关心「会不会做」,不关心「是谁」(结算函数只需要 TakeHit)→ 参数、数组元素用接口类型。
- 用它的那头只关心「会不会做」,不关心「是谁」(结算函数只需要对方会 TakeHit)→ 参数、数组每一格的类型写成接口。
- 想给已有继承树补能力,又不能动基类 → 接口可以叠加:
class(base, iface)。 - 想给已有的一条继承线补能力,又不能动它的父类 → 接口可以另外叠上去:
class(base, iface)。 - 反例:目前只有一个实现,也想不出第二个 → 别抽,直接写在类里,等第二个实现出现再重构。
- 反例:实现类之间要共享字段或默认实现 → 接口给不了(不许字段、不许函数体),该用
<abstract>抽象类。 - 反例:几个实现它的蓝图之间要共享字段、或共用一份默认做法 → 接口给不了(它不许带字段、不许带节点图),该用
<abstract>抽象类。
一句话总结:行为的「共同点」用接口表达,数据和实现的「共同点」用基类表达。两者可以同时上阵——基类管血缘,接口管技能。
一句话总结:行为的「共同点」用接口表达,数据和实现的「共同点」用父类表达。两者可以同时上阵——父类管血缘,接口(BPI)管技能。
四、你天天在用的接口:官方 API 巡礼
接口驱动不是教程里的玩具,Epic 自己的 API 就是这么写的。翻开 Verse API Reference 能看到不少熟面孔:fort_character——角色能力(血量、传送、移动)以接口形式定义,所以不同形态的「角色」都能统一操作;listenable(payload)——你从第 11 课就开始写的 InteractedWithEvent.Subscribe(...),背后正是「可监听」这份带参数的契约;enableable——大量设备的 Enable / Disable 来自同一份「可启停」合同,所以你可以用一段代码统一开关一批不同种类的设备。
接口驱动不是教程里的玩具,Epic 自己的 API 就是这么写的。翻开 Verse API Reference 能看到不少熟面孔:fort_character——角色能力(血量、传送、移动)以接口形式定义,所以各种形态的「角色」都能被统一操作;listenable(payload)——你从第 11 课就在写的 InteractedWithEvent.Subscribe(...)(把设备事件绑到自己的处理上,像蓝图里 Bind Event),背后正是「可监听」这份带参数的合同;enableable——大量设备的 Enable / Disable 来自同一份「可启停」合同,所以你能用一段代码统一开关一批不同种类的设备。
下次调 API 时不妨多看一眼类型签名:当参数类型是个接口,官方是在告诉你「这里认合同不认人」——你自己的类,签了约同样能递进去。读懂了这一层,你看 API 文档的方式会从「查函数」升级成「读设计」。
下次调 API 时不妨多看一眼参数类型:当某个参数的类型是个接口,官方其实在告诉你「这里认合同不认人」——你自己写的蓝图,只要签了这份约,同样能递进去。读懂这一层,你看 API 文档的方式就会从「查函数」升级成「读设计」。
下面哪件事是 interface 做得到、而 <abstract> 抽象类做不到的?
来源与延伸
本文整理自 Epic 官方社区教程与官方文档: