第6章 再论函数

本文深入探讨Lua中的函数特性,包括函数作为第一类值、词法定界、闭包、非全局函数及正确的尾调用等核心概念。展示了如何利用这些特性进行高效编程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

6 再论函数
Lua 中的函数是带有词法定界( lexical scoping )的第一类值( first-class values )。
第一类值指:在 Lua 中函数和其他值(数值、字符串)一样,函数可以被存放在变量中,也可以存放在表中,可以作为函数的参数,还可以作为函数的返回值。
词法定界指:嵌套的函数可以访问他外部函数中的变量。这一特性给 Lua 提供了强大的编程能力。
Lua 中关于函数稍微难以理解的是函数也可以没有名字,匿名的。当我们提到函数名(比如 print ),实际上是说一个指向函数的变量,像持有其他类型值的变量一样:
a = {p = print}
a.p("Hello World")   --> Hello World
print = math.sin -- `print' now refers to the sine function
a.p(print(1))     --> 0.841470
sin = a.p         -- `sin' now refers to the print function
sin(10, 20)       --> 10   20
既然函数是值,那么表达式也可以创建函数了, Lua 中我们经常这样写:
function foo (x) return 2*x end
这实际上是 Lua 语法的特例,下面是原本的函数:
foo = function (x) return 2*x end
函数定义实际上是一个赋值语句,将类型为 function 的变量赋给一个变量。我们使用 function (x) ... end 来定义一个函数和使用 {} 创建一个表一样。
table 标准库提供一个排序函数,接受一个表作为输入参数并且排序表中的元素。这个函数必须能够对不同类型的值(字符串或者数值)按升序或者降序进行排序。 Lua 不是尽可能多地提供参数来满足这些情况的需要,而是接受一个排序函数作为参数(类似 C++ 的函数对象),排序函数接受两个排序元素作为输入参数,并且返回两者的大小关系,例如:
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 域排序:
table.sort(network, function (a,b)
    return (a.name > b.name)
end)
以其他函数作为参数的函数在 Lua 中被称作高级函数( higher-order function ),如上面的 sort 。在 Lua 中,高级函数与普通函数没有区别,它们只是把“作为参数的函数”当作第一类值( first-class value )处理而已。
下面给出一个绘图函数的例子:
function eraseTerminal()
    io.write("/27[2J")
end
 
-- writes an '*' at column 'x' , 'row y'
function mark (x,y)
    io.write(string.format("/27[%d;%dH*", y, x))
end
 
-- Terminal size
TermSize = {w = 80, h = 24}
 
-- plot a function
-- (assume that domain and image are in the range [-1,1])
function plot (f)
    eraseTerminal()
    for i=1,TermSize.w do
       local x = (i/TermSize.w)*2 - 1
       local y = (f(x) + 1)/2 * TermSize.h
       mark(i, y)
    end
    io.read() -- wait before spoiling the screen
end
要想让这个例子正确的运行,你必须调整你的终端类型和代码中的控制符 [3] 一致:
plot(function (x) return math.sin(x*2*math.pi) end)
将在屏幕上输出一个正弦曲线。
将第一类值函数应用在表中是 Lua 实现面向对象和包机制的关键,这部分内容在后面章节介绍。
6.1 闭包
当一个函数内部嵌套另一个函数定义时,内部的函数体可以访问外部的函数的局部变量,这种特征我们称作词法定界。虽然这看起来很清楚,事实并非如此,词法定界加上第一类函数在编程语言里是一个功能强大的概念,很少语言提供这种支持。
下面看一个简单的例子,假定有一个学生姓名的列表和一个学生名和成绩对应的表;现在想根据学生的成绩从高到低对学生进行排序,可以这样做:
names = {"Peter", "Paul", "Mary"}
grades = {Mary = 10, Paul = 7, Peter = 8}
table.sort(names, function (n1, n2)
    return grades[n1] > grades[n2]    -- compare the grades
end)
假定创建一个函数实现此功能:
function sortbygrade (names, grades)
    table.sort(names, function (n1, n2)
       return grades[n1] > grades[n2]    -- compare the grades
    end)
end
例子中包含在 sortbygrade 函数内部的 sort 中的匿名函数可以访问 sortbygrade 的参数 grades ,在匿名函数内部 grades 不是全局变量也不是局部变量,我们称作外部的局部变量( external local variable )或者 upvalue 。( upvalue 意思有些误导,然而在 Lua 中他的存在有历史的根源,还有他比起 external local variable 简短)。
看下面的代码:
function newCounter()
    local i = 0
    return function()    -- anonymous function
       i = i + 1
        return i
    end
