OpenResty:Lua唯一的數據結構table和metatable特性


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

 

上面演示了 value 為弱引用的弱表,也就是數組類型的弱表。同樣可以把對象作為 key,來構建哈希表類型的弱表,比如下面這樣寫:
$ 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 的這些特性正是閉包的基礎。所謂閉包 ,簡單地理解,它其實是一個函數,不過它訪問了另外一個函數詞法作用域中的變量。

如果按照閉包的定義來看,Lua 的所有函數實際上都是閉包,即使你沒有嵌套。這是因為 Lua 編譯器會把 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。

一般情況下,我們不會超過這個閾值,但寫 OpenResty 代碼的時候,你還是要留意這個事情,不要過多地 使用局部變量和 upvalue,而是要盡可能地使用 do .. end 做一層封裝,來減少局部變量和 upvalue 的個 數。
來看下面這段偽碼:
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 文件中,算是一 個優化技巧。

 

 

 
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM