拔高 · EXTRA
为什么 decides 函数里禁止写 return
官方论坛真实案例:照抄文档也编译失败,Epic 员工亲自解释被没收的 return 与宽松求值的恩怨。
进入拓展 →别的语言的 if 问「真还是假」,Verse 的 if 问「这事能不能成」。这一课是整本手册的王牌:认识可失败表达式,看懂推测执行与回滚,拿下单行、多行与「逗号即 and」。翻过这座山,后面的方括号与 option 全是顺路的风景。
先回到桌游桌前。你对主持人说:「我要推开这块巨石。」主持人不会回答「真」或「假」,而是让你掷一次骰子做检定:达到难度值,检定成功,故事走支线 A;没达到,检定失败,故事走支线 B。没有人掀桌,没有人报错,冒险照常继续。
Verse 的 if 正是这位主持人。官方文档说得很直白:if 的条件不期望返回 Boolean,而是期望一个带 decides 效果的表达式。翻译成人话:括号里放的不是布尔值,而是一个「可失败表达式」(failable expression)——一件可能成、也可能不成的事。成了,走 then;没成,走 else。
用蓝图的话说:你早就认识 Branch 节点了——它收一个 Boolean,True 走一条线、False 走另一条。Verse 的 if 长得像 Branch,但它问的不是「这个 Boolean 是不是 true」,而是「括号里这件事能不能走通」:走得通就走 then(相当于 Branch 的 True 引脚),走不通就走 else(False 引脚)。差别就这一处,却是这一整课的地基。
这也顺便解释了一件事:Verse 没有 try/catch,因为它压根没有异常机制。失败不是事故,不会崩溃,它是正常控制流的另一半——控制流只是安静地拐进 else。Verse 里「失败」不是报错、也不会让蓝图崩掉,它只是执行线的另一条出路——就像 Branch 走了 False 引脚,白线安安静静拐进 else,冒险照常继续。下面这台掷骰机连做两次检定,一次成功一次失败,点「运行下一步」,亲眼看两条分支各自怎么走。
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
dice_check_device := class(creative_device):
# 角色卡上的力量属性
var Strength:int = 14
OnBegin<override>()<suspends>:void =
# 检定一:推开巨石,难度 10
if (Strength >= 10):
Print("检定成功,巨石被推开!")
else:
Print("检定失败,巨石纹丝不动。")
# 检定二:撞开城门,难度 18
if (Strength >= 18):
Print("检定成功,城门轰然洞开!")
else:
Print("检定失败,你揉着肩膀退了回来。")
点「运行下一步」,看代码怎么一行行执行。
留意第二次检定:14 < 18,表达式失败这次检定没走通,但日志里没有半个「Error」。在 Verse 的世界观里,「不成」与「成」同样体面——这一点跟你以往用过的大多数语言都不一样,也是这一课所有内容的地基。
既然 if 收的是「可失败表达式」,那到底哪些表达式可失败?下面这张清单,覆盖了日常写码九成的场景:
| 表达式 | 什么时候成功 | 什么时候失败 |
|---|---|---|
比较:A = B、A <> B、A < B、A <= B、A > B、A >= B |
关系成立 | 关系不成立 |
下标访问:Items[0]、Scores["A"] |
下标 / 键存在 | 越界或键不存在 |
方括号调用:FindItem[3](带 <decides> 的函数) |
函数体一路成功 | 函数体里任何一步失败 |
查询运算符:Flag?(logic) |
值为 true | 值为 false |
查询运算符:MaybeItem?(option) |
里面有值 | 为空 |
类型转换:my_subtype[Obj] |
Obj 确实是该类型 | 不是 |
int 除法:X / Y |
Y 不为 0 | 除以零 |
对着这张表按蓝图习惯认一遍:「比较」就是大小 / 相等判断;「下标访问」是从数组里按序号或键名取一个元素(取不到就算失败);「方括号调用」是调用一个「可能会失败的蓝图函数」;「查询运算符 ?」把一个 logic 或 option「翻译」成能不能走通;而「类型转换 my_subtype[Obj]」你其实最熟——它就是蓝图里的 Cast 节点,Cast 成功走一条线,Cast 失败走那根红色的 Cast Failed 引脚,和这里的成功 / 失败一模一样。
反过来,还有一份「失败上下文」(failure context) 清单——可失败表达式只被允许出现在这些位置:if 的条件、for 的迭代与过滤子句、not 的操作数、or 的左操作数、带 <decides> 效果的函数体、option{...} 的初始化块。在这些位置之外裸写一句 Items[0],编译器直接拒收。
反过来,「可失败表达式」也不是哪儿都能放——它只被允许待在几个「失败上下文」里:if 的条件括号、for / ForEach 循环的取值与过滤条件、not 里面、or 的左边、可失败蓝图函数(带 <decides> 的那种)的内部,以及 option{...} 的初始化块。在这些地方之外单独裸写一句 Items[0],一按 Compile 就会像亮红报错一样被直接拦下。
这个「强制配对」是 Verse 最聪明的安全设计之一。数组越界在别的语言里是运行时崩溃的常客;在 Verse 里,这种崩溃根本写不出来——你必须把下标访问放进失败上下文,也就必须当场回答「失败了怎么办」。主持人不允许你只说「我要撬锁」却不接受掷骰的结果。
if 本人有好几副面孔,全都围绕同一套「成 / 不成」语义。最常用的三副:
# 面孔一:单行 if,then / else 各接一个表达式
Bigger(A:int, B:int):int = if (A > B) then A else B
# 面孔二:块形式,冒号 + 缩进,可以串 else if
# if 本身是表达式:走到哪个分支,函数就返回哪个分支的值
RateScore(Score:int):string =
if (Score >= 90):
"S"
else if (Score >= 60):
"A"
else:
"C"
注意 Bigger:if 在 Verse 里是表达式,能直接产出一个值——相当于别的语言的三元运算符,但读起来是完整的句子。把 if 当值用时必须带 else,而且两个分支的类型要兼容;不带 else 的 if 整体是 void,只能当语句摆着。
读一下上面这段:Bigger 是一个蓝图函数,内部直接用 if「算出」一个值再交出去——效果就像蓝图里的 Select 节点(按条件在两个值里挑一个),把挑中的那个接到函数的 Return 引脚上。用 if 来「出值」时必须把 else 也接上(成功、失败两条都得给值),而且 then / else 两边给的值要是同一种类型;要是只接了成功那半边、没写 else,这个 if 就不产出任何值,只能当一句「做点事」的执行节点摆着。下面的 RateScore 则是把几个判断首尾串起来:先看够不够 90,不够再看够不够 60,像一串接龙的 Branch 逐级筛,走到哪一档就把哪一档的字母交出去。
第三副面孔是本课的招牌动作:逗号即 and。往条件里塞多个可失败表达式,用逗号隔开,Verse 从左到右依次求值——全部成功才进 then,任何一个失败,整个条件立刻失败:
# 逗号即 and:钥匙和门票,少一样都进不去
if (HasKey?, Coins >= 10):
Print("大门缓缓打开。")
# 与上面完全等价的写法
if (HasKey? and Coins >= 10):
Print("大门缓缓打开。")
更妙的是,条件里还能「现场发牌」:用 := 内联定义一个名字,前面的绑定可以被后续条件和 then 分支直接使用。这就把「取值」和「判断」合并成了一次检定:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
loot_check_device := class(creative_device):
Items:[]int = array{7, 2, 9}
OnBegin<override>()<suspends>:void =
# 两连检定:先取首个元素(可能失败),再比大小(可能失败)
if (First := Items[0], First > 5):
Print("首个战利品价值 {First},检定通过")
else:
# 注意:这里看不见 First——绑定只属于后续条件和 then 分支
Print("要么背包是空的,要么首件不够值钱")
读一下这段:First := Items[0] 干了两件事——从数组 Items 里取第 0 个元素,顺手把它存进一个临时变量 First;要是数组是空的、取不到,这一步就算失败。取到了,紧接着的 First > 5 再拿它做一次大小检定。两步都走通才进 then,并且 then 里能直接用刚存下的 First。这就把「取值」和「判断」并成了一次检定,省掉了先取、再单独判空的两道手续。
作用域规则记牢::= 绑定的名字对后续条件和 then 分支可见,对 else 分支不可见——毕竟走到 else 就意味着链条断在了某一环,First 可能根本没绑上。条件多到一行放不下时,还有一副竖排面孔:if: 换行列出各个条件,then: 接主体,else: 接失败分支——这正是 UEFN 设备代码的标配开场,拓展页「链式可失败绑定」里有完整拆解。
有个规矩要记牢::= 存下的那个临时变量,只有它后面的检定和 then 那条线能看见,else 那条线看不到——毕竟走到 else 说明链子在某一环断了,First 很可能压根没存上值。检定太多、一行塞不下时,还有一副竖着写的面孔:if: 换行把每个检定各列一行,then: 接主体,else: 接失败那条线——这正是 UEFN 设备代码最常见的开场,拓展页「链式可失败绑定」里有完整拆解。
动手时间到。下面这台大门机被挖掉了两个关键词:一个是把 logic 变量「翻译」成成功 / 失败的后缀运算符,一个是失败分支的关键字。填回去,点「检查答案」:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
gate_device := class(creative_device):
var HasKey:logic = true
var Coins:int = 30
OnBegin<override>()<suspends>:void =
# logic 不能直接当条件,要加一个后缀运算符
if (HasKey____, Coins >= 10):
Print("两项检定都过了,大门缓缓打开。")
____:
Print("条件不足,大门纹丝不动。")
现在是王牌中的王牌。失败上下文里的代码是「推测执行」(speculative execution) 的:Verse 先大胆往下算,如果整个条件最终失败,期间产生的所有效果——包括 set 对变量的修改——会被整体回滚。官方的措辞是:如果表达式失败,其效果会被回滚,就像该表达式从未发生过。
现在是王牌中的王牌:「推测执行」(speculative execution)。失败上下文里发生的事,Verse 会先在一张「影子图」上试连一遍——连 set 节点对变量做的改动也一起试——只有整条线全部走通,才把这些改动真正落到你的蓝图上;一旦整个条件最后失败,影子图直接作废,期间的所有改动一笔勾销,就像那几个节点从没连过。官方的说法是:表达式失败,它的效果会被回滚,就像它从未发生过。
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
rollback_device := class(creative_device):
var X:int = 5
OnBegin<override>()<suspends>:void =
# 推测执行:先大胆把 X 加 1,再看它过不过 10
if (set X += 1, X > 10):
Print("X 冲过了 10")
else:
Print("检定失败,X 依然是 {X}")
跑一遍这段代码:set X += 1 把 X 推到 6,接着 X > 10 失败——于是整个条件宣告失败,那次自增被当场撤销,else 分支打印出来的是 检定失败,X 依然是 5。世界线被拨回到掷骰之前,仿佛什么都没发生。这颗「后悔药」在 Verse 里是语言级保证,不是库函数的戏法。这颗「后悔药」是 Verse 语言本身给的保证,不是靠某个插件或函数库变出来的把戏。
回滚能力由 transacts 效果承载,所以语言立了一条规矩:带 <decides> 效果的函数,必须同时标注 <transacts>——既然你「可能失败」,就必须「可以回滚」。由此还派生出一条纪律:失败上下文里不能调用不可回滚的函数——Print、<suspends> 函数、多数设备方法统统不行,否则收获一条经典编译错误:This invocation calls a function that has effects that are not allowed by its context。副作用请放进 then / else 分支,那里不是失败上下文。
回滚这本事是靠 <transacts> 效果撑起来的,于是语言立了条规矩:凡是带 <decides>(可能失败)的函数,就必须同时挂上 <transacts>(可以回滚)——能失败,就得能反悔。由此又引出一条纪律:在失败上下文(那张「影子图」)里,不能调用「反悔不了」的节点——Print / Print String、带小钟表的 Delay 或 <suspends> 函数、大多数设备自带的动作,统统不行,硬连就会 Compile 亮红,报一句 This invocation calls a function that has effects that are not allowed by its context。这些会真正改动游戏世界的动作,请挪到 then / else 那两条正式的执行线上去做,那里不算失败上下文。
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
score_gate_device := class(creative_device):
# <decides> 必须与 <transacts> 成对出现:可失败,就必须可回滚
PassingScore(Score:int)<transacts><decides>:int =
Score >= 60
Score
OnBegin<override>()<suspends>:void =
# 可失败函数用方括号调用,且调用必须待在失败上下文里
if (S := PassingScore[75]):
Print("检定通过,拿到分数 {S}")
else:
Print("没到及格线")
看 PassingScore 的函数体:第一行 Score >= 60 是一个可失败表达式——它失败,整个函数就失败;它成功,函数继续走到最后一行,把 Score 本身当作结果交出去(Verse 的隐式返回)。调用端用方括号 PassingScore[75],这对括号是写给读代码的人看的警示牌:「此处可能失败」。至于回滚在引擎层面怎么实现、为什么 decides 函数里连 return 都被没收,拓展页里深挖。
看 PassingScore 这个蓝图函数内部:第一行 Score >= 60 就是一次检定——没过,整个函数就算失败;过了,就继续走到最后一行,把 Score 直接接到函数的 Return 引脚交出去(这就是「隐式返回」:最后一个节点的输出直接当返回值,不用专门放一个 Return 节点喊一嗓子)。调用它时用的是方括号 PassingScore[75]——这对方括号就是块醒目的提示牌:「这个函数可能会失败,记得接住 else」。至于回滚在引擎底层怎么实现、为什么这种可失败函数里连提前 return 都不许写,拓展页里深挖。
失败上下文是初学者错误消息的高产区。下面这张「事故对照表」,建议对号入座一遍:
| 你写的 | 编译器的反应 | 正确姿势 |
|---|---|---|
if (X == 10) |
== 不是 Verse 的运算符 |
判等就是一个 =:if (X = 10) |
if (IsOpen) |
logic 不能直接当条件 | if (IsOpen?)——用 ? 把它变成可失败表达式 |
条件里调 Print(...) |
效果不允许:effects that are not allowed by its context | 副作用放进 then / else 分支 |
只写 <decides> 不写 <transacts> |
编译错误:可失败必须可回滚 | 两个效果成对标注 |
圆括号调用可失败函数 PassingScore(75) |
括号形状与效果绑定,直接报错 | 可失败函数一律方括号:PassingScore[75] |
在 else 里引用条件中 := 绑定的名字 |
作用域里查无此人 | 绑定只覆盖后续条件与 then 分支 |
在 <decides> 函数体里写 return |
编译错误:失败上下文禁止显式 return | 用 option 收集结果(见拓展页) |
这张表整张都是蓝图里一按 Compile 就亮红的经典原因,挑几条按蓝图直觉记:logic(相当于 Boolean)不会自己变成「能不能走通」,得在后面补一个 ?;会改动游戏世界的动作(比如 Print String)不能塞进检定的括号里,要放到 then / else 的执行线上;可失败函数必须用方括号 [] 调用,用圆括号 () 直接报错;还有那个用 := 临时存下的名字,走到 else 就「查无此人」——因为那条线根本没走通,自然没给它赋上值。
最后再拆一个心理层面的坑:很多人第一反应是「失败了会不会崩?要不要兜底?」——不会,也不用。失败在 Verse 里是设计好的正常出口,静默走 else;真正要小心的反而是忘了失败会回滚:条件里做过的修改,失败后一个都不会留下,连 not 里的效果都是永远回滚的。想留下痕迹,就把动作放到分支里去做。
三道检定,答对拿 ★。答错零惩罚,可以一直重掷——毕竟你刚学过:失败只是走向另一条分支。
有 var IsOpen:logic = true,想在 if 里用它当条件,哪个写法能通过编译?
var X:int = 5,执行 if (set X += 1, X > 10) 后走了 else 分支,此时 X 是多少?
关于 if (Cond1, Cond2): 里的逗号,哪个说法是对的?