当函数本身成为一种值,代码就开始有了"传递行为"的能力。
前两章的函数都有名字、都活在顶层。本章解锁函数的另一重身份:值。函数可以装进变量、当参数传递、当返回值产出,还可以匿名创建、捕获身边的变量——这就是函数值与闭包。它是回调、事件处理、策略模式、乃至第 33 章任务并发的语法地基。
掌握 func(...) as T 函数类型的声明与调用;会写匿名函数的箭头体与块体;理解闭包"按引用捕获、函数体内只读"的规则与容器变通;避开循环捕获陷阱;会写 map/filter/compose 这类高阶函数;理解函数值与 var/auto 的配合与边界。
函数也有类型,写法是 func(参数类型...) as 返回类型——与定义语法同构,只是没有名字和函数体:
func add(int a, int b) as int return a + b end func(int, int) as int proc = add // 把函数装进类型化变量 println(string(proc(1, 2))) // 3:像普通函数一样调用
类型化函数变量带来静态检查的全部好处:调用 proc 时实参数量、类型照常在编译期核对([sem] func 调用实参数量和签名不匹配: expected 1, actual 2),赋错签名也当场拦截——只是报错文本目前比较粗糙(见坑 1)。
注意写法纪律:函数值签名用 func(...) 关键字加参数类型列表,不写参数名、不写完整单词 function。返回类型同样用 as,与第 11 章的定义语法严格对齐——学一套,用两处。
没有名字的函数直接写在表达式里,两种体型:
// 箭头体:单个表达式,一行收工 auto inc = func(int a) as int => a + 1 println(string(inc(41))) // 42 // 块体:完整函数体,return 显式返回 auto classify = func(int n) as string if n < 0 then return "negative" end return "non-negative" end println(classify(-5)) // negative
选型很直观:一个表达式能写完的用箭头体,有分支/循环/多步逻辑的用块体。箭头体 => expr 的值就是返回值,不用写 return。
匿名函数最常见的归宿是直接当实参:
func apply(int x, func(int) as int f) as int return f(x) end println(string(apply(10, func(int a) as int => a * a))) // 100
注意形参 func(int) as int f——函数类型的形参声明同样省略参数名。读到 apply 的签名就知道:它要一个 int,再加一个"int 进 int 出"的行为。
匿名函数引用体外变量时,就发生了捕获,形成的函数值叫闭包。实测规则两条:
int factor = 3 auto scale = func(int x) as int => x * factor println(string(scale(10))) // 30 factor = 100 println(string(scale(10))) // 1000:看到的是新值!
规则一:按引用捕获——闭包看到的不是"创建那一刻的快照",而是变量本身。factor 改成 100 后,再调用 scale 用的就是新值。
规则二:写入权限分两层——捕获顶层变量在匿名函数体内可读可写(写入直接生效于全局);捕获函数局部变量则只读:
计数器闭包需要"函数私有、跨调用存活"的可变状态——局部变量捕获只读,直接写不行;全局变量又无法做到每个实例独立。通行的变通:捕获容器,改容器的内容。容器本身是引用,往里面写元素不算"写捕获变量":
func counter() as func() as int auto state = [0] // 一个元素的数组当"状态盒" return func() as int state[0] += 1 // 改内容,不改绑定:合法 return state[0] as int end end auto c1 = counter() auto c2 = counter() println(string(c1())) // 1 println(string(c1())) // 2 println(string(c2())) // 1:各自独立的状态盒
这个例子还展示了闭包的核心价值:每次调用 counter() 都生成一个带私有状态的函数,c1、c2 互不干扰——函数成了"带记忆的物件"。
其他语言里"循环中创建闭包"有个著名陷阱:所有闭包共享循环变量,最后看到的都是终值。xlang 的情况更干脆——直接捕获循环变量目前根本编不过:
语义检查放行、C 代码生成阶段才炸——这是 RC0 的实现缺陷(见坑 3)。变通做法:循环体内先把循环变量复制给一个局部变量,再捕获这个局部变量:
auto fns = [] for i in range(3) int j = i * 10 // 每轮一个崭新的局部变量 fns.push(func() as int => j) end for f in fns println(string(f())) end
而且有个意外之喜:因为每轮的 j 都是全新绑定(第 9 章拾遗 2 的每轮重新绑定模型),三个闭包各抓各的 j——经典陷阱在 xlang 里天然不存在,每个闭包都拿到了自己那一轮的值。
函数值真正的威力在"把行为当参数传"。手写两个经典高阶函数——filter(过滤)与 compose(组合):
func filter(array<int> xs, func(int) as bool pred) as array<int> array<int> ys for x in xs if pred(x) then ys.push(x) end end return ys end array<int> evens = filter(array<int>[1, 2, 3, 4, 5, 6], func(int a) as bool => a % 2 == 0) for x in evens print(string(x) + " ") // 2 4 6 end println("") func compose(func(int) as int f, func(int) as int g) as func(int) as int return func(int x) as int => f(g(x)) end auto inc = func(int a) as int => a + 1 auto dbl = func(int a) as int => a * 2 auto h = compose(inc, dbl) println(string(h(5))) // 11:inc(dbl(5))
注意 compose 的返回类型:as func(int) as int——函数类型可以嵌套出现在签名里,返回"一个函数"和返回 int 一样自然。filter 与 compose 还展示了两种捕获:compose 返回的闭包捕获了参数 f 和 g(按引用),只要 h 还活着,f、g 就活着。
上面全部示例都走静态类型路径(参数写明 func 签名、容器用 array<int>)。把函数值塞进 var 走动态路径时能力边界会收缩(13.6 节与拾遗 2)——日常首选静态签名,类型检查会替你站岗。
auto 推断函数类型是最常用的写法(本章示例到处都是);var 也能装函数并动态调用:
func add(int a, int b) as int return a + b end var v = add println(string(v(1, 2))) // 3:var 动态调用
三条边界(全部实测):
v as func() as int 报 [sem] as 当前只支持基础值、class、native-backed class 和 union 目标类型;匿名函数没有名字,还能递归吗?实测:能——通过捕获"自己所在的变量"引用自身:
auto fact = func(int n) as int if n <= 1 then return 1 end return n * fact(n - 1) // 通过变量名引用自己 end println(string(fact(5))) // 120
原理:auto 变量 fact 先进入作用域,函数体编译时捕获它(按引用),调用时变量里已是完整的函数值——自引用闭环成立。不过工程上递归逻辑还是建议用第 11 章的具名函数:名字自带文档,递归意图一目了然,匿名递归留给脚本化的小场合。
"按引用捕获 + 局部捕获只读"是一组精心搭配的约束:按引用让闭包总能看到最新值(直观),只读则斩断了"闭包悄悄修改外层函数局部状态"的隐蔽通道——函数返回后其局部变量的生命周期本应结束,允许闭包写入会迫使语言回答"这份状态到底归谁"的难题。xlang 的回答是:局部捕获只读,想要可变状态请显式用容器状态盒(13.3 节),可变点一眼可见。顶层变量没有生命周期难题,故可读写。这与第 12 章 byref 调用点必须写标记是同一哲学:副作用必须写在明处。
实测发现:返回签名为 func(array<int>) as array<int> 的闭包后,在调用点会报 [checked] 当前 C 生成暂不支持将该表达式打包为 var——带静态泛型容器签名的函数值在 var 打包路径上尚未打通。绕行方案:高阶函数统一走静态签名(13.5 节的 filter 写法),或把容器参数改为 var 动态路径。这类"静态泛型容器 × 函数值 × var"的三叉交界是 RC0 尚未磨平的区域,碰到 checked 报错时先检查自己是否踩进了这片边界。
具名函数在 C 层是静态符号(第 11 章拾遗),闭包不同:捕获的变量随闭包对象一起进入 XRT 托管环境,函数值本质上是一个"代码指针 + 捕获环境"的包裹。这就是为什么 counter() 每次调用都能产出独立实例——每次创建闭包都新建一份捕获环境(state 数组各是各的)。理解"闭包 = 代码 + 环境"这个二元组,捕获、只读、独立实例这些规则全部不言自明。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:左边要"两个 int 进",右边是"一个 int 进"——签名不匹配。但报错只说 function/function,不展开差异。遇到它请自行逐项核对:参数个数、参数类型、返回类型。
原因:捕获的函数局部变量只读(顶层变量不受此限)。正确做法:捕获一个容器当状态盒,写容器的元素(13.3 节 counter 模式)。
原因:RC0 缺陷——循环变量的捕获语义检查没拦,cgen 生成的代码引用了不存在的符号。正确做法:循环体内 int j = i 复制一份再捕获 j,还能顺手免疫经典循环陷阱(13.4 节)。看到 tcc 报错且代码里有闭包,先查捕获。
原因:as 的目标类型清单不含 func。正确做法:var 装的函数直接调用即可(var 动态调用合法),或一开始就用静态 func 签名类型变量,别绕道 var。
| 知识点 | 写法/规则 | 要点 |
|---|---|---|
| 函数类型 | func(int, int) as int | 签名不带参数名;调用静态检查 |
| 匿名函数 | => expr 箭头体 / 块体 | 单表达式用箭头,多逻辑用块体 |
| 捕获 | 按引用;局部只读、顶层可写 | 私有可变状态用容器状态盒 |
| 循环捕获 | 直接捕获循环变量 cgen 失败 | 先复制给局部变量;天然免疫经典陷阱 |
| 高阶函数 | func 签名作参数/返回值 | compose 返回类型可嵌套函数类型 |
| var 路径 | var 可装函数动态调用 | as 不支持 func 目标;静态签名优先 |
| 递归匿名 | 经自身变量名自引用 | 可用但具名函数更清晰 |
as func(int) as int 说明了函数类型的什么性质?编写 mymap.xl:参照 13.5 节 filter,实现 map(array<int> xs, func(int) as int f) as array<int>,返回对每个元素应用 f 后的新数组。用平方、翻倍两个匿名函数分别测试。
参考思路:与 filter 同构——声明空 array<int>,循环里 push(f(x))。写完你就集齐了函数式三件套中的两件。
编写 adder.xl:makeAdder(int k) as func(int) as int 返回一个"给参数加 k"的闭包。创建 add5、add10,验证 add5(1)=6、add10(1)=11;随后验证两个闭包互不影响。
参考思路:闭包捕获参数 k——这是"函数当工厂"的最小范式,配置化的行为生产线。
编写 counters.xl:用 13.3 节的容器状态盒模式写 counter(),创建三个计数器,按 c1、c1、c2、c3、c1 的顺序调用并打印,验证各自独立计数(应输出 1 2 1 1 3)。
参考思路:每个 counter() 调用创建独立的 state 数组。体会"闭包 = 代码 + 私有环境"。