深入理解Lua闭包:作用域与高阶函数的实现 | 南锋

南锋

南奔万里空,脱死锋镝余

深入理解Lua闭包:作用域与高阶函数的实现

在Lua语言中,函数是严格遵循词法定界的第一类值。
“第一类值”以为这Lua语言中的函数与其他常见类型的值具有同等权限:一个程序可以将某个函数保存到变量中或表中,也可以将某个函数作为参数传递给其他函数,还可以将某个函数作为其他函数的返回值返回。

“词法定界”意味着Lua语言中的函数可以访问包含其自身的外部函数中的变量。
上述两个特行联合起来为Lua语言带来了极大的灵活性。例如,一个程序可以通过重新定义函数来增加新功能,也可以通过擦除函数来为不受信任的代码创建一个安全的运行时环境。更重要的是,上述两个特行允许我们在Lua语言中使用很多函数式语言的强大编程技巧。即使对函数式编程毫无兴趣,也不妨学习下如何使用这些技巧。因为这些技巧可以使程序变得更加小巧和简单。

函数是第一类值

如前所述,Lua语言中的函数是第一类值。以下的示例演示了第一类值的含义:

1
2
3
4
5
6
a = {p = print}				-- 'a.p'指向'print'函数
a.p("Hello World") -- HEllo World
print = math.sin -- 'print'现在指向sine函数
a.p(print(1)) -- 0.8414709848079
math.sin = a.p -- 'sin'现在指向print函数
math.sin(10,20) -- 10 20

如果函数也是值的话,那么是否有创建函数的表达式呢?答案是肯定的。事实上,Lua语言中常见的函数定义方式如下:

1
function foo(x) return 2*x end

就是所谓的语法糖的例子,它只是下面这种写法的一种美化形式:

1
foo = function(x) return 2*x end

赋值语句右边的表达式(function(x) body end)就是函数构造器,与表构造器{}相似。因此,函数定义实际上就是创建类型为”function”的值并把她赋值给一个变量的语句。
请注意,在Lua语言中,所有的函数都是匿名的。像其他所有的值一样,函数并没有名字。当讨论函数时,比如print,实际上指的是保存函数的变量。虽然我们通常会把函数赋值给全局变量,从而看似给函数起了一个名字,但在很多场景下仍然会保留函数的匿名性。
表标准库提供了函数table.sort,该函数以一个表为参数并对其中的元素排序。这种函数必须支持各种各样的排序方式:升序或降序、按数值顺序或按字母顺序、按表中的键等。函数sort并没有试图穷尽所有的排序方式,而是提供了一个可选的参数,也就是所谓的排序函数,排序函数接收两个参数并根据第一个元素是否应排在第二个元素之前返回不同的值。
假如,有一个如下所示的表:

1
2
3
4
5
6
network = {
{name = "grauna" , IP = "210,26.30.34"},
{name = "arraial", IP = "210,26,30,23"},
{name = "lua", IP = "210,26,23,12"},
{name = "derain", IP = "210,26,23,20"},
}

如果想针对name字段、按字母顺序逆序对这个表排序,只需使用下面语句:

1
talbe.sort(network,function (a,b) return (a.name > b.name) end)

可见,匿名函数在这条语句中显示出了很好的便利性。
像函数sort这样以另一个函数为参数的函数,我们称之为高阶函数。高阶函数是一种强大的编程机制,而利用匿名函数作为参数正式其灵活性的主要来源。不过尽管如此,请记住高阶函数也并没有什么特殊的,它们只是Lua语言将函数作为第一类值处理所带来的直接体现。
为了进一步演示高阶函数的用法,让我们再来实现一个常见的高阶函数,即导数。按照通常的定义,函数f的导数为$f’(x) = (f(x + d) - f(x))/d$,其中d趋向于无穷小。根据这个定义,可以赢如下方式近似第计算导数:

1
2
3
4
5
6
function derivative (f,delta)
delta = delta or 1e-4
return function (x)
return (f(x + delta) - f(x))/delta
end
end

