新来的总监,把C#闭包讲得那叫一个透彻

闭包作为前端面试的必考题目,常让1-3年工作经验的Javascripter感到困惑,我的主力语言C#/GO均有闭包。

1. 闭包:关键点在于函数是否捕获了其外部作用域的变量

闭包的形成: 定义函数时, 函数引用了其外部作用域的变量, 之后就形成了闭包。

闭包的结果: 引用的变量和定义的函数都会一同存在(即使已经脱离了函数定义/引用的变量的作用域),一直到闭包被消灭。

public static Action Closure()
{
var x = 1;
Action action= () =>
{
var y = 1;
var result = x + y;
Console.WriteLine(result);
x++;
};
return action;
}
public static void Main() {
var a=Closure();
a();
a();
}
// 调用函数输出
2
3

委托action是一个函数,它使用了“x”这个外部作用域的变量(x变量不是函数内局部变量),变量引用将被捕获形成闭包。

即使action被返回了(即使“x”已经脱离了它被引用时的作用域环境(Closure)),但是两次执行能输出2,3 说明它脱离原引用环境仍然能用。


当你在代码调试器(debugger)里观察“action”时,可以看到一个Target属性,里面封装了捕获的x变量:

实际上,委托,匿名函数和lambda都是继承自Delegate类
Delegate不允许开发者直接使用,只有编译器才能使用, 也就是说delegate Action都是语法糖。

  • Method:MethodInfo反射类型- 方法执行体
  • Target:当前委托执行的对象,这些语法糖由编译器生成了继承自Delegate类型的对象,包含了捕获的自由变量。

再给一个反例:

public class Program
{
private static int x = 1; // 静态字段
public static void Main()
{
var action = NoClosure();
action();
action();
}
public static Action NoClosure(){
Action action=()=>{
var y =1;
var sum = x+y;
Console.WriteLine($"sum = { sum }");
x++;
};
return action;
}
}

x 是静态字段,在程序中有独立的存储区域, 不在线程的函数堆栈区,不属于某个特定的作用域。

匿名函数使用了 x,但没有捕获外部作用域的变量,因此不构成闭包, Target属性对象无捕获的字段。

从编程设计的角度:闭包开创了除全局变量传值, 函数参数传值之外的第三种变量使用方式。

2. 闭包的形成时机和效果

闭包是词法闭包的简称,维基百科上是这样定义的:
在计算机科学中,闭包是在词法环境中绑定自由变量的一等函数”。

闭包的形成时机:

  • 一等函数
  • 外部作用域变量

闭包的形态:
会捕获闭包函数内引用的外部作用域变量, 一直持有,直到闭包函数不再使用被销毁。

内部实现是形成了一个对象(包含执行函数和捕获的变量,参考Target对象), 只有形成堆内存,才有后续闭包销毁的行为,当闭包这个对象不再被引用时,闭包被GC清理。

闭包的生命周期:
离不开作用域这个概念,函数理所当然管控了函数内的局部变量作用域,但当它引用了外部有作用域的变量时, 就形成了闭包函数。

当闭包(例如一个委托或 lambda 表达式)不再被任何变量、对象或事件持有引用时,它就变成了“不可达”对象, 闭包被gc清理,其实就是堆内存被清理。

2.1 一等函数

一等函数很容易理解,就是在各语言, 函数被认为是某类数据类型, 定义函数就成了定义变量, 函数也可以像变量一样被传递。

很明显,在C#中我们常使用的匿名函数、lambda表达式都是一等函数。

Func<string,string> myFunc = delegate(string var1)
{
return "some value";
};
Func<string,string> myFunc = var1 => "some value";
string myVar = myFunc("something");

2.2 自由变量

在函数中被引用的外部作用域变量, 注意, 这个变量是外部有作用域的变量,也就说排除全局变量(这些变量在程序的独立区域, 不属于任何作用域)。

public void Test()
{
var myVar = "this is good";
Func<string,string> myFunc = delegate(string var1)
{
return var1 + myVar;
};
}

上面这个示例,myFunc形成了闭包,捕获了myVar这个外部作用域的变量;
即使Test函数返回了委托myFunc(脱离了定义myVar变量的作用域),闭包依然持有myVar的变量引用,
注意,引用变量,并不是使用当时变量的副本值

我们再回过头来看结合了线程调度的闭包面试题。

3. 闭包函数关联线程调度: 依次打印连续的数字

static void Closure1()
{
for (int i = 0; i < 10; i++)
{
Task.Run(()=> Console.WriteLine(i));
}
}

每次输出数字不固定

并不是预期的 0.1.2.3.4.5.6.7.8.9

首先形成了闭包函数()=> Console.WriteLine(i), 捕获了外部有作用域变量i的引用, 此处捕获的变量i相对于函数是全局变量。
但是Task调度闭包函数的时机不确定, 所以打印的是被调度时引用的变量i值。

数字符合但乱序:为每个闭包函数绑定独立变量

循环内增加局部变量, 解绑全局变量 (或者可以换成foreach,foreach相当于内部给你整了一个局部变量)。

能输出乱序的0,1,2,3,4,5,6,7,8,9

因为每次循环内产生的闭包函数捕获了对应的局部变量j,这样每个任务执行环境均独立维护了一个变量j, 这个j不是全局变量, 但是由于Task启动时机依然不确定,故是乱序。

数字符合且有序

核心是解决 Task调度问题。

思路是:一个共享变量,每个任务打印该变量自增的一个阶段,但是该自增不允许被打断。

public static void Main(string[] args)
{
var s =0;
var lo = new Program();
for (int i = 0; i < 10; i++)
{
Task.Run(()=>
{
lock(lo)
{
Console.WriteLine(s); // 依然形成了闭包函数, 之后闭包函数被线程调度
s++;
}
});
}
Thread.Sleep(2000);
} // 上面是一个明显的锁争用

3.Golang闭包的应用

gin 框架中中间件的默认形态是:

package middleware
func AuthenticationMiddleware(c *gin.Context) {
......
}
// Use方法的参数签名是这样: type HandlerFunc func(*Context), 不支持入参
router.Use(middleware.AuthenticationMiddleware)

实际实践上我们又需要给中间件传参, 闭包提供了这一能力。

func Authentication2Middleware(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
... 这里面可以利用log 参数。
}
}
var logger *zap.Logger
api.Use(middleware.Authentication2Middleware(logger))

总结

本文屏蔽语言差异,理清了[闭包]的概念核心: 函数引用了其外部作用域的变量,

核心特征:一等函数、自由变量,核心结果: 即使脱离了原捕获变量的原作用域,闭包函数依然持有该变量引用。

不仅能帮助我们应对多语种有关闭包的面试题, 也帮助我们了解[闭包]在通用语言中的设计初衷。

另外我们通过C# 调试器巩固了Delegate 抽象类,这是lambda表达式,委托,匿名函数的底层抽象数据结构类,包含两个重要属性 Method Target,分别表征了方法执行体、当前委托作用的对象,

可想而知,其他语言也是通过这个机制捕获闭包当中的自由变量。

posted @   码甲哥不卷  阅读(2016)  评论(5编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示

目录导航