函数是一等公民吗?在 Verse 中传递函数
「能不能把函数当参数传?」——这是 Epic 开发者论坛上的一条经典提问。官方文档对此着墨极少,但答案是肯定的,而且写法出奇地直白。这一页顺着那条帖子,把「函数也是值」讲透,再画清它在当前 UEFN 里的现实边界。
一、论坛上的经典一问
提问者的困惑很有代表性:文档里翻不到「函数类型」的写法,那 Verse 的函数到底是不是一等公民?有趣的是,最终的高赞答案来自提问者自己:可以传,把参数声明写成「签名形状」就行。他给出的验证代码叫 EatTheFunction——把函数整个「喂」给另一个函数吃。下面是按本站命名约定整理后的版本:
提问者的困惑很有代表性:文档里翻不到「函数类型」该怎么写,那 Verse 的函数到底能不能像普通数值一样被传来传去?有趣的是,最终的高赞答案来自提问者自己:可以传,把那个输入参数声明成「函数签名的形状」就行——写清它吃什么、吐什么。他给出的验证代码叫 EatTheFunction——把一个函数整个「喂」给另一个函数吃。下面是按本站命名约定整理后的版本:
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
Double(N:int):int = N * 2
AddTen(N:int):int = N + 10
# 参数写成「签名形状」,就能吃下任何匹配的函数
EatTheFunction(IntToInt(X:int):int, Num:int):int =
IntToInt(Num)
first_class_device := class(creative_device):
OnBegin<override>()<suspends>:void =
A := EatTheFunction(Double, 21) # 42
B := EatTheFunction(AddTen, 32) # 42
Print("A={A} B={B}")
读图翻译:Double、AddTen 是两个普通函数。EatTheFunction 的第一个输入 IntToInt(X:int):int 是一个「函数插槽」——只要是吃一个 int、吐一个 int 的函数都能插进去;函数体里 IntToInt(Num) 就是把插进来的那个函数拿出来跑一遍。OnBegin(Event BeginPlay)里分别把 Double 和 AddTen 插进去,同一条流程因此跑出两种算法,结果都是 42。
参数 IntToInt(X:int):int 长得就像一个函数签名——事实上它就是:任何「吃一个 int、吐一个 int」的具名函数都能塞进这个坑位。函数体里拿它当普通函数调用即可。传入时写 Double 不带括号:带括号是调用,不带括号递过去的才是函数本身,而且传的是引用,不是复制品。
参数 IntToInt(X:int):int 长得就像一个函数的签名——事实上它就是:任何「吃一个 int、吐一个 int」的、有名字的函数都能塞进这个坑位。函数体里把它当普通函数用就行。传进去时写 Double 不带括号:带括号是当场释放,不带括号递过去的才是函数本身,而且传的是引用(就是 Double 本人,不是复制品)。
二、为什么可以:一切皆表达式
二、为什么可以:算出来的都是「值」
提问者自答时给出的解释被社区广泛认可:Verse 里一切皆表达式,所以任何东西都能当参数。这不是巧合,而是血统——Verse 被 Epic 定位为一门 functional logic language(函数式逻辑语言),函数天生就是值。第 15 课里那个 = 的深意在这里彻底展开:Double(N:int):int = N * 2 和 MaxHealth:int = 100 是同一种句子,都是把名字绑定到一个值上,只不过前者绑定的值恰好「可以被调用」。
提问者自答时给出的解释被社区广泛认可:Verse 里几乎任何东西算出来都是一个值,所以任何东西都能当参数。这不是巧合,而是血统——Verse 被 Epic 定位为一门 functional logic language(函数式逻辑语言),函数天生就是值。第 15 课里那个 = 的深意在这里彻底展开:Double(N:int):int = N * 2 和 MaxHealth:int = 100 其实是同一种写法,都是把一个名字绑定到一份东西上,只不过前者绑定的那份东西恰好「可以被拿来跑一遍」。
这带来一种很实用的架构手感:把「怎么算」从「算什么」里抽出来。上面的 EatTheFunction 就是最小示范——同一套流程,换一本「技能书」就换一种行为。伤害公式、掉落权重、排序规则,都可以做成可替换的函数插槽,主流程一行不改。
三、现实的边界:没有 lambda,没有闭包
说完能力,得画边界,免得你带着别的语言的肌肉记忆撞墙。第一,截至当前的 UEFN 版本,Verse 没有匿名函数(lambda)字面量:你不能在调用现场随手内联一个 x => x * 2 之类的小函数,想传就必须先定义一个有名有姓的函数,或者用对象的方法。第二,没有闭包捕获:函数不能顺手「捎带」定义处的局部变量。想让一个回调随身携带数据——比如按钮编号、奖励倍率——标准解法是把数据存进类的字段、把回调写成这个类的方法,也就是社区总结的 Handler 类模式(本课另一篇拓展专门讲它)。
说完能力,得画边界,免得你带着别的工具的肌肉记忆撞墙。第一,截至当前的 UEFN 版本,Verse 不能就地临时捏一个没名字的小函数(别的语言把这叫 lambda 字面量):你没法在调用的地方随手内联一个「进来 ×2 再出去」的小函数,想传就必须先定义一个有名有姓的函数,或者用某个对象的方法。第二,函数不能顺手把周围的变量打包带走(术语叫闭包捕获):它捎不上定义它的地方那些临时变量。想让一个回调(像蓝图里 Bind Event 挂上去的那段逻辑)随身带着数据——比如按钮编号、奖励倍率——标准解法是把数据存进一个类的字段、把回调写成这个类的方法,也就是社区总结的 Handler 类模式(本课另一篇拓展专门讲它)。
所以对「函数是一等公民吗?」的诚实回答是:公民资格有,护照齐全;只是暂时还不能隐姓埋名地旅行。语言的函数式基因摆在那里,当前实现选择了保守的子集——对写游戏逻辑来说,具名函数 + Handler 类这两件武器已经覆盖绝大多数场景。
想让函数 Run 接受「任何 int 进、int 出」的函数作为参数,参数应该怎么声明?
在当前的 UEFN 版本里,下面哪件事做不到?
来源与延伸
本文整理自 Epic 开发者论坛:
- Can I pass a function as an argument in Verse? Are functions first-class citizens? ↗ —— EatTheFunction 示例与「一切皆表达式」解释的出处。