Lua Metatables and Metamethods

Metatables and Metamethods

Lua 中的 table 由於定義的行爲,我們可以對 key-value 對執行加操作,訪問 key 對應的 value,遍歷所有的 key-value。但是我們不可以對兩個 table 執行加操作,也不可以比較兩個表的大小。

Metatables 允許我們改變 table 的行爲,例如,使用 Metatables 我們可以定義 Lua 如何計算兩個 table 的相加操作 a+b。當 Lua 試圖對兩個表進行相加時,他會檢查兩個表是否有一個表有 Metatable,並且檢查 Metatable 是否有__add 域。如果找到則調用這個__add函數(所謂的 Metamethod)去計算結果。

Lua 中的每一個表都有其 Metatable。(後面我們將看到 userdata 也有 Metatable),Lua默認創建一個不帶 metatable 的新表

t = {} 
print(getmetatable(t)) --> nil 

可以使用 setmetatable 函數設置或者改變一個表的 metatable

t1 = {} 
setmetatable(t, t1) 
assert(getmetatable(t) == t1) 

任何一個表都可以是其他一個表的 metatable,一組相關的表可以共享一個 metatable(描述他們共同的行爲)。一個表也可以是自身的 metatable(描述其私有行爲)。

算術運算的 Metamethods

這一部分我們通過一個簡單的例子介紹如何使用 metamethods。假定我們使用 table來描述結合,使用函數來描述集合的並操作,交集操作,like 操作。我們在一個表內定義這些函數,然後使用構造函數創建一個集合:

Set = {} 
function Set.new (t) 
	local set = {} 
	for _, l in ipairs(t) do set[l] = true end
	return set 
end 
function Set.union (a,b) 
	local res = Set.new{} 
	for k in pairs(a) do res[k] = true end
	for k in pairs(b) do res[k] = true end
	return res 
end 
function Set.intersection (a,b) 
	local res = Set.new{} 
	for k in pairs(a) do
		res[k] = b[k] 
	end 
	return res 
end 

爲了幫助理解程序運行結果,我們也定義了打印函數輸出結果:

function Set.tostring (set) 
	local s = "{"
	local sep = ""
	for e in pairs(set) do
		s = s .. sep .. e 
		sep = ", "
	end 
	return s .. "}"
end 
function Set.print (s) 
	print(Set.tostring(s)) 
end 

現在我們想加號運算符(+)執行兩個集合的並操作,我們將所有集合共享一個metatable,並且爲這個 metatable 添加如何處理相加操作。

第一步,我們定義一個普通的表,用來作爲 metatable。爲避免污染命名空間,我們
將其放在 set 內部。

Set.mt = {} -- metatable for sets 

第二步,修改 set.new 函數,增加一行,創建表的時候同時指定對應的 metatable。

function Set.new (t) -- 2nd version 
	local set = {} 
		setmetatable(set, Set.mt) 
	for _, l in ipairs(t) do set[l] = true end
	return set 
end 

這樣一來,set.new 創建的所有的集合都有相同的 metatable 了:

s1 = Set.new{10, 20, 30, 50} 
s2 = Set.new{30, 1} 
print(getmetatable(s1)) --> table: 00672B60 
print(getmetatable(s2)) --> table: 00672B60 
第三步,給 metatable 增加__add 函數。
Set.mt.__add = Set.union 

當 Lua 試圖對兩個集合相加時,將調用這個函數,以兩個相加的表作爲參數。通過 metamethod,我們可以對兩個集合進行相加:

s3 = s1 + s2 
Set.print(s3) --> {1, 10, 20, 30, 50} 

同樣的我們可以使用相乘運算符來定義集合的交集操作

Set.mt.__mul = Set.intersection 
Set.print((s1 + s2)*s1) --> {10, 20, 30, 50} 

對於每一個算術運算符,metatable 都有對應的域名與其對應,除了__add,__mul 外,還有__sub(減),__div(除),__unm(負),__pow(冪),我們也可以定義__concat 定義連接行爲。

當我們對兩個表進行加沒有問題,但如果兩個操作數有不同的 metatable 例如:

