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

unique:对象身份与 comparable 的秘密

在多数语言里,两个对象引用可以随手用 == 比一比。Verse 偏不:普通类实例根本没有「身份」,连 = 都不让用。这篇拆解 <unique> 如何给对象发身份证,以及 player 凭什么能当 map 的键。

在很多语言里,想知道两个对象是不是同一个,随手比一下就行。Verse 偏不:普通蓝图类造出来的实例根本没有「身份」,连用 = 比一下都不许。这篇就拆解 <unique> 怎么给对象发一张「身份证」,以及 player 凭什么能塞进 Map 变量当键。

一、默认设定:对象没有身份证

先做个思想实验:A := monster{Name := "史莱姆"},再来一只一模一样的 B := monster{Name := "史莱姆"}——A 和 B「相等」吗?按内容比,它们字段全同;按身份比,它们是两只不同的怪。多数语言默认选了「按引用比」,而 Verse 的选择更激进:很多语言默认按「是不是同一个对象」来比,而 Verse 更狠:普通类实例两种都不给比。写 if (A = B): 直接吃一个「类型不可比较(not comparable)」的编译错误——不是比较结果为假,是这行代码根本不成立直接 Compile 报红,说这个类型「不可比较(not comparable)」——不是比出来是 false,是这行根本写不出来

这是有意为之的设计:Verse 的血统偏函数式,一个纯粹的值(比如 42 或 "hello")只有内容、没有「这一个」和「那一个」的区别。类实例默认也被当成这样的值看待——身份是需要显式申请的特权,而申请的方式,就是给类挂上 <unique> specifier。

这是故意的:Verse 骨子里更像一门「只看内容」的语言——一个值(比如 42 或 "hello")只有内容,没有「这一个」跟「那一个」的分别。默认情况下,蓝图类造出来的实例也被当成这种「只看内容」的东西。想让它拥有「身份」,得专门申请一下——申请的方式,就是给这个类挂上 <unique> 这个尖括号标记。

二、<unique>:构造即发身份证

加上 <unique> 之后,每次构造实例,引擎都会为它分配一个独一无二的身份。从此实例之间可以用 =<>身份比较——比的不是字段内容,而是「是不是同一个」:

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

# unique:每个实例构造时获得唯一身份
session_token := class<unique>:
    var Score:int = 0

token_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        A := session_token{}
        B := session_token{}
        C := A
        # = 是失败上下文里的比较:身份相同则通过
        if (A = C):
            Print("A 和 C 是同一个实例")
        if (A = B):
            Print("这行不会打印:A、B 内容相同,身份不同")

读图翻译:session_token 挂了 <unique>,所以每写一次 {} 造一个,引擎就发一张新身份证。A、B 各造一次,是两张不同的证;C := A 没造新的,只是给 A 那张证多贴一个名字。后面两个 if 相当于两道 Branch:A = C 这条线走得通(同一张证),打印那一行;A = B 走不通(两张不同的证),直接走 else、那行不打印。

注意结尾两个 if:A = C 成立,因为 C := A 只是给同一个实例多起了个名字(第 16 课讲过,类是引用语义)(第 16 课讲过,蓝图对象是「引用」,给它换个名字不会复制);而 A = B 失败——尽管两个 token 的字段一模一样,它们是先后构造的两个实例,身份证号不同。比较发生在失败上下文里,失败就整体跳过,没有崩溃、没有异常,一切都是你熟悉的掷骰机制。这个比较就是一道 Branch:能走通就往下,走不通就整条跳过,不会崩、也不会弹异常。

三、通往 map 键的门票:comparable

<unique> 还有个隐藏福利:unique 类自动成为 comparable(可比较类型)的子类型。unique 类会自动算进 comparable(可比较类型)这一类。而「可比较」正是能当 map 键的门票——map 要判断「这个键存不存在」,就必须能对键做相等比较。于是:

registry.verse
# unique 类的实例可比较,因此能作为 map 的键
var Registry:[session_token]string = map{}

读图翻译:这一行就是新建一个 Map 变量 Registry,键的类型是 session_token(正因为它 unique、可比较),值是字符串——和你在蓝图里建一个 Map 变量、指定键值类型是一码事。

这也解开了一个你可能早就好奇的谜:为什么 player 能放进 map 当键?比如经典的 var Scores:[player]int = map{}——因为引擎把 player 这类类型设计成了「有唯一身份、可比较」的风格:每位玩家天生就是「独一无二的那一个」,拿身份当键再自然不过。你在游戏里写的每一张玩家计分表,底下都踩着 uniquecomparable 这套机制。官方的 Comparable in Verse 页面列出了完整的可比较类型清单,int、string 这些按内容比较的老朋友也在列。

最后回味一下这个设计的哲学:身份意味着「两个内容相同的东西可以被区分」,这本质上是一种副作用式的状态——纯函数式语义里没有它的位置。这其实是一种「带状态」的东西,和 Verse 那套「只看内容」的底子天生不太合。Epic 把身份做成 opt-in(默认没有,要就明说),既保住了语言核心的纯粹,又给游戏对象(玩家、道具、会话)留了现实主义的后门。什么时候该加 <unique>?问自己一句:「两个字段完全相同的实例,在我的玩法里算不算两个东西?」算,就加。

四、随堂小测 ◇

普通类(没有 <unique>)的两个实例 A、B,写 if (A = B): 会怎样?

给类加上 <unique> 之后,下面哪件事变成可能?

来源与延伸

本文整理自 Epic 官方文档:

官方文档:unique specifier ↗

官方文档:Comparable in Verse ↗

官方文档:Class in Verse ↗