跳转到内容

第 7 章 · 函数:一等函数、闭包、参数、多返回值

解决什么问题:怎样把一段计算当成值来传递和保存,让它记住创建时的环境,并且灵活地接收参数、交回多个结果。

四种语言的态度:四种语言的函数都是一等值,闭包都按变量捕获;参数和返回值上分歧最大:命名参数只有 Python 和 C# 有,Go 连默认参数也没有,C# 有重载而另外三种没有。

商品价格是 [100, 200, 300]。写一个“折扣生成器”:传入折扣百分比 80,它返回一个打折函数;把这个函数逐个应用到价格上,得到八折后的价格;再给每件减 20 元,最后由一个函数一次交回最低价和最高价。另外,打折函数要自己记住被调用了几次,计数器不放在全局变量里,而是藏在生成器创建的环境中。全部用整数运算(price * 80 // 100 这类),四种语言的输出才能逐字相同。

说一种语言的函数是一等函数(first-class function),意思是函数和整数、字符串一样是值:可以赋给变量、放进列表、作为参数传给别的函数、作为结果从函数里返回。接收或返回函数的函数叫高阶函数(higher-order function),map、filter、排序时传入的比较函数都是例子。场景里的折扣生成器就是一个返回函数的高阶函数。

函数一旦能被返回,就出现一个新问题:返回的函数用到了外层函数的局部变量 rate,而外层函数已经结束,它的栈帧(stack frame)早就销毁了,rate 去哪里读?答案是闭包(closure):函数值不只是一段代码,还带着它创建时能看到的变量,即它的环境(environment)。环境里的变量叫被捕获的变量(captured variable)。

打折函数和它捕获的环境

看下面的堆:make_discount 的栈帧已经销毁,rate 和 count 却活在堆上的环境对象里,apply 和 calls 两个函数指向同一个环境,所以 apply 改了 count,calls 读到的就是改过的值。

捕获有两种语义:按值捕获(创建闭包时复制一份当时的值)和按变量捕获(闭包和外层函数共用同一个变量,谁改了大家都看得到)。本章四种语言都按变量捕获,所以计数器能工作;代价是循环里创建的闭包可能共用同一个循环变量,后文会反复遇到。上图是概念模型,各语言实际怎样存放见后文。

参数传递有三个常见的便利:默认参数(default parameter),调用方不写就用默认值;可变参数(variadic parameter),一个参数收下任意多个值;命名参数(named argument),调用时写出参数名,不靠位置对应。默认值的表达式什么时候求值是个关键差别:在定义函数时求一次,还是每次调用时重新求。多返回值则有三种做法:返回一个元组(tuple)再解构,返回数组或对象再解构,或者语言本身让函数有多个返回位置。

Python 的函数是对象,一等函数、闭包、默认参数、可变参数、命名参数都是原生语法。闭包能读外层变量,要改它得先声明 nonlocal。

from collections.abc import Callable
def make_discount(rate: int) -> tuple[Callable[[int], int], Callable[[], int]]:
count = 0 # 被下面两个内部函数捕获,存进 cell 对象
def apply(price: int) -> int:
nonlocal count # 没有这一行,count += 1 会把 count 当成 apply 的局部变量
count += 1
return price * rate // 100
def calls() -> int:
return count
return apply, calls
def subtract_each(prices: list[int], *, off: int = 20) -> list[int]:
return [p - off for p in prices]
def min_max(*values: int) -> tuple[int, int]:
return min(values), max(values)
discount, calls = make_discount(80)
discounted = list(map(discount, [100, 200, 300]))
low, high = min_max(*subtract_each(discounted, off=20))
print(f"八折:{discounted}")
print(f"再减 20 后的最低价和最高价:{low} {high}")
print(f"打折函数调用次数:{calls()}")
# 输出:
# 八折:[80, 160, 240]
# 再减 20 后的最低价和最高价:60 220
# 打折函数调用次数:3

运行:python discount.py。def 是一条在运行时执行的语句,每执行一次就造出一个新的函数对象,所以每次调用 make_discount 得到的 apply 都是新的,带着各自的 count。Python 的规则是:函数里给一个名字赋值,这个名字就是局部变量。count += 1 是赋值,没有 nonlocal 时编译器把 count 当成 apply 的局部变量,运行到这一行时还没赋过值,抛出 UnboundLocalError;只读不写的 rate 不需要声明。

subtract_each 签名里单独的 * 表示后面的参数只能按名字传(keyword-only),调用必须写 off=20;*values 把任意多个位置参数收进一个元组,**kwargs 则把多余的命名参数收进字典;调用时的 * 反过来把列表展开成参数。min_max 返回的 min(values), max(values) 其实是一个元组,low, high = … 是解包(unpacking),Python 没有真正的多返回值。Python 也没有重载(overloading):同名的第二个 def 只是把名字重新绑定到新函数上;typing.overload 只给类型检查器看,functools.singledispatch 按第一个参数的类型分派。

底层怎么实现。 编译器在编译期就分析出哪些变量被内层函数用到:make_discount 的 rate 和 count 记在它代码对象的 co_cellvars 里,apply 的 co_freevars 列出它需要的外层变量。运行时,外层函数为每个被捕获的变量创建一个 cell 对象,变量的值放在 cell 里;创建 apply 时,这些 cell 被收进它的 __closure__ 元组。内外两层都通过同一个 cell 读写(字节码 LOAD_DEREF、STORE_DEREF),所以 apply 和 calls 看到同一个 count。外层函数返回后,cell 仍被闭包引用着,不会被回收。

默认值在 def 执行时求值一次,结果存进函数对象的 __defaults__(仅关键字参数的存在 __kwdefaults__),以后每次调用都拿出同一个对象。

  • 循环里创建的闭包晚绑定(late binding):[lambda: i for i in range(3)] 里三个函数共用一个 cell,调用时才去读 i,三个都返回 2。常见的修补是 lambda i=i: i,借默认值在定义时求值的规则把当时的值固定下来。
  • 可变默认参数:def add(x, items=[]) 的列表只在定义时创建一次,存在 __defaults__ 里,每次不传 items 的调用都往同一个列表里追加,第二次调用就能看到第一次留下的元素。惯用写法是默认 None,在函数体里新建列表。

JavaScript 的函数是对象,一等函数和闭包是语言的核心;默认参数、剩余参数(rest parameter)、展开语法(spread)和解构(destructuring)都是 ES2015 加入的原生语法。TypeScript 只给函数加上类型,运行时行为和 JS 一样。

function makeDiscount(rate: number): [(price: number) => number, () => number] {
let count = 0; // 被下面两个箭头函数捕获,V8 把它放进堆上的上下文对象
const apply = (price: number): number => {
count++;
return Math.floor((price * rate) / 100);
};
const calls = (): number => count;
return [apply, calls];
}
function subtractEach(prices: number[], off = 20): number[] {
return prices.map((p) => p - off);
}
function minMax(...values: number[]): { low: number; high: number } {
return { low: Math.min(...values), high: Math.max(...values) };
}
const [discount, calls] = makeDiscount(80);
const discounted = [100, 200, 300].map((p) => discount(p));
const { low, high } = minMax(...subtractEach(discounted));
console.log(`八折:[${discounted.join(", ")}]`); // 直接打印数组会是 [ 80, 160, 240 ]
console.log(`再减 20 后的最低价和最高价:${low} ${high}`);
console.log(`打折函数调用次数:${calls()}`);
// 输出:
// 八折:[80, 160, 240]
// 再减 20 后的最低价和最高价:60 220
// 打折函数调用次数:3

运行:tsx discount.ts。JS 的 number 是双精度浮点数,没有整数除法,Math.floor 补上 Python // 向下取整的效果。闭包改外层变量不需要任何声明,count++ 直接改的就是 makeDiscount 里那个 count。subtractEach(discounted) 没传 off,默认值 20 生效;JS 没有命名参数,需要时把参数写成一个对象再解构,如 function f({ off = 20 } = {})。多返回值也靠解构模拟:makeDiscount 返回数组,按位置取;minMax 返回对象,按名字取。

箭头函数(arrow function)没有自己的 this,它的 this 和外层一样,按词法捕获;普通 function 的 this 由调用方式决定。TS 可以给一个函数写多个重载签名,但实现只有一个,要在函数体里自己判断参数类型:编译后签名全部消失,运行时并没有重载。

底层怎么实现。 V8 在解析源码时做作用域分析,找出哪些变量被内层函数引用。没被引用的变量放在寄存器或栈上,函数返回就消失;被引用的变量(这里的 rate 和 count)放进一个在堆上分配的上下文对象(context),对它们的读写都变成对上下文对象槽位的读写。每个闭包对象里存着指向这个上下文对象的指针,同一个作用域里创建的闭包共用同一个上下文对象,即图里两个函数指向同一个环境。

默认参数每次调用时重新求值:引擎在参数缺省(值为 undefined)时执行默认值表达式,这个表达式还能引用前面的参数,如 function g(x, y = x * 2)。剩余参数是一个真正的数组,展开语法则把数组的元素逐个放进参数位置。

  • var 和闭包的老问题:for (var i = 0; i < 3; i++) fs.push(() => i) 里整个函数只有一个 i,三个闭包都读到 3;let 规定每次迭代创建新的绑定,三个闭包分别读到 0、1、2。
  • 同一作用域的闭包共用一个上下文对象:只要有一个闭包还活着,上下文对象里所有被捕获的变量都活着。一个用过就扔的回调引用了大数组,而同一作用域里另一个长期保存的闭包还在,大数组就一直不能回收。
  • ["1", "2", "3"].map(parseInt) 得到 [1, NaN, NaN]:map 会把下标作为第二个参数传进去,parseInt 正好把第二个参数当进制。所以本例写成 map((p) => discount(p)),不直接传 discount。

C# 的函数值叫委托(delegate),标准库提供泛型委托 Func<…>(有返回值)和 Action<…>(无返回值);lambda 表达式和局部函数(local function,C# 7)都能变成委托。可选参数、命名参数(C# 4)、params 可变参数、out 参数、元组返回(C# 7)和重载都是原生的。

using System;
using System.Linq;
var (discount, calls) = MakeDiscount(80);
int[] discounted = new[] { 100, 200, 300 }.Select(discount).ToArray();
var (low, high) = MinMax(SubtractEach(discounted, off: 20));
Console.WriteLine($"八折:[{string.Join(", ", discounted)}]");
Console.WriteLine($"再减 20 后的最低价和最高价:{low} {high}");
Console.WriteLine($"打折函数调用次数:{calls()}");
static (Func<int, int> Apply, Func<int> Calls) MakeDiscount(int rate)
{
int count = 0; // rate 和 count 都被捕获:编译器把它们搬进一个隐藏类(display class)
int Apply(int price)
{
count++;
return price * rate / 100;
}
return (Apply, () => count);
}
static int[] SubtractEach(int[] prices, int off = 20) => prices.Select(p => p - off).ToArray();
static (int Low, int High) MinMax(params int[] values) => (values.Min(), values.Max());
// 输出:
// 八折:[80, 160, 240]
// 再减 20 后的最低价和最高价:60 220
// 打折函数调用次数:3

运行:dotnet run discount.cs。int 相除就是整数除法。局部函数 Apply 在 return 处被转换成 Func<int, int> 委托,和 lambda () => count 一起装进元组返回。(int Low, int High) 是值元组(ValueTuple),字段名只在编译期存在;var (low, high) = … 把它解构。在元组出现以前,多个结果靠 out 参数交回,int.TryParse(s, out var n) 至今还是这种风格。

off: 20 是命名参数,可以和位置参数混用、调换顺序;int off = 20 是可选参数,默认值必须是编译期常量。重载在编译期决议:Console.WriteLine 有十几个重载,编译器按实参的静态类型选一个,运行时不再判断。

底层怎么实现。 委托是一个对象,里面存着目标对象(Target)和方法指针。lambda 捕获了局部变量时,编译器生成一个隐藏类,即显示类(display class,名字形如 <>c__DisplayClass0_0),被捕获的 rate 和 count 变成它的字段;MakeDiscount 一开始就创建这个类的实例,参数 rate 被复制进字段,此后方法里所有对 count 的读写都改成对这个实例字段的读写;lambda 和 Apply 变成这个类的实例方法,委托的 Target 就指向这个实例。两个委托共用一个实例,所以共享 count。不捕获任何变量的 lambda 被编译成静态缓存的委托,不重复分配。

可选参数的默认值不在被调方执行:编译器把常量写进方法的元数据,调用方编译时把它直接填进实参,库改了默认值而调用方不重新编译,就仍用旧值。命名参数同样在编译期就变回按位置传参。

  • for 循环变量被所有闭包共用:for (int i = 0; i < 3; i++) fs.Add(() => i) 三个都返回 3。foreach 自 C# 5 起每次迭代是一个新变量,返回 0、1、2;for 没有改,因为它的变量本来就是在循环外声明、每轮递增的那一个。
  • 捕获导致的意外分配:显示类实例在被捕获变量所在作用域的入口就创建,即使方法走了根本用不到 lambda 的提前返回分支,这次分配也已经发生。热点路径上可以用 static lambda(C# 9)让编译器保证不捕获,或者改用不转成委托的局部函数,后者把捕获变量放在栈上的结构体里。

Go 的函数是值,函数字面量就是闭包,原生支持多返回值和可变参数。Go 没有默认参数、命名参数和重载,需要可选配置时用选项结构体或函数选项模式(functional options)代替。

package main
import (
"fmt"
"slices"
"strings"
)
func makeDiscount(rate int) (func(int) int, func() int) {
count := 0 // 被两个闭包捕获,而闭包随返回值离开本函数,count 逃逸到堆上
apply := func(price int) int {
count++
return price * rate / 100
}
calls := func() int { return count }
return apply, calls
}
func mapInts(xs []int, f func(int) int) []int {
out := make([]int, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
func minMax(values ...int) (low, high int) {
return slices.Min(values), slices.Max(values)
}
func main() {
discount, calls := makeDiscount(80)
discounted := mapInts([]int{100, 200, 300}, discount)
off := 20 // Go 没有默认参数,调用方写出每一个值
low, high := minMax(mapInts(discounted, func(p int) int { return p - off })...)
// fmt.Println 打印切片是 [80 160 240],没有逗号,所以自己补上
fmt.Printf("八折:%s\n", strings.ReplaceAll(fmt.Sprint(discounted), " ", ", "))
fmt.Printf("再减 20 后的最低价和最高价:%d %d\n", low, high)
fmt.Printf("打折函数调用次数:%d\n", calls())
}
// 输出:
// 八折:[80, 160, 240]
// 再减 20 后的最低价和最高价:60 220
// 打折函数调用次数:3

运行:go run discount.go。makeDiscount 的签名 (func(int) int, func() int) 直接写出两个返回值,调用方用 discount, calls := 接住。这是真正的多返回值,不是元组:不能把两个结果存进一个变量,特例只有 f(g()) 和 return g():结果的个数和类型恰好对上参数或返回值时可以直接传。minMax 用了命名返回值(named result)(low, high int),读签名就知道哪个是哪个。values ...int 在函数内部就是一个 []int,调用时 xs... 把切片展开传入。

前面说的函数选项模式写成 NewServer(addr, WithTimeout(5*time.Second)),每个 WithXxx 返回一个修改配置的闭包。

底层怎么实现。 Go 的函数值是一个指针,指向一个小结构体:开头是代码地址,后面跟着闭包捕获的变量或它们的地址;调用闭包时,这个结构体的地址通过一个专门的寄存器传进去。捕获在语义上总是按变量的,但编译器会优化:像 rate 这样体积小、被捕获后再也没有赋值的变量,直接把值复制进结构体,因为复制和共享看不出区别;count 被闭包修改,只能共享。逃逸分析(escape analysis)发现闭包随返回值离开了 makeDiscount,count 活得比栈帧久,于是把它分配到堆上;go build -gcflags=-m 会打印 moved to heap: count。反过来,只在 mapInts 调用期间用到的减 20 的闭包不逃逸,连同它捕获的 off 都留在栈上。

多返回值在调用约定层面就是多个返回位置:早先放在栈上的结果槽里,Go 1.17 起 amd64(1.18 起 arm64 等)改用寄存器交回,都不用造元组对象。

  • Go 1.22 之前,for 循环变量在整个循环里只有一个:for i := 0; i < 3; i++ { go func() { use(i) }() } 里的 goroutine 很可能全都读到 3。1.22 起每次迭代新建变量,但新语义只对 go.mod 里声明 go 1.22 及以上的模块生效,老模块的代码行为不变。
  • 可变参数不复制切片:minMax(xs...) 直接把 xs 的底层数组交给函数,函数里改 values[0],调用方的 xs[0] 也变了;逐个传 minMax(1, 2, 3) 时才新建切片。
  • 闭包让变量逃逸到堆,每个被捕获的变量多一次堆分配和一份 GC 负担。在热点循环里创建并保存闭包,分配量会明显上升,可以用 -gcflags=-m 检查。

循环里创建的闭包捕获循环变量

四种语言的循环坑放在一起:上栏所有闭包指向同一个变量,读到的都是循环结束后的值;下栏每个闭包各指一个。

维度 Python JS/TS C# Go
支持程度 原生 原生 原生(委托) 原生
实现机制 每个被捕获变量一个 cell 对象 堆上的上下文对象(context) 编译器生成的显示类 闭包结构体 + 逃逸到堆
默认参数求值时机 定义时一次,存进 __defaults__ 每次调用时 编译期常量,填进调用方 没有默认参数
可变参数 *args(元组)、**kwargs(字典) ...rest(数组) params int[] ...int(切片,不复制)
命名参数 off=20,可强制仅关键字 没有,用对象参数解构 off: 20 没有,用选项结构体
多返回值 返回元组再解包 返回数组或对象再解构 值元组解构、out 参数 原生多返回值
重载 没有(singledispatch 按类型分派) 没有;TS 只有重载签名 有,编译期决议 没有

最大的差异在“默认参数求值时机”一行:同样写 = 20,在 Python 是定义时求一次,在 JS 是每次调用时求,在 C# 是编译期烧进调用方。

四种语言在闭包上几乎一致,都按变量捕获,是因为它们都有可变的局部变量,又都有垃圾回收:被捕获的变量挪到堆上之后,由垃圾回收器决定什么时候释放,不必像 C++ 那样让程序员在 [=] 和 [&] 之间选择、自己保证引用不悬空。差别只在把变量挪到哪里:Python 一个变量一个 cell,V8 一个作用域一个上下文对象,C# 一个作用域一个显示类,Go 由逃逸分析逐个决定,不逃逸的就留在栈上。循环变量的坑也来自同一个选择,所以 C#、JS、Go 先后都改成或补上了每轮一个新变量的语义,只有 Python 至今保持一个循环一个变量。

默认参数求值时机的分歧来自各自的执行模型。Python 的 def 是运行时执行的语句,执行时已经把函数对象造好,顺手把默认值算好存进去最简单,调用时也最便宜;可变默认值的坑是这个选择的代价。JS 的默认参数到 2015 年才加入,规范把它定义成“参数为 undefined 时执行这段表达式”,相当于函数开头的一条 if,自然每次调用都求值。C# 是静态编译的语言,把常量写进元数据、由调用方填入,运行时一点开销都没有,代价是版本问题:库的默认值一改,旧的调用方要重新编译。Go 干脆不提供,所有实参都写在调用处,读调用代码就知道传了什么。

重载的有无影响 API 的形状。C# 有重载,标准库可以让同一个名字接受各种类型,Console.WriteLine 接 int、string、object 都叫一个名字,编译器按静态类型挑;代价是调用处看不出到底选了哪个版本,加一个重载还可能让原来的调用改选新版本。Go 的 FAQ 说,没有重载让方法分派和类型系统都更简单,于是同样的功能要起不同的名字:strconv.FormatInt、strconv.FormatFloat,或者用 ...any 接收任何值,像 fmt.Println 那样在运行时判断类型。Python 和 JS 是动态类型,参数本来就能是任何类型,同名函数靠运行时判断来分支,没有在编译期选择版本的必要。

多返回值和错误处理是同一个决定的两面。Go 不用异常,可能失败的函数要同时交回结果和错误,于是多返回值必须是原生的、便宜的:n, err := strconv.Atoi(s) 是全语言最常见的一行,第 10 章会展开这条路线。Python、JS、C# 用异常报告失败,多个结果只是偶尔需要,返回元组、数组或对象就够了;C# 早年靠 out 参数交回第二个结果,不抛异常的 TryParse 就是这种写法,C# 7 有了值元组才算有了顺手的多结果写法。

Haskell 的函数都只接受一个参数,多参数函数是柯里化(currying)的:add x y = x + y 实际上是“接受 x、返回一个接受 y 的函数”,所以 map (discount 80) prices 里的 discount 80 不需要写成闭包,部分应用(partial application)本身就生成了打折函数。

  • 闭包里要改外层变量时,确认语言的写法:Python 写 nonlocal,JS、C#、Go 直接改;需要固定某一刻的值时,显式复制到一个新变量里再捕获,不要依赖循环变量的语义。
  • 默认值只用不可变的值。Python 里默认 None 再在函数体里新建列表;C# 的公共库里改默认值要当成破坏性改动;Go 用选项结构体的零值表示默认。
  • 多个结果有名字时给名字:Python 返回命名元组或数据类,TS 返回对象,C# 写带字段名的元组,Go 用命名返回值;两个以上的结果只靠位置区分很容易接反。
  • 函数是一等值时,策略和命令往往只是一个函数,不必另写接口和类,见设计模式书的策略和命令两章。

用 Python 和 C# 各写一个 make_counter(start)(C# 里叫 MakeCounter):它返回两个函数,一个把计数加一,一个读取当前值。创建两个计数器 a(从 10 开始)和 b(从 100 开始),a 加两次、b 加一次,打印两个计数器的值,证明它们互不影响。再打印一行,说明同一个计数器的两个函数是否共用同一份捕获变量,以及 a 和 b 是否共用。

提示:Python 的函数对象有 __closure__,里面是 cell;C# 委托的 Target 属性指向什么?ReferenceEquals 能派上用场。

  • 一等函数让函数成为值;返回的函数带着环境,就是闭包。四种语言都按变量捕获,被捕获的变量挪到堆上,外层函数返回后仍然活着。
  • 存放方式不同:Python 用 cell,V8 用上下文对象,C# 用显示类,Go 靠逃逸分析。
  • 循环里的闭包要看循环变量是一个还是每轮一个:Python 的 for、JS 的 var、C# 的 for 只有一个;JS 的 let、C# 5 起的 foreach、Go 1.22 起的 for 每轮一个。
  • 默认参数在 Python 定义时求一次,在 JS 每次调用时求,在 C# 编译期填进调用方,Go 没有;命名参数只有 Python 和 C# 有。
  • 只有 Go 有真正的多返回值,它和“用返回值传错误”是同一个设计;其余三种返回元组、数组或对象再解构。