Lua 第9部分 闭包

在 Lua 语言中,函数是严格遵循词法定界的第一类值。

"第一类值" 意味着 Lua 语言中的函数与其他常见类型的值(例如数值和字符串)具有同等权限: 一个程序可以将某个函数保存到变量中(全局变量和局部变量均可)或表中,也可以将某个函数作为参数传递给其他函数,还可以将某个函数作为其他函数的返回值返回 。

*"词法定界"*意味着 Lua 语言中的函数可以访问包含其自身的外部函数中的变量( 也意味着 Lua 语言完全支持 Lambda 演算)。

上述两个特性联合起来为 Lua 语言带来了极大的灵活性。 例如,一个程序可以通过重新定义函数来增加新功能,也可以通过擦除函数来为不受信任的代码(例如通过网络接收到的代码)创建一个安全的运行时环境 。更重要的是,上述两个特性允许我们在 Lua 语言中使用很多函数式语言的强大编程技巧。 即使对函数式编程毫无兴趣 ,也不妨学习一下如何探索这些技巧,因为这些技巧可以使程序变得更加小巧和简单。

9.1 函数是第一类值

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

Lua 复制代码
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语言中常见的函数定义方式如下 :

Lua 复制代码
function foo(x) return 2*x end

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

Lua 复制代码
foo = function (x) return 2*x end

赋值语句右边的表达式( function (x) body end )就是函数构造器,与表构造器{}相似。因此 ,函数定义实际上就是创建类型为 "function " 的值并把它赋值给一个变量的语句 。

请注意在 Lua 语言中,所有的函数都是匿名的( anonymous )。像其他所有的值一样,函数并没有名字。 当讨论函数名时,比如 print ,实际上指的是保存该函数的变量。 虽然我们通常会把函数赋值给全局变量 ,从而看似给函数起了一个名字, 但在很多场景下仍然会保留函数的匿名性。 下面来看几个例子。

表标准库提供了函数 table.sort ,该函数以一个表为参数并对其中的元素排序。 这种函数必须支持各种各样的排序方式 :升序或降序 、按数值顺序或按字母顺序、按表中的键等。函数 sort 并没有试图穷尽所有的排序方式,而是提供了一个可选的参数,也就是所谓的排序函数,排序函数接收两个参数并根据第一个元素是否应排在第二个元素之前返回不同的值。 例如,假设有一个如下所示的表:

Lua 复制代码
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 宇段 、按字母顺序逆序对这个表排序 ,只需使用如下语句:

Lua 复制代码
table.sort(network, function(a,b) return (a.name > b.name) end)

可见,匿名函数在这条语句中显示出了很好的便利性。

像函数 sort 这样以另一个函数为参数的函数,我们称之为高阶函数。 高阶函数是一种强大的编程机制 ,而利用匿名函数作为参数正是其灵活性的主要来源。 不过尽管如此,请记住高阶函数也并没有什么特殊的,它们只是 Lua 语言将函数作为第一类值处理所带来结果的直接体现。

为了进一步演示高阶函数的用法,让我们再来实现一个常见的高阶函数,即导数。 按照通常的定义,函数 f 的导数为 f ' (x) = (f (x + d) - f (x)) / d ,其中 d 趋向于无穷小 。 根据这个定义,可以用如下方式近似地计算导数:

Lua 复制代码
function derivative (f, delta)
	delta = delta or 1e-4
	return function(x)
			return (f(x + delta) - f(x))/delta
		end
end

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

Lua 复制代码
c = derivative(math.sin)
> print(math.cos(5.2), c(5.2))
0.46851667130038        0.46856084325086
> print(math.cos(10), c(10))
-0.83907152907645       -0.83904432662041

9.2 非全局函数

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

我们已经在前面的章节中见到过几个将函数存储在表字段中的示例,大部分 Lua 语言的库就采用了这种机制(例如 io.read 和 math.sin )。如果要在 Lua 语言中创建这种函数,只需将到目前为止我们所学到的知识结合起来 :

Lua 复制代码
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

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

Lua 复制代码
Lib = {
	foo = function (x,y) return x+y end
	goo = function (x,y) return x-y end
}

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

Lua 复制代码
Lib = {}
function Lib.foo (x,y) return x+y end
function Lib.goo (x,y) return x-y end

在表字段中存储函数是 Lua 语言中实现面向对象编程的关键要素 。

当把一个函数存储到局部变量时,就得到了一个局部函数 ,即一个被限定在指定作用域中使用的函数。 局部函数对于包( package )而言尤其有用 : 由于 Lua 语言将每个程序段作为一个函数处理,所以在一段程序中声明的函数就是局部函数,这些局部函数只在该程序段中可见。 词法定界保证了程序段中的其他函数可以使用这些局部函数。

对于这种局部函数的使用, Lua 语言提供了一种语法糖 :