s = Set.new{1,2,3} 
s = s + 8 

Lua 選擇 metamethod 的原則:如果第一個參數存在帶有__add 域的 metatable,Lua使用它作爲 metamethod,和第二個參數無關;
否則第二個參數存在帶有__add 域的 metatable,Lua 使用它作爲 metamethod 否則報錯。
Lua 不關心這種混合類型的,如果我們運行上面的 s=s+8 的例子在 Set.union 發生錯誤:

bad argument #1 to `pairs' (table expected, got number) 

如果我們想得到更加清楚地錯誤信息,我們需要自己顯式的檢查操作數的類型:

function Set.union (a,b) 
if getmetatable(a) ~= Set.mt or getmetatable(b) ~= Set.mt then
	error("attempt to `add' a set with a non-set value", 2) 
end 
 ... -- same as before

庫定義的 Metamethods

在一些庫中,在自己的 metatables 中定義自己的域是很普遍的情況。到目前爲止,我們看到的所有 metamethods 都是 Lua 核心部分的。有虛擬機負責處理運算符涉及到的metatables 和爲運算符定義操作的 metamethods。但是,metatable 是一個普通的表,任何人都可以使用。

tostring 是一個典型的例子。如前面我們所見,tostring 以簡單的格式表示出 table:print({}) --> table: 0x8062ac0 (注意:print 函數總是調用 tostring 來格式化它的輸出)。然而當格式化一個對象的時候,tostring 會首先檢查對象是否存在一個帶有__tostring 域的 metatable。如果存在則以對象作爲參數調用對應的函數來完成格式化,返回的結果即爲 tostring 的結果。

在我們集合的例子中我們已經定義了一個函數來將集合轉換成字符串打印出來。因此,我們只需要將集合的 metatable 的__tostring 域調用我們定義的打印函數:

Set.mt.__tostring = Set.tostring 
這樣,不管什麼時候我們調用 print 打印一個集合,print 都會自動調用 tostring,而tostring 則會調用 Set.tostring:
s1 = Set.new{10, 4, 5} 
print(s1) --> {4, 5, 10} 

setmetatable/getmetatable 函數也會使用 metafield,在這種情況下,可以保護metatables。
假定你想保護你的集合使其使用者既看不到也不能修改metatables。
如果你對 metatable 設置了__metatable 的值,getmetatable 將返回這個域的值,而調用 setmetatable 將會出錯:

Set.mt.__metatable = "not your business"
s1 = Set.new{} 
print(getmetatable(s1)) --> not your business 
setmetatable(s1, {}) 
stdin:1: cannot change protected metatable

表相關的 Metamethods

關於算術運算和關係元算的 metamethods 都定義了錯誤狀態的行爲,他們並不改變語言本身的行爲。針對在兩種正常狀態:表的不存在的域的查詢和修改,Lua 也提供了改變 tables 的行爲的方法。

The ____index Metamethod

前面說過,當我們訪問一個表的不存在的域,返回結果爲 nil,這是正確的,但並不一致正確。實際上,這種訪問觸發 lua 解釋器去查找__index metamethod:如果不存在,返回結果爲 nil;如果存在則由__index metamethod 返回結果。

這個例子的原型是一種繼承。假設我們想創建一些表來描述窗口。每一個表必須描述窗口的一些參數,比如:位置,大小,顏色風格等等。所有的這些參數都有默認的值,當我們想要創建窗口的時候只需要給出非默認值的參數即可創建我們需要的窗口。

第一種方法是,實現一個表的構造器,對這個表內的每一個缺少域都填上默認值。

第二種方法是,創建一個新的窗口去繼承一個原型窗口的缺少域。首先,我們實現一個原型和一個構造函數,他們共享一個 metatable:

-- create a namespace 
Window = {} 
-- create the prototype with default values 
Window.prototype = {x=0, y=0, width=100, height=100, } 
-- create a metatable 
Window.mt = {} 
-- declare the constructor function 
function Window.new (o) 
 setmetatable(o, Window.mt) 