end
 
c1 = newCounter()
print(c1()) --> 1
print(c1()) --> 2
匿名函数使用 upvalue i 保存他的计数,当我们调用匿名函数的时候 i 已经超出了作用范围,因为创建 i 的函数 newCounter 已经返回了。然而 Lua 用闭包的思想正确处理了这种情况。简单的说,闭包是一个函数以及它的 upvalues 。如果我们再次调用 newCounter ,将创建一个新的局部变量 i ,因此我们得到了一个作用在新的变量 i 上的新闭包。
c2 = newCounter()
print(c2()) --> 1
print(c1()) --> 3
print(c2()) --> 2
c1 c2 是建立在同一个函数上,但作用在同一个局部变量的不同实例上的两个不同的闭包。
技术上来讲,闭包指值而不是指函数,函数仅仅是闭包的一个原型声明;尽管如此,在不会导致混淆的情况下我们继续使用术语函数代指闭包。
闭包在上下文环境中提供很有用的功能,如前面我们见到的可以作为高级函数( sort )的参数;作为函数嵌套的函数( newCounter )。这一机制使得我们可以在 Lua 的函数世界里组合出奇幻的编程技术。闭包也可用在回调函数中,比如在 GUI 环境中你需要创建一系列 button ,但用户按下 button 时回调函数被调用,可能不同的按钮被按下时需要处理的任务有点区别。具体来讲,一个十进制计算器需要 10 个相似的按钮,每个按钮对应一个数字,可以使用下面的函数创建他们:
function digitButton (digit)
    return Button{ label = digit,
           action = function ()
              add_to_display(digit)
           end
    }
end
这个例子中我们假定 Button 是一个用来创建新按钮的工具, label 是按钮的标签, action 是按钮被按下时调用的回调函数。(实际上是一个闭包,因为他访问 upvalue digit )。 digitButton 完成任务返回后,局部变量 digit 超出范围,回调函数仍然可以被调用并且可以访问局部变量 digit
闭包在完全不同的上下文中也是很有用途的。因为函数被存储在普通的变量内我们可以很方便的重定义或者预定义函数。通常当你需要原始函数有一个新的实现时可以重定义函数。例如你可以重定义 sin 使其接受一个度数而不是弧度作为参数:
oldSin = math.sin
math.sin = function (x)
    return oldSin(x*math.pi/180)
end
更清楚的方式:
do
    local oldSin = math.sin
    local k = math.pi/180
    math.sin = function (x)
       return oldSin(x*k)
    end
end
这样我们把原始版本放在一个局部变量内,访问 sin 的唯一方式是通过新版本的函数。
利用同样的特征我们可以创建一个安全的环境(也称作沙箱,和 java 里的沙箱一样),当我们运行一段不信任的代码(比如我们运行网络服务器上获取的代码)时安全的环境是需要的,比如我们可以使用闭包重定义 io 库的 open 函数来限制程序打开的文件。
do
    local oldOpen = io.open
    io.open = function (filename, mode)
       if access_OK(filename, mode) then
           return oldOpen(filename, mode)
       else
           return nil, "access denied"
       end
    end
end
6.2 非全局函数
Lua 中函数可以作为全局变量也可以作为局部变量,我们已经看到一些例子:函数作为 table 的域(大部分 Lua 标准库使用这种机制来实现的比如 io.read math.sin )。这种情况下,必须注意函数和表语法:
1. 表和函数放在一起
Lib = {}
Lib.foo = function (x,y) return x + y end
Lib.goo = function (x,y) return x - y end
2. 使用表构造函数
Lib = {
    foo = function (x,y) return x + y end,
    goo = function (x,y) return x - y end
}
3. Lua 提供另一种语法方式
Lib = {}
function Lib.foo (x,y)
    return x + y
end
function Lib.goo (x,y)
    return x - y
end
当我们将函数保存在一个局部变量内时,我们得到一个局部函数,也就是说局部函数像局部变量一样在一定范围内有效。这种定义在包中是非常有用的:因为 Lua chunk 当作函数处理,在 chunk 内可以声明局部函数(仅仅在 chunk 内可见),词法定界保证了包内的其他函数可以调用此函数。下面是声明局部函数的两种方式:
1. 方式一
local f = function (...)
    ...
end
 