对于指定的函数f,调用derivative(f)将返回其导数,也就是另一个函数:

1
2
3
c = derivative(math.sin)
> print(math.cos(5.2),c(5.2))
print(math.cos(10),c(10))

非全局函数

由于函数是一种“第一类值”,因此一个显而易见的结果就是:函数不仅可以被存储在全局变量中,还可以被存储在表字段和局部变量中。
创建函数:

1
2
3
4
Lib = {}
Lib.foo = function(x,y) return x + y end
Lib.goo = function(x,y) return x - y end
print(Lib.foo(2,3),Lib.goo(2,3)) -- 5 -1

当然,也可使用表构造器:

1
2
3
4
Lib = {
foo = function(x,y) return x + y end
goo = function(X,y) return x - y end
}

除此以外,Lua语言还提供了另一种特殊的语言来定义这类函数:

1
2
3
Lib = {}
function Lib.foo(x,y) return x + y end
function Lib.goo(x,y) return x - y end

在表字段中存储函数是Lua语言中实现面向对象编程的关键要素。
当把一个函数存储到局部变量时,就得到一个局部函数,即一个被限定在指定作用域中使用的函数。局部函数对于包而言尤其有用:由于Lua语言将每个程序段作为一个函数处理,所以在一段程序中声明的函数就是局部函数,这些局部函数只在该程序段中可见。词法定界保证了程序段中的其他函数可以使用这些局部函数。
对于这种局部函数的使用,Lua语言提供了一种语法他那个:

1
2
3
local function f (params)
body
end

在定义局部递归函数时,由于原来的方法不使用,所有有一点是极易出错的。考虑如下代码:

1
2
3
4
5
local fact = function(n)
if n == 0 then return 1
else return n * fact(n-1) --有问题
end
end

当Lua语言编译函数体中的fact(n-1)调用时,局部的fact尚未定义。因此,这个表达式会尝试调用全局的fact而非局部的fact。我们可以通过先定义局部变量再定义函数的方式来解决这个问题:

1
2
3
4
5
6
local fact
fact = function(n)
if n == 0 then return 1
else return n * fact(n-1)
end
end

这样,函数内的fact指向的是局部变量。尽管在定义函数时,这个局部变量的值尚未确定,但到了执行函数时,fact肯定有了正确的赋值。
当Lua语言展开局部函数的语法糖时,使用的并不是之前的基本函数定义。相反,形如

1
local function foo(params) body end

的定义会被展开成

1
local foo; foo = function(params) body end

因此,使用这个语法来定义递归函数不会有问题。
当然,这个技巧对于简介递归函数是无效的。在间接递归的情况下,必须使用与明确的前向声明等价的形式:

1
2
3
4
5
6
7
8
9
local f 

local function g()
some code f() some code
end

function f()
some code g() some code
end

请注意,不能在最后一个函数定义前加上local。否则,Lua语言会创建一个全新的局部变量f,从而使得先前声明的f变为未定义状态。

词法定界

当编写一个被其他函数B包含的函数A时,被包含的函数A可以访问包含其的函数B的所有局部变量,我们将这种特行称为词法定界。虽然这种可见性规则听上去很明确,但实际上并非如此。词法定界外加嵌套的第一类值函数可以为编程语言提供强大的功能,但很多编程语言并不支持将这两者组合使用。
先看一个例子。假设有一个表,其中包含了学生的姓名和对应的成绩,如果我们想基于分数对学生姓名排序,分数高者在前,那么可以使用如下的代码完成上述需求:

1
2
3
4
5
name = {"Peter","Paul","MAry"}
grades = {Mary = 10, Paul = 7, Peter = 8}
table.sort(names,function(n1,n2)
return grades[n1] > grades[n2]
end)

现在,假设我们想创建一个函数来完成这个需求:

1
2
3
4
5
function sortbygrade(names,grades)
table.sort( names, function(n1,n2)
return grades[n1] > grades[n2]
end)
end

