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

struct 不可变怎么办:不可变数据模式实战

给 struct 加个 var 字段,编译器甩来错误 3607——而且这不是「还没做」,是「就不做」。这篇整理社区踩坑帖里的三条出路,给出「更新 struct 数组中一项」的完整范式,顺便讲清:不可变,其实是 struct 送你的礼物。

一、事故现场:错误 3607

论坛帖「Variable property inside Struct」记录了一次典型撞墙:开发者想给 struct 加一个可以 set 的字段,写下 var Count:int = 0,得到的是一条毫不含糊的编译错误——

论坛帖「Variable property inside Struct」记录了一次典型撞墙:有人想给结构体加一个能改(能接 Set 节点)的字段,写下 var Count:int = 0,换来的是一条毫不含糊的报错——Compile 当场亮红:

error_3607.verse
# ✗ 编译错误 3607:
# Structs may not contain mutable members
broken := struct:
    var Count:int = 0

更重要的是跟帖里的定性:这不是暂时限制,而是语言设计。struct 在 Verse 里被定义为不可变的数据聚合——纯数据、无身份、拷贝即隔离。同一时期,Epic 工程师 Andrew Scheidecker 在另一个帖子里回应「struct 里能不能写方法」时也只说「想做但暂无具体计划」。换句话说:别等版本更新救你,学会跟不可变共处才是正解。

更重要的是跟帖里的定性:这不是暂时的限制,而是故意的设计。结构体在 Verse 里的定位就是一包不会变的数据——纯数据、没有对象身份、整包拷贝天生隔离。同一时期,Epic 工程师 Andrew Scheidecker 回应「结构体里能不能写函数」时也只说「想做但暂无具体计划」。换句话说:别等版本更新救你,学会跟「不能改」共处才是正解。

二、三条出路

▢ 出路一:函数式不可变更新。把「修改」重新理解为「换新」:每次要改字段,就构造一个新实例——要改的字段填新值,其余字段照抄旧实例。数据量小的 struct(这正是 struct 的定位)拷贝成本可以忽略。

▢ 出路一:换新不改旧(函数式更新)。把「修改」重新理解为「换新」:每次想改某个字段,就照着旧的重造一整包——要改的字段填新值,其余字段照抄旧包。结构体本来就装得少(这正是它的定位),整包复制的成本可以忽略。

▢ 出路二:升级成 class。如果这份数据天生就该有可变状态和行为(血量会掉、冷却会转),那它可能从一开始就不该是 struct——换成 class 立刻获得 var 字段和方法;字段全有默认值的话再挂 <concrete>,还能用空原型 my_class{} 实例化并配合 @editable 暴露到面板。

▢ 出路二:升级成蓝图类(class)。如果这份数据天生就该有「会变的状态」和行为(血量会掉、冷却会转),那它可能从一开始就不该是结构体——换成蓝图类,立刻拥有可 Set 的字段和函数;字段都有默认值的话再挂 <concrete>,就能用空原型 my_class{} 造实例,还能配合 @editable(变量上那只 Instance Editable 小眼睛)把它暴露到关卡的 Details 面板里去调。

▢ 出路三:用类把 struct 包起来。折中方案:数据仍用 struct 存,但外面套一个类,把「换新实例」的操作封装成类方法——调用方看到的是 Backpack.AddOne("木材") 这样的普通接口,不用到处手写重建代码。

▢ 出路三:用一个类把结构体包起来。折中方案:数据仍用结构体存,但外面套一个蓝图类,把「重造一整包」这件事封装成类里的一个函数——外面用的人只看到 Backpack.AddOne("木材") 这么一句顺手的调用,不用到处手写重建代码。

immutable_update.verse
item_stack := struct:
    ItemName:string = ""
    Count:int = 0

# 「修改」= 造新包裹:其余字段照抄,要改的换新值
(Stack:item_stack).AddOne():item_stack =
    item_stack{ItemName := Stack.ItemName, Count := Stack.Count + 1}

注意这里用了扩展方法(第 15 课):struct 体内不能写函数,但 (Stack:item_stack).AddOne() 从体外把「行为」补了回来,调用时的手感和成员方法一模一样——这也是官方对 struct 加行为的唯一途径。

读图翻译:这里用了扩展方法(第 15 课):结构体里不能写函数,但 (Stack:item_stack).AddOne() 就是在结构体外面单挂了一个函数,把「行为」补了回来——它把传进来那包 Stack 的其余字段照抄、只把 Count 加一,造出并返回一整包新的 item_stack。调用起来手感和它自带的成员函数一模一样,这也是官方给结构体加行为的唯一路子。