return o 
end 
現在我們定義__index metamethod:
Window.mt.__index = function (table, key) 
return Window.prototype[key] 
end 
這樣一來,我們創建一個新的窗口,然後訪問他缺少的域結果如下:
w = Window.new{x=10, y=20} 
print(w.width) --> 100 

當 Lua 發現 w 不存在域 width 時,但是有一個 metatable 帶有__index 域,Lua 使用w(the table)和 width(缺少的值)來調用__index metamethod,metamethod 則通過訪問原型表(prototype)獲取缺少的域的結果。

__index metamethod 在繼承中的使用非常常見,所以 Lua 提供了一個更簡潔的使用方式。__index metamethod 不需要非是一個函數,他也可以是一個表。但它是一個函數的時候,Lua 將 table 和缺少的域作爲參數調用這個函數;當他是一個表的時候,Lua 將在這個表中看是否有缺少的域。所以,上面的那個例子可以使用第二種方式簡單的改寫爲:
Window.mt.__index = Window.prototype
現在,當 Lua 查找 metatable 的__index 域時,他發現 window.prototype 的值,它是一個表,所以 Lua 將訪問這個表來獲取缺少的值,也就是說它相當於執行:Window.prototype[“width”] 將一個表作爲__index metamethod 使用,提供了一種廉價而簡單的實現單繼承的方法。

一個函數的代價雖然稍微高點,但提供了更多的靈活性:我們可以實現多繼承,隱藏,和其他一些變異的機制。我們將在第 16 章詳細的討論繼承的方式。當我們想不通過調用__index metamethod 來訪問一個表,我們可以使用 rawget 函數。

Rawget(t,i)的調用以 raw access 方式訪問表。這種訪問方式不會使你的代碼變快(the overhead of a function call kills any gain you could have),但有些時候我們需要他,在後面我們將會看到。

The ____new index Metamethod

__newindex metamethod 用來對錶更新,__index 則用來對錶訪問。當你給表的一個缺少的域賦值,解釋器就會查找__newindex metamethod:如果存在則調用這個函數而不進行賦值操作。
像__index 一樣,如果 metamethod 是一個表,解釋器對指定的那個表,而不是原始的表進行賦值操作。
另外,有一個 raw 函數可以繞過 metamethod:調用rawset(t,k,v)不掉用任何 metamethod 對錶 t 的 k 域賦值爲 v。
__index 和__newindex metamethods 的混合使用提供了強大的結構:從只讀表到面向對象編程的帶有繼承默認值的表。
在這一張的剩餘部分我們看一些這些應用的例子,面向對象的編程在另外的章節介紹。

有默認值的表

在一個普通的表中任何域的默認值都是 nil。很容易通過 metatables 來改變默認值:

function setDefault (t, d) 
local mt = {__index = function () return d end} 
 setmetatable(t, mt) 
end 
tab = {x=10, y=20} 
print(tab.x, tab.z) --> 10 nil 
setDefault(tab, 0) 
print(tab.x, tab.z) --> 10 0 

現在,不管什麼時候我們訪問表的缺少的域,他的__index metamethod 被調用並返回 0。
setDefault 函數爲每一個需要默認值的表創建了一個新的 metatable。
在有很多的表需要默認值的情況下,這可能使得花費的代價變大。
然而 metatable 有一個默認值 d 和它本身關聯,所以函數不能爲所有表使用單一的一個 metatable。
爲了避免帶有不同默認值的所有的表使用單一的 metatable,我們將每個表的默認值,使用一個唯一的域存儲在表本身裏面。

如果我們不擔心命名的混亂,我可使用像"___"作爲我們的唯一的域:

local mt = {__index = function (t) return t.___ end} 
function setDefault (t, d) 
 t.___ = d 
 setmetatable(t, mt) 
end 

如果我們擔心命名混亂,也很容易保證這個特殊的鍵值唯一性。我們要做的只是創建一個新表用作鍵值:

local key = {} -- unique key 
local mt = {__index = function (t) return t[key] end} 
function setDefault (t, d) 
 t[key] = d 
 setmetatable(t, mt) 
