新来的总监,把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,分别表征了方法执行体、当前委托作用的对象,
可想而知,其他语言也是通过这个机制捕获闭包当中的自由变量。
本文来自博客园,作者:{有态度的马甲},转载请注明原文链接:https://www.cnblogs.com/JulianHuang/p/14618378.html
欢迎关注我的原创技术、职场公众号, 加好友谈天说地,一起进化
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?