技巧 · EXTRA
扩展方法:让 IntelliSense 为你打工
扩展方法:让编辑器的自动补全替你干活
官方 style guide 为什么建议用 Value.Clamp() 取代 Clamp(Value)?一个点号背后的可发现性哲学。
官方 style guide 为什么建议用 Value.Clamp() 取代 Clamp(Value)?一个点号背后「好不好找」的哲学。
进入拓展 →变量给了设备记忆,函数则给它技能栏:把一串操作打包、起名、装上参数,想用就释放。这一课拆解 Verse 函数的完整定义式,认识它最优雅的规矩——最后一个表达式就是返回值,不用写 return函数体里最后连出来的那个值,自动就是返回值,不用像蓝图里再拖一个专门的 Return 节点;顺带看看函数如何被当成值递来递去,以及给老类型外挂新招式的「扩展函数」。⚔️
前面的课里你已经调用过不少函数:Print 报数、Sleep 数秒、Subscribe 挂回调Print(就是蓝图的 Print String 节点)报数、Sleep(蓝图的 Delay 节点)数秒、Subscribe(把设备的事件绑到你的处理逻辑上,像 Bind Event)挂回调……这一课轮到你自己造。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,和变量一样);参数表,每个参数都是「名字:类型」;冒号后面的返回类型,宣告这个技能放完会交出什么;一个 =;最后是函数体。函数体只有一个表达式时直接写在 = 后面,长了就换行缩进——跟 if、for 的代码块一个规矩。函数体只做一步计算时,直接写在 = 后面;要连一长串就换行、整体缩进——跟蓝图里 Branch、ForEach 这些节点内部要展开的那串执行线是一个意思。
注意那个 =:在 Verse 眼里,定义函数和定义常量是同一件事——把一个名字绑定到一段计算上。Add 不是「一段会跑起来的语句」,它本身就是一个值。这颗种子先埋着,第四节它会发芽。🌱
注意那个 =:在 Verse 眼里,定义一个函数,和新建一个锁死、不给 Set 节点的常量,是同一件事——把一个名字绑定到一份东西上。Add 不是「一串会被跑起来的节点」,它本身就是一个值,可以像变量一样被整个拎起来传走。这颗种子先埋着,第四节它会发芽。🌱
你早已见过的 <suspends>、<override> 这类尖括号标签,固定写在参数表之后、返回类型之前,比如 OnBegin<override>()<suspends>:void。它们叫 specifier,本章后面有专门一课拆解,这里先认个脸熟。另外提醒一句:普通函数必须有 = 和函数体,只写签名不写身体,编译器会当场退货。
你早已见过的 <suspends>、<override> 这类尖括号标签,固定写在输入参数之后、输出类型之前,比如 OnBegin<override>()<suspends>:void。它们叫 specifier:<override> 就是蓝图里函数那个 Override 下拉菜单,<suspends> 表示函数体里有带小钟表的节点(像 Delay 那种要等一会儿的)。本章后面有专门一课拆解,这里先认个脸熟。另外提醒一句:普通函数必须有 = 和函数体;只搭了空壳(有名字、有输入输出,里面却没连东西)按下 Compile 就会亮红退货。
大多数语言里,函数想交答案得喊一声 return。Verse 不用:函数体里最后一个被求值的表达式,它的值就是整个函数的值。想返回谁,就让谁站在最后一行——像考卷最后写答案一样自然。
在蓝图里,函数要往外交结果,你得把值连到 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 表示「调用方别指望拿到东西」,函数体最后是什么都无所谓。
规则只有两条。第一,只要你给函数留了输出(返回类型不是 void),最后一行交出来的值,类型必须跟输出引脚对得上:FinalScore 说好交 int,最后一行的 Total 正是 int,成交。第二,返回类型写 void 就是「这个函数没有输出引脚,调用方别指望拿到东西」,那函数体最后是什么都无所谓。
本课头号大坑就藏在第一条里:把 Print(...) 放在函数体最后一行。Print 的结果是 void,一个声明返回 :int 的函数以它收尾,类型对不上,编译器立刻报错。解法很简单——先打日志,再让真正的答案压轴,或者干脆调整顺序。记住口诀:答案永远坐最后一排。
本课头号大坑就藏在第一条里:把 Print(...)(那个 Print String 节点)放在函数体最后一行。Print String 不往外吐值(结果是 void),而一个说好要交 int 的函数拿它收尾,类型对不上,一按 Compile 就红。解法很简单——先打那行调试字,再让真正的答案压轴,或者干脆调整顺序。记住口诀:答案永远坐最后一排。
Verse 其实也有显式 return,可以提前交卷;但地道的 Verse 代码几乎不需要它——让答案自然沉到最后一行,整个函数读起来就像一条数学等式。本教程的示例一律用隐式返回。
Verse 其实也有显式 return(相当于蓝图里真拖一个 Return 节点、提前把这条线收掉),可以提前交卷;但地道的 Verse 代码几乎不需要它——让答案自然沉到最后一行,整个函数读起来就像一条数学等式。本教程的示例一律用这种「最后一行即答案」的隐式返回。
参数是调用方递进来的「入场券」,有两条脾气要摸清。第一,每张票都必须标类型,编译器凭票验人。第二,参数是不可变的:在函数体里对参数喊 set,编译器直接翻脸。想在参数的基础上加加减减?先把它抄进自己的 var 格子。
参数是调用方递进来的「入场券」——蓝图里就是函数那些输入引脚,有两条脾气要摸清。第一,每张票都必须标类型,类型不对当场验不过。第二,参数是只读的:你没法在函数体里对着输入引脚拖一个 Set 节点去改它,编译器直接翻脸。想在参数的基础上加加减减?先在变量面板新建一个自己的变量 var,把值抄进去,再对那个变量用 Set。
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) 会报错——只有戴 ? 的参数享受点名服务。
再看 IncreaseScore(?Points:int = 1) 里那个问号:给参数名加 ? 前缀,它就成了命名参数——调用时必须连名带姓地写出来,像 IncreaseScore(?Points := 10);再配上默认值(就像蓝图里给输入引脚填的那个默认值框),它同时是可选参数,不填就用默认的 1。反过来不行:没戴 ? 的普通参数不能按名字传,Add(A := 1, B := 2) 会报错——只有戴 ? 的参数才享受点名服务。
光看不练假把式,下面这台奖励机被挖掉了两个关键点:一个是隐式返回要求匹配的返回类型,一个是命名参数的调用写法。填回去,点「检查答案」。✍️
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)
第一节埋的种子该发芽了:既然函数定义就是「把名字绑定到一个值」,那函数当然能像 int 一样递给别的函数——把技能书整本借给队友,而不是替他放技能。接收方要做的,只是把参数声明写成「签名形状」:
第一节埋的种子该发芽了:既然定义一个函数就是「把名字绑定到一个值」,那函数当然能像一个普通数字一样,被拎起来当参数递给别的函数——把整本技能书借给队友,而不是替他放技能。接收方要做的,只是把那个输入声明成「函数签名的形状」——写清它吃什么、吐什么:
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}")
读图翻译:Double、Triple 是两个普通蓝图函数。Apply 的第一个输入 F(X:int):int 不是普通值,而是一个「函数插槽」——任何吃一个 int、吐一个 int 的函数都能插进去;函数体里 F(N) 就是把插进来的那个函数拿出来跑一遍。OnBegin 里 Apply(Double, 5) 把 Double 这本「技能书」插进插槽(注意不带括号,带括号就成了当场释放)。最后那行 (Score:int).Doubled() 是「扩展函数」,下面正文细讲。
三个看点。其一,Apply(Double, 5) 里的 Double 不带括号——括号是「释放技能」的按键,不按它,递过去的就是技能书本身。反过来这也是个经典坑:X := Foo(忘了括号)不会调用 Foo,X 拿到的是函数本身,后续多半收获一条莫名其妙的类型不匹配。其二,顺便提一句:传函数传的是引用——Apply 拿到的就是 Double 本人,不是复制品。其三,Verse 目前没有匿名函数(lambda)字面量,也没有闭包捕获,要传就传有名有姓的函数;想让回调「随身携带数据」,得借助类的方法——本课拓展里有完整的 Handler 模式。
三个看点。其一,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 甚至推荐用扩展函数代替单参数工具函数,理由和玩法详见本课拓展。
代码里最后那位新面孔叫扩展函数(extension method):把「接收者」用括号写在函数名前面,(Score:int).Doubled():int = Score * 2,就给 int 外挂了一个新招式,调用时写 A.Doubled(),跟对象自带的方法一模一样。它可以挂到任何对你可见的类型上——引擎类型、别人的蓝图类、结构体(struct)、枚举(enum)——完全不用新建子蓝图去继承。函数体里用你声明的接收者名(这里是 Score)引用那个对象;注意它不是真的长进了类里,碰不到人家藏起来的私有(private)内部状态。官方 style guide(可以理解成官方的连线/写法规范)甚至推荐用扩展函数代替只有一个参数的工具函数,理由和玩法详见本课拓展。
把本课的装备全穿上,组装一台连招伤害计算器:单行函数、多行函数、隐式返回、函数互相调用,一条龙。点「运行下一步」,亲眼看函数怎么「进去一趟、带答案回来」。👇
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 只想改一处;② 一段代码需要靠注释才能看懂时,不如给它起个好名字,名字就是最好的注释;③ OnBeginOnBegin(Event BeginPlay) 应该读起来像目录,细节都住进函数里——上面 stepper 的主流程就是范本;④ 一个函数只干一件事,名字用动词说清它干嘛。另外,函数体里还能套局部函数,只在外层函数内可见,适合「只有这里用得上」的小助手。另外,函数体里还能再套一个小函数,它只在外层这个函数里看得见、别处调不到,适合「只有这里用得上」的小助手。
最后奉上本课常见坑速查表,踩过一遍就免疫:
= 和身体,光有签名编译器不收。= 和函数体,只有名字加输入输出、里面空着,编译器不收。X := Foo 忘了括号:没有调用,X 绑定的是函数本身,后续类型错误让人一头雾水。X := Foo 忘了括号:没按下「释放」键,X 绑到的是函数本身而不是它的结果,后续类型对不上让人一头雾水。var。var 变量、抄进去,再对它拖 Set。Foo(A := 1) 报错,只有 ? 命名参数支持按名传递;反之调用命名参数忘写 ? 也一样报错。Foo(A := 1) 报错,只有戴 ? 的命名参数支持写名字传;反过来,调用命名参数时忘写 ? 也一样报错。<decides> 的可失败函数要用方括号 Foo[] 调用,普通函数用圆括号 Foo(),混用都是编译错误——本章后面的 specifier 课会细讲。<decides> 的「可能走不通」的函数(就像 Branch 那种问「能不能走通」的判断)要用方括号 Foo[] 调用,普通函数用圆括号 Foo(),混用都是编译错误——本章后面的 specifier 课会细讲。技能栏配置完毕,闯三道小关卡验收。答错零惩罚,可以一直重试。
一个声明为 :int 的函数,函数体最后一行写了 Print("算好了"),会发生什么?
一个说好要交出 int 的函数,函数体最后一行写了 Print("算好了")(也就是一个 Print String),会发生什么?
Foo 是个函数,写下 X := Foo(后面没有括号),X 拿到的是什么?
想声明一个「可以不填、要填就必须写名字」的参数,正确写法是?
技巧 · EXTRA
官方 style guide 为什么建议用 Value.Clamp() 取代 Clamp(Value)?一个点号背后的可发现性哲学。
官方 style guide 为什么建议用 Value.Clamp() 取代 Clamp(Value)?一个点号背后「好不好找」的哲学。
进入拓展 →拔高 · EXTRA
论坛经典问答:参数写成签名形状就能接住函数——以及「没有 lambda」这道现实边界。
论坛经典问答:把输入声明成函数签名的形状就能接住函数——以及「不能就地捏没名字的小函数(lambda)」这道现实边界。
进入拓展 →技巧 · EXTRA
Subscribe 只吃一种形状的函数,想给回调带数据怎么办?被社区引用数千次的标准解法。
Subscribe(绑定设备事件)只吃一种固定形状的函数,想让回调多带点数据怎么办?被社区引用数千次的标准解法。
进入拓展 →