三、范式代码:更新 struct 数组中的一项

实战里最常见的需求:背包是一个 []item_stack 数组,想给「木材」那一格 +1。因为取出来的元素是拷贝,直接改没有意义;标准做法是重建数组——遍历旧数组,要更新的那项换成新实例,其余原样搬运,最后整体替换:

实战里最常见的需求:背包是一个 []item_stack 数组,想给「木材」那一格 +1。因为取出来的元素是一份拷贝,直接改它没意义;标准做法是重建整个数组——用 ForEach 过一遍旧数组,要更新的那一项换成重造的新包,其余原样搬过去,最后整包 Set 回去替换:

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

item_stack := struct:
    ItemName:string = ""
    Count:int = 0

(Stack:item_stack).AddOne():item_stack =
    item_stack{ItemName := Stack.ItemName, Count := Stack.Count + 1}

backpack_device := class(creative_device):

    var Stacks:[]item_stack = array{
        item_stack{ItemName := "木材", Count := 30},
        item_stack{ItemName := "砖块", Count := 12}}

    # 给名字匹配的那一格 +1:重建数组,整体替换
    AddOneTo(Target:string):void =
        var Result:[]item_stack = array{}
        for (Stack : Stacks):
            if (Stack.ItemName = Target):
                set Result += array{Stack.AddOne()}
            else:
                set Result += array{Stack}
        set Stacks = Result

    OnBegin<override>()<suspends>:void =
        AddOneTo("木材")
        Print("背包更新完毕")

读图翻译:这段里 AddOneTo 是核心:它先开一个空数组 Result,用 ForEach 逐个遍历背包 Stacks;碰到名字和目标一致的那格,就把它换成 Stack.AddOne() 造出的新包加进 Result,其余的原样加进去;跑完再用一个 Set 把 Stacks 整个替换成 ResultOnBegin(相当于 Event BeginPlay)里喊一句 AddOneTo("木材"),木材那格就 +1 了。

初看有点绕:改一个数,居然要重建整个数组?但这段代码没有任何隐藏状态——每一步产生的都是新值,旧值随时可以回看,任何人拿到 item_stack 都不必担心「别处有人偷偷改了它」。写惯了你会发现,这种「换新不改旧」的思路让 bug 少得惊人。

四、为什么说不可变是礼物

Verse 强迫 struct 不可变,换来的是三笔实打实的红利。第一,拷贝即隔离:值语义加不可变,意味着把 struct 递给任何函数、存进任何容器,都绝不会被「远程修改」——共享可变状态这个 bug 温床,直接被端了。第二,天然适配事务回滚:Verse 的失败上下文依赖「推测执行、失败回滚」,不可变的值没有需要撤销的修改,和 <transacts> 这套事务机制配合起来最干净(第 18 课细讲)。第三,持久化友好:struct 支持 <persistable>,能直接充当跨局存档数据的值类型——一份没有身份、不会变动的纯数据,存盘读盘才不会有歧义。

Verse 强迫结构体不可变,换来的是三笔实打实的红利。第一,拷贝即隔离:整包拷贝加上不能改,意味着把结构体递给任何函数、存进任何容器,都绝不会被「远处某段代码偷偷改掉」——「大家共用一份可改数据」这个 bug 温床,直接被端了。第二,天然适配「回滚」:Verse 的失败判定靠的是「先在影子图上试连一遍,全走通才落到真图,走不通就当没连过」;一包不会变的数据根本没有需要撤销的改动,和这套会回滚的机制(<transacts>,第 18 课细讲)配合起来最干净。第三,存档友好:结构体能挂 <persistable>,直接当跨对局存档(SaveGame)里保存的数据——一份没有对象身份、不会变动的纯数据,存盘读盘才不会有歧义。

所以下次再撞上 3607,别急着骂编译器:它只是在提醒你,这份数据要么本该换新不改旧(留在 struct),要么本该是个有状态的对象(搬去 class)。想清楚这一点,选型就不会错。

所以下次再撞上 3607,别急着骂 Compile:它只是在提醒你,这份数据要么本该换新不改旧(留在结构体),要么本该是个有状态的对象(搬去蓝图类)。想清楚这一点,选型就不会错。

想把一个 item_stack 的 Count 加 1,Verse 里的正确姿势是?想把一个 item_stack 结构体的 Count 字段加 1(在一包不能改的数据里「改」一个字段),Verse 里的正确姿势是?

来源与延伸

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

论坛:Variable property inside Struct(错误 3607 实录)↗

论坛:Structs and Enums in Verse(Epic 工程师回应)↗

官方文档:Struct in Verse ↗