類別作為建構函式閉包

lua-users home
wiki

緒論

在十多年或更多的 Lua 程式設計中,我嘗試了許多物件導向 (OO) 的方法,包括「Lua 程式設計」、「Lua 程式設計寶石」和這個 wiki,以及我自己的程式,有些帶有廣泛的「C」支持函式庫。我最終標準化了本文中所概述的程式,它非常靈活且相對簡單,可以完全在 Lua 中實作,並具備一些我未在其他 Lua 實作中看過的理想 OO 特性。

理想的 OO 特性

大家普遍同意,OO 系統應該提供多個值(欄位)的封裝,以及對那些欄位進行操作的函式(方法)。大家相當普遍地同意,系統應該提供一個產生多個具有共享特性的物件(最常見的是方法實作)的簡單方式。類別或原型是實作這一點的兩種方式。

我也希望我的類別是 Lua 類型系統的無縫延伸。在純粹的 OO 語言中,類型和類別之間沒有區別,但 Lua 的一個好的折衷方案是類別是類型,但反之則不然。給定一個未知的 Lua 值,我們將需要一個函式,它可以確定它是否屬於指定的類別或類型。

另一個理想的特點是在 Lua API 中使用「C」建立的類別和物件與在純粹 Lua 中建立的類別和物件之間的交互作業。這允許「C」函式庫提供類別和在那些函式庫中定義的函式或方法,以建立和傳回物件。理想情況下也應該有可能讓「C」程式碼從 Lua 中定義的類別建立物件。

作為建構函式閉包實作的類別

我這篇論文中提議的方案的核心是類別是以 Lua 函式閉包實作的,它在執行時會傳回類別的新物件。建立物件所需的所有資源儲存在類別閉包的上值或傳遞給閉包函式作為參數。這表示類別是獨立於其儲存空間的 Lua 一等值。

一些特徵化的上值必須存在於所有類別中,以支援型別比對。這稱為 TID,用於類型 ID。也必須有可能從由類別產生的物件中衍生出 TID 值,以便一般謂詞函式可以將物件與其類別匹配。透過一個簡單的權宜之計,即將不是類別或物件的值的 TID 定義為 Lua 類型名稱,這個方案可以延伸,讓所有 Lua 值都有一個 TID,並可以使用相同的謂詞函式進行型別比對。

這個方案的一個良好結果是針對代表集合的物件的建構函式語法。例如假設定義了一個叫做 List 的類別,我們可以寫成

mylist = List{"ham", "eggs", "toast"}
        

這使用了標準 Lua 語法捷徑,使得可以省略函式調用大括弧。在 List 函式中,我們可以偵測到已傳入一個參數,一個沒有元表的表格(稱為「原始表格」),在這個情況下,將其原地轉換為一個物件,而不用複製項目。我想這看起來很像是 Lua 表格建立語法的很自然的擴充。也可以使用其他參數簽章提供其他建構語法,或者做為替代方案。

建立類別和物件

在定義類別實作時稍嫌刻板,在物件實作時,我們可以靈活得多,同時依然維持通用的類型比對。在這一節裡,我將展示說明在文檔中建議的所有主要物件樣式都能夠被這個方案所容納。

首先,我將展示說明一個會產生基於元表的物件的類別。此種樣式中,物件是一個 Lua 表格(或者,如果是在「C」中實作,可能是一個使用者資料)有一個與同一個類別的所有物件共用的元表。類別的方法和後設方法儲存在這個元表中,而欄位儲存在物件表格中。元表同時也是類別 TID。

do  -- Class 'MetaBaseClass'
  local _TID = class.newmeta()

  -- Class Closure:
  local _C = function(p1)
    local o
    if class.istype(p1, 'rawtable') then o = p1 else o = {} end
    o.value = o.value or 0
    setmetatable(o, _TID)
    return o
  end

  -- Methods:
  function _TID:mymethod()
    class.checkmethod(self, _C)
    print("Executing 'mymethod'")
  end

  -- Metamethods:
  function _TID.__add(p1, p2)
    class.checkmethod(p1, _C)
    class.checkmethod(p2, _C)
    local rv = _C()
    rv.value = p1.value + p2.value
    print("Executing add metamethod")
    return rv
  end
  
  -- Class Closure is a first-class value, for example, store it as a global:
  MetaBaseClass = _C
end -- Class 'MetaBaseClass'

