技巧 · EXTRA
struct 不可变怎么办:不可变数据模式实战
错误 3607 的三条出路:函数式更新、升级 class、方法封装,附「更新 struct 数组中一项」的完整范式。
错误 3607 的三条出路:照新值重造(函数式更新)、升级成蓝图类、用函数封装,附一份「更新数组里某一个结构体」的完整套路。
进入拓展 →class 递给你的是一枚指向对象的「遥控器」,struct 递来的却是整个「包裹」——赋值即拷贝,互不打扰;interface 更绝,连对象都不给,只递一纸「合同」。这一课看清值语义与引用语义的分水岭,再用一份 pickup 合同,让金币和药水这些不相干的道具被同一段代码统一处理。
上一课你学会了 class:一张蓝图,造出的实例通过「遥控器」(引用)操作——把实例赋给另一个名字,复制的只是遥控器,电视还是同一台。但游戏里有大量数据根本不需要这种「共享」:一个坐标、一条伤害记录、一份战绩快照……它们只是几个数字打包在一起,拷来拷去、各用各的才是常态。为这种场景,Verse 准备了 struct(结构体):
上一课的 class 就是你早已熟悉的蓝图类:放进关卡的实例,你手里攥着的是一根「引用」(遥控器)——把这个引用变量赋给另一个变量,复制的只是那根引用,两根线指着同一个 Actor。但游戏里一大半数据根本不需要这种「共用一个 Actor」:一个坐标、一条伤害记录、一份战绩快照……它们只是几个数字凑一堆,拷来拷去、各用各的才正常。为这种场景,Verse 备了 struct(结构体)——就是蓝图里右键新建的那种 Structure 资产:
# struct:一只轻量的数据包裹,字段全是常量
coord := struct:
X:int = 0
Y:int = 0
# 实例化和 class 一样用原型语法,没有 new
Origin := coord{}
Target := coord{X := 3, Y := 4}
定义和 class 几乎长得一样:类型名小写下划线,字段带类型和默认值,实例化用原型语法 coord{X := 3}——没有默认值的字段必须在花括号里补齐,一个都不能少。真正的分水岭藏在赋值语义里:struct 是值语义(value semantics),class 是引用语义(reference semantics)。把 struct 赋给另一个变量、传进函数、塞进数组,发生的都是整体拷贝;从那一刻起,两份数据彻底分家,改谁都影响不到对方。口说无凭,下面这台实验设备把两种语义放在一起对照,点「运行下一步」亲眼看差别:
上面这段代码就是新建一个 coord 结构体资产,里面塞两个整数字段 X、Y(默认都是 0),再造两只实例:一只空的 coord{}(字段全取默认),一只 coord{X := 3, Y := 4}(在花括号里把字段填好)。定义方式和蓝图类几乎一样:名字小写下划线,字段各带类型和默认值,没有默认值的字段实例化时必须在花括号里补齐,一个都不能少。真正的分水岭在「赋值那一下到底发生了什么」:struct 是整包拷贝,class 是复制引用。把一个结构体赋给别的变量、当参数传进函数、塞进数组,每一次都是把整包复制一份;从那刻起两份数据彻底分家,改哪份都碰不到另一份。口说无凭,下面这台实验设备把两种脾气摆在一起,点「运行下一步」亲眼看:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
# hero 是 class:引用语义
hero := class:
var Health:int = 100
# coord 是 struct:值语义,字段不许 var
coord := struct:
X:int = 0
copy_lab_device := class(creative_device):
OnBegin<override>()<suspends>:void =
HeroA := hero{}
HeroB := HeroA
set HeroB.Health = 10
Print("HeroA 的血量:{HeroA.Health}")
P1 := coord{X := 1}
var P2:coord = P1
set P2 = coord{X := 99}
Print("P1.X = {P1.X} / P2.X = {P2.X}")
点「运行下一步」,看代码怎么一行行执行。
同一段代码里,class 的修改「隔空生效」,struct 的修改「与我无关」。记住这幅画面:class 复制遥控器,struct 复制包裹——第五章后面所有的选型问题,都从这一句出发。
struct 不是「低配版 class」,它是刻意设计出来的不可变数据聚合。为此它立了三条家规,条条都有编译器撑腰:
struct 不是「缩水版蓝图类」,它是被特意设计出来的一包装好就不能改的数据。为此它立了三条家规,条条都有 Compile(蓝图左上角那个编译按钮)在背后撑腰,违规当场亮红:
▢ 家规一:字段不许 var。在 struct 里写 var Count:int = 0,编译器直接甩出错误 3607:「Structs may not contain mutable members」。struct 的字段全部是常量,想「修改」一个 struct?没有这回事——你只能构造一个带新值的新实例,旧的原封不动。这不是限制没做完,而是语言设计:不可变的值拷贝起来永远安全。
▢ 家规一:字段不许带 var(不许可改)。在结构体里写一个能改的字段 var Count:int = 0,Compile 直接甩出错误 3607:「Structs may not contain mutable members」。结构体的字段全是锁死的、根本不给你接 Set 节点,想「改」一个结构体?没这回事——你只能照着新值重新造一整包,旧的那包原封不动。这不是功能没做完,而是故意的:一包不会变的数据,拷来拷去永远安全。
▢ 家规二:体内不许写方法。struct 的花括号里只放数据,函数一律免谈。想给 struct 挂行为,官方指定的出路是第 15 课学过的扩展方法——在类型体外定义,调用起来却像成员方法:
▢ 家规二:里面不许写函数。结构体的花括号里只能放数据字段,蓝图函数一律免谈。想给结构体挂点行为,官方指定的路子是第 15 课学过的扩展方法——在结构体外面单独定义一个函数,可调用起来却像是它自带的成员函数:
coord := struct:
X:int = 0
Y:int = 0
# struct 体内不能写方法,扩展方法从体外补上
# 顺便示范「不可变更新」:返回一只新包裹
(P:coord).Mirrored():coord =
coord{X := P.Y, Y := P.X}
读图翻译:上面先建了个 coord 结构体(两个字段 X、Y)。下面那行 (P:coord).Mirrored():coord 就是给 coord 外挂一个函数,名叫 Mirrored、输出还是一个 coord:它把传进来的那包的 X、Y 对调,造一只全新的包裹返回,原来那包一点没动——这正是家规一说的「不能改,只能重造」。
▢ 家规三:不能比较。struct 不在 Verse 的 comparable(可比较类型)名单里:两个 struct 不能用 = 比较,也不能当 map 的键,硬来会收获「not comparable」类错误。想按内容比较,只能自己逐字段写;想当键,先把关键字段拆出来用 int / string 当键。
▢ 家规三:不能直接比较。结构体不在 Verse 的「可比较类型」名单里:两个结构体不能用 = 判相等,也不能拿来当 Map(储物柜)的键,硬来会收获「not comparable」这类报错。想按内容比,只能自己一个字段一个字段地比;想当 Map 的键,就先把关键的那个字段(比如一个 int 或 string)单拆出来当键。
作为补偿,struct 有一项 class 羡慕不来的特权:它支持 <persistable> specifier,可以作为跨局持久化数据(weak_map 存档)的值类型——正因为它是纯数据、不可变、没有身份,存起来才干净利落。这条线索我们在第 18 课讲 specifiers 时收网。
作为补偿,struct 有一项蓝图类羡慕不来的特权:它能挂 <persistable> 标记,当作跨对局存档(SaveGame)里保存的数据——就是那种「玩家退出重进还在、每人一份」的存档值。正因为它是纯数据、不会变、也没有对象身份,存起来才干净利落。这条线索留到第 18 课讲各种标记(specifiers)时收网。
看完管数据的 struct,再看管行为的 interface(接口)。接口是一纸合同:它只规定「签约者必须会做什么」,一个字的实现都不写。体内只允许函数签名——不许有字段,不许有函数体,连「部分实现」都不行(这正是它和 <abstract> 抽象类的区别:抽象类可以带字段和默认实现,接口是纯契约)。
看完管数据的 struct,再看管行为的 interface(接口)。这个你其实早就会了——它就是蓝图接口(Blueprint Interface,BPI)的代码形态。接口是一纸合同:它只规定「签约的人必须会做哪些事」,具体怎么做一个字都不写。里面只允许写函数的「名字和引脚」(签名)——不许有变量字段,不许有函数体(没有节点图),连「先实现一半」都不行。这正是它和 <abstract> 抽象类的区别:抽象类可以带字段和默认做法,接口是纯合同、光有条款没内容。
# 合同:凡是「可拾取物」,必须会报名字、会被收集
pickup := interface:
GetName():string
Collect():void
读图翻译:这三行就是新建一个蓝图接口 pickup,往里加两个函数:GetName(输出一段文字 string)和 Collect(没有输出)。两个都只有声明、没有实现——跟你在编辑器里建 BPI 时只填函数名和引脚、不画节点图,是一模一样的事。
围绕这纸合同,有四条规则:
其一,接口不能实例化——pickup{} 直接编译错误,合同本身不是道具。其二,类通过 class(pickup) 签约,签了就必须实现合同里的每一个方法,而且每个实现都要挂 <override>,签名(包括 effect specifiers)必须与接口声明完全一致。其三,类只能继承一个基类,却可以同时实现任意多个接口:class(base, iface1, iface2)——这是 Verse 里唯一的「多继承」。其四,接口自己也可以继承多个接口,把小合同拼成大合同。
围绕这纸合同有四条规则,蓝图里全找得到对应:其一,接口不能实例化——写 pickup{} 想直接造一个,直接 Compile 报红,合同本身不是道具(就像 BPI 资产不能直接拖进关卡)。其二,一个蓝图类写 class(pickup) 就是签了这份约(相当于在 Class Settings 里勾上这个接口),签了就必须把合同里每一个函数都实现掉,而且每个实现都要挂 <override>,入参、返回、各种标记都得和接口里声明的一字不差。其三,一个类只能有一个父类,却可以同时实现任意多个接口:class(base, iface1, iface2)——这是 Verse 里唯一的「多继承」,也正是蓝图里一个蓝图能在 Implemented Interfaces 列表里勾一长串接口的那件事。其四,接口自己也能继承好几个接口,把小合同拼成大合同。
先来一道热身填空,把两个关键词填回去,再点「检查答案」:
# 一纸合同:体内只有签名,没有函数体(填空 1)
pickup := ____:
GetName():string
# coin 签约履约:实现接口方法必须挂的 specifier(填空 2)
coin := class(pickup):
GetName<____>():string = "金币"
接口真正的威力在「统一处理」。金币和药水是两个八竿子打不着的类:一个记面值,一个记回复量,互相没有任何继承关系。但只要都签了 pickup 合同,它们就能躺进同一个数组,被同一段循环处理——调用方只认合同,不认具体是谁。这就是多态(polymorphism):
接口真正的威力在「统一处理」。金币和药水是两个八竿子打不着的蓝图:一个记面值,一个记回复量,谁也不是谁的子类。但只要都签了 pickup 这份接口合同,它们就能躺进同一个数组,被同一个 ForEach Loop 逐个处理——调用的那头只认合同「你会 Collect 就行」,根本不问你具体是谁。这就是多态(polymorphism):
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 合同:凡是「可拾取物」,必须会报名字、会被收集
pickup := interface:
GetName():string
Collect():void
# 金币:一种履约方式
coin := class(pickup):
Value:int = 10
GetName<override>():string = "金币"
Collect<override>():void =
Print("金币入账 +{Value}")
# 药水:完全不同的另一种履约方式
potion := class(pickup):
Heal:int = 25
GetName<override>():string = "回血药水"
Collect<override>():void =
Print("生命回复 {Heal} 点")
loot_demo_device := class(creative_device):
OnBegin<override>()<suspends>:void =
# 数组元素类型写接口:装得下所有签约者
Pickups:[]pickup = array{coin{}, potion{}, coin{Value := 100}}
for (Item : Pickups):
ItemName := Item.GetName()
Print("捡到:{ItemName}")
Item.Collect()
读图翻译:这段从上往下:先建接口 pickup(合同:会 GetName、会 Collect);再建两个各不相干的蓝图 coin 和 potion,它们都签了这份接口、各自把 GetName 和 Collect 用 <override> 实现成自己的样子(金币打「入账」,药水打「回血」);最后在 OnBegin(相当于 Event BeginPlay)里,把一枚金币、一瓶药水、一枚大金币塞进一个数组,用一个 ForEach Loop 把它们一个个拿出来,先喊它 GetName、再让它 Collect。同一个循环,不同的道具,各跑各的实现。
注意 Pickups:[]pickup 这一行:数组的元素类型写的是接口,所以 coin{}、potion{}、连同一枚面值 100 的大金币都能一起入列。循环体里,Item.Collect() 到底执行哪份代码,由每个对象的真实类型在运行时决定——以后再加一个 gem := class(pickup),这段循环一个字都不用改。
盯住 Pickups:[]pickup 这一行:数组格子的类型写的是接口,所以只要签了 pickup 约,金币、药水、连那枚面值 100 的大金币,都能挤进同一个数组。ForEach 里 Item.Collect() 到底跑哪个蓝图的实现,是由每个实例的真实身份在游戏跑起来时才定的——以后再加一个 gem := class(pickup),这段循环一个字都不用改,新道具自动被同样处理。
反过来,如果某一刻你确实想知道「这只 pickup 到底是不是金币」,可以用类型转型(type cast):写法是 coin[Item],方括号说明它是可失败表达式,必须住在 if 这样的失败上下文里——转型失败就像掷骰失败,整个条件安静地不成立:
反过来,如果某一刻你确实想知道「这只 pickup 到底是不是金币」,可以做一次类型转型——就是蓝图里的 Cast To 节点。写法是 coin[Item],相当于「Cast To coin」;方括号表示这一下可能失败(它就不是金币呢),所以得待在 if(一个 Branch)里——Cast 失败就像掷骰没过,这条线安静地走不通(走 else)。下面这段就是在 ForEach 里对每个 Item 试着 Cast 成 coin,成功了 TheCoin 就拿到金币、能读它的 Value 并打出来:
for (Item : Pickups):
# 转型成功,TheCoin 就是 coin 类型,能读它的 Value
if (TheCoin := coin[Item]):
Print("这是一枚面值 {TheCoin.Value} 的金币")
不过别急着到处转型:多数时候,把「各自不同的行为」写进各自的 <override> 里让多态自己分发,比一长串 if 转型优雅得多。
不过别急着到处 Cast:多数时候,把「各自不同的行为」写进各个蓝图自己实现的接口函数(那些 <override>)里,让多态自动分发,比一长串 if + Cast 优雅得多。
struct、class、interface 各管一摊,选型其实只看你要什么:
| 你要的东西 | 选谁 | 凭什么 |
|---|---|---|
| 一小包纯数据,拷贝隔离、可持久化 | struct |
值语义天生拷贝;支持 <persistable>;无 var、无方法,轻整包拷贝天生隔离;能挂 <persistable> 当 SaveGame 存档;没有可改字段、没有函数,轻 |
| 可变状态 + 方法 + 继承 + 对象身份能改的状态 + 蓝图函数 + 继承 + 对象身份(引用) | class |
引用语义;var 字段、方法、单继承、<unique> 都在这边引用(遥控器)语义;可 Set 的字段、蓝图函数、单继承、<unique> 都在这边 |
| 只约定「会做什么」,让无关类型统一多态只约定「会做什么」,让不相干的蓝图被统一多态处理 | interface |
纯契约,跨继承树;一个类可以同时签任意多份纯合同,跨不同继承线;一个蓝图能同时勾任意多份接口 |
再把本课的高频编译错误排个雷:
▢ 坑一:给 struct 塞 var 字段。错误 3607「Structs may not contain mutable members」,新手几乎必踩一次。想要可变字段,要么改用 class,要么接受「构造新实例」的不可变更新。
▢ 坑一:给结构体塞一个可改(var)字段。错误 3607「Structs may not contain mutable members」,新手几乎必踩一次。想要能改的字段,要么改用蓝图类(class),要么接受「整包重造」这种不动原件、只出新件的更新方式。
▢ 坑二:在 struct 体内写方法。直接报错。行为请走扩展方法,或者干脆问自己:是不是一开始就该用 class?
▢ 坑二:在结构体里写函数。直接报错。行为请走外挂的扩展方法,或者干脆问自己:是不是一开始就该用蓝图类(class)?
▢ 坑三:「我改了但没生效」。从数组或 map 里取出一个 struct,改完发现容器里那份纹丝不动——当然,取出来的那一刻就已经是拷贝了。要更新容器里的 struct,得构造新实例再放回去。
▢ 坑三:「我明明改了却没生效」。从数组或 Map 里取出一个结构体,改完发现容器里那份纹丝不动——当然,你取出来的那一刻拿到的就已经是一份拷贝了。要真更新容器里那份,得照新值造一整包、再塞回原来的位置。
▢ 坑四:履约忘挂 <override>,或签名对不上。实现接口方法必须 <override>,而且参数、返回类型、effect specifiers(比如 <decides>)都要与接口声明一字不差,缺一个都过不了编译。
▢ 坑四:履约忘挂 <override>,或签名对不上。实现接口里的函数必须挂 <override>(就是蓝图里那个 Override 的意思),而且入参、返回类型、各种效果标记(比如 <decides>)都要跟接口里声明的一字不差,差一个 Compile 就过不了。
▢ 坑五:实例化接口。pickup{} 和实例化抽象类一样,编译器当场拒收——能实例化的永远是签了约的具体类。
▢ 坑五:想直接造一个接口。pickup{} 和造抽象类一样,Compile 当场拒收——能造出实例的,永远是签了这份约的具体蓝图,不是合同本身。
▢ 坑六:转型写错括号。coin(Item) 是函数调用不是转型;coin[Item] 才是,而且必须待在 if 等失败上下文里,裸写同样报错。
▢ 坑六:Cast 写错括号。coin(Item) 用圆括号,那是函数调用、不是转型;coin[Item] 用方括号才是 Cast To,而且必须待在 if(Branch)里,顶格裸写照样报红。
三道关卡验收本课战利品:值语义、struct 家规、接口规则各考一题。答错零惩罚,可以一直重试。
三道关卡验收本课战利品:拷贝语义、struct 家规、接口规则各考一题。答错零惩罚,可以一直重试。
P1 := coord{X := 1},然后 var P2:coord = P1,再 set P2 = coord{X := 99}。此刻 P1.X 是多少?造一个结构体 P1(X=1),把它整包赋给 P2(var P2:coord = P1),再把 P2 整只换成一个新结构体(set P2 = coord{X := 99})。此刻 P1.X 是多少?
在 struct 里写 var Count:int = 0,会发生什么?在结构体(struct)里加一个可改字段 var Count:int = 0,会发生什么?
关于 interface,下面哪句是对的?
技巧 · EXTRA
错误 3607 的三条出路:函数式更新、升级 class、方法封装,附「更新 struct 数组中一项」的完整范式。
错误 3607 的三条出路:照新值重造(函数式更新)、升级成蓝图类、用函数封装,附一份「更新数组里某一个结构体」的完整套路。
进入拓展 →拔高 · EXTRA
把「类型」装进变量之后,你会撞上 Verse 类型系统最硬的一堵墙——以及它背后的 Rice 定理。
当你想把「某个类型」本身当成值塞进变量之后,会撞上 Verse 判断类型时最硬的一堵墙——以及它背后的 Rice 定理。
进入拓展 →拓展 · EXTRA
什么时候该抽接口?官方教程的完整示例,加上引擎自带接口的真实案例,给你一张决策清单。
进入拓展 →