Lua 复制代码
local function f (params)
	-- body
end

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

Lua 复制代码
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 。 我们可以通过先定义局部变量再定义函数的方式来解决这个问题 :

Lua 复制代码
local fact

fact = function(n)
	if n == 0 then return 1
	else return n*fact(n-1)
	end
end

这样,函数内的 fact 指向的是局部变量。 尽管在定义函数时,这个局部变量的值尚未确定,但到了执行函数时, fact 肯定已经有了正确的赋值。

当 Lua 语言展开局部函数的语法糖时,使用的并不是之前的基本函数定义。 相反,形如

Lua 复制代码
lcoal function foo (params) body end

的定义会被展开成

Lua 复制代码
local foo; foo = function (params) body end

因此,使用这种语法来定义递归函数不会有问题。

当然,这个技巧对于间接递归函数是无效的 。 在间接递归的情况下,必须使用与明确的前向声明等价的形式:

Lua 复制代码
local f 			-- "前向"声明

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

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

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

9.3 词法定界

当编写一个被其他函数 B 包含的函数 A 时,被包含的函数 A 可以访问包含其的函数 B的所有局部变量,我们将这种特性称为词法定界( lexical scoping )。 虽然这种可见性规则听上去很明确 ,但实际上并非如此。 词法定界外加嵌套的第一类值函数可以为编程语言提供强大的功能,但很多编程语言并不支持将这两者组合使用。

先看一个简单的例子。 假设有一个表,其中包含了学生的姓名和对应的成绩, 如果我们想基于分数对学生姓名排序,分数高者在前,那么可以使用如下的代码完成上述需求:

Lua 复制代码
names = {"Peter", "Paul", "Marry"}
grades = {Marr = 10, Paul = 7, Peter = 8}
table.sort(names, function(n1, n2) 
	return grades[n1] > grades[n2] 			-- 比较分数
	end)

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

Lua 复制代码
function sortbygrade (names, grades)
	table.sort( names, function (n1, n2)
		return grades[n1] > grades[n2]		-- 比较分数
	end )
end

在后一个示例中,有趣的一点就在于传给函数 sort 的匿名函数可以访问 grades ,而 grades是包含匿名函数的外层函数 sortbygrade 的形参。 在该匿名函数中, grades 既不是全局变量也不是局部变量,而是我们所说的非局部变量(由于历史原因,在 Lua语言中非局部变量也被称为上值)。

这一点之所以如此有趣是因为,函数作为第一类值,能够逃逸出它们变量的原始定界范围 。 考虑如下的代码:

Lua 复制代码
function newCounter ()
	local count = 0
	return function ()		-- 匿名函数
			count = count + 1
			return count
		end
end

c1 = newCounter()
print(c1()) 		--> 1
print(c1()) 		--> 2

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

Lua 复制代码
c2 = newCounter()
print(c2()) 		--> 1
print(c1()) 		--> 3
print(c2()) 		--> 2

因此,c1 和 c2 是不同的闭包。它们建立在相同的函数之上,但是各自拥有局部变量count的独立实例。

从技术上讲, Lua 语言中只有闭包而没有函数。 函数本身只是闭包的一种原型。 不过尽管如此,只要不会引起混淆,我们就仍将使用术语"函数"来指代闭包。

闭包在许多场合中均是一种有价值的工具。正如我们之前已经见到过的,闭包在作为诸如sort 这样的高阶函数的参数时就非常有用 。 同样,闭包对于那些创建了其他函数的函数也很有用 ,例如我们之前的newCounter示例及求导数的示例;这种机制使得 Lua 程序能够综合运用函数式编程世界中多种精妙的编程技巧。另外,闭包对于回调函数来说也很有用 。 对于回调函数而言,一个典型的例子就是在传统 GUI 工具箱中创建按钮。 每个按钮通常都对应一个回调函数,当用户按下按钮时,完成不同的处理动作的回调函数就会被调用 。

例如,假设有一个具有 10 个类似按钮的数字计算器(每个按钮代表一个十进制数字),我们就可以使用如下的函数来创建这些按钮:

Lua 复制代码
function digitButton (digit)
	return Button{ lable = tostring(digit),
					action = function ()
							add_to_display(digit)
						end
				}
end

在上述示例中,假设 Button 是一个创建新按钮的工具箱函数, label 是按钮的标签, action是当按钮按下时被调用的回调函数。 回调可能发生在函数 digitButton 早已执行完后,那时变量 digit 已经超出了作用范围,但闭包仍可以访问它 。

闭包在另一种很不一样的场景下也非常有用。 由于函数可以被保存在普通变量中,因此在Lua 语言中可以轻松地重新定义函数,甚至是预定义函数。这种机制也正是 Lua 语言灵活的原因之一。 通常,当重新定义一个函数的时候,我们需要在新的实现中调用原来的那个函数。 例如,假设要重新定义函数 sin 以使其参数以角度为单位而不是以弧度为单位。 那么这个新函数就可以先对参数进行转换,然后再调用原来的 sin 函数进行真正的计算。 代码可能形如:

