拔高 · EXTRA
写一个「会失败的函数」:<decides> 与 option 的镜像
失败上下文里不许写 return,那失败函数怎么把值带出来?官方给出的 option 暂存箱范式,把本课概念串成闭环。
判定区(会失败的地方)里不许写 return,那一个「会失败的函数」怎么把值送出来?官方的 option 暂存箱套路,把本课的箱子概念串成一个闭环。
进入拓展 →背包(array)和储物柜(map)都默认「东西一定在」,可游戏里更多的是「可能没有」:宝箱可能是空的,BOSS 可能还没掉落。这一课认识 option——给「可能没有」发正式身份证;再认识 tuple——把几个不同类型的值捆成一个包裹寄出去。
先想一个游戏里的老场景:地图角落有只宝箱。它可能装着一把金钥匙,也可能什么都没有。用之前学过的类型来存这件事,你会立刻卡住——var Key:string = ""?空字符串是「没有钥匙」还是「有一把名字为空的钥匙」?含糊。很多语言用 null 蒙混过关,结果 null 成了「十亿美元的错误」:到处都可能是空,到处都可能崩。
先想一个游戏里的老场景:地图角落有只宝箱,可能装着一把金钥匙,也可能空空如也。这种「可能有、可能没有」你在蓝图里天天遇到:一个对象引用变量,可能指向某个 Actor,也可能是 None。要是没检查就直接拿一个 None 引用去用,屏幕上就会蹦出那句眼熟的红字「Accessed None」。很多做法把「空」和「有」塞进同一个格子里蒙混过关,结果满图都可能是 None、满图都可能炸。Verse 偏不这样。
Verse 的答案干脆利落:把「可能没有」写进类型本身。在任何类型前面加一个 ?,就得到它的可选版本(option):?int 是「可能装着 int 的宝箱」,?string 是「可能装着 string 的宝箱」。而「空宝箱」的字面量,就是 false:
Verse 的答案干脆利落:把「可能没有」这件事,直接写进变量格子的类型里。在任何类型前面加一个 ?,就得到它的「可空版本」,官方叫 option:?int 是「可能装着一个整数、也可能是空的格子」,?string 是「可能装着一段文字、也可能是空的格子」——就像蓝图里那个「可能有 Actor、也可能是 None」的引用变量,只不过 Verse 给这种状态发了张正式身份证。那「空箱子」本身怎么写?就是一个 false:
# ?int:可能装着一个 int,也可能是空的
var MaybeANumber:?int = false
# ?string:BOSS 可能还没掉落任何战利品
var LastDrop:?string = false
敲黑板:这里的 false 不是布尔假!它是「空 option」的专用字面量——宝箱造好了,里面什么都没有。这跟 logic 类型的 false 撞了名,但类型系统分得清清楚楚:?int 格子里的 false 就是空箱,仅此而已。官方文档的示例正是这么写的:var SavedPlayer:?player = false——「还没存下任何玩家」。
敲黑板:这里的 false 不是那个「否 / 关」的布尔值!它是「空箱子」的专用写法——箱子造好了,里面什么都没放。它跟蓝图里 Bool 的 false 只是长得一样、撞了名,系统分得清清楚楚:?int 这个格子里的 false,意思就是「这里现在是空的,相当于 None」,仅此而已。官方文档的示例也是这么写:var SavedPlayer:?player = false——「还没存下任何玩家」,就像一个还没指向任何东西、停在 None 上的引用变量。
好处立竿见影:类型签名会替你说话。看到 ?string,你就知道「这里可能没东西,取值前必须开箱检查」;看到 string,你就知道「这里保证有值,放心用」。「可能为空」从一句口头约定,变成了编译器帮你把守的合同。
好处立竿见影:光看格子的类型,就知道该不该防空。看到 ?string(带问号),你就知道「这里可能是空的(None),用之前得先检查一下」;看到 string(没问号),你就知道「这里保证有值,放心往下连线」。「可能为空」从一句你自己心里的口头约定,变成了 Compile 时帮你盯着的硬规矩——忘了检查,左上角 Compile 直接给你亮红。
宝箱有了,两件事必须会:往里装东西(构造),把东西取出来(解包)。
装箱用 option{值}——注意是花括号,和 array{}、map{} 一家人。写 set MaybeANumber = option{42},42 就被放进了箱子。更妙的是,option{…} 的花括号里本身就是一个失败上下文:你可以把一个可能失败的表达式塞进去,成功就装箱,失败就得到空箱 false。比如 MaybeFirst := option{MyArray[0]}——数组取值失败?没关系,收获一只空宝箱,程序稳稳继续。这是「失败」和「没有」之间的官方换乘通道。
装箱(把东西放进箱子)用 option{值}——注意括号是花括号 {},和第 12、13 课的 array{}、map{} 一家人。写 set MaybeANumber = option{42} 就是一个 Set 节点:把「装着 42 的箱子」这个新值连进变量。更妙的是,option{…} 的花括号里是一个「能不能走通」的判定区(失败上下文):你把一个可能拿不到东西的操作塞进去,走通了就把结果装箱,走不通就自动得到空箱 false。比如 option{MyArray[0]}——想从数组里拿第一个元素,数组要是空的、根本没这一格?没关系,这里不报错,只是收获一只空箱子,后面的线稳稳继续。它把蓝图里最烦的两件事——「操作失败了」和「值是 None」——接成了同一根管道:拿得到就有,拿不到就空。
开箱用后缀 ?:MaybeANumber?。但开箱这个动作本身可能失败(箱子可能是空的!),所以它和数组下标一样,只能出现在失败上下文里。最常用的姿势是掷骰子式的 if:
开箱(把东西取出来)用一个后缀问号 ?:写成 MaybeANumber?。但开箱这个动作本身可能扑空(箱子可能是空的!),所以它跟第 12 课的数组取值一样,只能待在「能不能走通」的判定区里。最顺手的姿势,就是套一个 Branch——在 Verse 里,Branch 就是 if:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
treasure_device := class(creative_device):
# 宝箱格子:可能装着一把金钥匙,也可能是空的
var MaybeKey:?string = false
OnBegin<override>()<suspends>:void =
Print("开局检查:宝箱还没放东西")
set MaybeKey = option{"金钥匙"}
if (Key := MaybeKey?):
Print("开箱成功,拿到:{Key}")
else:
Print("宝箱是空的……")
点「运行下一步」,看代码怎么一行行执行。
把这套动作背下来:if (Key := MaybeKey?):——开箱、检查、接值,一行完成。箱里有货就进成功分支,Key 拿到的是拆掉包装的裸值(string,不再是 ?string);箱子是空的就走 else,绝无崩溃。是不是和第 12 课数组下标 if (X := Items[0]): 一模一样的手感?这不是巧合——Verse 把所有「可能拿不到」的操作统一成了「失败」,一套语法通吃。
把这套动作记牢:if (Key := MaybeKey?):——开箱、检查、接值,一根 Branch 一步到位。箱里有货就走真引脚,Key 拿到的是拆掉包装的「裸值」(直接是一段 string 文字,不再是那个带问号的箱子);箱子空了就走 else 引脚,绝不崩。是不是和第 12 课从数组里取值 if (X := Items[0]): 一模一样的手感?这不是巧合——Verse 把所有「可能拿不到」的操作,都统一成同一种「这条线走不走得通」的判定,一根 Branch 语法通吃。
顺手练一把,给这台捡金币设备把两个关键语法填回去:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
coin_device := class(creative_device):
# 可能捡到金币,也可能两手空空
var MaybeCoin:?int = false
OnBegin<override>()<suspends>:void =
# 装箱:构造一个非空 option(记住括号的形状)
set MaybeCoin = ____{99}
# 开箱:解包要加一个后缀符号
if (Coin := MaybeCoin____):
Print("捡到 {Coin} 枚金币")
换一个痛点。你想写一个函数返回出生点坐标——X 一个 float,Y 一个 float,顺便再带个地点名 string。可函数只能返回一个值,难道要开三个全局变量来传话?不用。Verse 的答案是 tuple(元组):把几个值——类型可以各不相同——捆成一个包裹,当作一个值来传递。
换一个痛点。你想做一个蓝图函数,吐出出生点坐标——一个 X(小数)、一个 Y(小数),顺手再带个地点名(文字)。麻烦来了:蓝图函数默认只有一个返回引脚,难道要为这仨值各开一个变量来回传话?不用。Verse 的答案是 tuple(元组):把几个值——类型可以各不相同(小数、文字混着来都行)——捆成一个包裹,当成一个值来传。你可以把它想成蓝图里的一个小结构体(Struct),或者一个「一次性拉出好几个输出引脚」的节点。
类型写作 tuple(t1, t2, …),字面量就是一对圆括号加逗号:(1, 2.0, "three")。还记得第 12 课说过 array{1, "a"} 混装会编译报错吗?当时留的话现在兑现:混装找 tuple。
类型写成 tuple(t1, t2, …),而具体的值就是一对圆括号加逗号:(1, 2.0, "three")。还记得第 12 课说过 array{1, "a"} 这种混装会 Compile 报红吗?当时埋的伏笔现在兑现:数组要求每格同类型,想混装不同类型,就找 tuple。
using { /UnrealEngine.com/Temporary/Diagnostics }
# 混装包裹:int、float、string 打成一包
Loot:tuple(int, float, string) = (3, 0.5, "回复药水")
# 函数的多返回值:直接返回一个 tuple
GetSpawnPoint():tuple(float, float) =
(128.0, 256.0)
ShowLoot():void =
# 下标访问用圆括号,从 0 数起
Count := Loot(0)
Name := Loot(2)
Print("{Name} x{Count}")
# 接住多返回值:先存整包,再按位置拆
Point := GetSpawnPoint()
X := Point(0)
Y := Point(1)
Print("出生点:({X}, {Y})")
读图翻译:这段代码干了三件事。第一,Loot 是一个装着「3、0.5、回复药水」的三格包裹(int、float、string 混装的 tuple)。第二,GetSpawnPoint 是个蓝图函数,直接吐出一个两格包裹 (128.0, 256.0) 当返回值。第三,ShowLoot 里用 Loot(0)、Loot(2) 按格子序号取值(相当于从包裹上拉出第 0、第 2 个输出引脚),再把 GetSpawnPoint() 的整包接住、拆成 X 和 Y 打出来。取值的圆括号 (0) 就是「拉出第几号引脚」的意思。
三个关键性格,一个比一个有意思:
1. 下标用圆括号,而且不会失败。数组是 A[0](方括号、可失败、要失败上下文),tuple 是 T(0)(圆括号、不可失败、随便在哪写)。为什么待遇差这么多?因为 tuple 的长度和每个位置的类型在编译期就板上钉钉——tuple(int, float, string) 永远正好三格。你要是写 Loot(3),编译器当场报错,根本轮不到运行时「失败」。代价是下标必须是编译期常量:T(i) 里放一个运行时变量 i 编译不过,因为每格类型不同,编译器无法确定 T(i) 该是什么类型。需要动态下标?那说明你要的其实是数组。
1. 取值用圆括号,而且绝不会扑空。从数组里取值是 A[0](方括号、可能扑空、得待在判定区里);从 tuple 取值是 T(0)(圆括号、绝不扑空、随便哪儿都能写)。为什么差这么多?因为 tuple 有几格、每格是什么类型,在 Compile 的那一刻就钉死了——tuple(int, float, string) 永远正好三格。你要是写 Loot(3)(第 4 格,不存在),Compile 当场亮红,根本轮不到游戏跑起来才出事。代价是:格子序号必须是写死的固定数字,不能是一个跑起来才知道的变量。T(i) 里塞一个变量 i 会 Compile 不过——因为每格类型不同,编译器没法提前知道你这一下拉出来的是文字还是数字。真需要拿变量当序号来回取?那说明你要的其实是数组,不是 tuple。
2. 不可变,而且比数组更彻底。没有 set T(0) = X 这种写法,想「改」就造一个新 tuple 整体替换。
2. 装好就锁死,比数组还彻底。没有 set T(0) = X 这种「改其中一格」的 Set 写法——tuple 一旦做出来,就不给你单独改某一格。想「改」?只能整包重新造一个,再替换掉。
3. 多返回值的官方姿势。Verse 没有 (A, B) := F() 这样的解构赋值——先把整包接住存进一个名字,再用 Result(0)、Result(1) 按位置拆,这就是标准流程。另外剧透一句:tuple 和函数参数列表在 Verse 里是近亲,把一个 tuple 当一个实参传给函数,它会自动展开成一串实参——这个「拆包直通」的技巧我们放在拓展页细讲。
3. 这就是「一个函数吐好几个值」的官方姿势。Verse 里没有 (A, B) := F() 这种「一行拆两个」的写法——你得先把整包接住、存进一个名字,再用 Result(0)、Result(1) 按序号一格一格拆,这就是标准流程(很像蓝图里先接住节点的输出、再一根根引脚往下连)。另外剧透一句:tuple 和「函数的参数列表」在 Verse 里是近亲,把一整包 tuple 当作参数传给一个多入口的函数,它会自动摊开、对号入座——这个「一包直接喂满多个入参」的技巧,放到拓展页细讲。
option 和 tuple 的坑,九成都出在「括号形状」和「和谁比较」上。逐条排雷:
坑 1:option(42) 圆括号构造。编译错误。装箱只有花括号一种写法:option{42}。官方风格指南还推荐短表达式写成单行 option{a}。
坑 1:option(42) 用圆括号来装箱。Compile 报红。装箱只认花括号这一种写法:option{42}。官方风格指南还建议:短的话写成一行 option{a} 就行。
坑 2:T[0] 用方括号取 tuple。编译不过。方括号是数组和 map 的地盘,tuple 认圆括号:T(0)。一张对照表帮你焊死记忆:
坑 2:T[0] 用方括号去取 tuple。Compile 不过。方括号 [] 是数组和 map 的地盘,tuple 只认圆括号:T(0)。下面这张对照表帮你把记忆焊死:
| 写法 | 谁在用 | 会失败吗 |
|---|---|---|
A[0] |
array / map | 会,必须住在失败上下文里会——可能扑空,只能待在判定区(Branch 的条件)里 |
T(0) |
tuple | 不会,越界是编译错误不会——超出格数是 Compile 报红,不是运行时扑空 |
M? |
option 解包 | 会,必须住在失败上下文里会——可能扑空,只能待在判定区(Branch 的条件)里 |
坑 3:在失败上下文之外开箱。Key := MaybeKey? 顶格裸写,收获老朋友:「This invocation calls a function that has the 'decides' effect, which is not allowed by its context.」第 12、13 课见过两回了,处方不变——搬进 if、for 过滤条件或 option{} 里。
坑 3:没套判定区就开箱。把 Key := MaybeKey? 顶格裸写(外面没有 Branch / if 罩着),Compile 会亮红,报的正是那句老朋友:「This invocation calls a function that has the 'decides' effect, which is not allowed by its context.」——意思是「这个可能扑空的动作,不许在这种地方裸跑」。第 12、13 课撞见过两回了,处方不变:把它搬进 if、搬进 for 的过滤条件、或者塞进 option{} 里。
坑 4:拿 option 直接和裸值比。if (MaybeX = 42) 是类型不匹配:左边是 ?int,右边是 int,两个世界的人。先开箱再比:if (MaybeX? = 42)——解包和比较住在同一个失败上下文里,箱子空了或值不等都算失败,一步到位。
坑 4:拿箱子直接跟裸值比大小。if (MaybeX = 42) 会报类型对不上:左边是一个「可能空的箱子」(?int),右边是一个光溜溜的整数(int),俩不是一路人,没法直接比。得先开箱再比:if (MaybeX? = 42)——开箱和比较都待在同一个判定区里,箱子要是空的、或者值不等于 42,这条线就走不通(走 else),一步到位。
坑 5:幻想解构赋值。(A, B) := GetXY() 在别的语言里潇洒,在 Verse 里是编译错误。老老实实先接整包再按位置拆。
坑 5:幻想「一行拆成好几个」。(A, B) := GetXY() 这种「一次接住两个」的写法,某些编程语言里很潇洒,但 Verse 里是 Compile 报红。老老实实先把整包接住,再按序号一格格拆。
坑 6:tuple 用运行时变量当下标。T(i) 编译不过,原因上一节讲过——每格类型不同,编译器必须静态知道你拿的是哪一格。
坑 6:拿一个跑起来才定的变量当 tuple 的格子序号。T(i) Compile 不过,原因上一节说了——每格类型不一样,编译器必须在 Compile 时就确定你拉的是哪一格,不能等游戏跑起来才知道。
到这里,第四章的四件容器全部到齐。合影留念,顺便发一张选型速查表:
| 容器 | 类型写法 | 字面量 | 什么时候用它 |
|---|---|---|---|
| array | []int |
array{1, 2, 3} |
数量不定、类型相同、讲顺序:一队怪物、弹药清单 |
| map | [string]int |
map{"A" => 1} |
按键查值:玩家名对应分数、道具名对应价格 |
| option | ?int |
false / option{42} |
0 或 1 个值,「可能没有」:上一个命中的目标、可能为空的宝箱 |
| tuple | tuple(int, string) |
(1, "剑") |
个数固定、类型可混、打包同行:函数多返回值、坐标对 |
选型口诀四连问:数量会变吗?会——array 或 map(讲顺序用 array,按键查用 map)。可能压根没有吗?是——option。就固定那几个、还想混类型?——tuple。再补一条冷知识:只要成员类型都是 comparable,option 和 tuple 自己也是 comparable,可以直接放进 map 当键——比如用 tuple(int, int) 的格子坐标当键做一张棋盘 map,四件容器就这样咬合成了一套齿轮。
选型口诀四连问:数量会变吗?会——array 或 map(讲顺序用 array,按名字查用 map)。可能压根就没有吗?是——option。就固定那么几个、还想混不同类型?——tuple。再补一条冷知识:只要里面装的成员都是「能比大小、能判相等」的类型(官方叫 comparable),option 和 tuple 自己也就能比、能判等,于是它们能反过来当 map 的键用——比如拿 tuple(int, int) 的格子坐标当键做一张棋盘 map。四件容器就这样咬合成了一套齿轮。
宝箱开完、包裹拆完,来三道关卡验收。答错零惩罚,可以一直重试。
var MaybeCoin:?int = false 这一行里的 false 是什么意思?
已有 T:tuple(int, string) = (7, "剑"),下面哪种取值写法能编译通过?
想把可能失败的数组取值 Items[0] 转换成一个 ?int 值(拿不到就得到空 option),应该怎么写?想把「可能扑空的数组取值 Items[0]」变成一个可空的整数箱 ?int(拿到就装箱,拿不到就得到空箱),该怎么写?
拔高 · EXTRA
失败上下文里不许写 return,那失败函数怎么把值带出来?官方给出的 option 暂存箱范式,把本课概念串成闭环。
判定区(会失败的地方)里不许写 return,那一个「会失败的函数」怎么把值送出来?官方的 option 暂存箱套路,把本课的箱子概念串成一个闭环。
进入拓展 →技巧 · EXTRA
把 tuple 当单个实参传给函数,它会自动拆成一串参数——背后是 Verse「一切调用都是传一个 tuple」的统一模型。
把一整包 tuple 当成一个参数传进函数,它会自动摊开、对号入座填满好几个入参——背后是 Verse「每次调用其实都在传一个 tuple」的统一世界观。
进入拓展 →拓展 · EXTRA
并发原语 sync 等所有任务完成后,把各自结果按书写顺序打包成 tuple 返回——tuple 静态定长的特性在这里刚好严丝合缝。
并发指令 sync 等几件事全部干完之后,把各自的结果按你写代码的先后顺序打成一个 tuple 交回来——tuple「格数固定」的脾气在这儿刚好严丝合缝。
进入拓展 →