Verse Wiki — 写给 UE6 时代的 Verse 互动手册
拓展 · EXTRA

效果系统与事务回滚: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 证明它能倒回去。两种姿势各来一遍:

combo_device.verse
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 ↗