7

C#语法糖系列 —— 第三篇:聊聊闭包的底层玩法

 2 years ago
source link: https://www.cnblogs.com/huangxincheng/p/16201568.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

有朋友好奇为什么将 闭包 归于语法糖,这里简单声明下,C# 中的所有闭包最终都会归结于 方法,为什么这么说,因为 C# 的基因就已经决定了,如果大家了解 CLR 的话应该知道, C#中的最终都会用 MethodTable 来承载,方法都会用 MethodDesc 来承载, 所以不管你怎么玩都逃不出这三界之内。

这篇我们就来聊聊C#中的闭包底层原理及玩法,表面上的概念就不说了哈。

一:普通闭包玩法

1. 案例演示

放了方便说明,先上一段测试代码:


        static void Main(string[] args)
        {
            int y = 10;

            Func<int, int> sum = x =>
            {
                return x + y;
            };

            Console.WriteLine(sum(11));
        }

刚才也说了,C#的基因决定了最终会用 classmethod闭包 进行面向对象改造,那如何改造呢? 这里有两个问题:

  • 匿名方法如何面向对象改造

方法 不能脱离 而独立存在,所以 编译器 必须要为其生成一个类,然后再给匿名方法配一个名字即可。

  • 捕获到的 y 怎么办

捕获是一个很抽象的词,一点都不接底气,这里我用 面向对象 的角度来解读一下,这个问题本质上就是 栈变量堆变量 混在一起的一次行为冲突,什么意思呢?

大家应该知道 栈变量 所在的帧空间是由 espebp 进行控制,一旦方法结束,esp 会往回收缩造成局部变量从栈中移除。

委托是一个引用类型,它是由 GC 进行管理回收,只要它还被人牵着,自然就不会被回收。

到这里我相信你肯定发现了一个严重的问题, 一旦 sum 委托逃出了方法,这时局部变量 y 肯定会被销毁,如果真的被销毁了, 后续再执行 sum 委托自然就是一个巨大的bug,那怎么办呢?

编译器自然早就考虑到了这种情况,它在进行面向对象改造的时候,特意为 定义了一个 public 类型的字段,用这个字段来承载这个局部变量。

2. 手工改造

有了这些多前置知识,我相信你肯定会知道如何改造了,参考代码如下:


    class Program
    {
        static void Main(string[] args)
        {
            int y = 10;

            //Func<int, int> sum = x =>
            //{
            //    return x + y;
            //};

            //面向对象改造
            FuncClass funcClass = new FuncClass() { y = y };

            Func<int, int> sum = funcClass.Run;

            Console.WriteLine(sum(11));
        }
    }

    public class FuncClass
    {
        public int y;

        public int Run(int x)
        {
            return x + y;
        }
    }

如果你不相信的话,可以看下 MSIL 代码。