函式 class.newmeta() 是一個簡單的協助函式,建立一個新的元表,並且將索引後設方法設為自我參考。class.istype() 是個多用途的類型測試謂詞,而 class.checkmethod() 封裝了這個測試,如果謂詞為 false 就引發錯誤(我將在本篇論文稍後的章節展示說明這些函式的實作方式)。請注意,常規使用 class.checkmethod 也表示所有類別方法都保留對類別本身的上值參考,便利建立同一個類別的傳回參數。

要建立物件,只要呼叫類別

obj1 = MetaBaseClass{value = 13}
obj2 = MetaBaseClass{value = 10}
obj1:mymethod()
obj3 = obj1 + obj2
print(obj3.value, 23)

要支援單一繼承,可以擴充 class.newmeta() 接收一個父類別。這會取得父類別的 TID(這也是它的元表),並且將它設為新的元表(後設元表?)的元表。父類別也會作為語法便利而未變地傳入

        
do  -- Class 'MetaChildClass' (Inherits MetaBaseClass)
  local _TID, _PC = class.newmeta(MetaBaseClass)

  -- Class Closure:
  local _C = function(p1)
    local o = _PC(p1)
    -- Extra initialisation for child class
    setmetatable(o, _TID)
    return o
  end

  -- Methods are inherited automatically, but may be overridden.

  -- To inherit metamethods add explicit delegations:
  _TID.__add = class.gettid(_PC).__add

  MetaChildClass = _C
end -- Class 'MetaChildClass'

obj4 = MetaChildClass{value = 2}
obj4:mymethod()
print((obj4 + obj1).value, 15)

我將探討的第二種樣式是基於原型的。傳統上,原型方法根本不使用「類別」的概念;相反地,任何物件都可以作為建立其他物件的原型。但,我會在原型物件周圍使用一個標準的類別「封裝器」,而這將實作表格複製碼。產生的物件是 Lua 表格,包含方法和欄位。如果需要後設方法,也需要一個元表。要支援類型比對,元表會在必要時被指派,即使它為空白。

do  -- Class 'ProtoBaseClass'
  local _C, _PRT, _MTB = class.newproto()

  _PRT.field = "Hello from ProtoBaseClass"

  function _PRT:method()
    class.checkmethod(self, _C)
    print(self.field)
  end

  ProtoBaseClass = _C
end -- Class 'ProtoBaseClass'        
        

支援函式 class.newproto() 在此情況下執行大部分的工作。它產生類別(函式封閉),同時也傳回原型表格和準備用來填入資料的元表的參考。類別函式已標準化,提供複製原型表格的功能。

obj5 = ProtoBaseClass()
obj5:method() -- Prints 'Hello from ProtoBaseClass'
obj6 = ProtoBaseClass{field="Hello from obj6"}
obj6:method() -- Prints 'Hello from obj6'
        

標準類別函式接收一個選擇性的表格參數,它可以在複製自原型後覆寫(初始化)既有欄位。

此模式可以適應多重繼承 (聚合)。函數 class.newproto() 可以接受任何數目的物件 (表格) 參數。這些表格及其元資料表合併成原型表格和元資料表。合併順序依參數順序,所以之後的參數中的欄位會覆寫先前參數中名稱相同的欄位。

do  -- Class 'ProtoAggregateClass'
  local _C, _PRT = class.newproto(ProtoBaseClass())
  _PRT.field = "Hello from ProtoAggregateClass"
  ProtoAggregateClass = _C
end -- Class 'ProtoAggregateClass'        
        
obj7 = ProtoAggregateClass()
obj7:method() -- Prints 'Hello from ProtoAggregateClass'

請注意,在此模式中,繼承是來自父類別的物件,而不是類別本身。

最後要考慮的模式是單一函數模式。這有一個類似類別實作的物件實作 (類別中也是這種類型的物件)。物件只有一個方法形成封閉,欄位儲存在 upvalue 中。此模式不支援繼承或元方法。

do -- Class 'FunctionClass'
  local _TID = "FunctionClass"
  local _TID_O = _TID
  local _C = function()
    local tid = _TID
    local val = 0
    return function()
      local tid = _TID_O
      val = val + 1
      return val
    end
  end
  FunctionClass = _C
end -- Class 'FunctionClass'

obj8 = FunctionClass()
obj9 = FunctionClass()
print(obj8(), 1)
print(obj8(), 2)
print(obj9(), 1)

在此模式中,TID 僅用於類型測試,並設為任意但不唯一的字串。或者,可以使用空表格。由於 Lua 類型無法區分類別和物件的實作,因此使用擴充標籤慣例。類別的標籤正好是「_TID」,但在物件中使用時會有字尾。按照此慣例,類別是物件,但物件不是類別。

