Verse Wiki — 写给蓝图作者的 Verse 手册
第五章 · 第 18 课

option 与 tuple:可能为空的宝箱,打包多值的包裹

背包(array)和储物柜(map)都默认「东西一定在」,可游戏里更多的是「可能没有」:宝箱可能是空的,BOSS 可能还没掉落。这一课认识 option——给「可能没有」发正式身份证;再认识 tuple——把几个不同类型的值捆成一个包裹寄出去。

一、?t 可选类型:这只宝箱可能是空的

先想一个游戏里的老场景:地图角落有只宝箱,可能装着一把金钥匙,也可能空空如也。这种「可能有、可能没有」你在蓝图里天天遇到:一个对象引用变量,可能指向某个 Actor,也可能是 None。要是没检查就直接拿一个 None 引用去用,屏幕上就会蹦出那句眼熟的红字「Accessed None」。很多做法把「空」和「有」塞进同一个格子里蒙混过关,结果满图都可能是 None、满图都可能炸。Verse 偏不这样。

Verse 的答案干脆利落:把「可能没有」这件事,直接写进变量格子的类型里。在任何类型前面加一个 ?,就得到它的「可空版本」,官方叫 option:?int 是「可能装着一个整数、也可能是空的格子」,?string 是「可能装着一段文字、也可能是空的格子」——就像蓝图里那个「可能有 Actor、也可能是 None」的引用变量,只不过 Verse 给这种状态发了张正式身份证。那「空箱子」本身怎么写?就是一个 false:

option_basics.verse
# ?int:可能装着一个 int,也可能是空的
var MaybeANumber:?int = false

# ?string:BOSS 可能还没掉落任何战利品
var LastDrop:?string = false

敲黑板:这里的 false 不是那个「否 / 关」的布尔值!它是「空箱子」的专用写法——箱子造好了,里面什么都没放。它跟蓝图里 Bool 的 false 只是长得一样、撞了名,系统分得清清楚楚:?int 这个格子里的 false,意思就是「这里现在是空的,相当于 None」,仅此而已。官方文档的示例也是这么写:var SavedPlayer:?player = false——「还没存下任何玩家」,就像一个还没指向任何东西、停在 None 上的引用变量。

好处立竿见影:光看格子的类型,就知道该不该防空。看到 ?string(带问号),你就知道「这里可能是空的(None),用之前得先检查一下」;看到 string(没问号),你就知道「这里保证有值,放心往下连线」。「可能为空」从一句你自己心里的口头约定,变成了 Compile 时帮你盯着的硬规矩——忘了检查,左上角 Compile 直接给你亮红。

二、装箱与开箱:option{…} 与 X?

宝箱有了,两件事必须会:往里装东西(构造),把东西取出来(解包)。

装箱(把东西放进箱子)用 option{值}——注意括号是花括号 {},和第 16、17 课的 array{}map{} 一家人。写 set MaybeANumber = option{42} 就是一个 Set 节点:把「装着 42 的箱子」这个新值连进变量。更妙的是,option{…} 的花括号里是一个「能不能走通」的判定区(失败上下文):你把一个可能拿不到东西的操作塞进去,走通了就把结果装箱,走不通就自动得到空箱 false。比如 option{MyArray[0]}——想从数组里拿第一个元素,数组要是空的、根本没这一格?没关系,这里不报错,只是收获一只空箱子,后面的线稳稳继续。它把蓝图里最烦的两件事——「操作失败了」和「值是 None」——接成了同一根管道:拿得到就有,拿不到就空。

开箱(把东西取出来)用一个后缀问号 ?:写成 MaybeANumber?。但开箱这个动作本身可能扑空(箱子可能是空的!),所以它跟第 16 课的数组取值一样,只能待在「能不能走通」的判定区里。最顺手的姿势,就是套一个 if——它就相当于蓝图里的 Branch 节点:

treasure_device.verse
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?):——开箱、检查、接值,一个 Branch 一步到位。箱里有货就走真引脚,Key 拿到的是拆掉包装的「裸值」(直接是一段 string 文字,不再是那个带问号的箱子);箱子空了就走 else 引脚,绝不崩。是不是和第 16 课从数组里取值 if (X := Items[0]): 一模一样的手感?这不是巧合——Verse 把所有「可能拿不到」的操作,都统一成同一种「这条线走不走得通」的判定,一个 Branch 通吃。

