为什么越界不抛异常:失败上下文与事务回滚
第 12 课说「越界 = 失败,不崩溃」。这一页往深处挖一铲子:失败到底是什么机制?为什么 Verse 敢承诺失败之后世界完好如初?答案藏在两个词里——效果系统,和推测执行。
第 12 课说「掏过头 = 走不通,不崩溃」。这一页往深处挖一铲子:「走不通」到底是怎么做到的?为什么 Verse 敢承诺:一条线走不通之后,世界还完好如初、像没动过?答案藏在两件事里——Verse 怎么把「可能做不成」标进函数的门牌,和它那套「先试连、再落地」的机制。
一、异常 vs 失败:两种世界观
先看老牌语言怎么处理「下标越界」。C++ 的 v[99] 是未定义行为,运气好崩溃、运气差静默写坏内存;Python 抛 IndexError,你可以 try/except 接住——但没人强迫你接,忘了接就是运行时炸给玩家看。共同点是:「这里可能出错」这件事,不写在函数签名里,全靠文档和程序员的记性。
先看看「掏过头」这件事,在别的做法里通常是怎么收场的:要么当场崩溃(甚至悄悄把数据写坏),要么弹出一个错误、需要你专门写一段「接住错误」的逻辑兜着——可没人强迫你接,忘了接,游戏就跑着跑着炸在玩家面前。它们的共同毛病是:「这里可能出错」这件事,没写在函数的门牌上,全靠文档和你自己记得住。
Verse 换了一种世界观:把「可能出错」编码进类型系统。数组下标访问带一个 <decides> 效果标记,意思是「本表达式可能失败」;而带这个标记的表达式,只允许出现在失败上下文里——if 的条件、for 的过滤条件、option{...}、或另一个 <decides> 函数的函数体。忘了套?编译器直接报错,根本不给你部署到玩家面前的机会。异常是「运行时的惊吓」,失败是「编译期的合同」——这就是第 12 课那句「越界不崩溃」背后的第一层原理。
Verse 换了一种做法:把「可能出错」直接刻进函数的门牌。取一格这个动作带一个 <decides> 记号,意思是「这一步可能做不成」;而带这个记号的动作,只准待在「有退路的地方」——Branch(if)的条件里、ForEach 的筛选条件里、option{...}、或另一个同样带 <decides> 记号的函数体里。忘了给退路?一点 Compile 就亮红,根本不给你把它带到玩家面前的机会。别处那种错误是「游戏跑起来时的惊吓」,Verse 的「走不通」则是「Compile 时就签好的合同」——这就是第 12 课那句「掏过头不崩溃」背后的第一层原理。
二、第二层:失败上下文是一场「推测执行」
更精彩的在后面。官方《Failure in Verse》文档明确说:失败上下文里的代码是推测执行(speculative execution)的——Verse 先「试着」把整段条件跑一遍,全部成功才算数;中途任何一个表达式失败,整个上下文里已经执行过的副作用会被整体回滚,包括改变量的 set,就像从未发生过。
更精彩的在后面。官方《Failure in Verse》文档明确说:「有退路的地方」里的那串动作,是先在影子图上试连一遍的(官方叫 speculative execution)——Verse 先「试着」把整段条件从头跑一遍,全部走通才作数;中途只要有一步走不通,这段里已经做过的改动会被整个撤销,连给变量接的 set 都算,像从没发生过。
口说无凭,看一个最小示例:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
rollback_device := class(creative_device):
var Numbers:[]int = array{1, 2, 3}
OnBegin<override>()<suspends>:void =
# 逗号 = and:两个表达式共处同一个失败上下文
if (set Numbers[0] = 99, Numbers[5] > 0):
Print("到不了这里")
else:
Print("整体失败,set 被回滚")
# 验货:0 号格到底是几?
if (First := Numbers[0]):
Print("Numbers[0] = {First}")
逐行推演:set Numbers[0] = 99 下标合法,执行成功;紧接着 Numbers[5] 越界,失败。按直觉,0 号格应该已经变成 99 了——但最后打印出来的是 Numbers[0] = 1。因为两个表达式共处一个失败上下文,后者失败,前者已提交的 set 被连根撤销。一荣俱荣,一损俱损:失败上下文是一个事务,要么整体生效,要么整体作废。顺带一提,你在函数签名里常见的另一个效果 <transacts>,名字正来源于此——「可事务化」,是允许被回滚的前提。
读一下这段并逐步推演:set Numbers[0] = 99 的 0 号格合法,这一步做成了;紧接着 Numbers[5] 掏过头,走不通。按直觉,0 号格这会儿应该已经是 99 了——可最后打印出来的却是 Numbers[0] = 1。因为这两步同处一个「有退路的地方」(逗号把它们绑成一组),后一步没走通,前一步那个已经接好的 set 就被连根撤回。一荣俱荣、一损俱损:这一整段要么全部落地,要么整体作废。顺带一提,你在函数门牌上常看到的另一个记号 <transacts>,名字正来自这里——「可被撤销」,是能回滚的前提。
三、这买卖为什么划算
事务回滚给程序员的礼物是:不存在「改了一半」的世界。写异常安全的 C++ 时,最折磨人的就是「抛异常那一刻,哪些状态已经改了、哪些还没改」;Verse 直接把这类中间态从语义里抹掉了——条件失败,世界就是条件开始前的样子,一个字节都不多改。
这套「撤销」机制送你的礼物是:永远不存在「改了一半」的世界。很多做法里最折磨人的,就是出错那一刻「到底哪些改了、哪些还没改」得自己一点点排查;Verse 直接把这种「改一半」的中间状态从根上抹掉了——条件一旦走不通,世界就还是它开始前的样子,一丝一毫都不多改。
代价是你要换一种思维:把「检查 + 修改」尽量写进同一个失败上下文,让语言替你保证原子性。比如「从金库扣 100 金币并放进背包 0 号格」,写成 if (set Bag[0] = 100, Vault[3] > 0): 这样的组合条件,任何一环失败都不会留下半截操作。这也解释了为什么 Verse 宁可让 set Items[0] = X 这种写入也标成可失败——统一进事务模型,回滚才有得谈。
代价是你得换一种思路:把「先检查、再改动」尽量塞进同一个「有退路的地方」,让语言替你保证「要么全成、要么全不动」。比如「从金库扣 100 金币、放进背包 0 号格」,写成 if (set Bag[0] = 100, Vault[3] > 0): 这样一组条件,任何一环走不通都不会留下半截操作。这也解释了为什么 Verse 宁可把 set Items[0] = X 这种「往格子里写」也标成可能做不成——都纳进这套「可撤销」的模型,回滚才有得谈。
四、随堂测验
上面 rollback_device 的例子跑完后,Numbers[0] 的值是多少?
Verse 把「下标可能越界」这件事,主要放在哪个环节处理?Verse 把「取一格可能掏过头」这件事,主要放在哪个环节来管?
来源与延伸
本文整理自 Epic 官方文档:《Failure in Verse》(dev.epicgames.com ↗)与《Array in Verse》(dev.epicgames.com ↗)。前者对推测执行与回滚有完整定义,值得精读。