支援函數

可以使用以下規則按順序套用至決定 TID 值為止,來從任何 Lua 值衍生 TID。

1. TID 是具有金鑰 '__tid' 的任何元方法的非 nil 值。

2. 如果值是具有元資料表的表格或使用者資料,那麼 TID 就是元資料表。

3. 如果值是具有名稱以「_TID」開頭 (標籤) 的 upvalue 的函數,那麼該 upvalue 就是 TID。這適用於在 Lua 中建立的函數封閉。

4. 如果值是具有至少兩個未命名 upvalue 的函數,其中第一個具有以「_TID」開頭 (標籤) 的字串值,那麼第二個 upvalue 就是 TID。這適用於在 'C' 中建立的函數封閉。

5. 否則,值的 Lua 類型就是 TID (會是字串)。

類別是一個具有標籤正好為「_TID」且使用第 3 或第 4 條規則決定的 TID 的值。如果標籤在「_TID」後方有其他字元,則值是物件而非類別。

以下是在純 Lua 中實作的 gettid

do
  local getupvalue = require('debug').getupvalue
  class = class or {}
  class.gettid = function(v)
    -- Rule 1: metafield
    local mt = getmetatable(v)
    if mt and mt.__tid then return mt.__tid, "object_mf" end
    -- Rule 2: metatable
    if mt and (type(v) == 'userdata' or type(v) == 'table') then 
      return mt, "object_mt"
    elseif type(v) == 'function' then
      local un, uv = getupvalue(v, 1)
      local r = "class"
      if un and un ~= "" then
    -- Rule 3: named upvalue
        local i = 2
        while un and un:sub(1,4) ~= "_TID" do
          un, uv = getupvalue(v, i)
          i = i + 1
        end
        if un and #un ~= 4 then r = "object_fn" end
      elseif un and type(uv) == 'string' and uv:sub(1,4) == "_TID" then
    -- Rule 4: upvalue pair with tag
        if #uv ~= 4 then r = "object_fn" end
        un, uv = getupvalue(v, 2)
      end
      if un then return uv, r end
    end
    -- Rule 5: Lua type name
    return type(v), "type"
  end
end

取得類型 (或類別) 的 TID 和值 (或物件) 的 TID 後,身分由以下規則決定

1. 如果 TID 值是 Lua 相等函數,則呼叫該函數並傳遞值和類型。測試結果是第一個回傳值布林值。

2. 如果值的 TID 是具有金鑰編號 1 的條目的表格,則如果類型的 TID 與以 1 開始且逐一增加的整數鍵結的任何條目相符,則測試為真。

3. 如果值的 TID 是元資料表,類型 TID 是表格,則如果類型 TID 與元資料表、元資料表的元資料表等相符,且直到沒有元資料表的元資料表為止,則測試為真。

4. 如果上述均不適用,則測試為兩個 TID 值之間的簡單 Lua 等式測試。

下列 Lua 函數實作這些比對規則,也結合了標準 Lua 類型偵測,其中字串用於取代類型參數,以及一些其他有用的類型謂詞,並具備判定兩個任意 Lua 值(字串除外)是否為同類型的能力

class.istype = function(vl, ty)
  local tvl = type(vl)
  local tid1, isc = class.gettid(vl)
  if type(ty) == 'string' and ty ~= "" then
    if ty == "class" then
      return isc == 'class'
    elseif ty == "object" then
      return isc ~= 'type'
    elseif ty == "rawtable" then
      if tvl ~= "table" then return false end
      return getmetatable(vl) == nil
    elseif ty == "callable" then
      if tvl == "function" then return true end
      local m = getmetatable(vl)
      if not m then return false end
      return type(m.__call) == "function"
    else
      return tvl == ty
    end
  end
  local tid2 = class.gettid(ty)
  if tid2 == tvl then return true end
  if type(tid1) == 'function' and tid1 == tid2 then return not not (tid1(vl, ty)) end
  if type(tid1) == 'table' and tid1[1] then
    for i=1, #tid1 do
      if tid2 == tid1[i] then return true end
    end
  end
  if isc == 'object_mt' and type(tid2) == 'table' then
    repeat
      if tid2 == tid1 then return true end
      tid1 = getmetatable(tid1)
    until not tid1
  end
  return tid1 == tid2
end

class.checkmethod = function(vl, ty)
  if not class.istype(vl, ty) then error("Bad method call") end
end

-- Export "istype" as a global since it will be widely used:
istype = class.istype

用於元表的支援函數現在很單純了

