【Unity游戏开发】浅谈Lua和C#中的闭包

一、前言

  目前在Unity游戏开发中,比较流行的两种语言就是Lua和C#。通常的做法是:C#做些核心的功能和接口供Lua调用,Lua主要做些UI模块和一些业务逻辑。这样既能在保持一定的游戏运行效率的同时,又可以让游戏具备热更新的功能。无论我们有意或者无意,其实我们经常会在Unity游戏开发中使用到闭包。那么,马三今天就要和大家来谈谈Lua和C#中的闭包,下面首先让我们先来谈谈Lua中的闭包。

二、Lua中的闭包

  相信,对于经常使用Javascript的前端开发者来说,闭包这个概念一定不会陌生,在Javascript开发中,一些高级的应用都需要闭包来实现。而对于传统的C++开发者或者C#开发者来说,闭包这个词或多或少都会有些玄之又玄的感觉。那么,在开讲之前,让我们先来了解几个Lua中基础知识和概念,这样有助于我们理解Lua闭包。

1.一些前提概念

  词法定界:当一个函数内嵌套另一个函数的时候,内函数可以访问外部函数的局部变量,这种特征叫做词法定界。如下面这段代码,func2作为func1的内嵌函数,可以自由地访问属于func1的局部变量i : 

function func1()
    local i = 100  --upvalue
    local func2 = function()
        print(i+1)
    end
    i = 101
    return func2
end

local f = func1()
print(f())    --输出102

  第一类值:在Lua中,函数是一个值,它可以存在于变量中、可以作为函数参数,也可以作为返回值return。还是以上面的代码举例,我们将一个内嵌在func1中的函数赋值给局部变量func2,并将func2这个变量在函数结尾return。

  upvalue:内嵌函数可以访问外部函数已经创建的局部变量,而这些局部变量则称为该内嵌函数的外部局部变量(即upvalue)。在我们的第一个例子中,func1的局部变量i就是内嵌函数func2的upvalue。

2.什么是Lua闭包

  好了有了以上的概念以后,我们也该引入Lua中闭包的概念了。闭包是由函数和与其相关的引用环境组合而成的实体,闭包=函数+引用环境

  在第一个例子中,func1函数返回了一个函数,而这个返回的函数就是闭包的组成部分中的函数;引用环境就是变量i所在的环境。实际上,闭包只是在形式和表现上像函数,但实际上不是函数。我们都知道,函数就是一些可执行语句的组合体,这些代码语句在函数被定义后就确定了,并不会再执行时发生变化,所以函数只有一个实例。而闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例,就好比相同的类代码,可以创建不同的类实例一样。

  用一句比较通俗和不甚严谨的话来讲:子函数可以使用父函数中的局部变量,这种行为就叫做闭包。这种说法其实就说明了闭包的一种表象,让我们从外在形式上,能更好的理解什么是闭包。

  对于学习C++或者是C#之类的语言入门的朋友,可能对闭包理解起来比较吃力(至少马三是这样,一会明白一会糊涂,看了很多文章、写了很多代码以后才理解,笨得要命~ o(≧口≦)o)。其实我们可以把Lua中的闭包和C++中的类做一下类比。闭包是数据和行为的结合体,这就好比C++中的类,有一些成员变量(Lua中的upvalue)+成员方法(Lua中的内嵌函数)。这样就使得闭包具有较好的抽象能力,在某些场合下,我们需要记住某次调用函数完成以后数据的状态,就好比C++中的static类型的变量,每次调用完成以后,static类型的变量并不会被清除。使用闭包就可以很好的完成该功能,比如利用Lua闭包特性实现一个简单地迭代器,在下面的小节中我们会介绍到。

3.典型Lua闭包例子

  1.闭包的数据隔离

function counter()
    local i = 0
    return function() --匿名函数,闭包
        i = i + 1
        return i
    end
end

counter1 = counter()
counter2 = counter() 
-- counter1,counter2 是建立在同一个函数,同一个局部变量的不同实例上面的两个不同的闭包
--                   闭包中的upvalue各自独立,调用一次counter()就会产生一个新的闭包
print(counter1()) -- 输出1
print(counter1()) -- 输出2
print(counter2()) -- 输出1
print(counter2()) -- 输出2

  上面的代码中,注释已经解释地很详细了。尽管看起来counter1,counter2是由同一个函数和同一个局部变量创建的闭包。但是其实它们是不同实例上面的两个不同的闭包。闭包中的upvalue各自独立,调用一次counter()就会产生一个新的闭包。有点像工厂函数一样,每调用一次counter()都会new出来一个新的对象,不同的对象之间的数据,当然也就是隔离的了。

  2.闭包的数据共享

function shareVar(n)
    local function func1()
        print(n)
    end
    
    local function func2()
        n = n + 10
        print(n)
    end
    return func1,func2
end

local f1,f2 = shareVar(1024) --创建闭包,f1,f2两个闭包共享同一份upvalue

