效果系统与事务回滚:no_rollback 全解
「This invocation calls a function that has the no_rollback effect...」——几乎每个 Verse 新手都会撞上的第一条天书报错。本页把它拆到原子级:什么是效果系统、失败上下文为什么是一台时光机、以及把代码改对的两种姿势。
「This invocation calls a function that has the no_rollback effect...」——几乎每个 Verse 新手都会撞上的第一条天书报红。本页把它拆到原子级:什么是效果系统、那种「可能反悔」的地方为什么像一台时光机、以及把逻辑改对的两种姿势。
一、报错现场还原
场景通常长这样:你想在 if 里顺手调用一个函数——可能是某个设备 API(论坛上的经典案例是一个不带 transacts 的 GetTags() 调用),也可能是你自己随手写的工具函数——然后编译器甩来这句:
场景通常长这样:你想在一个 Branch(if)判断里顺手调用某个函数——可能是引擎自带的某个设备节点(论坛上的经典案例是一个不带 transacts 的 GetTags() 调用),也可能是你自己随手搭的小函数——然后 Compile 甩来这句:
This invocation calls a function that has the 'no_rollback' effect,
which is not allowed by its context.
字面意思是:「这次调用的函数带 no_rollback 效果,而它所在的上下文不允许。」翻译成人话:你把一个「做了就收不回」的函数,塞进了一个「随时可能要反悔」的地方。要看懂这句话,得先认识 Verse 的效果系统。
字面意思是:「这次调用的函数带 no_rollback 效果,而它所在的地方不允许。」翻译成人话:你把一个「做了就收不回」的函数,塞进了一个「随时可能要反悔、要整体撤销」的地方。要看懂这句话,得先认识 Verse 的效果系统。
二、失败上下文是一台「时光机」
第 18 课讲过:失败上下文(if 的条件、<decides> 函数体等)里的一切都是推测执行——先记账再动手,一旦某个可失败表达式宣布失败,账本一撕,所有改动整体回滚,像什么都没发生过。这不是比喻,而是 Verse 把「推测执行 + 回滚」做成了语言级的事务内存:每个进入失败上下文的写操作都被记录在案,失败即撤销,成功才提交。decides 函数失败后「像什么都没发生」的干净利落,靠的就是这台机器。
第 18 课讲过:那种「可能走不通」的地方(Branch 的判断、<decides> 函数体等)里的一切都是推测执行——先在影子图上记账再动手,一旦某一步宣布「没过」,账本一撕,所有改动整体撤回,像什么都没发生过。这不是比喻:Verse 真的把「先试连、能过才落地、不过就当没连」做进了语言里(相当于一整套自动回滚的事务)。每个进入这种地方的写动作都被记在案,没过就撤、过了才提交。decides 函数走不通后「像什么都没发生」的干净利落,靠的就是这台机器。
但时光机有一个硬性前提:舱里的每一件事都必须可逆。改一个 var?可逆,改回去就行。分配一个对象?可逆,丢掉就行。这些正是 <transacts> 承诺的范围——它隐含了读、写、分配三种底层效果,全部登记在账本上。可是往屏幕上打一行日志呢?玩家已经看见了,收不回来。让引擎放一个音效、动一扇真正的门呢?世界已经变了。这类「外部可见的副作用」没法回滚——所以 Print 这类操作进不了失败上下文。
但时光机有一个硬性前提:舱里的每件事都必须能倒回去。改一个变量(Set 一下)?能,再 Set 回来就行。造一个对象?能,丢掉就行。这些正是 <transacts> 承诺的范围——读、改、造这几种事它都兜得住,全部记在账本上。可是往屏幕上用 Print String 打一行字呢?玩家已经看见了,收不回来。让引擎放一个音效、真开一扇门呢?世界已经变了。这类「外面看得见的动作」没法倒回去——所以 Print String 这类操作进不了那种「可能反悔」的地方。
关键在默认值:一个什么效果都不标的函数,默认拿到的就是 no_rollback。编译器不知道它内部干没干「收不回的事」,只能按最坏情况处理——一律拒之门外。于是你随手写的无辜小函数,进 if 就报错。
关键在默认值:一个什么效果徽章都不戴的函数,默认拿到的就是 no_rollback。Compile 不知道它内部干没干「收不回的事」,只能按最坏情况处理——一律拒之门外。于是你随手搭的无辜小函数,一进 Branch 判断就报红。
三、把代码改对的两种姿势
知道了原理,修法就顺理成章:要么别把它带进时光机,要么向编译器证明它可逆。两种姿势各来一遍:
知道了原理,修法就顺理成章:要么别把它带进时光机,要么向 Compile 证明它能倒回去。两种姿势各来一遍:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
combo_device := class(creative_device):
var Hits:int = 0
# 没有效果标注 → 默认 no_rollback(它含 Print,本来也回滚不了)
AddHitAndReport():int =
set Hits += 1
Print("Hits: {Hits}")
Hits
# 只读写内部状态 → 可以承诺 transacts
AddHit()<transacts>:int =
set Hits += 1
Hits
OnBegin<override>()<suspends>:void =
# if (AddHitAndReport() > 3): # 编译报错:no_rollback 进了失败上下文
Current := AddHitAndReport()
if (Current > 3):
Print("姿势一:先在外面调用存进常量,条件里只用常量")
if (AddHit() > 3):
Print("姿势二:transacts 函数可以直接进失败上下文")
姿势一(挪出去):在失败上下文外面调用函数,把结果存进常量,条件里只比较常量。读一个常量当然可逆,编译器放行。这一招对你改不了签名的设备 API 尤其管用——API 不带 transacts,你就先取值再判断。
姿势一(挪出去):在那种「可能反悔」的地方外面先把函数调用跑掉,结果存进一个常量,判断里只比较这个常量。读一个常量当然能倒回去,Compile 放行。这一招对你改不了的引擎自带节点尤其管用——它不带 transacts,你就先取到值、再拿值去判断。
姿势二(戴徽章):如果函数是你自己写的、而且内部只读写状态和分配对象,直接给它标上 <transacts>。注意这是承诺不是装饰:一旦标了,函数体里就不能再有 Print 这类不可回滚的动作,否则编译器会在函数定义处拦下你。
姿势二(戴徽章):如果这个函数是你自己搭的、而且里面只是改改变量、造造对象,直接给它戴上 <transacts>。注意这是一句承诺、不是装饰:一旦戴了,函数体里就不能再有 Print String 这类收不回的动作,否则 Compile 会直接在这个函数定义的地方拦下你。
最后从设计角度看一眼:Epic 为什么把门槛设得这么严?因为 Verse 的失败机制(decides、if 条件、可失败表达式)是整个语言的地基,而「失败即无事发生」的语义只有在一切副作用可控时才能成立。效果系统就是把「这个函数会对世界做什么」写进签名、交给编译器审计的机制——你在函数签名里看到的每一枚效果徽章,都是一份编译器强制执行的合同。
最后从设计角度看一眼:Epic 为什么把门槛设得这么严?因为 Verse 的「可能走不通」机制(decides、Branch 判断、可能没过的表达式)是整个语言的地基,而「没过就当没发生」这句话,只有在一切外部动作都可控时才站得住。效果系统就是把「这个函数会对世界做什么」写进它的名字旁边、交给 Compile 逐条审的机制——你在一个函数的签名上看到的每一枚效果徽章,都是一份 Compile 会强制执行的合同。
为什么 PrintPrint String 不能出现在失败上下文「可能反悔」的地方里?
一个不标任何效果 specifier 效果徽章的函数,默认效果是什么?
本文整理自 Epic 开发者论坛与官方文档:论坛:This invocation calls a function that has the no_rollback effect ↗、论坛:no_rollback effect struggle ↗、官方文档:transacts ↗。