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

为什么给 map 赋值也「可能失败」?:效果系统的设计争论

纯赋值要么新增要么覆盖,怎么看都不该失败——Epic 官方论坛上真有人替你把这个问题拍到了桌面上。这一页带你围观那场争论,顺便看懂 Verse 效果系统在下的一盘大棋。往 map 里写一行,要么新增要么覆盖,怎么看都不该「走不通」——可 Epic 官方论坛上真有人替你把这个问题拍到了桌面上。这一页带你围观那场争论,顺便看懂 Verse 为什么给这么多操作都盖上「可能走不通」的戳。

一、一个理直气壮的疑问

开发者 ZeroYaHero 在 Epic 官方论坛发帖:为什么 if (set MyMap[Key] = Value) 是可失败的?按直觉,给 map 的某个键赋值,结局只有两种——键不存在,新增;键存在,覆盖。横竖都成功,失败从何谈起?可 Verse 的编译器铁面无私:map 的索引赋值带 <decides> 效果,不包进失败上下文就不给编译,报的正是那条经典错误:「This invocation calls a function that has the 'decides' effect, which is not allowed by its context.」

开发者 ZeroYaHero 在 Epic 官方论坛发帖:为什么 if (set MyMap[Key] = Value) 这句非得接进 Branch 不可?按直觉,给 map 的某个键写值,结局只有两种——键不存在就新增,键存在就覆盖,横竖都成,哪来的「走不通」?可 Verse 的 Compile 铁面无私:往 map 的某个键写值这件事带着「可能走不通」的戳,不接进 Branch 就不给过,亮红报的正是那条经典错误:「This invocation calls a function that has the 'decides' effect, which is not allowed by its context.」

failable_set_demo.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

failable_set_demo := class(creative_device):

    var Scores:[string]int = map{}

    OnBegin<override>()<suspends>:void =
        # 编译错误:'decides' effect not allowed by its context
        # set Scores["A"] = 1

        # 正确姿势:包进失败上下文
        if (set Scores["A"] = 1):
            Print("A 登记成功")

        # 复合赋值是真的会失败:B 从未登记,先读就挂了
        if (set Scores["B"] += 1):
            Print("这行不会执行")
        else:
            Print("B 不存在,+= 失败并回滚")

读图翻译:一个 Event BeginPlay 里三段。第一段是被注释掉的反面教材——直接写 set Scores["A"] = 1 不接 Branch,过不了 Compile。第二段是正确写法:接进 Branch,走通就 Print String 打「A 登记成功」。第三段对从没登记的「B」做「在原值上加 1」,「读」那步查无此人,走 else 打出失败提示——注意这条走不通的线上试着写进去的改动会被整个撤销(回滚)。

二、社区的解释:被复合赋值「连坐」社区的解释:被「在原值上加」连坐

回帖里最有说服力的解释来自 Sprintermax:关键在 +=-= 这些复合赋值。set M[K] += 1 必须先把旧值出来,算完再回去;键不存在时,「读」那一步是实打实会失败的——这不是设计洁癖,是逻辑必然。而 Verse 把「对 map 索引位赋值」当作一整个语法家族看待:纯 =+= 共用同一套索引赋值机制,于是全家统一标记 <decides>,一个都不例外。

回帖里最有说服力的解释来自 Sprintermax:关键在 +=-= 这些「在原值上加/减」的写法。set M[K] += 1 必须先把旧值出来,算完再回去;键不存在时,「读」那一步实打实会走不通——这不是设计洁癖,是逻辑必然。而 Verse 把「往 map 的某个键写值」当作一整个家族看待:最普通的「直接写」和「在原值上加」共用同一套写入机制,于是全家统一盖上「可能走不通」的戳,一个都不例外。

也有回帖猜测原因藏在运行时内部实现里,比如并发访问时的安全处理。讨论最后的结论相当坦率:纯 = 赋值「逻辑上可以不失败,但语言没有这样设计」。换句话说,这不是编译器的 bug,而是语言作者刻意选择的一致性。

也有回帖猜测原因藏在引擎内部实现里,比如多条线同时访问时的安全处理。讨论最后的结论相当坦率:最普通的「直接写」逻辑上可以不「走不通」,但语言就是没这么设计。换句话说,这不是 Compile 的 bug,而是语言作者刻意选择的一致性。

三、效果系统在下一盘大棋三、这套「可能走不通」的戳在下一盘大棋

把镜头拉远,你会看到 Verse 的核心哲学:「这个操作可能失败」不靠运行时抛异常来表达,而是直接写进函数签名——也就是效果标记。签名统一了,调用者的心智模型就统一了:凡是方括号索引操作,array 读、array 写、map 读、map 写,一律可失败、一律包失败上下文。你永远不用背「哪个会失败、哪个不会」的例外清单。

把镜头拉远,你会看到 Verse 的核心哲学:「这个操作可能走不通」不靠运行时报错崩游戏来表达,而是明明白白盖在这个操作的「铭牌」上——也就是那个「可能走不通」的戳。铭牌统一了,你的心智模型就统一了:凡是用方括号按位置 / 按键存取的操作,array 读、array 写、map 读、map 写,一律可能走不通、一律接进 Branch。你永远不用背「哪个会走不通、哪个不会」的例外清单。

代价是什么?偶尔要给「明明不会失败」的操作也套一层 if,看起来啰嗦。收益呢?所有失败路径在编译期就摆上台面,还附赠事务回滚——失败上下文里已执行的 set 会被整体撤销,就像从没发生过。语言设计常常要在「局部直觉」和「全局一致」之间二选一,Verse 在这里毫不犹豫地选了后者。

代价是什么?偶尔要给「明明不会走不通」的操作也套一层 Branch,看起来啰嗦。收益呢?所有可能走不通的岔路在点 Compile 时就摆上台面,还附赠一个「回滚」福利——这就像先在一张「影子图」上把节点试连一遍,只要这条线走不通,试连时写进去的改动当场全部作废,真正的图纹丝不动。语言设计常常要在「局部直觉」和「全局一致」之间二选一,Verse 在这里毫不犹豫地选了后者。

实践上的小抄:不需要 else 分支时,用单行 if (set M[K] = V) {} 收尾,读代码的人会把它当作「一次必然成功的赋值」——形式上服从效果系统,心理上不增加负担。

实践上的小抄:不需要 else 那一侧时,用单行 if (set M[K] = V) {} 收尾,那对空 {} 就是「else 什么都不做」——看的人会把它当作「一次必然成功的写入」,形式上服从规则,心理上不增加负担。

按论坛讨论的主流解释,set Scores[K] += 1 在 K 不存在时为什么会失败?按论坛讨论的主流解释,对不存在的键 K 做 set Scores[K] += 1(在原值上加 1)为什么会走不通?

来源

本文整理自 Epic 开发者论坛与官方文档:

Why is setting a key value in a map type failable in Verse?(Epic 开发者论坛)↗
Map in Verse(官方文档)↗