顺手练一把,给这台捡金币设备把两个关键语法填回去:

coin_device.verse
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} 枚金币")

三、tuple 元组:把几个值打成一个包裹

换一个痛点。你想做一个蓝图函数,吐出出生点坐标——一个 X(小数)、一个 Y(小数),顺手再带个地点名(文字)。麻烦来了:蓝图函数还能拉好几个输出引脚,Verse 的函数却只能返回一个值,难道要为这仨值各开一个变量来回传话?不用。Verse 的答案是 tuple(元组):把几个值——类型可以各不相同(小数、文字混着来都行)——捆成一个包裹,当成一个值来传。你可以把它想成蓝图里的一个小结构体(Struct),或者一个「一次性拉出好几个输出引脚」的节点。

类型写成 tuple(t1, t2, …),而具体的值就是一对圆括号加逗号:(1, 2.0, "three")。还记得第 16 课说过 array{1, "a"} 这种混装会 Compile 报红吗?当时埋的伏笔现在兑现:数组要求每格同类型,想混装不同类型,就找 tuple。

tuple_basics.verse
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 有几格、每格是什么类型,在 Compile 的那一刻就钉死了——tuple(int, float, string) 永远正好三格。你要是写 Loot(3)(第 4 格,不存在),Compile 当场亮红,根本轮不到游戏跑起来才出事。代价是:格子序号必须是写死的固定数字,不能是一个跑起来才知道的变量。T(i) 里塞一个变量 i 会 Compile 不过——因为每格类型不同,编译器没法提前知道你这一下拉出来的是文字还是数字。真需要拿变量当序号来回取?那说明你要的其实是数组,不是 tuple。

2. 装好就锁死,比数组还彻底。没有 set T(0) = X 这种「改其中一格」的 Set 写法——tuple 一旦做出来,就不给你单独改某一格。想「改」?只能整包重新造一个,再替换掉。

3. 这就是「一个函数吐好几个值」的官方姿势。Verse 里没有 (A, B) := F() 这种「一行拆两个」的写法——你得先把整包接住、存进一个名字,再用 Result(0)Result(1) 按序号一格一格拆,这就是标准流程(很像蓝图里先接住节点的输出、再一根根引脚往下连)。另外剧透一句:tuple 和「函数的参数列表」在 Verse 里是近亲,把一整包 tuple 当作参数传给一个多入口的函数,它会自动摊开、对号入座——这个「一包直接喂满多个入参」的技巧,放到拓展页细讲。

四、常见坑:括号形状引发的血案

option 和 tuple 的坑,九成都出在「括号形状」和「和谁比较」上。逐条排雷:

坑 1:option(42) 用圆括号来装箱。Compile 报红。装箱只认花括号这一种写法:option{42}。官方风格指南还建议:短的话写成一行 option{a} 就行。

坑 2:T[0] 用方括号去取 tuple。Compile 不过。方括号 [] 是数组和 map 的地盘,tuple 只认圆括号:T(0)。下面这张对照表帮你把记忆焊死:

写法 谁在用 会失败吗
A[0] array / map 会——可能扑空,只能待在判定区(Branch 的条件)里
T(0) tuple 不会——超出格数是 Compile 报红,不是运行时扑空
M? option 解包 会——可能扑空,只能待在判定区(Branch 的条件)里

坑 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:拿箱子直接跟裸值比大小。if (MaybeX = 42) 会报类型对不上:左边是一个「可能空的箱子」(?int),右边是一个光溜溜的整数(int),俩不是一路人,没法直接比。得先开箱再比:if (MaybeX? = 42)——开箱和比较都待在同一个判定区里,箱子要是空的、或者值不等于 42,这条线就走不通(走 else),一步到位。

坑 5:幻想「一行拆成好几个」。(A, B) := GetXY() 这种「一次接住两个」的写法,某些编程语言里很潇洒,但 Verse 里是 Compile 报红。老老实实先把整包接住,再按序号一格格拆。

坑 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 自己也就能比、能判等,于是它们能反过来当 map 的键用——比如拿 tuple(int, int) 的格子坐标当键做一张棋盘 map。四件容器就这样咬合成了一套齿轮。

蓝图对照

这一课的每个概念在蓝图里都有对应物,只不过蓝图把它们放在类型系统外面,Verse 把它们放在了里面