f1() -- 输出1024
f2() -- 输出1034
f1() -- 输出1034
f2() -- 输出1044

  乍一看起来,这个概念和第一个概念矛盾啊,其实他们之间并不矛盾。在Lua中,同一闭包创建的其他的闭包共享一份upvalue。闭包在创建之时其需要的变量就已经不在堆栈上,而是引用更外层外部函数的局部变量(即upvalue)。在上面的例子中,f1,f2共享同一份upvalue,这是因为f1、f2都是由同一个闭包shareVar(1024)创建的,所以他们引用的upvalue(变量n)实际也是同一个变量,而它们的upvalue引用都会指向同一个地方。说白了就是func1和func2的引用环境是一样,它们的上下文是一样的。再类比一下我们比较熟悉的C++,就好比C++类中有两个不同的成员函数,它们都可以对类中的同一个成员变量进行访问和修改。这第二点概念尤其要和第一点概念进行区分,它们很容易混淆。

  3.利用闭包实现迭代器功能

--- 利用闭包实现iterator,iterator是一个工厂,每次调用都会产生一个新的闭包,该闭包内部包括了upvalue(t,i,n)
--- 因此每调用一次该函数都会产生闭包,那么该闭包就会根据记录上一次的状态,以及返回table中的下一个元素
function iterator(t)
    local i = 0
    local n = #t
    return function()
        i = i + 1
        if i <= n then
            return t[i]
        end
    end
end

testTable = {1,2,3,"a","b"}

-- while中使用迭代器
iter1 = iterator(testTable) --调用迭代器产生一个闭包
while true do
    local element = iter1()
    if nil == element then
        break;
    end
    print(element)
end

-- for中使用迭代器
for element in iterator(testTable) do --- 这里的iterator()工厂函数只会被调用一次产生一个闭包函数,后面的每一次迭代都是用该闭包函数,而不是工厂函数
    print(element)
end

  利用闭包我们可以很方便地实现一个迭代器,例如上面代码中的iterator。iterator是一个工厂,每次调用都会产生一个新的闭包,该闭包内部包括了upvalue(t,i,n),因此每调用一次该函数都会产生闭包,那么该闭包就会根据记录上一次的状态,以及返回table中的下一个元素,从而实现了迭代器的功能。需要额外注意的是:迭代器只是一个生成器,他自己本身不带循环。我们还需要在循环里面去调用它才行。

  在while循环的那段例子代码中,我们首先调用迭代器创建一个闭包,然后不断地调用它就可以获取到表中的下一个元素了,就好像是游标一样。而由于 for ... in ... do 的这种写法很具有迷惑性,所以在for循环中使用迭代器的话,我们需要注意:这里的iterator()工厂函数只会被调用一次产生一个闭包函数,后面的每一次迭代都是用该闭包函数,而不是工厂函数。相信许多朋友此时会和马三一样产生一个疑问,为什么在for循环中使用迭代器,iterator()工厂函数只会被调用一次呢?难道不是每次判断执行条件的时候都去执行一次iterator函数吗?其实这和Lua语言对for...in...do这种控制结构的内部实现方式有关。for in在自己内部保存三个值:迭代函数、状态常量、控制变量。for...in 这种写法其实是一种语法糖,在《Programming in Lua》中给出的等价代码是:

do
    local _f,_s,_var = iter,tab,var
    while true do
        local _var,value = _f(_s, _var)
        if not _var then break end
        body
    end
end

  怎么样,for...in 的内部实现代码和我们在while中调用Iterator的方式是不是很类似?Iterator(table)函数返回一个匿名函数作为迭代器,该迭代函数会忽略掉传给它的参数table和nil,table和控制变量已被保存在迭代函数中,因此将上面的for循环展开后应该是这个样子:

iter = iterator(testTable)
element,value = iter(nil,nil)--忽略参数,value置为nil
if(element) then
    repeat
        print(element)
        element,value = iter(nil,element)--忽略参数
    until(not element)
end

三、C#中的闭包

  我们在上面花了很大的篇幅来介绍Lua的闭包,其实在C#中也是有闭包概念的。由于我们已经有了之前的Lua闭包基础,所以再理解C#中的闭包概念也就不那么困难了。照例在开讲之前我们还是先介绍一些C#中的基础知识与概念,一边有助于我们的理解。