end 

另外一種解決表和默認值關聯的方法是使用一個分開的表來處理,在這個特殊的表中索引是表,對應的值爲默認值。
然而這種方法的正確實現我們需要一種特殊的表:weak table,到目前爲止我們還沒有介紹這部分內容,將在第 17 章討論。

爲了帶有不同默認值的表可以重用相同的原表,還有一種解決方法是使用 memoize metatables,然而這種方法也需要 weak tables,所以我們再次不得不等到第 17 章。

監控表

__index 和__newindex 都是隻有當表中訪問的域不存在時候才起作用。捕獲對一個表的所有訪問情況的唯一方法就是保持表爲空。因此,如果我們想監控一個表的所有訪問情況,我們應該爲真實的表創建一個代理。這個代理是一個空表,並且帶有__index和__newindex metamethods,由這兩個方法負責跟蹤表的所有訪問情況並將其指向原始的表。假定,t 是我們想要跟蹤的原始表,我們可以:

t = {} -- original table (created somewhere) 
-- keep a private access to original table 
local _t = t 
-- create proxy 
t = {} 
-- create metatable 
local mt = { 
 __index = function (t,k) 
 print("*access to element " .. tostring(k)) 
return _t[k] -- access the original table 
end, 
 __newindex = function (t,k,v) 
 print("*update of element " .. tostring(k) .. 
 " to " .. tostring(v)) 
 _t[k] = v -- update original table 
end 
} 
setmetatable(t, mt) 
這段代碼將跟蹤所有對 t 的訪問情況:
> t[2] = 'hello' 
*update of element 2 to hello 
> print(t[2]) 
*access to element 2 
hello 

(注意:不幸的是,這個設計不允許我們遍歷表。Pairs 函數將對 proxy 進行操作,而不是原始的表。)如果我們想監控多張表,我們不需要爲每一張表都建立一個不同的metatable。我們只要將每一個 proxy 和他原始的表關聯,所有的 proxy 共享一個公用的metatable 即可。
將表和對應的 proxy 關聯的一個簡單的方法是將原始的表作爲 proxy 的域,只要我們保證這個域不用作其他用途。
一個簡單的保證它不被作他用的方法是創建一個私有的沒有他人可以訪問的 key。將上面的思想彙總,最終的結果如下:

-- create private index 
local index = {} 
-- create metatable 
local mt = { 
 __index = function (t,k) 
 print("*access to element " .. tostring(k)) 
 return t[index][k] -- access the original table 
end 
 __newindex = function (t,k,v) 
 print("*update of element " .. tostring(k) .. " to "
 .. tostring(v)) 
 t[index][k] = v -- update original table 
end 
} 
function track (t) 
local proxy = {} 
 proxy[index] = t 
 setmetatable(proxy, mt) 
return proxy 
end 

現在,不管什麼時候我們想監控表 t,我們要做得只是 t=track(t)。

只讀表

採用代理的思想很容易實現一個只讀表。我們需要做得只是當我們監控到企圖修改表時候拋出錯誤。通過__index metamethod,我們可以不使用函數而是用原始表本身來使用表,因爲我們不需要監控查尋。這是比較簡單並且高效的重定向所有查詢到原始表的方法。但是,這種用法要求每一個只讀代理有一個單獨的新的 metatable,使用__index指向原始表:

function readOnly (t) 
local proxy = {} 
local mt = { -- create metatable 
 __index = t, 
 __newindex = function (t,k,v) 
 error("attempt to update a read-only table", 2) 
 end 
 } 
 setmetatable(proxy, mt) 
return proxy 
end 
(記住:error 的第二個參數 2,將錯誤信息返回給企圖執行 update 的地方)作爲一個簡單的例子,我們對工作日建立一個只讀表:
days = readOnly{"Sunday", "Monday", "Tuesday", "Wednesday", 
 "Thursday", "Friday", "Saturday"} 
print(days[1]) --> Sunday 
days[2] = "Noday"
stdin:1: attempt to update a read-only table
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章