这篇无博文对我近来学习lua模块有很大的帮助。与诸君共享。
我们钟爱的module()函数真的就要消失了。因为在Lua 5.2中,它存在的作用仅限于向后兼容标识,这是不争的事实:它将在
Lua 5.3中永远消失。因此,在上学期我编写的一个新的Lua项目中,我决定用用module()函数,同时确保我的代码能够运行于
Lua 5.1 和5.2(附带的结果是,我启动了 compat52项目,它允许你以良好的Lua 5.2 风格编写代码,并同时运行于5.1和5.2)。
那么,为什么我之前喜欢module()函数?虽然它受到某些人的谴责,但我认为使用module()是利大于弊的。它的确拥有一些良好的属性:
- 它为模块间的互操作性提供了急需的策略。人们第一次以相同的方式编写Lua模块,并看些合作。
- 它鼓励在参数中记录模块名称,这在没有明确定义路径策略的世界中特别管用。人们通常会通过模块名来查找模块的存储位置。别在意那么编写
module(...)的不良建议:你可以随心所欲地为模块命名。
模块的用户必须重视模块的命名规则,这样需要它的其它模块都可以以正确的方式调用require()。 - 它推动模块通过require()返回一张表。但是Lua的模块机制过于宽松,导致经常泄漏全局信息。一致使用
module()
意味着你可以local foo = require("foo")
今赖这样的写法,尽管有点繁琐,但这是一种保持环境整洁的办法。 - 你可以通过语法明确表达可见性,私有函数定义为
local function
,公有函数定义为function
。 - 除了笨拙的
package.seeall
参数之外,使用module() 是完全自由的。(我讨厌代码中的重复冗余,不论是Lua里的local print = print
还是C语言里的.h 和.c文件)
那么,在没有module()的日子里,怎样保留这些特性呢?我找到的解决方案是定制一些策略,也是本文所描述的内容。
没错,定制一点策略。我知道在Lua世界里是多么地仇恨策略,但我不会用枪指着某人的头要求他遵循这些。我只分享一些对我有用的东西,希望它能帮助到你。别担心,这里没有什么深奥的内容,只是一些精选的最佳实践描述。
由外及内
请记住:模块的目标是实现其它代码所需要的功能,引用 foo.bar模块的方式:
local bar = require("foo.bar") -- requiring the module
bar.say("hello") -- using the module
一个有趣的观察现象是:尽管我们的模块有分层结构,但是把它们加载到本地意味着它们被提取到一个平面空间里。因此,这里给出Policy Bit #1:
Policy Bit #1: 要求模块导入后,有局部变量与之对应,局部变量名对应模块的组件的完整名称。
不要偷懒: local skt = require("socket")
这样的代码可读性很差,我们不得不经常回到文件头,来检查模块的调用匹配。
模块命名
既然知道模块终将导入别人代码的局部变量,那么请你认真考虑模块的命名。(我希望有一种大小写策略能够自然地区分它们,但在Lua 里,LikeThis 这样的名字往往只用在面向对象代码里)
在便利性和唯一性之间平衡,选择可用的名字。还有什么比这个更好的命名方法呢?因此,Policy Bit #2:在声明中使用模块名称!
Policy Bit #2: 模块代码开始于一个表的声明,表的名称和模块名称完全相同,并使用小写。因此,在模块foo.bar(位于foo/bar.lua)里,代码始于:
local bar = {}
与过去的 module("foo.bar", package.seall)相比,这并不是一种良好的自说明文件头,但它至少包含了我们需要的一些内容。我们可以通过注释说明来做些提升:
--- @module foo.bar
local bar = {}
不要为模块取一些含糊不清的名字,比如:"size"。
声明函数
在我浏览源码时,我希望能够了解这段代码的影响范围。这是个很小的辅助函数吗?是不是只在这个文件里使用?还是提供给别人使用的重要功能特性?添加或删除参数会不会导致API损坏?理想情况下,我希望无需在代码中来回滚动就能看出这点,因此,我希望可以利用语法明确声明可见性。
我们的模块不允许声明全局函数(或其它全局类型)?这里讨论全局与局部的区别没什么用。我们直接选择声明策略:
Policy Bit #3: 使用local function
声明 局部函数,换句话说,外部模块不通用直接访问模块内部的函数。
也就是说local function helper_foo()
意味着helper_foo函数是局部函数。
这看起来很明显,但也很多人拥护这样的规约:把所有函数声明为local,然后在模块末尾写一个导出列表。阅读这样的代码对我来说就像看悬疑小说的结尾:哈哈,其实我一直是是个public函数!
那么我们要怎么写public函数呢?我们不能声明全局函数,但有办法可以选择。我们必须提供函数给其模块使用方调用怎么办(比如说 bar.say("hello")
)?很简单,我们只需要这样声明函数:
function bar.say(greeting)
print(greeting)
end
Policy Bit #4: public函数以模块表加点的格式声明
利用语法显示说明可见性。那些倡导把模块表命名为“M”的人也支持这一观点,不同之处是自己的狗粮自己吃,直接命名为你期望用户使用的名字。这也能更加保证一致性,因为调用say()函数要写成bar.say()
,而不是say()或M.say()。(此外,
“M.” 看起来很丑陋,人们不知道应该使用“M” 还是“_M”)。
如果你对通过模块表进行函数调用有速度上的担忧,其实大可不必。首先,这是你的用户将要经历的事情;其次,这与通过冒号语法和self进行分派的方式没有本质上的区别,没有人会报怨这点;第三,如果你确实需要(并且有一个基准案例),请继续操持,明确声明local进行优化;第四,如果你真的追求性能,那么可以选择使用LuaJIT;最后,我听说把函数缓存为local值是有问题的。
类与对象
在我们讨论类与对象的时候,是时候考虑LikeThis这样的命名了。如果你不使用OOP,那么可以放飞自我了,直接跳过这一节。
如上所述,让我们由外及内重新开始,如何实例化一个对象。有两种常见的实践方法(哦,为什么我没有感到惊讶:()你可以通过“new” 方法创建一个类表;也可以通过可调用函数创建“类对象”(函数或带有__call 元方法的表;等等,好像这是第三种方法了)。
local myset1 = Set.new() -- style 1
local myset2 = Set() -- style 2.1 (set is a function)
local myset3 = Set() -- style 2.2 (set is a table)
如果模块描述一个类,我更喜欢style 1,因为:
- 它保持了模块就是一个表的定式
- 它很方便将“static”方法存储为表里的其它函数
- 它的魔力值较小,我经常通过
for k,v in pairs(bar) do print(k,v) end 运行模块交互,快速查询它们导出的内容。
- 它只会尖叫一声:我正在创建一个对象
如果你的模块所做的事就是定义一个类,我认为把模块文件名命名为 MyClass.lua,并且把类表作为模块表是有意义的。但我不喜欢这种风格,因为在纯OOP语言中,我们通常模块函数存储为“static”类方法。我依然使用大写字母实现类表,就像这样:
--- @module myproject.myclass
local myclass = {}
-- class table
local MyClass = {}
function MyClass:some_method()
-- code
end
function MyClass:another_one()
self:some_method()
-- more code
end
function myclass.new()
local self = {}
setmetatable(self, { __index = MyClass })
return self
end
return myclass
从上面的代码中,很容易看出带有MyClass
签名的函数是方法定义。有时候,在表定义内部把函数声明为(表的)字段是良好的,但是,像上述实例那样独立声明函数,让你能够在靠近它们的使用点保留本地帮助功能。
如果模块的所有功能就是定义一个类,那么类和模块表可以合二为一。如果你喜欢style 2,那么写出的代码是这像这样的:
--- @module myproject.MyClass
local MyClass = {}
function MyClass:some_method()
-- code
end
function MyClass:another_one()
self:some_method()
-- more code
end
local metatable = {
__call = function()
local self = {}
setmetatable(self, { __index = MyClass })
return self
end
}
setmetatable(MyClass, metatable)
return MyClass
上面的两种方法都是能接受的,主要的目的是显式说明你做的是OOP。
Policy Bit #5: 为类构造一个表,并以LikeThis这样的风格命名,让别人知道这张表是某个类所属的。
Policy Bit #6: 对象所支持的方法应当显示标识出来,冒号格式的语法就非常棒。
不要让别人阅读你的代码时还要去猜测某个函数是对象的方法、还是模块函数,或者还需要去查找才能确定函数是全局的,还是局部的。
封装
返回模块表。这样做有些刻板,但在没有module()的世界里,这是我们不得不做的事情:
return bar
Policy Bit #7: 不要在模块里设置任何的全局变量,并在模块结束处返回一张表。
总之,一个完整的foo.bar模块看起来是这样的:
--- @module foo.bar
local bar = {}
local function happy_greet(greeting)
print(greeting.."!!!! :-D")
end
function bar.say(greeting)
happy_greet(greeting)
end
return bar
结局是:我们输入的内容会比module()在的时候多一些,一不小心,我们会有全局变量污染的风险,但是我们有一组应对策略:
- 完全自我说明的代码
- 可见性规则通过语法体现
- 可以预见模块返回一张表
- 尽量保持一致性,尽可能少使用样板文件
这在最大程度上满足我对module()的偏好,在为使用_ENV 技巧的前提下就能实现。
我已经在一个大学项目中成功运用这些策略,并计划在我更新LuaRocks代码时放弃使用module()面选择它们。根据自己的实际状况,完全或部分采纳,但最最重要的是,无论如何,重在坚持!祝你在后module()世界中愉快!