.method private hidebysig static 
	void Main (
		string[] args
	) cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 43 (0x2b)
	.maxstack 2
	.entrypoint
	.locals init (
		[0] class ConsoleApp1.Program/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
		[1] class [System.Runtime]System.Func`2<int32, int32> sum
	)

	IL_0000: newobj instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::.ctor()
	IL_0005: stloc.0
	IL_0006: nop
	IL_0007: ldloc.0
	IL_0008: ldc.i4.s 10
	IL_000a: stfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::y
	IL_000f: ldloc.0
	IL_0010: ldftn instance int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::'<Main>b__0'(int32)
	IL_0016: newobj instance void class [System.Runtime]System.Func`2<int32, int32>::.ctor(object, native int)
	IL_001b: stloc.1
	IL_001c: ldloc.1
	IL_001d: ldc.i4.s 11
	IL_001f: callvirt instance !1 class [System.Runtime]System.Func`2<int32, int32>::Invoke(!0)
	IL_0024: call void [System.Console]System.Console::WriteLine(int32)
	IL_0029: nop
	IL_002a: ret
} // end of method Program::Main


.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
	extends [System.Runtime]System.Object
{
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	// Fields
	.field public int32 y

	// Methods
	.method public hidebysig specialname rtspecialname 
		instance void .ctor () cil managed 
	{
		// Method begins at RVA 0x2090
		// Code size 8 (0x8)
		.maxstack 8

		IL_0000: ldarg.0
		IL_0001: call instance void [System.Runtime]System.Object::.ctor()
		IL_0006: nop
		IL_0007: ret
	} // end of method '<>c__DisplayClass0_0'::.ctor

	.method assembly hidebysig 
		instance int32 '<Main>b__0' (
			int32 x
		) cil managed 
	{
		// Method begins at RVA 0x209c
		// Code size 14 (0xe)
		.maxstack 2
		.locals init (
			[0] int32
		)

		IL_0000: nop
		IL_0001: ldarg.1
		IL_0002: ldarg.0
		IL_0003: ldfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::y
		IL_0008: add
		IL_0009: stloc.0
		IL_000a: br.s IL_000c

		IL_000c: ldloc.0
		IL_000d: ret
	} // end of method '<>c__DisplayClass0_0'::'<Main>b__0'

} // end of class <>c__DisplayClass0_0

二:循环下闭包玩法

为了方便说明,还是先上一段代码。


        static void Main(string[] args)
        {
            var actions = new Action[10];

            for (int i = 0; i < actions.Length; i++)
            {
                actions[i] = () => Console.WriteLine(i);
            }

            foreach (var item in actions) item();
        }

然后把代码跑起来:

我相信有非常多的朋友都踩过这个坑,那为什么会出现这样的结果呢? 我试着从原理上解读一下。

1. 原理解读

根据前面所学的 面向对象 改造法,我相信大家肯定会很快改造出来,参考代码如下:


    class Program
    {
        static void Main(string[] args)
        {
            var actions = new Action[10];

            for (int i = 0; i < actions.Length; i++)
            {
                //actions[i] = () => Console.WriteLine(i);

                //改造后
                var funcClass = new FuncClass() { i = i };
                actions[i] = funcClass.Run;
            }

            foreach (var item in actions) item();
        }
    }

    public class FuncClass
    {
        public int i;

        public void Run()
        {
            Console.WriteLine(i);
        }
    }

然后跑一下结果:

真奇葩,我们的改造方案一点问题都没有,咋 编译器 就弄不对呢?要想找到案例,只能看 MSIL 啦,简化后如下:


		IL_0001: ldc.i4.s 10
		IL_0003: newarr [System.Runtime]System.Action
		IL_0008: stloc.0
		IL_0009: newobj instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::.ctor()
		IL_000e: stloc.1
		IL_000f: ldloc.1
		IL_0010: ldc.i4.0
		IL_0011: stfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i
		IL_0016: br.s IL_003e
		// loop start (head: IL_003e)
			IL_0018: nop
			IL_0019: ldloc.0
            ...
		// end loop

如果有兴趣大家可以看下完整版,它的实现方式大概是这样的。


        static void Main(string[] args)
        {
            var actions = new Action[10];

            var funcClass = new FuncClass();

            for (int i = 0; i < actions.Length; i++)
            {
                actions[i] = funcClass.Run;

                funcClass.i = i + 1;
            }

            foreach (var item in actions) item();
        }

原来问题就出在了它只 new 了一次,同时 for 循环中只是对 i 进行了赋值,导致了问题的发生。

2. 编译器的想法

为什么编译器会这么改造代码,我觉得可能基于下面两点。

  • 不想 new 太多的类实例

new一个对象,其实并没有大家想象的那么简单,在 clr 内部会分 快速路径慢速路径,同时还为此导致 GC 回收,为了保存一个变量 需要专门 new 一个实例,这代价真的太大了。。。

  • 有更好的解决办法

更好的办法就是用 方法参数 ,方法的字节码是放置在 CLR 的 codeheap 上,独此一份,同时方法参数只是在上多了一个存储空间而已,这代价就非常小了。

三: 代码改造

知道编译器的苦衷后,改造起来就很简单了,大概有如下两种。

1. 强制 new 实例

这种改造法就是强制在每次 for 中 new 一个实例来承载 i 变量,参考代码如下:


        static void Main(string[] args)
        {
            var actions = new Action[10];

            for (int i = 0; i < actions.Length; i++)
            {
                var j = i;
                actions[i] = () => Console.WriteLine(j);
            }

            foreach (var item in actions) item();
        }

2. 采用方法参数

为了能够让 i 作为方法参数,只能将 Action 改成 Action<int>,虽然你可能要为此掉头发,但对程序性能来说是巨大的,参考代码如下:


        static void Main(string[] args)
        {
            var actions = new Action<int>[10];

            for (int i = 0; i < actions.Length; i++)
            {
                actions[i] = (j) => Console.WriteLine(j);
            }

            for (int i = 0; i < actions.Length; i++)
            {
                actions[i](i);
            }
        }

好了,洋洋洒洒写了这么多,希望对大家有帮助。

图片名称


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK