面试官,你为什么老是问我”闭包“

前言

写这边博文的背景是前段时间在参加深圳鹏城实验室后台研发工程师一职时被问及闭包是什么,之前对闭包的理解只是停留在使用层面,并未做深层次的了解。我的回答是闭包可以让内部函数访问其所在函数的局部变量。这个回到好像并非是面试官想要的答案,然后又问到闭包的学术性定义是什么,我懵了,戳中了我的知识盲区。鉴于闭包是面试求职过程中被高频问到的一个知识点,且闭包这个术语因难以定义而臭名昭著,我们有必要对其有个全面透彻的了解。

定义

了解一个事物,最直接方式是看其定义。给一个事物下定义是一件非常困难的事情。下面看一下闭包在编程语言中的定义。

在计算机科学中,闭包(Closure)是词法闭包(Lexical Closure)的简称,指定义在函数内部引用了函数内部变量的函数。

如果是第一次接触闭包,那么光看上面的定义绝对无法正真理解闭包,但是当我们了解了闭包实际的作用和用法后,回过头来再看上面的定义,闭包就不再那么晦涩难懂了。

作用

不同的编程语言如 Scheme、PHP、Scala、Common Lisp、Smalltalk、Groovy、JavaScript、Ruby、Python、Go、Lua、Objective C、Swift、Java(>=Java8)及C++(>=C++11)等都对闭包有不同程度的支持。闭包有什么作用呢,让众多语言拥抱闭包。概括地说,闭包有两个作用:
(1)在函数外部访问函数内部变量成为可能;
(2)函数内部变量离开其作用域后始终保持在内存中而不被销毁。

示例

为了便于理解闭包的作用,需要结合具体地实例。下面以 Go 为例,给出闭包的使用示例。注意 Go 里的闭包函数必须是匿名函数。

(1)闭包与逃逸分析。

package main

import "fmt"

func clonsure() func(int) int {
	var x int
	return func(a int) int {
		x++
		return a + x
	}
}

func main() {
	cl := clonsure()
	fmt.Println("x=", cl(1)) // x=2
	fmt.Println("x=", cl(2)) // x=4
}

Go 提供了相关的命令,可以查看变量是否发生逃逸。使用如下命令编译上述代码。

go build -gcflags "-l -m" main.go
# command-line-arguments
./main.go:6:6: moved to heap: x
./main.go:7:9: func literal escapes to heap
./main.go:15:13: main ... argument does not escape
./main.go:15:14: "x=" escapes to heap
./main.go:15:22: cl(1) escapes to heap
./main.go:16:13: main ... argument does not escape
./main.go:16:14: "x=" escapes to heap
./main.go:16:22: cl(2) escapes to heap

从输出结果可以看到函数 clonsure() 的内部变量 x 发生了逃逸,成为了堆变量,以此延长了内部变量 x 的生命周期,保证了其离开其作用域后始终保持在内存中而不被销毁。

(2)利用闭包实现一个简单的计数器。

package main

import (
    "fmt"
)

// Counter 创建计数器,返回一个匿名函数 func() int
func Counter(begin, step int) func() int {
	begin -= step
	return func() int {
		begin += step
		return begin
	}
}

func main() {
	counter0 := Counter(0, 1)
	fmt.Println(counter0())
	fmt.Println(counter0())
	fmt.Println(counter0())
	
	counter10 := Counter(10, 1)
	fmt.Println(counter10())
	fmt.Println(counter10())
}

运行输出:

0
1
2
10
11

通过上面的例子可以看到,将返回的匿名函数赋给 counter0,通过 counter0 完成了对函数 Counter 内部变量 begin 的访问,并使 begin 离开其作用域后始终保持在内存中而不被销毁,只要闭包还被使用,那么被闭包引用的变量会一直存在。新创建的匿名函数对象 counter10 所引用的内部变量 begin 将重新被赋予指定的初始值。

(3)通过闭包可以比较优雅地实现一些功能,比如斐波那契数列。

package main

import (
    "fmt"
)

// FibGen 斐波那契数列生成器
func FibGen() func() int {
	f1, f2 := 0, 1
	return func() int {
		f1, f2 = f2, f1+f2
		return f1
	}
}

func main() {
	fibGen := FibGen()
	for i := 0; i < 10; i++ {
		fmt.Print(fibGen(), " ")
	}
}

运行输出:

1 1 2 3 5 8 13 21 34 55

(4)延迟调用与闭包。
defer 调用会在当前函数执行结束前才被执行,这些调用被称为延迟调用 。defer 中使用的匿名函数也是一个闭包。

package main

import "fmt"

func main() {
	x, y := 1, 2

	defer func(a int) {
		fmt.Printf("x=%v y=%v\n", a, y) // y 为闭包引用
	}(x) // 复制 x 的值

	x += 100
	y += 100
	fmt.Println(x, y)
}

运行输出:

101 102
x=1 y=102

注意 defer 中的 x 是 1 而不是 101,其实是原因是在 defer 定义时已经将 x 的当前值 1 复制给了 defer,defer 执行时使用的是当时 defer 定义时 x 的拷贝,而不是当前环境中 x 的值。

(5)涉及 Goroutine 的情况,多个闭包函数访问同一个内部变量。

package main

import "fmt"

func main() {
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Print(i, " ") 
		}()
	}
	time.Sleep(time.Second * 1)
}

多次运行输出结果可能不一致:

2 2 5 5 5

// 或
3 3 5 5 5

// 或
5 5 5 5 5

多个闭包函数作为单独的 Go 程执行,因 Go 程调度时机的不确定性,当闭包函数被执行时,i 的值可能是 0~5 中任意某一个值。

如果想让闭包函数被执行时 i 的值是确定的,那么可以利用信道控制 Go 程同步执行,但这也失去了并发带来的性能提升。

func main() {
	ch := make(chan int, 1)
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Print(i, " ")
			ch <- 1
		}()
		<-ch
	}
}

运行输出结果一致:

0 1 2 3 4

如果想让闭包函数被执行时 i 的值是确定的,还有一种方法:只需要每次将变量 i 的拷贝传进函数即可,但此时就不是使用的上下文环境中的变量了。

func main() {
	for i := 0; i < 5; i++ {
		go func(i int) {
			fmt.Print(i, " ")
		}(i)
	}
	time.Sleep(time.Second * 1)
}

可能的结果:

3 2 1 4 0

每次运行输出结果可能不同,因为 Go 程的执行顺序是不确定的,但是有一点可以肯定的是输出的结果是 0~4 五个数字,只是顺序不确定而已。

注意事项

闭包会导致变量逃逸到堆上来延长变量的生命周期,增加内存消耗,给 GC 带来压力,所以不能滥用闭包。

小结

本文虽然并未详尽列出闭包的用例,但我希望这里讨论的内容能够让你清楚地了解它的作用。闭包是函数式编程语言中一个非常强大和有用的工具,每个开发人员都应该习惯使用它。


参考文献

[1] 百度百科.闭包
[2] Wikipedia.Closure (computer programming)
[3] 阮一峰.学习Javascript闭包(Closure)
[4] 简书.go 闭包与匿名函数这一篇就够了

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页