Lua 复制代码
local oldSin = math.sin 
math.sin = function (x)
	return oldSin(x*(math.pi/180))
end

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

Lua 复制代码
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 来限制一个程序能够访问的文件 :

Lua 复制代码
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 语言提供的是一套"元机制",借助这种机制可以根据特定的安全需求来裁剪具体的运行时环境。

9.4 小试函数式编程

再举一个函数式编程的具体示例 。在本节中我们要开发一个用来表示几何区域的简单系统。 我们的目标就是开发一个用来表示几何区域的系统,其中区域即为点的集合。我们希望能够利用该系统表示各种各样的图形,同时可以通过多种方式(旋转、变换、并集等)组合和修改这些图形。

为了实现这样的一个系统,首先需要找到表示这些图形的合理数据结构。 我们可以尝试着使用面向对象的方案,利用继承来抽象某些图形;或者,也可以直接利用特征函数来进行更高层次的抽象(集合 A 的特征函数 是指当且仅当 x 属于 A 时 成立)。鉴于一个几何区域就是点的集合,因此可以通过特征函数来表示一个区域,即可以提供一个点(作为参数)并根据点是否属于指定区域而返回真或假的函数来表示一个区域。

举例来说,下面的函数表示一个以点(1.0, 3.0)为圆心、半径 4.5 的圆盘(一个圆形区域):

Lua 复制代码
function disk1 (x, y)
	return (x - 1.0)^2 + (y - 3.0)^2 <= 4.5^2
end

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

Lua 复制代码
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 等价的圆盘。

下面的函数创建了一个指定边界的轴对称矩形:

Lua 复制代码
function rect (left, right, bottom, up)
	return function (x, y)
				return left <= x and x <= right and 
						bottom <= y and y <= up 
			end
end

按照类似的方式,可以定义函数以创建诸如三角形或非轴对称矩形等其他基本图形。每一种图形都具有完全独立的实现,所需的仅仅是一个正确的特征函数。

接下来让我们考虑一下如何改变和组合区域。我们可以很容易地创建任何区域的补集 :

Lua 复制代码
function complement ( r )
	return function (x, y)
			return not r(x, y)
	end
end

并集、交集和差集也很简单,参见示例 9.1 。

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

Lua 复制代码
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

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

Lua 复制代码
function translate(r, dx, dy)
	return function (x, y)
			return r(x - dx, y - dy)
	end
end

为了使一个区域可视化,我们可以遍历每个像素进行视口测试;位于区域内的像素被绘制为黑色,而位于区域外的像素被绘制为白色。为了用简单的方式演示这个过程,我们接下来写一个函数来生成一个 PBM(可移植位图)格式的文件来绘制指定的区域。

PBM 文件的结构很简单(这种结构也同样极为高效,但是这里强调的是简单性)。 PBM文件的文本形式以字符串"P1 "开头,接下来的一行是图片的宽和高(以像素为单位),然后是对应每一个像素、由1 和 0 组成的数字序列(黑为1,白为0,数字和数字之间由可选的空格分开),最后是 EOF 。 示例 9.2 中的函数 plot 创建了指定区域的 PBM 文件,并将虚拟绘图区域(-1, 1],[-1,1)映射到视口区域[1, M], [1, N]中 。

Lua 复制代码
function plot (r, M, N)
	io.write("P1n", 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

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

Lua 复制代码
c1 = disk(0, 0, 1)
plot(difference(c1, translate(c1, 0.3, 0)), 500, 500)
相关推荐
凌叁儿3 分钟前
Python 的 datetime 模块使用详解
开发语言·python
谁家有个大人4 分钟前
Python数据清洗笔记(上)
开发语言·笔记·python·数据分析
MurphyStar1 小时前
UV: Python包和项目管理器(从入门到不放弃教程)
开发语言·python·uv
阿让啊1 小时前
单片机获取真实时间的实现方法
c语言·开发语言·arm开发·stm32·单片机·嵌入式硬件
Freak嵌入式2 小时前
一文速通Python并行计算:09 Python多进程编程-进程之间的数据同步-基于互斥锁、递归锁、信号量、条件变量、事件和屏障
开发语言·python·多线程·并发·并行
T糖锅G2 小时前
小白自学python第一天
开发语言·python
学Java的小半2 小时前
用键盘实现控制小球上下移动——java的事件控制
java·开发语言·算法·intellij-idea·gui·事件监听
阿桂天山3 小时前
实现批量图片文字识别(python+flask+EasyOCR)
开发语言·python·flask
天天进步20153 小时前
Python跨平台桌面应用程序开发
开发语言·python