Verse Wiki — 写给蓝图作者的 Verse 手册
第六章 · 第 19 课

函数与隐式返回:把连招打包成技能

你在蓝图里画过函数图:几个输入引脚、一个输出引脚、末尾拖一个 Return 节点。这一课把那张图翻译成 Verse 的一条等式——参数就是输入引脚,返回类型就是输出引脚,而函数体里最后连出来的那个值,自动就是返回值,不用再拖 Return;顺带看看函数如何被当成值递来递去,以及给老类型外挂新招式的「扩展函数」。⚔️

一、函数定义全形:一条等式,五个零件

前面的课里你已经调用过不少函数:Print(就是蓝图的 Print String 节点)报数、Sleep(蓝图的 Delay 节点)数秒、Subscribe(把设备的事件绑到你的处理逻辑上,像 Bind Event)挂回调……这一课轮到你自己造。Verse 的函数定义只有一种「全形」,记住它,后面所有变体都是它的影子:

skill_functions.verse
using { /UnrealEngine.com/Temporary/Diagnostics }

# 单行形:签名 = 表达式,一行搞定
Add(A:int, B:int):int = A + B

# 多行形:= 之后换行,函数体整体缩进 4 空格
HealthAfterHit(Health:int, Damage:int):int =
    Remaining := Health - Damage
    Remaining

# 不需要给调用方答案时,返回类型写 void
Cheer(Name:string):void =
    Print("为 {Name} 欢呼!")

读图翻译:这三段各是一个蓝图函数。Add 就是一个开了两个整数输入引脚(A、B)、一个整数输出引脚的函数,里面一个加法节点把 A + B 接到输出。HealthAfterHit 一样,只是中间先算 Health - Damage 存进临时值 Remaining,再把它接到输出。Cheer 的返回类型写成 void,意思是它没有输出引脚、不往外交任何值,只负责用 Print String 打一行字。

Add(A:int, B:int):int = A + B 为例,从左到右五个零件:函数名(约定 PascalCase,和变量一样);参数表,每个参数都是「名字:类型」;冒号后面的返回类型,宣告这个技能放完会交出什么;一个 =;最后是函数体。函数体只做一步计算时,直接写在 = 后面;要连一长串就换行、整体缩进——就像蓝图函数图表里从入口开始一路往下连的那一整串节点,都归属于这个函数。

注意那个 =:在 Verse 眼里,定义一个函数,和新建一个锁死、不给 Set 节点的常量,是同一件事——把一个名字绑定到一份东西上Add 不是「一串会被跑起来的节点」,它本身就是一个值,可以像变量一样被整个拎起来传走。这颗种子先埋着,第四节它会发芽。🌱

你早已见过的 <suspends><override> 这类尖括号标签,固定写在输入参数之后、输出类型之前,比如 OnBegin<override>()<suspends>:void。它们叫 specifier:<override> 就是蓝图里函数那个 Override 下拉菜单,<suspends> 表示函数体里有带小钟表的节点(像 Delay 那种要等一会儿的)。本章后面有专门一课拆解,这里先认个脸熟。另外提醒一句:普通函数必须= 和函数体;只搭了空壳(有名字、有输入输出,里面却没连东西)按下 Compile 就会亮红退货。

二、隐式返回:最后一行算出来的就是答案

在蓝图里,函数要往外交结果,你得把值连到 Return 节点的输出引脚上。Verse 更省事:函数体里最后算出来的那个值,自动就是整个函数的返回值。想返回谁,就让谁站在最后一行——像考卷最后写答案一样自然。

implicit_return.verse
# 最后一行是 Total,函数的值就是 Total
FinalScore(Kills:int, Assists:int):int =
    Total := Kills * 3 + Assists
    Total

# if/else 也是表达式:走到哪个分支,答案就是那个分支的最后表达式
RankLabel(Score:int):string =
    if (Score >= 100):
        "王牌"
    else:
        "新兵"

读图翻译:上半段 FinalScore 是一个两个整数输入、一个整数输出的蓝图函数,里面先算 Kills×3 + Assists 存进 Total,再把 Total 接到输出。下半段 RankLabel 里那个 if/else 就是一个 Branch:分数够 100 走一条线、结果是「王牌」,否则走另一条线、结果是「新兵」——两条线各把自己那句话接到同一个字符串输出上。

