函数与面向对象

变量声明与 C 语言的不同

Lua 中有一个常见的用法,不论变量、函数都可以用下面这种方法保存到局部变量中(同时加快访问速度):

  1. local foo = foo

书里加了个括号来解释这种写法:

The local foo becomes visible only after its declaration.

这一点需要瞎扯的是 C 语言里相应的东西。

  1. int foo = 12;
  2. int bar = 6;
  3. void foobar(void)
  4. {
  5. int foo = foo;
  6. int bar[bar];
  7. }

与 Lua 不同,在 C 语言中初始赋值是声明之后的事情。所以这里函数 foobar 中的 foo 会被初始化为自己(而不是全局的 foo,所以值不确定),bar 却被合法地定义为一个含有 6 个元素的数组。

看似多余的限制

另一个有趣的现象是在 4.4 节中说到:

For syntactic reasons, a break or return can appear only as the last statement of a block; in other words, as the last statement in your chunk or just before an end, an else, or an until.

乍一看觉得加上这个限制真是麻烦,但想想这不正是 break/return 的正确用法么?因为其后的语句都永远不会被执行到,所以如果不是在块的最后写 break/return 是毫无意义的(调试除外)。虽然看上去是挺多余的一段话,但也算是说出了事物的本源。

函数的本质

第六章 More About Functions 中说到我们平时在 Lua 中写的函数声明

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

其实是一种语法糖,本质上我们可以把它写成如下代码:

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

于是也就可以说

  • Lua 中的所有函数都是匿名函数,之前所谓「具名函数」只是保存了某个匿名函数的变量罢了。
  • Lua 中的函数声明其实只是一个语句而已。

终于有用的知识

在第 47 页看到了一段令人泪流满面的代码和运行结果:

  1. function derivative (f, delta)
  2. delta = delta or 1e-4
  3. return function (x)
  4. return (f(x + delta) - f(x))/delta
  5. end
  6. end
  7. c = derivative(math.sin)
  8. print(math.cos(10), c(10))
  9. --> -0.83907152907645 -0.83904432662041

最初我并不知道 derivative 是什么意思,但看了示例代码和运行结果,顿时恍然大悟:这货不就是导数吗?

沙盒

背景知识

Lua 给我的感觉是:各种内置函数和标准库的存在感都是比较强的。如果执行这句:

  1. for name in pairs(_G) do print(name) end

就会把各种环境中已存在名称的打印出来:

  • 全局变量:比如字符串 _VERSION。
  • 内置函数:比如 print、tonumber、dofile 之类。
  • 模块名称:比如 string、io、coroutine 之类。

这里的全局变量 _G 就是存放环境的表(于是会有 _G 中存在着 _G._G 的递归)。

于是,平时对于全局变量的访问就可以等同于对 _G 表进行索引:

  1. value = _G[varname] --> value = varname
  2. _G[varname] = value --> varname = value

改变函数的环境

函数的上下文环境可以通过 setfenv(f, table) 函数改变,其中 table 是新的环境表,f 表示需要被改变环境的函数。如果 f 是数字,则将其视为堆栈层级(Stack Level),从而指明函数(1 为当前函数,2 为上一级函数):

  1. a = 3 -- 全局变量 a
  2. setfenv(1, {}) -- 将当前函数的环境表改为空表
  3. print(a) -- 出错,因为当前环境表中 print 已经不存在了

没错,不仅是 a 不存在,连 print 都一块儿不存在了。如果需要引用以前的 print 则需要在新的环境表中放入线索:

  1. a = 3
  2. setfenv(1, { g = _G })
  3. g.print(a) -- 输出 nil
  4. g.print(g.a) -- 输出 3

沙盒

于是,出于安全或者改变一些内置函数行为的目的,需要在执行 Lua 代码时改变其环境时便可以使用 setfenv 函数。仅将你认为安全的函数或者新的实现加入新环境表中:

  1. local env = {} -- 沙盒环境表,按需要添入允许的函数
  2. function run_sandbox(code)
  3. local func, message = loadstring(code)
  4. if not func then return nil, message end -- 传入代码本身错误
  5. setfenv(func, env)
  6. return pcall(func)
  7. end

Lua 5.2 的 _ENV 变量

Lua 5.2 中所有对全局变量 var 的访问都会在语法上翻译为 _ENV.var。而 _ENV 本身被认为是处于当前块外的一个局部变量。(于是只要你自己定义一个名为 _ENV 的变量,就自动成为了其后代码所处的「环境」(enviroment)。另有一个「全局环境」(global enviroment)的概念,指初始的 _G 表。)

Lua 的作者之一 Roberto Ierusalimschy 同志在介绍 Lua 5.2 时说:

the new scheme, with _ENV, allows the main benefit of setfenv with a little more than syntactic sugar.

