第 13 章 / 共 58 章 卷三 函数

函数值与闭包

当函数本身成为一种值,代码就开始有了"传递行为"的能力。

本章导读

前两章的函数都有名字、都活在顶层。本章解锁函数的另一重身份:值。函数可以装进变量、当参数传递、当返回值产出,还可以匿名创建、捕获身边的变量——这就是函数值与闭包。它是回调、事件处理、策略模式、乃至第 33 章任务并发的语法地基。

本章学习目标

掌握 func(...) as T 函数类型的声明与调用;会写匿名函数的箭头体与块体;理解闭包"按引用捕获、函数体内只读"的规则与容器变通;避开循环捕获陷阱;会写 map/filter/compose 这类高阶函数;理解函数值与 var/auto 的配合与边界。

13.1 函数值类型

函数也有类型,写法是 func(参数类型...) as 返回类型——与定义语法同构,只是没有名字和函数体:

fntype.xl
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 章的定义语法严格对齐——学一套,用两处。

13.2 匿名函数两种形态

没有名字的函数直接写在表达式里,两种体型:

anon.xl
// 箭头体:单个表达式,一行收工
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。

匿名函数最常见的归宿是直接当实参:

inline.xl
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 出"的行为。

13.3 闭包捕获规则

匿名函数引用体外变量时,就发生了捕获,形成的函数值叫闭包。实测规则两条:

capture.xl
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 用的就是新值。

规则二:写入权限分两层——捕获顶层变量在匿名函数体内可读可写(写入直接生效于全局);捕获函数局部变量则只读:

# bad.xl 内容: # func counter() as func() as int # int n = 0 # return func() as int # n += 1 ← 写捕获的局部变量 # return n # end # end > xl run bad.xl bad.xl:4:9: [sem] 捕获变量在匿名函数体内只读

计数器闭包需要"函数私有、跨调用存活"的可变状态——局部变量捕获只读,直接写不行;全局变量又无法做到每个实例独立。通行的变通:捕获容器,改容器的内容。容器本身是引用,往里面写元素不算"写捕获变量":

counter.xl
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 互不干扰——函数成了"带记忆的物件"。

13.4 循环中捕获的陷阱与变通

其他语言里"循环中创建闭包"有个著名陷阱:所有闭包共享循环变量,最后看到的都是终值。xlang 的情况更干脆——直接捕获循环变量目前根本编不过:

# bad.xl 内容: # for i in range(3) # auto g = func() as int => i ← 捕获循环变量 # println(string(g())) # end > xl run bad.xl tcc: __xlvfs__/module.c:1608: error: 'i' undeclared

语义检查放行、C 代码生成阶段才炸——这是 RC0 的实现缺陷(见坑 3)。变通做法:循环体内先把循环变量复制给一个局部变量,再捕获这个局部变量:

loopfix.xl
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
> xl run loopfix.xl 0 10 20

而且有个意外之喜:因为每轮的 j 都是全新绑定(第 9 章拾遗 2 的每轮重新绑定模型),三个闭包各抓各的 j——经典陷阱在 xlang 里天然不存在,每个闭包都拿到了自己那一轮的值。

13.5 高阶函数实战

函数值真正的威力在"把行为当参数传"。手写两个经典高阶函数——filter(过滤)与 compose(组合):

higher.xl
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 就活着。

静态签名 vs var 路径

上面全部示例都走静态类型路径(参数写明 func 签名、容器用 array<int>)。把函数值塞进 var 走动态路径时能力边界会收缩(13.6 节与拾遗 2)——日常首选静态签名,类型检查会替你站岗。

13.6 函数值与 var/auto

auto 推断函数类型是最常用的写法(本章示例到处都是);var 也能装函数并动态调用:

dynfn.xl
func add(int a, int b) as int
    return a + b
end
var v = add
println(string(v(1, 2)))        // 3:var 动态调用

三条边界(全部实测):

  • 唯一、非泛型的具名函数可直接装入 var;存在重载歧义时必须先用 func 签名类型"点名"(第 14 章);
  • var 装的函数不能用 as 转回静态函数类型——v as func() as int 报 [sem] as 当前只支持基础值、class、native-backed class 和 union 目标类型;
  • var 动态调用跳过编译期检查,参数错了到运行期才暴露——能走静态签名就别走 var。

13.7 递归匿名函数

匿名函数没有名字,还能递归吗?实测:能——通过捕获"自己所在的变量"引用自身:

selffact.xl
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 章的具名函数:名字自带文档,递归意图一目了然,匿名递归留给脚本化的小场合。

深入细节

拾遗 1:为什么局部捕获要只读

"按引用捕获 + 局部捕获只读"是一组精心搭配的约束:按引用让闭包总能看到最新值(直观),只读则斩断了"闭包悄悄修改外层函数局部状态"的隐蔽通道——函数返回后其局部变量的生命周期本应结束,允许闭包写入会迫使语言回答"这份状态到底归谁"的难题。xlang 的回答是:局部捕获只读,想要可变状态请显式用容器状态盒(13.3 节),可变点一眼可见。顶层变量没有生命周期难题,故可读写。这与第 12 章 byref 调用点必须写标记是同一哲学:副作用必须写在明处。

拾遗 2:函数值的 var 打包边界

实测发现:返回签名为 func(array<int>) as array<int> 的闭包后,在调用点会报 [checked] 当前 C 生成暂不支持将该表达式打包为 var——带静态泛型容器签名的函数值在 var 打包路径上尚未打通。绕行方案:高阶函数统一走静态签名(13.5 节的 filter 写法),或把容器参数改为 var 动态路径。这类"静态泛型容器 × 函数值 × var"的三叉交界是 RC0 尚未磨平的区域,碰到 checked 报错时先检查自己是否踩进了这片边界。

