OpenResty:Lua唯一的数据结构table和metatable特性
LuaJIT 中只有 table 这一个数据结构,并没有区分开数组、哈 希、集合等概念,而是揉在了一起。
之前的一个例子:
1 2 3 4 5 6 | local color = {first = "red" , "blue" , third = "green" , "yellow" } print (color[ "first" ]) - - > output: red print (color[ 1 ]) - - > output: blue print (color[ "third" ]) - - > output: green print (color[ 2 ]) - - > output: yellow print (color[ 3 ]) - - > output: ni |
color 这个 table 包含了数组和哈希,并且可以互不干扰地进行访问。比如,你可以用 ipairs 函数,只遍历数组部分的内容:
1 2 3 4 5 6 7 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" } for k, v in ipairs(color) do print (k .. " " .. v) end' 1 blue 2 yellow |
table 的操作是如此重要,以至于 LuaJIT 对标准 Lua 5.1 的 table 库做了扩展,而 OpenResty 又对 LuaJIT 的 table 库做了更进一步的扩展。
table 库函数
table.getn 获取元素个数
对于序列,你用table.getn 或者一元操作符 # ,就可以正确返回元素的个数。
1 2 3 4 5 6 7 8 9 | $ resty - e 'local t = { 1 , 2 , 3 } > print (table.getn(t)) ' 3 $ resty - e 'local t = { 1 , 2 , 3 } print ( #t) ' 3 |
这种难以理解的函数,已经被 LuaJIT 的扩展替代,所以在 OpenResty 的环境下,除非明确知道正在获取序列的长度,否则请不要使用函数 table.getn 和一元操作符 # 。
另外,table.getn 和一元操作符 # 并不是 O(1) 的时间复杂度,而是 O(n),这也是尽量避免使用它们的另外一个理由。
table.remove 删除指定元素
它的作用是在 table 中根据下标来删除元素,也就是说只能删除 table 中数组部分的元素。
1 2 3 4 5 6 7 8 9 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" } > table.remove(color, 1 ) > for k, v in pairs(color) do > print (v) > end' yellow green red |
这段代码会把下标为 1 的 blue 删除掉。删除 table 中的哈希部分,把 key 对应的 value 设置为 nil 即可。这样,color这个例子中,third 对应的green就被删除了。
1 2 3 4 5 6 7 8 9 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" } > color.third = nil > for k, v in pairs(color) do > print (v) > end' blue yellow red |
table.concat 元素拼接函数
它可以按照下标,把 table 中的元素拼接起来。既然这里又是根据下标来操作的,那么显然还是针对 table 的数组部分。
1 2 3 4 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" } > print (table.concat(color, ", " ))' blue, yellow |
这个函数还可以指定下标的起始位置来做拼接:
1 2 3 4 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" , "orange" } > print (table.concat(color, ", " , 2 , 3 ))' yellow, orange |
table.insert 插入一个元素
它可以下标插入一个新的元素,影响的还是 table 的数组部分。
1 2 3 4 5 | $ resty - e 'local color = {first = "red" , "blue" , third = "green" , "yellow" } > table.insert(color, 1 , "orange" ) > print (color[ 1 ])' orange |
也可以不指定下标,这样就会默认插入队尾。
table.insert 虽然是一个很常见的操作,但性能并不乐观。如果不根据指定下标来插入元素,那么每次都需要调用 LuaJIT 的 lj_tab_len 来获取数组的长度,以便插入队尾。正如在 table.getn 中提到的,获取 table 长度的时间复杂度为 O(n) 。
对于table.insert 操作,我们应该尽量避免在热代码中使用
1 2 3 4 | resty - e 'llocal t = {} for i = 1 , 10000 do table.insert(t, i) end' |
LuaJIT 的 table 扩展函数
LuaJIT 在标准 Lua 的基础上,扩展了两个很有用的 table 函数, 分别用来新建和清空一个 table。
table.new(narray, nhash) 新建 table
这个函数,会预先分配好指定的数组和哈希的空间大小, 而不是在插入元素时自增长,这也是它的两个参数 narray 和 nhash 的含义。自增长是一个代价比较高的操作,会涉及到空间分配、resize 和 rehash 等,应该尽量避免。
这个函数是扩展出来的,所以在使用它之 前,需要先 require 一下:
1 2 3 4 5 6 7 8 9 | $ resty - e 'local new_tab = require "table.new" > local t = new_tab( 5 , 0 ) > for i = 1 , 5 do > t[i] = i > end > print (table.concat(t, "," )) > ' 1 , 2 , 3 , 4 , 5 |
新建一个同时包含 100 个数组元素和 50 个 哈希元素的 table:
1 | local t = new_tab( 100 , 50 ) |
超出预设的空间大小,也可以正常使用,只不过性能会退化,也就失去了使用 table.new 的意义
比如下面这个例子,我们预设大小为 100,而实际上却使用了 200:
1 2 3 4 5 | local new_tab = require "table.new" local t = new_tab( 100 , 0 ) for i = 1 , 200 do t[i] = i end |
需要根据实际场景,来预设好 table.new 中数组和哈希空间的大小,这样才能在性能和内存占用上找到一个平衡点。
table.clear() 清空 table
它用来清空某个 table 里的所有数据,但并不会释放数组和哈希部分占用的内存。所以,它在循环利用 Lua table 时非常有用,可以避免反复创建和销毁 table 的开销。
1 2 3 4 5 6 | $ resty - e 'local clear_tab = require "table.clear" > local color = {first = "red" , "blue" , third = "green" , "yellow" } > clear_tab(color) > for k, v in pairs(color) do > print (k) > end' |
事实上,能使用这个函数的场景并不算多,大多数情况下,我们还是应该把这个任务交给 LuaJIT GC 去完成。
OpenResty 的 table 扩展函数
OpenResty 自己维护的 LuaJIT 分支,也对 table 做了扩展,它新增了几个 API:table.isempty、table.isarray、 table.nkeys 和 table.clone。
需要注意的是,在使用这几个新增的 API 前,请记住检查你使用的 OpenResty 的版本,这些API 大都只能 在 OpenResty 1.15.8.1 之后的版本中使用。这是因为, OpenResty 在 1.15.8.1 版本之前,已经有一年左右没有发布新版本了,而这些 API 是在这个发布间隔中新增的。
table.nkeys函数是获取 table 长度的函数, 返回的是 table 的元素个数,包括数组和哈希部分的元素。因此,我们可以用它来替代 table.getn,比如 下面这样来用:
1 2 3 4 5 | local nkeys = require "table.nkeys" print (nkeys({})) - - 0 print (nkeys({ "a" , nil, "b" })) - - 2 print (nkeys({ dog = 3 , cat = 4 , bird = nil })) - - 2 print (nkeys({ "a" , dog = 3 , cat = 4 })) - - 3 |
元表
由 table 引申出来元表(metatable),元表是 Lua 中独有的概念,在 实际项目中的使用非常广泛,在几乎所有的 lua-resty-* 库中,都能看到它的身影。
元表的表现行为类似于操作符重载,比如我们可以重载 __add,来计算两个 Lua 数组的并集;或者重载 __tostring,来定义转换为字符串的函数。
Lua 提供了两个处理元表的函数:
- 第一个是setmetatable(table, metatable), 用于为一个 table 设置元表;
- 第二个是getmetatable(table),用于获取 table 的元表。
用 setmetatable ,重新设置 version 这个 table 的 __tostring 方法,就可以打印出版本 号: 1.1.1。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | $ resty - e ' local version = { > major = 1 , > minor = 1 , > patch = 1 > } > version = setmetatable(version, { > __tostring = function(t) > return string. format ( "%d.%d.%d" , t[ "major" ], t[ "minor" ], t[ "patch" ]) > end > }) > print (tostring(version)) > ' 1.1 . 1 |
除了 __tostring 之外,在实际项目中,我们还经常重载元表中的以下两个元方法 (metamethod)。
__index。我们在 table 中查找一个元素时,首先会直接从 table 中查询,如果没有找到,就继续到元表的 __index 中查询。
把 patch 从 version 这个 table 中去掉:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | $ resty - e ' local version = { > major = 1 , > minor = 1 > } > version = setmetatable(version, { > __index = function(t, key) > if key = = "patch" then > return 2 > end > end, > __tostring = function(t) > return string. format ( "%d.%d.%d" , t.major, t.minor, t.patch) > end > }) > print (tostring(version)) > ' 1.1 . 2 |
t.patch 其实获取不到值,那么就会走到 __index 这个函数中,结果就会打印出 1.1.2。
__index 不仅可以是一个函数,也可以是一个 table,如下实现的效果是一样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | $ resty - e ' local version = { > major = 1 , > minor = 1 > } > version = setmetatable(version, { > __index = {patch = 2 }, > __tostring = function(t) > return string. format ( "%d.%d.%d" , t.major, t.minor, t.patch) > end > }) > print (tostring(version)) > ' 1.1 . 2 |
另一个元方法则是__call。它类似于仿函数,可以让 table 被调用。
是基于上面打印版本号的代码来做修改,看如何调用一个 table:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | $ resty - e ' > local version = { > major = 1 , > minor = 1 , > patch = 1 > } > > local function print_version(t) > print (string. format ( "%d.%d.%d" , t.major, t.minor, t.patch)) > end > > version = setmetatable(version, > {__call = print_version}) > > version() > ' 1.1 . 1 |
使用 setmetatable,给 version 这个 table 增加了元表,而里面的 __call 元方法指 向了函数 print_version 。那么尝试把 version 当作函数调用,这里就会执行函数 print_version。
而 getmetatable 是和 setmetatable 配对的操作,可以获取到已经设置的元表,比如下面这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $ resty - e ' local version = { > major = 1 , > minor = 1 > } > version = setmetatable(version, { > __index = {patch = 2 }, > __tostring = function(t) > return string. format ( "%d.%d.%d" , t.major, t.minor, t.patch) > end > }) > > print (getmetatable(version).__index.patch) > ' 2 |
面向对象
Lua 并不是一个面向对象(Object Orientation)的语言,但我们 可以使用 metatable 来实现 OO。
lua-resty-mysql 是 OpenResty 官方的 MySQL 客户端,里面就使用元表模拟了类和类方法,它的使用方式如下所示:
1 2 3 | $ resty - e 'local mysql = require "resty.mysql" - - 先引⽤ lua - resty 库 local db, err = mysql:new() - - 新建⼀个类的实例 db:set_timeout( 1000 ) - - 调⽤类的⽅法' |
在调用类方法的时候,为什么是冒号而不是点号呢?
其实,在这里冒号和点号都是可以的,db:set_timeout(1000) 和 db.set_timeout(db, 1000) 是 完全等价的。冒号是 Lua 中的一个语法糖,可以省略掉函数的第一个参数 self。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | local _M = { _VERSION = '0.21' } - - 使⽤ table 模拟类 local mt = { __index = _M } - - mt 即 metatable 的缩写,__index 指向类⾃⾝ - - 类的构造函数 function _M.new( self ) local sock, err = tcp() if not sock then return nil, err end return setmetatable({ sock = sock }, mt) - - 使⽤ table 和 metatable 模拟类的实例 end - - 类的成员函数 function _M.set_timeout( self , timeout) - - 使⽤ self 参数,获取要操作的类的实例 local sock = self .sock if not sock then return nil, "not initialized" end return sock:settimeout(timeout) end |
_M 这个 table 模拟了一个类,初始化时,它只有 _VERSION 这一个成员变量,并在随后定义 了 _M.set_timeout 等成员函数。在 _M.new(self) 这个构造函数中,我们返回了一个 table,这个 table 的元表就是 mt,而 mt 的 __index 元方法指向了 _M,这样,返回的这个 table 就模拟了类 _M 的实 例。
弱表
弱表(weak table),它是 Lua 中很独特的一个概念,和垃圾回收相关。和其他高级语言一样,Lua 是自动垃圾回收的,不用关心具体的实现,也不用显式 GC。没有被引用到的空间,会被垃圾收集器自动完成回收。
把一个 Lua 的对象 Foo(table 或者函数)插入到 table tb 中,这就会产生对这个对象 Foo 的引用。即使没有其他地方引用 Foo,tb 对它的引用也还一直存在,那么 GC 就没有办法回收 Foo 所占用的内存。
就只有两种选择:
一是手工释放 Foo;
二是让它常驻内存。
1 2 3 4 5 6 7 8 9 10 | $ resty - e 'local tb = {} tb[ 1 ] = {red} tb[ 2 ] = function() print ( "func" ) end print ( #tb) -- 2 collectgarbage() print ( #tb) -- 2 table.remove(tb, 1 ) print ( #tb) -- 1 |
弱表,首先它是一个表,然后这个表里面的所有元素 都是弱引用
1 2 3 4 5 6 7 8 | $ resty - e 'local tb = {} tb[ 1 ] = {red} tb[ 2 ] = function() print ( "func" ) end setmetatable(tb, {__mode = "v" }) print ( #tb) -- 2 collectgarbage() print ( #tb) -- 0 |
没有被使用的对象都被 GC 了。这其中,最重要的就是下面这一行代码:
1 | setmetatable(tb, {__mode = "v" }) |
当一个 table 的元表中存在 __mode 字段时,这个 table 就是弱表(weak table)了。
- 如果 __mode 的值是 k,那就意味着这个 table 的 键 是弱引用。
- 如果 __mode 的值是 v,那就意味着这个 table 的 值 是弱引用。
- 也可以设置为 kv,表明这个表的键和值都是弱引用。
这三者中的任意一种弱表,只要它的 键 或者 值 被回收了,那么对应的整个键值对象都会被回收。
在上面的代码示例中,__mode 的值 v,而tb 是一个数组,数组的 value 则是 table 和函数对象,所以可 以被自动回收。
不过,如果把__mode 的值改为 k,就不会 GC 了,比如看下面这段代码:
1 2 3 4 5 6 7 8 | $ resty - e 'local tb = {} tb[ 1 ] = {red} tb[ 2 ] = function() print ( "func" ) end setmetatable(tb, {__mode = "k" }) print ( #tb) -- 2 collectgarbage() print ( #tb) -- 2 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | $ resty - e 'local tb = {} > tb[{color = red}] = "red" > local fc = function() print ( "func" ) end > tb[fc] = "func" > fc = nil > setmetatable(tb, {__mode = "k" }) > for k,v in pairs(tb) do > print (v) > end > collectgarbage() > print ( "----------" ) > for k,v in pairs(tb) do > print (v) > end > ' red func - - - - - - - - - - |
在手动调用 collectgarbage() 进行强制 GC 后,tb 整个 table 里面的元素,就已经全部被回收了。在实际的代码中,我们大可不必手动调用 collectgarbage(),它会在后台自动运行,无须我们担心。
collectgarbage() 函数可以传入多个不同的选项,且默认是 collect,即完整的 GC。另一个比较有用的是 count,它可以返回 Lua 占用的内存空间大小。这个统计数据很有用,可以看出是否存在内存泄漏,也可以提醒我们不要接近 2G 的上限值。
闭包和 upvalue
在 Lua 中,所有的值都是一等公民,包含函数也是。这就意味着函数可以保存在变量中,当作参数传递,以及作为另一个函数的返回值。比如在上面弱表中出现的这段示例代码:
1 | tb[ 2 ] = function() print ( "func" ) end |
其实就是把一个匿名函数,作为 table 的值给存储了起来。
在 Lua 中,下面这段代码中动两个函数的定义是完全等价的。不过注意,后者是把函数赋值给一个变量, 这也是我们经常会用到的一种方式:
1 2 | local function foo() print ( "foo" ) end local foo = fuction() print ( "foo" ) end |
Lua 支持把一个函数写在另外一个函数里面,即嵌套函数,比如下面的示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $ resty - e ' > local function foo() > local i = 1 > local function bar() > i = i + 1 > print (i) > end > return bar > end > > local fn = foo() > print (fn()) - - 2 > ' 2 |
bar 这个函数可以读取函数 foo 里面的局部变量 i,并修改它的值,即使这个变量并不在 foo 里面定义。这个特性叫做词法作用域(lexical scoping)。
事实上,Lua 的这些特性正是闭包的基础。所谓闭包 ,简单地理解,它其实是一个函数,不过它访问了另外一个函数词法作用域中的变量。
1 2 3 4 5 | local foo, bar local function fn() foo = 1 bar = 2 end |
在编译后,就会变为下面的样子:
1 2 3 4 5 6 7 | function main(...) local foo, bar local function fn() foo = 1 bar = 2 end end |
而函数 fn 捕获了主函数的两个局部变量,因此也是闭包。
只有理解了闭包,才能明白upvalue。
upvalue 就是 Lua 中独有的概念了。从字面意思来看,可以翻译成上⾯的值。实际上,upvalue 就是闭包中捕获的自己词法作用域外的那个变量。还是继续看上面那段代码:
1 2 3 4 5 | local foo, bar local function fn() foo = 1 bar = 2 end |
函数 fn 捕获了两个不在自己词法作用域的局部变量 foo 和 bar,而这两个变量,实际上就 是函数 fn 的 upvalue。
常见的坑
下标从 0 开始还是从 1 开始
第一个坑,Lua 的下标是从 0 开始的,在 LuaJIT 中,使用 ffi.new 创建的数组,下标又是从 0 开始的:
1 | local buf = ffi_new( "char[?]" , 128 ) |
如果要访问上面这段代码中 buf 这个 cdata,下标从 0 开始,而不是 1。在使用 FFI 和 C 交 互的时候,一定要特别注意这个地方。
正则模式匹配
OpenResty 中并行着两套字符串匹配方法:Lua 自带的 sting 库,以及 OpenResty 提供的 ngx.re.* API。
Lua 正则模式匹配是自己独有的格式,和 PCRE 的写法不同。下面是一个简单的示例:
1 2 3 | $ resty - e 'print(string.match("foo 123 bar", "%d%d%d"))' 123 |
这段代码从字符串中提取了数字部分,它和我们的熟悉的正则表达式完全不同。Lua 自带的正则匹配库,不仅代码维护成本高,而且性能低——不能被 JIT,而且被编译过一次的模式也不会被缓存。
使用 Lua 内置的 string 库去做 find、match 等操作时,如果有类似正则这样的需求,请直接使用 OpenResty 提供的 ngx.re 来替代。只有在查找固定字符串的时候,才考虑使用 plain 模式来调用 string 库。
在 OpenResty 中,我们总是优先使用 OpenResty 的 API,然后是 LuaJIT 的 API,使用 Lua 库则需要慎之又慎。
json 编码时无法区分 array 和 dict
json 编码时无法区分 array 和 dict。由于 Lua 中只有 table 这一个数据结构,所以在 json 对空 table 编码的时候,自然就无法确定编码为数组还是字典:
1 2 3 4 5 6 | $ resty - e 'local cjson = require "cjson" > local t = {} > print (cjson.encode(t)) > ' {} |
上面这段代码,它的输出是 {},由此可见, OpenResty 的 cjson 库,默认把空 table 当做字典来编码。
可以通过 encode_empty_table_as_object 这个函数,来修改这个全局的默认值:
1 2 3 4 5 6 7 | $ resty - e 'local cjson = require "cjson" > cjson.encode_empty_table_as_object(false) > local t = {} > print (cjson.encode(t)) > ' [] |
空 table 就被编码为了数组:[]。
全局这种设置的影响面比较大,有 两种方法可以指定某个 table 的编码规则呢:
第一种方法,把 cjson.empty_array 这个 userdata 赋值给指定 table。这样,在 json 编码的时候,它 就会被当做空数组来处理:
1 2 3 4 5 | $ resty - e 'local cjson = require "cjson" > local t = cjson.empty_array > print (cjson.encode(t))' [] |
有时候我们并不确定,这个指定的 table 是否一直为空。我们希望当它为空的时候编码为数组,那么 就要用到 cjson.empty_array_mt 这个函数,也就是我们的第二个方法。
它会标记好指定的 table,当 table 为空时编码为数组。从cjson.empty_array_mt 这个命名你也可以看 出,它是通过 metatable 的方式进行设置的,比如下面这段代码操作:
1 2 3 4 5 6 7 8 9 10 | $ resty - e 'local cjson = require "cjson" > local t = {} > setmetatable(t, cjson.empty_array_mt) > print (cjson.encode(t)) > t = { 123 } > print (cjson.encode(t)) > ' [] [ 123 ] |
变量的个数限制
Lua 中,一个函数的局部变量的个数,和 upvalue 的个数都是有 上限的,你可以从 Lua 的源码中得到印证:
1 2 3 4 5 6 7 8 9 10 | / * @@ LUAI_MAXVARS is the maximum number of local variables per function @ * (must be smaller than 250 ). * / #define LUAI_MAXVARS 200 / * @@ LUAI_MAXUPVALUES is the maximum number of upvalues per function @ * (must be smaller than 250 ). * / #define LUAI_MAXUPVALUES 60 |
这两个阈值,分别被硬编码为 200 和 60。虽说可以手动修改源码来调整这两个值,不过最大也只能设置 为 250。
1 2 3 4 | local re_find = ngx.re.find function foo() ... end function bar() ... end function fn() ... end |
如果只有函数 foo 使用到了 re_find, 那么我们可以这样改造下:
1 2 3 4 5 6 | do local re_find = ngx.re.find function foo() ... end end function bar() ... end function fn() ... end |
在 main 函数的层面上,就少了 re_find 这个局部变量。这在单个的大的 Lua 文件中,算是一 个优化技巧。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步