就我的理解来说,优点就是原先虚无缥缈只能通过 setfenv、getfenv 访问的所谓「环境」终于实体化为一个始终存在的变量 _ENV 了。

于是以下两个函数内容大致是一样的:

  1. -- Lua 5.1
  2. function foobar()
  3. setfenv(1, {})
  4. -- code here
  5. end
  6. -- Lua 5.2
  7. function foobar()
  8. local _ENV = {}
  9. -- code here
  10. end

而更进一步的是,5.2 中对 load 函数作出了修改。(包括但不限于 :))合并了 loadstring 功能,并可以在参数中指定所使用的环境表:

  1. local func, message = load(code, nil, "t", env)

面向对象

没错,Lua 中只存在表(Table)这么唯一一种数据结构,但依旧可以玩出面向对象的概念。

添加成员函数

好吧,如果熟悉 C++ 还是很好理解类似的进化过程的:如果说 struct 里可以添加函数是从 C 过渡到 C++ 的第一认识的话,为 Table 添加函数也可以算是认识 Lua 是如何面向对象的第一步吧。

  1. player = { health = 200 } --> 一个普通的 player 表,这里看作是一个对象
  2. function takeDamage(self, amount)
  3. self.health = self.health - amount
  4. end
  5. takeDamage(player, 20) --> 调用

如何将独立的 takeDamage 塞进 player 中咧?答案是直接定义进去:

  1. player = { health = 200 }
  2. function player.takeDamage(self, amount)
  3. self.health = self.health - amount
  4. end
  5. player.takeDamage(player, 20) --> 调用

这样就相当于在 player 表中添加了一个叫做 takeDamage 的字段,和下面的代码是一样的:

  1. player = {
  2. health = 200,
  3. takeDamage = function(self, amount) --> Lua 中的函数是 first-class value
  4. self.health = self.health - amount
  5. end
  6. }
  7. player.takeDamage(player, 20) --> 调用

调用时的 player.takeDamage(player, 20) 稍显不和谐(据说用术语叫做 DRY),于是就要出动「冒号操作符」这个专门为此而生的语法糖了:

  1. player:takeDamage(20) --> 等同于 player.takeDamage(player, 20)
  2. function player:takeDamage(amount) --> 等同于 function player.takeDamage(self, amount)

从对象升华到类

类的意义在于提取一类对象的共同点从而实现量产(我瞎扯的 >_<)。同样木有 Class 概念的 Javascript 使用 prototype 实现面向对象,Lua 则通过 Metatable 实现与 prototype 类似的功能。

  1. Player = {}
  2. function Player:create(o) --> 参数 o 可以暂时不管
  3. o = o or { health = 200 } --> Lua or 与一般的 || 不同,如果非 nil 则返回该非 nil
  4. setmetatable(o, self)
  5. self.__index = self
  6. return o
  7. end
  8. function Player:takeDamage(amount)
  9. self.health = self.health - amount
  10. end
  11. playerA = Player:create() --> 参数 o nil
  12. playerB = Player:create()
  13. playerA:takeDamage(20)
  14. playerB:takeDamage(40)

顾名思义 Metatable 也是一个 Table,可以通过在其中存放一些函数(称作 metamethod)从而修改一些默认的求值行为(如何显示为字符串、如何相加、如何连接、如何进行索引)。Metatable 的 index 域设置了「如何进行索引」的方法。例如调用 foo.bar 时,如果在 foo 中没有找到名为 bar 的域时,则会调用 Metatable:index(foo, bar)。于是:

  1. playerA:takeDamage(20)

因为在 playerA 中并不存在 takeDamge 函数,于是求助于 Metatable:

  1. getmetatable(playerA).__index.takeDamage(playerA, 20)

带入 Metatable 后:

  1. Player.__index.takeDamage(playerA, 20)

因为 Player 的 __index 在 create 时被指定为 self,所以最终变为:

  1. Player.takeDamage(playerA, 20)

于是 takeDamage 的 self 得到了正确的对象 playerA。

继承

继承是面向对象的一大特性,明白了如何创建「类」,那么继承也就比较明了了,还记得大明湖畔的参数 o 么?

  1. RMBPlayer = Player:create()
  2. function RMBPlayer:broadcast(message) --> 为子类添加新的方法
  3. print(message)
  4. end
  5. function RMBPlayer:takeDamage(amount) --> 子类重载父类方法
  6. self.health = self.health - amount / (self.money / 100)
  7. end
  8. vip = RMBPlayer:create { money = 200 } --> 子类添加新成员(单个 Table 作为参数可以省略括号)
  9. vip:takeDamage(20)
  10. vip:broadcast("F*ck")

以上便是 Lua 中实现面向对象的基本方法。

导航