local g = function (...)
    ...
    f()   -- external local `f' is visible here
    ...
end
2. 方式二
local function f (...)
    ...
end
有一点需要注意的是在声明递归局部函数的方式:
local fact = function (n)
    if n == 0 then
       return 1
    else
       return n*fact(n-1)   -- buggy
    end
end
上面这种方式导致 Lua 编译时遇到 fact(n-1) 并不知道他是局部函数 fact Lua 会去查找是否有这样的全局函数 fact 。为了解决这个问题我们必须在定义函数以前先声明:
local fact
 
fact = function (n)
    if n == 0 then
       return 1
    else
       return n*fact(n-1)
    end
end
这样在 fact 内部 fact(n-1) 调用是一个局部函数调用,运行时 fact 就可以获取正确的值了。
但是 Lua 扩展了他的语法使得可以在直接递归函数定义时使用两种方式都可以。
在定义非直接递归局部函数时要先声明然后定义才可以:
local f, g        -- `forward' declarations
 
function g ()
    ... f() ...
end
 
function f ()
    ... g() ...
end
6.3 正确的尾调用(Proper Tail Calls
Lua 中函数的另一个有趣的特征是可以正确的处理尾调用( proper tail recursion ,一些书使用术语“尾递归”,虽然并未涉及到递归的概念)。
尾调用是一种类似在函数结尾的 goto 调用,当函数最后一个动作是调用另外一个函数时,我们称这种调用尾调用。例如:
function f(x)
    return g(x)
end
g 的调用是尾调用。
例子中 f 调用 g 后不会再做任何事情,这种情况下当被调用函数 g 结束时程序不需要返回到调用者 f ;所以尾调用之后程序不需要在栈中保留关于调用者的任何信息。一些编译器比如 Lua 解释器利用这种特性在处理尾调用时不使用额外的栈,我们称这种语言支持正确的尾调用。
由于尾调用不需要使用栈空间,那么尾调用递归的层次可以无限制的。例如下面调用不论 n 为何值不会导致栈溢出。
function foo (n)
    if n > 0 then return foo(n - 1) end
end
需要注意的是:必须明确什么是尾调用。
一些调用者函数调用其他函数后也没有做其他的事情但不属于尾调用。比如:
function f (x)
    g(x)
    return
end
上面这个例子中 f 在调用 g 后,不得不丢弃 g 地返回值,所以不是尾调用,同样的下面几个例子也不时尾调用:
return g(x) + 1      -- must do the addition
return x or g(x)     -- must adjust to 1 result
return (g(x))        -- must adjust to 1 result
Lua 中类似 return g(...) 这种格式的调用是尾调用。但是 g g 的参数都可以是复杂表达式,因为 Lua 会在调用之前计算表达式的值。例如下面的调用是尾调用:
return x[i].foo(x[j] + a*b, i + j)
可以将尾调用理解成一种 goto ,在状态机的编程领域尾调用是非常有用的。状态机的应用要求函数记住每一个状态,改变状态只需要 goto(or call) 一个特定的函数。我们考虑一个迷宫游戏作为例子:迷宫有很多个房间,每个房间有东西南北四个门,每一步输入一个移动的方向,如果该方向存在即到达该方向对应的房间,否则程序打印警告信息。目标是:从开始的房间到达目的房间。
这个迷宫游戏是典型的状态机,每个当前的房间是一个状态。我们可以对每个房间写一个函数实现这个迷宫游戏,我们使用尾调用从一个房间移动到另外一个房间。一个四个房间的迷宫代码如下:
function room1 ()
    local move = io.read()
    if move == "south" then
       return room3()
    elseif move == "east" then
       return room2()
    else
       print("invalid move")
       return room1()   -- stay in the same room
    end
end
 
function room2 ()
    local move = io.read()
    if move == "south" then
       return room4()
    elseif move == "west" then
       return room1()
    else
       print("invalid move")
       return room2()
    end
end
 
function room3 ()
    local move = io.read()
    if move == "north" then
       return room1()
    elseif move == "east" then
       return room4()
    else
       print("invalid move")
       return room3()
    end
end
 
function room4 ()
    print("congratilations!")
end
我们可以调用 room1() 开始这个游戏。
如果没有正确的尾调用,每次移动都要创建一个栈,多次移动后可能导致栈溢出。但正确的尾调用可以无限制的尾调用,因为每次尾调用只是一个 goto 到另外一个函数并不是传统的函数调用。
 
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值