规则只有两条。第一,只要你给函数留了输出(返回类型不是 void),最后一行交出来的值,类型必须跟输出引脚对得上:FinalScore 说好交 int,最后一行的 Total 正是 int,成交。第二,返回类型写 void 就是「这个函数没有输出引脚,调用方别指望拿到东西」,那函数体最后是什么都无所谓。

本课头号大坑就藏在第一条里:Print(...)(那个 Print String 节点)放在函数体最后一行。Print String 不往外吐值(结果是 void),而一个说好要交 int 的函数拿它收尾,类型对不上,一按 Compile 就红。解法很简单——先打那行调试字,再让真正的答案压轴,或者干脆调整顺序。记住口诀:答案永远坐最后一排

Verse 其实也有显式 return(相当于蓝图里真拖一个 Return 节点、提前把这条线收掉),可以提前交卷;但地道的 Verse 代码几乎不需要它——让答案自然沉到最后一行,整个函数读起来就像一条数学等式。本教程的示例一律用这种「最后一行即答案」的隐式返回。

三、参数:只读的入场券

参数是调用方递进来的「入场券」——蓝图里就是函数那些输入引脚,有两条脾气要摸清。第一,每张票都必须标类型,类型不对当场验不过。第二,参数是只读的:你没法在函数体里对着输入引脚拖一个 Set 节点去改它,编译器直接翻脸。想在参数的基础上加加减减?先在变量面板新建一个自己的变量 var,把值抄进去,再对那个变量用 Set。

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

params_device := class(creative_device):

    # 参数只读:想改,先抄进自己的 var 格子
    CountdownFrom(Start:int):void =
        # set Start -= 1    # 编译报错:参数不可变
        var Current:int = Start
        set Current -= 1
        Print("还剩 {Current} 秒")

    # ? 前缀 = 命名参数;再给默认值 = 可选参数
    IncreaseScore(?Points:int = 1):void =
        Print("加 {Points} 分")

    OnBegin<override>()<suspends>:void =
        CountdownFrom(10)
        IncreaseScore()                 # 省略,用默认值 1
        IncreaseScore(?Points := 10)    # 按名传递,? 和 := 都不能少

读图翻译:params_device := class(creative_device) 就是你摆进关卡的一个设备蓝图。里面 CountdownFrom 演示了刚说的「参数只读」——被注释掉的那行想直接改参数会报错,正确做法是新建变量 Current 再拖 Set。OnBegin 就是 Event BeginPlay,游戏一开始自动跑的那根线,它依次调用了倒计时和加分两个函数。

再看 IncreaseScore(?Points:int = 1) 里那个问号:给参数名加 ? 前缀,它就成了命名参数——调用时必须连名带姓地写出来,像 IncreaseScore(?Points := 10);再配上默认值(就像蓝图里给输入引脚填的那个默认值框),它同时是可选参数,不填就用默认的 1。反过来不行:没戴 ? 的普通参数不能按名字传,Add(A := 1, B := 2) 会报错——只有戴 ? 的参数才享受点名服务。

光看不练假把式,下面这台奖励机被挖掉了两个关键点:一个是隐式返回要求匹配的返回类型,一个是命名参数的调用写法。填回去,点「检查答案」。✍️

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

reward_device := class(creative_device):

    # 最后一行是 Coins * Bonus,返回类型要跟它匹配
    Reward(Coins:int, Bonus:int):____ =
        Coins * Bonus

    Announce(?Times:int = 1):void =
        Print("庆祝 {Times} 次!")

    OnBegin<override>()<suspends>:void =
        Total := Reward(10, 3)
        Print("拿到 {Total} 金币")
        Announce(____ := 3)

四、函数也是值:递技能书与外挂新招

第一节埋的种子该发芽了:既然定义一个函数就是「把名字绑定到一个值」,那函数当然能像一个普通数字一样,被拎起来当参数递给别的函数——把整本技能书借给队友,而不是替他放技能。接收方要做的,只是把那个输入声明成「函数签名的形状」——写清它吃什么、吐什么:

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