蓝图里的做法 Verse 里的写法 差异
Object Reference 变量:可能指向一个 Actor,也可能是 None ?type 可选类型,如 var SavedPlayer:?player = false 蓝图里任何对象引用都可能是 None,而类型上看不出来;Verse 里只有带 ? 的类型才可能为空,不带 ? 就是编译器担保「一定有值」。「可能为空」从口头约定变成了写在脸上的标记
Is Valid 节点 / Is Valid ? 宏:自己记得在用之前插一个检查 if (X := MaybeX?):(解包只能待在失败上下文里) 蓝图的 Is Valid 是自觉行为,忘了插照样编译通过、照样发版;Verse 里不解包就拿不到值,而解包又必须接进 Branch,忘了检查直接编译不过
Accessed None:运行时红字刷屏,这条执行线当场中断,后面的节点全不跑 这类错误在 Compile 阶段就被拦住 这是本课最大的一条差异:同一类 bug,蓝图让它在玩家面前发生,Verse 让它在你按下 Compile 时发生。Verse 里没有「空引用」这种运行时状态可言
函数节点拉出多个输出引脚(Return Node 上加几个 output) 返回一个 tuple:GetSpawnPoint():tuple(float, float) 蓝图的多输出是节点 UI 给的便利;文本代码只有一个返回位,所以 Verse 让那一个位置装一个包裹。取值用 Point(0)Point(1),而且没有「一行拆成两个变量」的写法
Make Struct / Break Struct 节点:把几个不同类型的值捆一起、再拆开 tuple 的圆括号构造 (1, "剑") 与按位取值 T(0) Struct 的成员有名字,tuple 只有位置号,而且位置号必须是编译期写死的常量(T(i) 编译不过)。要名字就用 struct,见第 21 课
Set Members in Struct 节点:改结构体里的某一个成员 没有对应写法:tuple 装好即锁死 set T(0) = X 都不存在,想「改」只能整包重造——和 array、map 的值语义是同一条规矩

这一课的差异只有一个根:蓝图把「可能没有」留在类型系统外面,Verse 把它搬了进来。在蓝图里,一个 Actor 引用的类型是 Actor,「它可能是 None」这件事写在你脑子里、写在团队规范里、写在你希望别人记得插的那个 Is Valid 节点里——但编译器一无所知,所以它管不了你。Verse 让 ?playerplayer 成为两个不同的类型,于是「忘了检查」不再是一种疏忽,而是一处类型错误。Accessed None 这一整类 bug,就是这样被搬到编译期去消灭的。

tuple 那半边则来自另一个更朴素的事实:节点图能长出任意多根输出引脚,一行文本代码只有一个返回位。Verse 没有为此发明特殊语法,而是复用了已有的答案——让那一个返回位装一个「个数固定、类型可混」的包裹。作为交换,你得接受包裹按位置而非名字取值,以及位置号必须在编译期就定死(否则编译器无法知道这一格是文字还是数字)。真需要名字,请换 struct;真需要用变量当序号来回取,那说明你要的其实是 array。

六、关卡挑战

宝箱开完、包裹拆完,来三道关卡验收。答错零惩罚,可以一直重试。

var MaybeCoin:?int = false 这一行里的 false 是什么意思?

已有 T:tuple(int, string) = (7, "剑"),下面哪种取值写法能编译通过?

想把「可能扑空的数组取值 Items[0]」变成一个可空的整数箱 ?int(拿到就装箱,拿不到就得到空箱),该怎么写?

拓展阅读

拔高 · EXTRA

写一个「会失败的函数」:<decides> 与 option 的镜像

判定区(会失败的地方)里不许写 return,那一个「会失败的函数」怎么把值送出来?官方的 option 暂存箱套路,把本课的箱子概念串成一个闭环。

进入拓展 →

技巧 · EXTRA

tuple 展开(splatting):一包当一串传

把一整包 tuple 当成一个参数传进函数,它会自动摊开、对号入座填满好几个入参——背后是 Verse「每次调用其实都在传一个 tuple」的统一世界观。

进入拓展 →

拓展 · EXTRA

sync 的结果是 tuple:并发世界的多返回值

并发指令 sync 等几件事全部干完之后,把各自的结果按你写代码的先后顺序打成一个 tuple 交回来——tuple「格数固定」的脾气在这儿刚好严丝合缝。

进入拓展 →