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

for 即列表推导:一行完成 map + filter

别的语言要 map 和 filter 两个函数接力才能干完的活,Verse 一个 for 表达式就收工。这一页把「生成器、过滤器、变换」三件套拆开看清楚,再和命令式累加写法打一场对照赛。

别的工具要「先筛一遍、再逐个改一遍」两步接力才能干完的活,Verse 一个 for 就顺手收工,还自动把结果攒成新数组。这一页把「供货、筛选、加工」三件套拆开看清楚,再和「手动开个数组、循环里一个个 Add」的老实写法打一场对照赛。

一、三件套:生成器供货、过滤器把关、主体加工

官方文档对 for 有一句关键定性:「循环求值为值的数组,因此可以基于现有数组快速创建新数组」。这句话的分量,写过 Python 列表推导式或 JavaScript 的 arr.filter(...).map(...) 的同学一眼就懂——Verse 的 for 天生就是列表推导:括号里的生成器负责供货,后续过滤子句负责把关,大括号(或缩进体)负责把每件货加工成新形态,加工结果自动攒成一个新数组。

官方文档对 for 有一句关键定性:「循环求值为值的数组,因此可以基于现有数组快速创建新数组」。用大白话说:for 一边遍历一边把每轮的产出收集起来,跑完直接给你一个新数组——就像给蓝图 ForEach 自动接上了「每轮 Add 到一个新 Array」的出口。三件套是这么分工的:括号里打头的数组或范围负责供货,后面跟的筛选条件负责把关(像一道 Branch 拦着),大括号里的那串加工把每件货变成新形态,加工结果自动攒成一个新数组,全程不用你手动建数组、手动 Add。

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

report_device := class(creative_device):

    Scores:[]int = array{55, 72, 91, 38, 87}

    OnBegin<override>()<suspends>:void =
        # 纯 filter:只留及格分 -> array{72, 91, 87}
        Passed := for (S : Scores, S >= 60) { S }

        # 纯 map:全员翻倍 -> array{110, 144, 182, 76, 174}
        Doubled := for (S : Scores) { S * 2 }

        # map + filter 一行流:及格分翻倍 -> array{144, 182, 174}
        Boosted := for (S : Scores, S >= 60) { S * 2 }

        Print("及格 {Passed.Length} 人,加成清单 {Boosted.Length} 条")

读图翻译:三行分别是纯筛选、纯加工、筛选加加工。Passed = ForEach 遍历 Scores、带一道「大于等于 60」的筛选,把通过的原样收进新数组;Doubled = 不筛选、每个乘 2 收集;Boosted = 先「大于等于 60」筛一道、通过的再乘 2 收集。相当于蓝图里 ForEach 接 Branch 接乘法节点,再把结果 Add 进一个新 Array——只是 Verse 一行就写完了。

注意 Boosted 那一行的信息密度:遍历、筛选、变换、建新数组,四件事一行写完,而且全程没有出现任何可变状态。失败的迭代(38 和 55 那两轮)被自动排除在结果数组之外——这正是第 10 课讲过的「子句为每次迭代创建失败上下文」在产出端的体现:失败即无货,无货即不进新数组,连 if 都省了。所以过滤条件里的各类可失败表达式——比较、数组索引 Arr[I]、方括号调用——都可以直接往子句里堆。

注意 Boosted 那一行的信息密度:遍历、筛选、加工、建新数组,四件事一行写完,而且全程没有任何「会被改来改去的中间变量」。没通过筛选的那两轮(38 和 55)自动不进结果数组——这正是第 10 课讲过的「筛选一轮一判」在产出端的表现:这一轮没通过就没货,没货就不进新数组,连一个 Branch 都省了。所以筛选条件里那些「能通过 / 通不过」的判断——比较大小、按下标取数组元素 Arr[I] 等——都能直接往括号里堆。

子句还不止能把关。官方规则是:括号里第一个表达式必须是生成器,后面可以跟任意多个过滤器或内联定义。也就是说,你可以先给中间值起个名字再筛:for (S : Scores, D := S * 2, D >= 120) { D }——先算出翻倍分 D,再拿 D 把关,前面定义的绑定能被后面的子句和主体直接使用。这行和 Boosted 等价,但当加工逻辑变复杂时,内联定义能让你不必把所有计算硬塞进一个表达式里。

括号里能放的不止筛选。规则是:打头必须是供货的数组或范围,后面可以跟任意多条筛选,或者顺手起个中间变量。也就是说,你可以先算个中间值、起个名字,再拿它来筛:for (S : Scores, D := S * 2, D >= 120) { D }——先把翻倍分算成 D,再用 D 把关,先起的名字后面都能接着用。这行和 Boosted 效果一样,但当加工逻辑变复杂时,拆出中间变量能让你不必把所有计算硬挤进一个式子里。

二、对照赛:命令式累加 vs 表达式一行流

不用 for 表达式,同样的活当然也能干,套路是「循环外开一个 var 数组、循环里逐个追加」:

不用 for 的自动收集,同样的活当然也能干,套路就是蓝图里最常见的那招:「循环外先建一个空 Array 变量,循环体里用 Add 一个个塞」:

imperative_style.verse
# 命令式写法:能跑,但每一行都是给 bug 留的门
var Collected:[]int = array{}
for (S : Scores):
    if (S >= 60):
        set Collected += array{S}

读图翻译:先建一个空数组变量 Collected(蓝图里 Make Array 出个空的、存进变量),ForEach 遍历 Scores,循环体里接一个 Branch 判「大于等于 60」,通过的用 set Collected += array{S}(相当于 Add 节点把 S 塞进 Collected)。四个节点串下来,才等于上面 for 一行的效果。

四行对一行,差距还不只是长短。命令式版本引入了一个中间可变状态 Collected:它在循环开始前是半成品,循环途中每一刻都是半成品,别处的代码若不小心在错误的时机读它、改它,bug 就来了。for 表达式版本没有这个空窗期——Passed 诞生的那一刻就是完全体,而且是不写 var 的常量,谁都改不动。这是 Verse「表达式优先」设计里体验最好的一个瞬间:你描述「要什么」,而不是指挥「怎么攒」。

四行对一行,差距还不只是长短。手动版本多了一个「会被改来改去的半成品」Collected:循环没开始时它是空的,循环途中每一刻都只装了一半,别处的节点要是在错误的时机去读它、改它,bug 就冒出来了。for 版本没有这个空窗期——Passed 一出生就是完全体,而且是「锁死、不给 Set 节点」的常量,谁都改不动。这是 Verse 最舒服的一个瞬间:你只管说「我要什么」,不用一步步指挥「怎么一个个攒」。

什么时候仍然该用命令式?当每一轮的「加工」本身就是副作用(打日志、开门、发奖励)而不是造数据时,老老实实写循环体;以及当过滤、变换逻辑复杂到一行塞不下时,拆成多个内联定义子句、甚至退回普通循环加 if,可读性优先。一行流是工具,不是信仰。

什么时候还是该老老实实写循环?当每一轮要干的是「做了就算数」的动作(打印、开门、发奖励)而不是造数据时,就把动作放进 Loop Body;以及当筛选、加工逻辑复杂到一行塞不下时,拆成好几个中间变量、甚至退回「ForEach 里接 Branch」的普通写法,怎么好读怎么来。一行流是工具,不是信仰。

三、常见坑:一行流的三个翻车点

在过滤子句里打日志。子句是失败上下文,里面的代码必须可回滚,而 Print 这类副作用函数不可回滚——编译器会报「This invocation calls a function that has effects that are not allowed by its context」。想看中间值,把 Print 挪进循环体:主体不是失败上下文,副作用随便放。

在括号筛选里打日志。筛选必须能「试完收回」,而 Print String 这类「做了就改屏幕、收不回」的节点不行——Compile 会亮红报「This invocation calls a function that has effects that are not allowed by its context」。想看中间值,把 Print String 挪进 Loop Body:那里没有这道限制,副作用随便放。

以为一轮失败就全线停工。过滤器失败只是该轮不产出、直接进入下一轮(相当于其他语言的 continue),既不会中止整个 for,也不会让 for 表达式本身失败。反过来,想「够数就提前收工」也办不到——break 只属于 loop,写进 for 是编译错误;真要中途撤离,改用 loop 配手动索引(第 11 课的主场),或把 for 挪进独立函数、靠函数整体结束退出。

以为一轮没通过就全线停工。筛选没通过只是这一轮不产出、直接进下一轮(像 Branch 走 False),既不会中止整个 for,也不会让 for 本身失败。反过来,想「够数就提前收工」也办不到——break 只属于 loop,写进 for 会 Compile 报错;真要中途撤离,改用 loop 配自己维护的下标(第 11 课的主场),或把 for 挪进一个函数、靠函数整体结束退出。

担心空数组会炸。不会。生成器一件货都没有时,主体一次也不执行,for 安静地交出一个空数组,后续取 .Length、再遍历都照常工作——对空集合做一行流不需要先判长度。

担心空数组会炸。不会。供货的数组一件货都没有时,加工一次也不跑,for 安静地交出一个空数组,之后问它长度 .Length、再拿去遍历都照常工作——对空数组用一行流,不需要先判长度。

四、随堂小测

Result := for (X : array{1, 2, 3, 4}, X > 2) { X * 10 } 之后,Result 是什么?一个 for 遍历 array{1, 2, 3, 4}、带一道「X 大于 2」的筛选、通过的乘 10 收集,跑完 Result 是什么?

来源

本文整理自 Epic 官方文档:Control Flow in Verse ↗Verse Language Quick Reference ↗