拾遗 3:闭包在生成代码里如何存活

具名函数在 C 层是静态符号(第 11 章拾遗),闭包不同:捕获的变量随闭包对象一起进入 XRT 托管环境,函数值本质上是一个"代码指针 + 捕获环境"的包裹。这就是为什么 counter() 每次调用都能产出独立实例——每次创建闭包都新建一份捕获环境(state 数组各是各的)。理解"闭包 = 代码 + 环境"这个二元组,捕获、只读、独立实例这些规则全部不言自明。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:赋错签名的"鸡同鸭讲"报错
# bad.xl 内容: # func(int, int) as int proc = func(int a) as int => a > xl run bad.xl bad.xl:1:30: [sem] 初始化表达式类型和声明类型不匹配: target function, value function

原因:左边要"两个 int 进",右边是"一个 int 进"——签名不匹配。但报错只说 function/function,不展开差异。遇到它请自行逐项核对:参数个数、参数类型、返回类型。

坑 2:在匿名函数体内写捕获的局部变量
# bad.xl 内容: # func outer() # int m = 0 # auto inc = func() as int # m += 1 ← m 是局部变量 # return m # end # println(string(inc())) # end > xl run bad.xl bad.xl:4:9: [sem] 捕获变量在匿名函数体内只读

原因:捕获的函数局部变量只读(顶层变量不受此限)。正确做法:捕获一个容器当状态盒,写容器的元素(13.3 节 counter 模式)。

坑 3:直接捕获循环变量,C 编译层才炸
# bad.xl 内容: # for i in range(3) # auto g = func() as int => i # println(string(g())) # end > xl run bad.xl tcc: __xlvfs__/module.c:1608: error: 'i' undeclared

原因:RC0 缺陷——循环变量的捕获语义检查没拦,cgen 生成的代码引用了不存在的符号。正确做法:循环体内 int j = i 复制一份再捕获 j,还能顺手免疫经典循环陷阱(13.4 节)。看到 tcc 报错且代码里有闭包,先查捕获。

坑 4:对 var 里的函数值用 as 转类型
# bad.xl 内容: # auto fns = [] # fns.push(func() as int => 1) # auto f = fns[0] as func() as int ← as 不支持函数类型 > xl run bad.xl bad.xl:3:26: [sem] as 当前只支持基础值、class、native-backed class 和 union 目标类型

原因:as 的目标类型清单不含 func。正确做法:var 装的函数直接调用即可(var 动态调用合法),或一开始就用静态 func 签名类型变量,别绕道 var。

本章小结

知识点写法/规则要点
函数类型func(int, int) as int签名不带参数名;调用静态检查
匿名函数=> expr 箭头体 / 块体单表达式用箭头,多逻辑用块体
捕获按引用;局部只读、顶层可写私有可变状态用容器状态盒
循环捕获直接捕获循环变量 cgen 失败先复制给局部变量;天然免疫经典陷阱
高阶函数func 签名作参数/返回值compose 返回类型可嵌套函数类型
var 路径var 可装函数动态调用as 不支持 func 目标;静态签名优先
递归匿名经自身变量名自引用可用但具名函数更清晰

复习题

✍️ 先遮住答案想一想
  1. 闭包捕获是"快照"还是"引用"?用一个例子说明区别。
  2. 为什么捕获的函数局部变量在匿名函数体内只读,而顶层变量可写?需要私有可变状态时怎么办?
  3. 循环体内创建闭包,xlang 与其他语言的坑有何不同?正确姿势是什么?
  4. var 装函数值与静态 func 签名变量装函数值,能力差异有哪些?
  5. compose 的返回类型 as func(int) as int 说明了函数类型的什么性质?
参考答案
  1. 引用。factor 从 3 改为 100 后,闭包 scale(10) 的结果从 30 变 1000——闭包看到的是变量本身而非创建时的值。
  2. 局部变量随函数返回而生命周期结束,允许闭包写入会产生状态归属难题,故只读;顶层变量无此问题。需要私有可变状态时捕获容器、写容器元素(状态盒模式)。
  3. 其他语言是"全部闭包共享循环变量看到终值"的运行期陷阱;xlang 直接捕获循环变量在 cgen 阶段报错。正确姿势:循环体内复制给局部变量再捕获,且每轮局部变量是全新绑定,天然没有共享陷阱。
  4. var 路径:可动态调用、但无编译期参数检查、不能 as 回静态类型;静态签名:调用全检查、可进签名做参数/返回值。重载具名函数装 var 还有歧义限制。
  5. 函数类型是一等类型——可以嵌套出现在返回类型(和参数类型)里,"返回一个函数"是类型系统原生支持的操作。

编程练习

练习 1:手写 map

编写 mymap.xl:参照 13.5 节 filter,实现 map(array<int> xs, func(int) as int f) as array<int>,返回对每个元素应用 f 后的新数组。用平方、翻倍两个匿名函数分别测试。

参考思路:与 filter 同构——声明空 array<int>,循环里 push(f(x))。写完你就集齐了函数式三件套中的两件。

练习 2:加法器工厂

编写 adder.xl:makeAdder(int k) as func(int) as int 返回一个"给参数加 k"的闭包。创建 add5、add10,验证 add5(1)=6、add10(1)=11;随后验证两个闭包互不影响。

参考思路:闭包捕获参数 k——这是"函数当工厂"的最小范式,配置化的行为生产线。

练习 3:带状态的计数器组

编写 counters.xl:用 13.3 节的容器状态盒模式写 counter(),创建三个计数器,按 c1、c1、c2、c3、c1 的顺序调用并打印,验证各自独立计数(应输出 1 2 1 1 3)。

参考思路:每个 counter() 调用创建独立的 state 数组。体会"闭包 = 代码 + 私有环境"。