class.newmeta = function(pcl)
  local mt = {}
  if pcl then
    if not class.istype(pcl,'class') then error("Bad Parent Class") end
    local pmt = class.gettid(pcl)
    if not class.istype(pmt, 'table') then error("Bad Parent Class") end
    setmetatable(mt, pmt)
  else
    setmetatable(mt, nil)
  end
  rawset(mt, "__index", mt)
  return mt, pcl
end

用於原型模式的支援函數則較微妙。外部函數會將父物件合併到新的類別原型表格和元表中。這個元表也是類別的 TID。父類別的元表也參照新的元表中數值鍵下的內容,這使用戶得以前述類別的物件,比對任何父類別或包含這些物件的類別的物件,或是封裝這些類別的函數。類別封包中傳回的內部函數會將原型 upvalue 複製到新的物件表格,使用任何初始化表格執行最終合併,並將 TID upvalue 設定為新物件的元表。

class.newproto = function(...)
  local _TID, _PRT, pmt = {}, {}, nil
  for i, t in ipairs{...} do
    if type(t) ~= 'table' then error("prototype must be table") end
    pmt = getmetatable(t)
    for k, v in pairs(t) do _PRT[k] = v end
    if pmt then 
      for k, v in pairs(pmt) do _TID[k] = v end
      _TID[#_TID + 1] = pmt
    end
  end
  local _C = function(init)
    local tid, prt, ob = _TID, _PRT, {}
    for k, v in pairs(prt) do ob[k] = v end
    if init then for k, v in pairs(init) do
      if ob[k] == nil then
        error("attempt to initialise non-existant field: " .. k)
      end
      if type(k) ~= 'string' or k:sub(1,1) == '_' then
        error("attempt to initialise private field: " .. k)
      end
      ob[k] = v
    end end
    setmetatable(ob, tid)
    return ob
  end
  return _C, _PRT, _TID
end
                

類型測試範例

使用稍早開發的範例類別,以下類型測試皆印出「true」

print( istype(MetaBaseClass(), MetaBaseClass) )
print( istype(MetaChildClass(), MetaBaseClass) )
print( istype(MetaChildClass(), MetaChildClass) )
print( not istype(MetaBaseClass(), MetaChildClass) )
print( istype(MetaChildClass(), MetaBaseClass() ) )
print( not istype(MetaBaseClass(), MetaChildClass() ) )
print( istype(MetaBaseClass(), 'table') )
print( not istype(MetaBaseClass(), 'rawtable') )
print( istype({}, 'rawtable') )
print( istype(ProtoBaseClass(), ProtoBaseClass) )
print( not istype(ProtoAggregateClass(), MetaBaseClass) )
print( istype(ProtoAggregateClass(), ProtoAggregateClass) )
print( istype(ProtoAggregateClass(), ProtoBaseClass) )
print( istype(FunctionClass(), FunctionClass) )
print( istype(FunctionClass(), 'function') )
print( not istype(FunctionClass(), 'table') )
print( istype(FunctionClass(), 'callable') )
print( istype(FunctionClass(), 'object') )
print( not istype(FunctionClass(), 'class') )
print( istype(FunctionClass, 'class') )
print( istype(FunctionClass, 'object') )
print( istype(12, 'number') )

與「C」的共用

有關這些想法的混合作業實作範例,其中一部份用於「C」,一部份用於 Lua,請在此查看檔案 LibClass.hLibClass.cppLibClass.lua

https://github.com/JohnHind/Winsh.lua/tree/master/Winsh/Libraries

特別要指出,這些檔案中清單類別的實作一開始使用「C」,並在後續完成於 Lua 中!

結論

由於對「類別」概念的實作具有預設規範,並實作高度彈性的類型比對架構,因此我們較能放寬對物件實作的規定。同一個 Lua 狀態中的不同類別可以使用許多不同的方式實作它們的物件,同時仍能維護同質的類型系統,其中任何值的類型都可以透過通用謂詞函數來比對和描述。

將類別實作為函數封包,使得封裝的函數成為那個類別物件的工廠函數或建構函數,這會帶來很大的好處。特別是,使用這種方式定義的類別可以是封裝式的以及第一類的 Lua 值,而不會仰賴登錄檔或全域資源且獨立於儲存。這種方式也利用已提供給已命名函數參數的語法捷徑,提供一個集合類別建構函數的語法,這似乎是 Lua 表格建構函數的自然延伸。

JohnHind (2014 年 2 月 13 日)


RecentChanges · 偏好設定
編輯 · 歷程
上一次編輯於 2014 年 2 月 24 日 下午 12:03 GMT (diff)