在后一个示例中,有趣的一点就在于传给函数sort的匿名函数可以访问grades,而grades是包含匿名函数的外层函数sortbygrade的形参。在该匿名函数中,grades既不是全局变量也不是局部变量,而是我们所说的非局部变量。
这一点之所有如此有趣是因为函数作为第一类值,能够逃逸出它们变量的原始定界范围。考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
function newCounter()
local count = 0
return function ()
count = count + 1
return count
end
end

c1 = newCounter()
print(c1()) -- 1
pirnt(c2()) -- 2

在上述代码中,匿名函数访问了一个非局部变量并将其当作计数器。然而,游艺创建变量的函数已经返回,因此当我们调用匿名函数时,变量count似乎已经超出了作用范围。但其实不然,由于闭包概念的存在,Lua语言能够正确地应对这种情况。简单地说,一个闭包就是一个函数外加能够使该函数正确访问非局部变量所需的其他机制。如果我们再次调用newCounter,那么一个新的局部变量count和一个新的闭包会被创建出来,这个新的闭包针对的是这个新变量:

1
2
3
4
c2 = newCounter()
print(c2()) -- 1
print(c1()) -- 3
print(c3()) -- 2

因此,c1和c2是不同的闭包。它们建立在相同的函数之上,但是各自拥有局部变量count的独立实例。
从技术上讲,Lua语言中只有闭包而没有函数。函数本身只是闭包的一种原型。不过尽管如此,只要不会引起混淆,我们就仍将使用术语“函数”来指代闭包。
闭包在许多场合中均是一种有价值的工具。闭包在作为诸如sort这样的高阶函数的参数时就非常有用。同样,闭包对于那些创建了其他函数的函数也很有用。这种机制使得Lua程序能够综合运用函数式编程世界中多种精妙的编程技巧。另外,闭包对于回调函数来说也很有用。对于回调函数而言,一个典型的例子就是在传统GUI工具箱中创建按钮。每个按钮通常都对应一个回调函数,当用户按下按钮时,完成不同的处理动作的回调函数就会被调用。
例如,假设有一个具有10个类似按钮的数字计算器,我们就可以使用如下的函数来创建这些按钮:

1
2
3
4
5
6
7
function digitButton(digit)
return Button{ label = tostring(digit),
action = function()
add_to_display(digit)
end
}
end

在上述示例中,假设Button是一个创建新按钮的工具箱函数,label是按钮的标签,action是当按钮按下时被调用的回调函数。回调可能发生在函数digitButton早已执行完后,那时变量digit已经超出了作用范围,但闭包仍可以访问它。
闭包在另一种很不一样的场景下也非常有用。由于函数可以保存在普通变量中,因此在Lua语言中可以轻松地重新定义函数,甚至是预定义函数。这种机制也正是Lua语言灵活的原因之一。通常,当重新定义一个函数的时候,我们需要在新的实现中调用原来的那个函数。例如,假设要重新定义函数sin以使其参数以角度为单位而不是以弧度为单位。那么这个新函数就可以先对参数进行转换,然后再调用原来的sin函数进行真正的计算。代码可能形如:

1
2
3
4
local oldSin - math.sin 
math.sin = function(x)
return oldSin(x * (math.pi/180))
end

另一种更清晰一点的完成重新定义的写法是:

1
2
3
4
5
6
7
do 
local oldSin = math.sin
local k = math.pi/180
math.sin = function(x)
return oldSin(x * k)
end
end

上述代码使用了do代码段来限制局部变量oldSin的作用范围;根据可见性规则,局部变量oldSin只在这部分代码段中有效。因此,只有新版本的函数sin才能访问原来的sin函数,其他部分的代码则访问不了。
我们可以使用同样的技巧来创建安全的运行时环境,即所谓的沙盒。当执行一些诸如从远程服务器上下载到的未受信任代码时,安全的运行时环境非常重要。例如,我们可以通过使用闭包重定义函数io.open来限制一个程序能够访问的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
do 
local oldOpen = io.open
local access_OK - function (filename, mode)
check access
end
io.open = function (filename ,mode)
if access_OK(filename, mode) then
return oldOpen(filename,mode)
else
return nil, "access denied"
end
end
end

