LuaJIT 中只有 table 這一個數據結構,並沒有區分開數組、哈 希、集合等概念,而是揉在了一起。
之前的一個例子:
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 函數,只遍歷數組部分的內容:
$ 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 或者一元操作符 # ,就可以正確返回元素的個數。
$ 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 中數組部分的元素。
$ 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就被刪除了。
$ 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 的數組部分。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
> print(table.concat(color, ", "))'
blue, yellow
這個函數還可以指定下標的起始位置來做拼接:
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow", "orange"}
> print(table.concat(color, ", ", 2, 3))'
yellow, orange
table.insert 插入一個元素
它可以下標插入一個新的元素,影響的還是 table 的數組部分。
$ 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 操作,我們應該盡量避免在熱代碼中使用
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 一下:
$ 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:
local t = new_tab(100, 50)
超出預設的空間大小,也可以正常使用,只不過性能會退化,也就失去了使用 table.new 的意義
比如下面這個例子,我們預設大小為 100,而實際上卻使用了 200:
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 的開銷。
$ 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,比如 下面這樣來用:
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。
$ 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 中去掉:
$ 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,如下實現的效果是一樣的:
$ 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:
$ 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 配對的操作,可以獲取到已經設置的元表,比如下面這段代碼:
$ 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 客戶端,里面就使用元表模擬了類和類方法,它的使用方式如下所示:
$ 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。
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;
二是讓它常駐內存。
$ 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
弱表,首先它是一個表,然后這個表里面的所有元素 都是弱引用
$ 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 了。這其中,最重要的就是下面這一行代碼:
setmetatable(tb, {__mode = "v"})
當一個 table 的元表中存在 __mode 字段時,這個 table 就是弱表(weak table)了。
- 如果 __mode 的值是 k,那就意味着這個 table 的 鍵 是弱引用。
- 如果 __mode 的值是 v,那就意味着這個 table 的 值 是弱引用。
- 也可以設置為 kv,表明這個表的鍵和值都是弱引用。
這三者中的任意一種弱表,只要它的 鍵 或者 值 被回收了,那么對應的整個鍵值對象都會被回收。
在上面的代碼示例中,__mode 的值 v,而tb 是一個數組,數組的 value 則是 table 和函數對象,所以可 以被自動回收。
不過,如果把__mode 的值改為 k,就不會 GC 了,比如看下面這段代碼:
$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "k"})
print(#tb) -- 2
collectgarbage()
print(#tb) -- 2
$ 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 中,所有的值都是一等公民,包含函數也是。這就意味着函數可以保存在變量中,當作參數傳遞,以及作為另一個函數的返回值。比如在上面弱表中出現的這段示例代碼:
tb[2] = function() print("func") end
其實就是把一個匿名函數,作為 table 的值給存儲了起來。
在 Lua 中,下面這段代碼中動兩個函數的定義是完全等價的。不過注意,后者是把函數賦值給一個變量, 這也是我們經常會用到的一種方式:
local function foo() print("foo") end
local foo = fuction() print("foo") end
Lua 支持把一個函數寫在另外一個函數里面,即嵌套函數,比如下面的示例代碼:
$ 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 的這些特性正是閉包的基礎。所謂閉包 ,簡單地理解,它其實是一個函數,不過它訪問了另外一個函數詞法作用域中的變量。
local foo, bar
local function fn()
foo = 1
bar = 2
end
在編譯后,就會變為下面的樣子:
function main(...)
local foo, bar
local function fn()
foo = 1
bar = 2
end
end
而函數 fn 捕獲了主函數的兩個局部變量,因此也是閉包。
只有理解了閉包,才能明白upvalue。
upvalue 就是 Lua 中獨有的概念了。從字面意思來看,可以翻譯成上⾯的值。實際上,upvalue 就是閉包中捕獲的自己詞法作用域外的那個變量。還是繼續看上面那段代碼:
local foo, bar
local function fn()
foo = 1
bar = 2
end
函數 fn 捕獲了兩個不在自己詞法作用域的局部變量 foo 和 bar,而這兩個變量,實際上就 是函數 fn 的 upvalue。
常見的坑
下標從 0 開始還是從 1 開始
第一個坑,Lua 的下標是從 0 開始的,在 LuaJIT 中,使用 ffi.new 創建的數組,下標又是從 0 開始的:
local buf = ffi_new("char[?]", 128)
如果要訪問上面這段代碼中 buf 這個 cdata,下標從 0 開始,而不是 1。在使用 FFI 和 C 交 互的時候,一定要特別注意這個地方。
正則模式匹配
OpenResty 中並行着兩套字符串匹配方法:Lua 自帶的 sting 庫,以及 OpenResty 提供的 ngx.re.* API。
Lua 正則模式匹配是自己獨有的格式,和 PCRE 的寫法不同。下面是一個簡單的示例:
$ 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 編碼的時候,自然就無法確定編碼為數組還是字典:
$ resty -e 'local cjson = require "cjson"
> local t = {}
> print(cjson.encode(t))
> '
{}
上面這段代碼,它的輸出是 {},由此可見, OpenResty 的 cjson 庫,默認把空 table 當做字典來編碼。
可以通過 encode_empty_table_as_object 這個函數,來修改這個全局的默認值:
$ 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 編碼的時候,它 就會被當做空數組來處理:
$ 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 的方式進行設置的,比如下面這段代碼操作:
$ 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 的源碼中得到印證:
/* @@ 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。
local re_find = ngx.re.find function foo() ... end function bar() ... end function fn() ... end
如果只有函數 foo 使用到了 re_find, 那么我們可以這樣改造下:
do
local re_find = ngx.re.find
function foo() ... end
end
function bar() ... end
function fn() ... end
在 main 函數的層面上,就少了 re_find 這個局部變量。這在單個的大的 Lua 文件中,算是一 個優化技巧。
