1. 程式人生 > >lua的元表metatable及元方法

lua的元表metatable及元方法

元表對應的英文是metatable,元方法是metamethod。我們都知道,在C++中,兩個類是無法直接相加的,但是,如果你過載了“+”符號,就可以進行類的加法運算。在Lua中也有這個道理,兩個table型別的變數,你是無法直接進行“+”操作的,如果你定義了一個指定的函式,就可以進行了。那這篇博文就是主要講的如何定義這個指定的函式,這個指定的函式是什麼?希望對學習Lua的朋友有幫助。

Lua是怎麼做的?

通常,Lua中的每個值都有一套預定義的操作集合,比如數字是可以相加的,字串是可以連線的,但是對於兩個table型別,則不能直接進行“+”操作。這需要我們進行一些操作。在Lua中有一個元表,也就是上面說的metatable,我們可以通過元表來修改一個值得行為,使其在面對一個非預定義的操作時執行一個指定的操作。比如,現在有兩個table型別的變數a和b,我們可以通過metatable定義如何計算表示式a+b,具體的在Lua中是按照以下步驟進行的:

先判斷a和b兩者之一是否有元表;
檢查該元表中是否有一個叫__add的欄位;
如果找到了該欄位,就呼叫該欄位對應的值,這個值對應的是一個metamethod;(Lua中方法是可以放在一個欄位中的,還記得???忘了點這裡
呼叫__add對應的metamethod計算a和b的值。
上述四個步驟就是計算table型別變數a+b的過程。在Lua中,每個值都有一個元表,table和userdata型別的每個變數都可以有各自獨立的元表,而其他型別的值則共享其型別所屬的單一元表。

告別metatable小白

現在就說說最基本的metatable內容。Lua在建立新的table時不會建立元表,比如以下程式碼就可以演示:

複製程式碼程式碼如下:
local t = {1, 2}
print(getmetatable(t))     -- nil

我們是使用getmetatable來獲取一個table或userdata型別變數的元表,當建立新的table變數時,使用getmetatable去獲得元表,將返回nil;同理,我們也可以使用setmetatable去設定一個table或userdata型別變數的元表,例如以下程式碼:
複製程式碼程式碼如下:
local t = {}
print(getmetatable(t))     -->nil
 
local t1 = {}
setmetatable(t, t1)
assert(getmetatable(t) == t1)

任何table都可以作為任何值得元表,而一組相關的table有可以共享一個通用的元表,此元表描述了它們共同的行為。一個table甚至可以作為它自己的元表,用於描述其特有的行為。總之,任何搭配形式都是合法的。

在Lua程式碼中,只能設定table的元表。若要設定其它型別的值得元表,則必須通過C程式碼來完成。還存在一個特例,對於字串,標準的字串程式庫為所有的字串都設定了一個元表,而其它型別在預設情況下都沒有元表。檢視兩句程式碼的列印值,就可以看出來:

複製程式碼程式碼如下:
print(getmetatable("Hello World"))
print(getmetatable(10))

在table中,我可以重新定義的元方法有以下幾個:

複製程式碼程式碼如下:
__add(a, b) --加法
__sub(a, b) --減法
__mul(a, b) --乘法
__div(a, b) --除法
__mod(a, b) --取模
__pow(a, b) --乘冪
__unm(a) --相反數
__concat(a, b) --連線
__len(a) --長度
__eq(a, b) --相等
__lt(a, b) --小於
__le(a, b) --小於等於
__index(a, b) --索引查詢
__newindex(a, b, c) --索引更新(PS:不懂的話,後面會有講)
__call(a, ...) --執行方法呼叫
__tostring(a) --字串輸出
__metatable --保護元表

接下來就介紹介紹如果去重新定義這些方法。

算術類的元方法

現在我使用完整的例項程式碼來詳細的說明算術類元方法的使用。我準備定義一些對集合的操作方法,所有的方法都放入Set這個table中,至於為什麼table中可以存放函式,可以參考《Lua中的函式》這篇文章。下面的程式碼是我模擬的一個集合的操作:

複製程式碼程式碼如下:
Set = {}
local mt = {} -- 集合的元表
 
-- 根據引數列表中的值建立一個新的集合
function Set.new(l)
    local set = {}
     setmetatable(set, mt)
    for _, v in pairs(l) do set[v] = true end
     return set
end
 
-- 並集操作
function Set.union(a, b)
    local retSet = Set.new{} -- 此處相當於Set.new({})
    for v in pairs(a) do retSet[v] = true end
    for v in pairs(b) do retSet[v] = true end
    return retSet
end
 
-- 交集操作
function Set.intersection(a, b)
    local retSet = Set.new{}
    for v in pairs(a) do retSet[v] = b[v] end
    return retSet
end
 
-- 列印集合的操作
function Set.toString(set)
     local tb = {}
     for e in pairs(set) do
          tb[#tb + 1] = e
     end
     return "{" .. table.concat(tb, ", ") .. "}"
end
 
function Set.print(s)
     print(Set.toString(s))
end

現在,我定義“+”來計算兩個集合的並集,那麼就需要讓所有用於表示集合的table共享一個元表,並且在該元表中定義如何執行一個加法操作。首先建立一個常規的table,準備用作集合的元表,然後修改Set.new函式,在每次建立集合的時候,都為新的集合設定一個元表。程式碼如下:

複製程式碼程式碼如下:
Set = {}
local mt = {} -- 集合的元表
 
-- 根據引數列表中的值建立一個新的集合
function Set.new(l)
    local set = {}
     setmetatable(set, mt)
    for _, v in pairs(l) do set[v] = true end
     return set
end

在此之後,所有由Set.new建立的集合都具有一個相同的元表,例如:

複製程式碼程式碼如下:
local set1 = Set.new({10, 20, 30})
local set2 = Set.new({1, 2})
print(getmetatable(set1))
print(getmetatable(set2))
assert(getmetatable(set1) == getmetatable(set2))

最後,我們需要把元方法加入元表中,程式碼如下:

複製程式碼程式碼如下:
mt.__add = Set.union

這以後,只要我們使用“+”符號求兩個集合的並集,它就會自動的呼叫Set.union函式,並將兩個運算元作為引數傳入。比如以下程式碼:
複製程式碼程式碼如下:
local set1 = Set.new({10, 20, 30})
local set2 = Set.new({1, 2})
local set3 = set1 + set2
Set.print(set3)

在上面列舉的那些可以重定義的元方法都可以使用上面的方法進行重定義。現在就出現了一個新的問題,set1和set2都有元表,那我們要用誰的元表啊?雖然我們這裡的示例程式碼使用的都是一個元表,但是實際coding中,會遇到我這裡說的問題,對於這種問題,Lua是按照以下步驟進行解決的:

1.對於二元操作符,如果第一個運算元有元表,並且元表中有所需要的欄位定義,比如我們這裡的__add元方法定義,那麼Lua就以這個欄位為元方法,而與第二個值無關;

2.對於二元操作符,如果第一個運算元有元表,但是元表中沒有所需要的欄位定義,比如我們這裡的__add元方法定義,那麼Lua就去查詢第二個運算元的元表;

3.如果兩個運算元都沒有元表,或者都沒有對應的元方法定義,Lua就引發一個錯誤。
以上就是Lua處理這個問題的規則,那麼我們在實際程式設計中該如何做呢?比如set3 = set1 + 8這樣的程式碼,就會打印出以下的錯誤提示:

複製程式碼程式碼如下:
lua: test.lua:16: bad argument #1 to 'pairs' (table expected, got number)

但是,我們在實際編碼中,可以按照以下方法,彈出我們定義的錯誤訊息,程式碼如下:複製程式碼程式碼如下:
function Set.union(a, b)
     if getmetatable(a) ~= mt or getmetatable(b) ~= mt then
          error("metatable error.")
     end
 
    local retSet = Set.new{} -- 此處相當於Set.new({})
    for v in pairs(a) do retSet[v] = true end
    for v in pairs(b) do retSet[v] = true end
    return retSet
end

當兩個運算元的元表不是同一個元表時,就表示二者進行並集操作時就會出現問題,那麼我們就可以打印出我們需要的錯誤訊息。

上面總結了算術類的元方法的定義,關係類的元方法和算術類的元方法的定義是類似的,這裡不做累述。

__tostring元方法

寫過Java或者C#的人都知道,Object類中都有一個tostring的方法,程式設計師可以重寫該方法,以實現自己的需求。在Lua中,也是這樣的,當我們直接print(a)(a是一個table)時,是不可以的。那怎麼辦,這個時候,我們就需要自己重新定義__tostring元方法,讓print可以格式化打印出table型別的資料。

函式print總是呼叫tostring來進行格式化輸出,當格式化任意值時,tostring會檢查該值是否有一個__tostring的元方法,如果有這個元方法,tostring就用該值作為引數來呼叫這個元方法,剩下實際的格式化操作就由__tostring元方法引用的函式去完成,該函式最終返回一個格式化完成的字串。例如以下程式碼:

複製程式碼程式碼如下:
mt.__tostring = Set.toString

如何保護我們的“乳酪”——元表

我們會發現,使用getmetatable就可以很輕易的得到元表,使用setmetatable就可以很容易的修改元表,那這樣做的風險是不是太大了,那麼如何保護我們的元表不被篡改呢?

在Lua中,函式setmetatable和getmetatable函式會用到元表中的一個欄位,用於保護元表,該欄位是__metatable。當我們想要保護集合的元表,是使用者既不能看也不能修改集合的元表,那麼就需要使用__metatable欄位了;當設定了該欄位時,getmetatable就會返回這個欄位的值,而setmetatable則會引發一個錯誤;如以下演示程式碼:

複製程式碼程式碼如下:
function Set.new(l)
    local set = {}
     setmetatable(set, mt)
    for _, v in pairs(l) do set[v] = true end
     mt.__metatable = "You cannot get the metatable" -- 設定完我的元表以後,不讓其他人再設定
     return set
end
 
local tb = Set.new({1, 2})
print(tb)
 
print(getmetatable(tb))
setmetatable(tb, {})

上述程式碼就會列印以下內容:

複製程式碼程式碼如下:
{1, 2}
You cannot get the metatable
lua: test.lua:56: cannot change a protected metatable

__index元方法

是否還記得當我們訪問一個table中不存在的欄位時,會返回什麼值?預設情況下,當我們訪問一個table中不存在的欄位時,得到的結果是nil。但是這種狀況很容易被改變;Lua是按照以下的步驟決定是返回nil還是其它值得:

1.當訪問一個table的欄位時,如果table有這個欄位,則直接返回對應的值;
2.當table沒有這個欄位,則會促使直譯器去查詢一個叫__index的元方法,接下來就就會呼叫對應的元方法,返回元方法返回的值;
3.如果沒有這個元方法,那麼就返回nil結果。

下面通過一個實際的例子來說明__index的使用。假設要建立一些描述視窗,每個table中都必須描述一些視窗引數,例如顏色,位置和大小等,這些引數都是有預設值得,因此,我們在建立視窗物件時可以指定那些不同於預設值得引數。

複製程式碼程式碼如下:
Windows = {} -- 建立一個名稱空間
 
-- 建立預設值表
Windows.default = {x = 0, y = 0, width = 100, height = 100, color = {r = 255, g = 255, b = 255}}
 
Windows.mt = {} -- 建立元表
 
-- 宣告建構函式
function Windows.new(o)
     setmetatable(o, Windows.mt)
     return o
end
 
-- 定義__index元方法
Windows.mt.__index = function (table, key)
     return Windows.default[key]
end
 
local win = Windows.new({x = 10, y = 10})
print(win.x)               -- >10 訪問自身已經擁有的值
print(win.width)          -- >100 訪問default表中的值
print(win.color.r)          -- >255 訪問default表中的值

根據上面程式碼的輸出,結合上面說的那三步,我們再來看看,print(win.x)時,由於win變數本身就擁有x欄位,所以就直接列印了其自身擁有的欄位的值;print(win.width),由於win變數本身沒有width欄位,那麼就去查詢是否擁有元表,元表中是否有__index對應的元方法,由於存在__index元方法,返回了default表中的width欄位的值,print(win.color.r)也是同樣的道理。

在實際程式設計中,__index元方法不必一定是一個函式,它還可以是一個table。當它是一個函式時,Lua以table和不存在key作為引數來呼叫該函式,這就和上面的程式碼一樣;當它是一個table時,Lua就以相同的方式來重新訪問這個table,所以上面的程式碼也可以是這樣的:

複製程式碼程式碼如下:
-- 定義__index元方法
Windows.mt.__index = Windows.default

__newindex元方法

__newindex元方法與__index類似,__newindex用於更新table中的資料,而__index用於查詢table中的資料。當對一個table中不存在的索引賦值時,在Lua中是按照以下步驟進行的:

1.Lua直譯器先判斷這個table是否有元表;
2.如果有了元表,就查詢元表中是否有__newindex元方法;如果沒有元表,就直接新增這個索引,然後對應的賦值;
3.如果有這個__newindex元方法,Lua直譯器就執行它,而不是執行賦值;
4.如果這個__newindex對應的不是一個函式,而是一個table時,Lua直譯器就在這個table中執行賦值,而不是對原來的table。

那麼這裡就出現了一個問題,看以下程式碼:

複製程式碼程式碼如下:
local tb1 = {}
local tb2 = {}
 
tb1.__newindex = tb2
tb2.__newindex = tb1
 
setmetatable(tb1, tb2)
setmetatable(tb2, tb1)
 
tb1.x = 10

發現什麼問題了麼?是不是迴圈了,在Lua直譯器中,對這個問題,就會彈出錯誤訊息,錯誤訊息如下:

複製程式碼程式碼如下:
loop in settable

丟掉那該死的元表

有的時候,我們就不想從__index對應的元方法中查詢值,我們也不想更新table時,也不想執行__newindex對應的方法,或者__newindex對應的table。那怎麼辦?在Lua中,當我們查詢table中的值,或者更新table中的值時,不想理那該死的元表,我們可以使用rawget函式,呼叫rawget(tb, i)就是對table tb進行了一次“原始的(raw)”訪問,也就是一次不考慮元表的簡單訪問;你可能會想,一次原始的訪問,沒有訪問__index對應的元方法,可能有效能的提升,其實一次原始訪問並不會加速程式碼執行的速度。對於__newindex元方法,可以呼叫rawset(t, k, v)函式,它可以不涉及任何元方法而直接設定table t中與key k相關聯的value v。

總結

這篇博文具體的總結了Lua中的元表和元方法,可以說Lua中的元表和元方法是很多內容的基礎,所以我在這裡總結的很詳細,並結合了很多程式碼。如果你有幸看到了這篇文章,希望你也花點時間認真的讀一讀,想要理解Lua,玩轉Lua,當然了,不能只是會一些語法,掌握元表和元方法是必不可少的。最後,也希望這篇文章對大家有用。下一篇博文,我會結合__index和__newindex說一些例項程式碼。