1.一些前提概念

  变量作用域:在C#里面,变量作用域有三种,一种是属于类的,我们常称之为field(字段/属性);第二种则属于函数的,我们通常称之为局部变量;还有一种,其实也是属于函数的,不过它的作用范围更小,它只属于函数局部的代码片段,这种同样称之为局部变量。这三种变量的生命周期基本都可以用一句话来说明,每个变量都属于它所寄存的对象,即变量随着其寄存对象生而生和消亡。

  对应三种作用域我们可以这样说,类里面的变量是随着类的实例化而生,同时伴随着类对象的资源回收而消亡(当然这里不包括非实例化的static和const对象)。而函数(或代码片段)的变量也随着函数(或代码片段)调用开始而生,伴随函数(或代码片段)调用结束而自动由GC释放,它内部变量生命周期满足先进后出的特性。

  那么,有没有例外的情况呢?答案当然是有的,它就是我们的今天的主角:C#闭包。

  委托:委托是一个类,它定义了方法的类型,使得可以将方法当作另一个方法的参数来进行传递,这种将方法动态地赋给参数的做法,可以避免在程序中大量使用If-Else(Switch)语句,同时使得程序具有更好的可扩展性。(关于委托的讲解,网上已经有很多文章了,这里不再赘述,笼统一点你可以把委托简单地理解为函数指针)

2.什么是C#闭包?

  闭包其实就是使用的变量已经脱离其作用域,却由于和作用域存在上下文关系,从而可以在当前环境中继续使用其上文环境中所定义的一种函数对象。(本质上和Lua闭包的概念没有什么不同,只是换种说法罢了)

3.典型的C#闭包例子

  首先让我们来看下面这一段C#代码:

public class TCloser
{
    public Func<int> T1()
    {
        var n = 999;
        return () =>
        {
            Console.WriteLine(n);
            return n;
        };
    }
}

class Program
{
    static void Main()
    {
        var a = new TCloser();
        var b = a.T1();
        Console.WriteLine(b());
    }
}

  从上面的代码我们不难看到,变量n实际上是属于函数T1的局部变量,它本来的生命周期应该是伴随着函数T1的调用结束而被释放掉的,但这里我们却在返回的委托b中仍然能调用它,这里正是C#闭包的特性。在T1调用返回的匿名委托的代码片段中我们用到了n,而在编译器看来,这些都是合法的,因为返回的委托b和函数T1存在上下文关系,也就是说匿名委托b是允许使用它所在的函数或者类里面的局部变量的,于是编译器通过一系列操作使b中调用的函数T1的局部变量自动闭合,从而使该局部变量满足新的作用范围。

  所以对于C#中的闭包,你就可以像之前介绍的Lua闭包那样理解它。由于返回的匿名函数对象是在函数T1中生成的,因此相当于它是属于T1的一个属性。如果你把T1的对象级别往上提升一个层次就很好理解了,这里就相当于T1是一个类,而返回的匿名对象则是T1的一个属性,对属性而言,它可以调用它所寄存的对象T1的任何其他属性或者方法,包括T1寄存的对象TCloser内部的其他属性。如果这个匿名函数会被返回给其他对象调用,那么编译器会自动将匿名函数所用到的方法T1中的局部变量的生命周转期自动提升,并与匿名函数的生命周期相同,这样就称之为闭合。

  如果你想了解C#编译器是如何操作,使得闭包产生的,可以去反编译一下C#程序,然后观察它的IL代码(如何反编译并查看IL代码,马三已经在《【小白学C#】浅谈.NET中的IL代码》这篇博客中做了详细的介绍) 。C#的闭包,其实只是编译器对IL代码做了一些操作而已,它仍然没有脱离C#对象生命周期的规则。它将需要修改作用域的变量直接封装到返回的类中,变成类的一个属性n,从而保证了变量的生命周期不会随函数T1调用结束而结束,因为变量n在这里已经成了返回的类的一个属性了。

  在C#中,闭包其实和类中其他属性、方法是一样的,它们的原则都是下一层可以任意调用上一层定义的各种设定,但上一层则不具备访问下一层设定的能力。好比一个类中方法里可以自由访问类中的所有属性和方法,而闭包又可以访问它的上一层即方法中的各种设定。但类不可以访问方法的局部变量,同理,方法也不可以访问其内部定义的匿名函数所定义的局部变量。在我们工作中经常会用到的匿名委托、Lamda和LINQ,他们本质上都会使用到闭包这个特性。

四、总结

  无论是在Javascript、Lua还是C#开发中,闭包的使用相当广泛,也正是由于闭包和各种语法糖的存在,才使得我们的代码更加简洁,使用更方便。灵活、可靠地使用闭包,可以为我们的程序代码增光添彩,优化代码结构,益处多多。总之,闭包是一个好理解而又难理解的东西,我们应该多写多练,多参与到各类项目开发中,以提高自己的理解层次。

   本篇博客中的示例代码托管在Github:https://github.com/XINCGer/Unity3DTraining/tree/master/SomeTest/Closure  欢迎fork!

 

 

作者:马三小伙儿
出处:http://www.cnblogs.com/msxh/p/8283865.html 
请尊重别人的劳动成果,让分享成为一种美德,欢迎转载。另外,文章在表述和代码方面如有不妥之处,欢迎批评指正。留下你的脚印,欢迎评论!

posted @ 2018-01-21 15:43  马三小伙儿  阅读(5402)  评论(0编辑  收藏  举报