Double(N:int):int = N * 2
Triple(N:int):int = N * 3

# 参数 F 声明成「签名形状」,就能接住任何匹配的函数
Apply(F(X:int):int, N:int):int = F(N)

# 扩展函数:给已有的 int 类型「外挂」一个方法
(Score:int).Doubled():int = Score * 2

first_class_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        A := Apply(Double, 5)    # 传的是 Double 本身,不带括号
        B := Apply(Triple, 5)
        C := A.Doubled()         # 扩展函数,调用起来像成员方法
        Print("A={A} B={B} C={C}")

读图翻译:DoubleTriple 是两个普通蓝图函数。Apply 的第一个输入 F(X:int):int 不是普通值,而是一个「函数插槽」——任何吃一个 int、吐一个 int 的函数都能插进去;函数体里 F(N) 就是把插进来的那个函数拿出来跑一遍。OnBeginApply(Double, 5)Double 这本「技能书」插进插槽(注意不带括号,带括号就成了当场释放)。最后那行 (Score:int).Doubled() 是「扩展函数」,下面正文细讲。

三个看点。其一,Apply(Double, 5) 里的 Double 不带括号——括号是「释放技能」的按键,不按它,递过去的就是技能书本身。反过来这也是个经典坑:X := Foo(忘了括号)不会真的去跑 Foo,X 拿到的是函数本身,后续多半收获一条莫名其妙的类型对不上。其二,顺便提一句:传函数传的是引用——Apply 拿到的就是 Double 本人,不是复制品。其三,Verse 目前没有「就地临时捏一个没名字的小函数」(别的语言叫 lambda)的写法,也不能让函数顺手把周围的变量打包带走;要传就传有名有姓的函数。想让一个回调「随身携带数据」(像蓝图里 Bind Event 时想多塞点信息),得把数据存进一个类、把回调写成这个类的方法——本课拓展里有完整的 Handler 模式。

代码里最后那位新面孔叫扩展函数(extension method):把「接收者」用括号写在函数名前面,(Score:int).Doubled():int = Score * 2,就给 int 外挂了一个新招式,调用时写 A.Doubled(),跟对象自带的方法一模一样。它可以挂到任何对你可见的类型上——引擎类型、别人的蓝图类、结构体(struct)、枚举(enum)——完全不用新建子蓝图去继承。函数体里用你声明的接收者名(这里是 Score)引用那个对象;注意它不是真的长进了类里,碰不到人家藏起来的私有(private)内部状态。官方 style guide(可以理解成官方的连线/写法规范)甚至推荐用扩展函数代替只有一个参数的工具函数,理由和玩法详见本课拓展。

五、实战组装:什么时候该拆函数?

把本课的装备全穿上,组装一台连招伤害计算器:单行函数、多行函数、隐式返回、函数互相调用,一条龙。点「运行下一步」,亲眼看函数怎么「进去一趟、带答案回来」。👇

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

combo_device := class(creative_device):

    # 单行函数:连招数乘 5 就是基础伤害
    BaseDamage(Combo:int):int = Combo * 5

    # 多行函数体:算完加成,让「答案」坐最后一排
    FinalDamage(Combo:int, Crit:logic):int =
        Base := BaseDamage(Combo)
        if (Crit?):
            Base * 2
        else:
            Base

    OnBegin<override>()<suspends>:void =
        Normal := FinalDamage(3, false)
        Lucky := FinalDamage(3, true)
        Print("普通连招:{Normal} 点伤害")
        Print("暴击连招:{Lucky} 点伤害")
输出日志

点「运行下一步」,看代码怎么一行行执行。

什么时候该把逻辑拆成函数?给你四条经验法则: 同一段代码要复制第二遍时,别粘贴,拆函数——改 bug 只想改一处; 一段代码需要靠注释才能看懂时,不如给它起个好名字,名字就是最好的注释; OnBegin(Event BeginPlay) 应该读起来像目录,细节都住进函数里——上面 stepper 的主流程就是范本; 一个函数只干一件事,名字用动词说清它干嘛。另外,函数体里还能再套一个小函数,它只在外层这个函数里看得见、别处调不到,适合「只有这里用得上」的小助手。

最后奉上本课常见坑速查表,踩过一遍就免疫:

蓝图对照

把这一课压成一张速查表:左边是你在函数图表里做的事,右边是它在代码里的形态。差异那一列才是重点。

蓝图里的做法 Verse 里的写法 差异
My Blueprint 面板 → Functions → 新建一张函数图 写在类体里的一条等式,BaseDamage(Combo:int):int = Combo * 5 蓝图函数必须挂在某张蓝图上;Verse 的函数还能写在文件顶层、不属于任何类,像 DoubleTriple 那样
函数的输入引脚(Inputs) 括号里的参数表,(A:int, B:int) 引脚顺序变成参数顺序,不能再靠拖线随便接;想按名字传,参数必须戴 ?
输入引脚上填的默认值框 ?Points:int = 1 蓝图任何输入引脚都能填默认值;Verse 只有戴 ? 的命名参数才允许有默认值
函数的输出引脚(Outputs),可以开好几个 冒号后面的返回类型,:int 一个 Verse 函数只交出一个值;想要多个输出引脚,把它们打包成 tuple(见第 18 课)再一起交出去
Return Node,把值连到它的引脚上 隐式返回:函数体最后一个表达式就是返回值 没有节点可拖,也没有节点可忘;代价是「答案必须坐最后一排」,最后一行放个 Print 就编译报红
Pure 节点(勾上 Pure,执行引脚消失) 效果说明符 <computes> 蓝图的 Pure 只是「没有执行引脚」,值会被重复求值;<computes> 是 Compile 强制的「无副作用」承诺,第 22 课细讲
Macro(宏):可以有多个执行输出口 没有直接对应物 Verse 里没有宏。想表达「这条路可能走不通」,用 <decides> + 方括号调用;想复用一段流程,就老老实实拆函数
Call Function 节点(从对象引脚拖出来) A.Doubled() / Apply(Double, 5) 调用写在行内;而且函数本身可以不带括号被当成值传走——蓝图里没有「把一张函数图当成引脚上的值递出去」这回事

这些差异的根子只有一句话:蓝图的函数是一张图,Verse 的函数是一个值。图必须住在某张蓝图资产里,靠引脚和连线跟外界打交道,所以它能有任意多个输入输出口,却没法被「拿起来」递给别人。Verse 把函数降格成了和数字、字符串平级的东西——一个名字绑定到一份东西上,于是它能被传参、能被存进变量,代价是接口必须收窄:一进一出,多出来的值请自己打包。

隐式返回则是另一种取舍。蓝图的 Return 节点是显式的、看得见的,你能一眼在画布上找到出口;Verse 把出口藏进了「最后一行」这条约定里,读代码时得多绕一下,但换来函数读起来像一条等式。真需要提前交卷时,Verse 也有显式 return,只是地道的写法几乎不用它。

关卡挑战 🏁

技能栏配置完毕,闯三道小关卡验收。答错零惩罚,可以一直重试。

一个说好要交出 int 的函数,函数体最后一行写了 Print("算好了")(也就是一个 Print String),会发生什么?

Foo 是个函数,写下 X := Foo(后面没有括号),X 拿到的是什么?

想声明一个「可以不填、要填就必须写名字」的参数,正确写法是?

拓展阅读

技巧 · EXTRA

扩展方法:让编辑器的自动补全替你干活

官方 style guide 为什么建议用 Value.Clamp() 取代 Clamp(Value)?一个点号背后「好不好找」的哲学。

进入拓展 →

拔高 · EXTRA

函数是一等公民吗?在 Verse 中传递函数

论坛经典问答:把输入声明成函数签名的形状就能接住函数——以及「不能就地捏没名字的小函数(lambda)」这道现实边界。

进入拓展 →

技巧 · EXTRA

事件订阅想传额外参数?Handler 类模式

Subscribe(绑定设备事件)只吃一种固定形状的函数,想让回调多带点数据怎么办?被社区引用数千次的标准解法。

进入拓展 →