上述示例的巧妙之处在于,在经过重新定义后,一个程序就只能通过新的受限版本来调用原来未受限版本的io.open函数。示例代码将原来不安全的版本保存为闭包的一个私有变量,该变量无法从外部访问。通过这一技巧,就可以在保证简洁性和灵活性的前提下在Lua语言本身上构建Lua沙盒。相对于提供一套大而全的解决方案,Lua语言提供的是一套“元机制”,借助这种机制可以根据特定的安全需求来裁剪具体的运行时环境。

小试函数式编程

这里,我们的目标就是开发一个用来表示几何区域的系统,其中区域即为点的几何。我们希望能够利用该系统表示各种各样的图形,同时可以通过多种方式组合和修改这些图形。
为了实现这样的一个系统,首先需要找到表示这些图形的合理数据结构。我们可以尝试着使用面向对象的方案,利用继承来抽象某些图形;或者,也可以直接利用特征函数来进行更高层次的抽象。鉴于一个几何区域就是点的集合,因此可以通过特征函数来表示一个区域,即可以提供一个点并根据点是否属于指定区域而返回真或假的函数来表示一个区域。
举例来说,下面的函数表示一个点(1.0,3.0)为圆心、半径4.5的圆盘:

1
2
3
function disk1(x,y)
return (x - 1.0)^2 + (y - 3.0)^2 <= 4.5^2
end

利用高阶函数和词法定界,可以很容易地定义一个根据指定的圆心和半径创建圆盘的工厂:

1
2
3
4
5
function disk(cx,cy,r)
return function (x,y)
return (x - cx)^2 + (y - cy)^2 <= r^2
end
end

形如disk(1.0,3.0,4.5)的调用会创建一个与disk1等价的圆盘。
下面的函数创建了一个指定边界的轴对称矩形:

1
2
3
4
5
function rect(left,right,bottom,up)
return function(x,y)
return left <= x and x <= right and bottom <= y and y <= up
end
end

按照类似的方式,可以定义函数以创建诸如三角形或非轴承矩形等其他基本图形。每一种图形都具有完全独立的实现,所需的仅仅是一个正确的特征函数。
接下来让我们考虑一下如何改变和组合区域。我们可以很容易地创建任何区域的补集:

1
2
3
4
5
function complement(r)
return function(x,y)
return not r(x,y)
end
end

并集、交集和差集也很简单。

示例 区域的并集、交集和差集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function union (r1,r2)
return function(x,y)
return r1(x,y) or r2(x,y)
end
end

function intersection (r1,r2)
return function (x,y)
return r1(x,y) and r2(x,y)
end
end

function difference (r1,r2)
return function (x,y)
return r1(x,y) and not r2(x,y)
end
end

以下函数按照指定的增量平移指定的区域:

1
2
3
4
5
function translate(r,dx,dy)
return function (x,y)
return r(x - dx, y -dy)
end
end

为了使一个区域可视化,我们可以遍历每个像素进行视口测试;位于区域内的像素被绘制成黑色,而位于区域外的像素被绘制成白色。为了用简单的方式演示这个过程,我们接下来写一个函数来生成一个PBM格式的文件来绘制指定的区域。
PBM文件的结构很简单。PBM文件的文本形式以字符串”P1”开头,接下来的一行是图片的宽和高,然后是对应每一个像素、由1和0组成的数字序列,最后是EOF。

示例 在PBM文件中绘制区域

1
2
3
4
5
6
7
8
9
10
11
function plot(r,M,N)
io.write("P1\n",M," ",N,"\n") -- 文件头
for i = 1, N do
local y = (N - i*2)/N
for j = 1, M do
local x = (j*2 - M)/M
io.write(r(x,y) and "1" or "0")
end
io.write("\n")
end
end

为了让示例更加完整,一下的代码绘制了一个南半球所能看到的娥眉月:

1
2
c1 = disk(0,0,1)
plot(difference(c1,translate(c1,0.3,0)),500,500)
+