美思 [Lua] 程式設計教學:撰寫基於物件的 (object-based) 程式

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

Lua 的物件系統 (object system) 是以原型 (prototype) 為基礎,和一般 Java 或 Python 等以類別 (class) 為基礎的物件系統略為不同。

因 Lua 的物件系統較為原始 (primitive),程式設計者需要自己手動加入一些慣有的樣板程式碼 (boilerplate code)。雖然 Lua 官方教材對此現象有做一些學理上的說明,筆者認為,不如就把這些程式碼當成制式的流程,實際寫過幾個物件就會熟悉如何使用 Lua 建立物件。

[Update on 2018/05/20] 由於 Lua 缺乏完整的物件導向特性,Lua 僅能用來撰寫基於物件的程式 (object-based programming),無法撰寫真正的物件導向程式 (object-oriented programming)。

雖然我們可以用一些手法模擬物件導向特性,但撰寫大量樣板程式碼來模擬這些特性,反而失去 Lua 做為輕量級語言的原意。對於這些模擬的手法,筆者建議謹慎使用。

Metatables

Metatables 是用來修改表 (tables) 的行為,metatables 本身也是表;Lua 的物件即是建立在 metatables 之上。有些 Lua 教材會將 metatables 分開介紹,但筆者建議在學習撰寫物件時一併學習 metatables。

典型的類別建立方式

在這裡,我們直接以實例來展示如何以 Lua 撰寫物件。以下程式建立一個坐標點物件:

-- Class itself is a table.
local Point = {}

-- _index is a metamethod which is called when there is no pre-defined function.
Point.__index = Point

-- The constructor.
function Point.new(x, y)
    obj = {}
    obj._x = x
    obj._y = y

    -- Bind the metatable of obj to Point.
    setmetatable(obj, Point)

    return obj
end

function Point:x()
    return self._x
end

function Point:y()
    return self._y
end

-- The main program.
do
    local p = Point.new(3, 4)

    assert(p:x() == 3)
    assert(p:y() == 4)
end

典型的 Lua 類別使用 table,並且會將 __index 這個特有的方法指向自己。__index 的用意在於當沒有預先宣告的函式時,就會呼叫 __index 函式。在這裡 Lua 使用了一些黑魔法,我們只要指定一個 table,Lua 就會自動呼叫該 table 內所有的函式。

Lua 沒有制式的建構子 (constructor),程式設計者可任恴自訂某個函式為建構子,此處使用 new 僅是便於記憶。在 Lua 中,使用冒號 : 的函式是物件導向的語法,在該函式內會隱性地 (implicitly) 使用 self 這個關鍵字代表物件本身。以本例中來說,我們在 new 函式中將 self 初始化後回傳,日後就可以在其他方法 (method) 中呼叫。

在建構子中另一個關鍵在於 setmetatable 方法,這個方法會指定物件的 metatable,之後就可以呼叫此物件的其他函式。在本例中,我們將 self 的 metatable 指向 Point 類別,之後就可以呼叫 Point 宣告的其他函式。

: 是 Lua 物件的語法糖,使用該運算子時,Lua 會隱性地呼叫 self

替代的類別寫法

我們可以把 self 視為方法中隱藏的第一個參數,如果我們不使用 self 可用 C 語言風格的物件語法來改寫上例:

local Point = {}

Point.new = function (x, y)
    obj = {}

    obj._x = x
    obj._y = y

    return obj
end

Point.x = function (obj)
    return obj._x
end

Point.y = function (obj)
    return obj._y
end

-- The main program.
do
    local p = Point.new(3, 4)

    assert(Point.x(p) == 3)
    assert(Point.y(p) == 4)
end

由於這種寫法沒有充份利用到 Lua 的物件語法,我們不鼓勵這種寫法,本例僅做參考。

模擬繼承的方式

由於 Lua 缺乏內建的繼承機制,僅能自行用一些樣板語法來模擬繼承。在官方教材和一些網路上的教學文件會使用一些函式來模擬繼承的動作,但筆者認為那些方法過於複雜和迂迴,而且官方函式庫中並未使用這些方法,代表其實沒有官方認可的最佳解。比較簡單易懂的方式,是直接拷貝父類別的函式,

如以下範例:

local Point = {}

Point.__index = Point

function Point.new(x, y)
    obj = {}
    obj._x = x
    obj._y = y

    setmetatable(obj, Point)

    return obj
end

function Point:x()
    return self._x
end

function Point:y()
    return self._y
end

local Point3D = {}

-- "Inherit", i.e. copy, all methods from Point.
for k, v in pairs(Point) do
    Point3D[k] = v
end

-- Overwrite the __index table of Point3D.
Point3D.__index = Point3D

function Point3D.new(x, y, z)
    obj = {}
    obj._x = x
    obj._y = y
    obj._z = z

    setmetatable(obj, Point3D)

    return obj
end

function Point3D:z()
    return self._z
end

-- The main program.
do
    local p = Point.new(1, 2)

    assert(p:x() == 1)
    assert(p:y() == 2)

    -- Point class, which is the parent class, does not own z method.
    local state, error = pcall(function () p:z() end)
    assert(not state)

    local p3 = Point3D.new(3, 4, 5)

    -- Point3D class inherits x and y methods from Point.
    assert(p3:x() == 3)
    assert(p3:y() == 4)

    -- Point3D class, which is the child class, owns z method.
    assert(p3:z() == 5)
end

在本例中,我們先將 Point 類別所有的方法拷貝到 Point3D 類別中,之後關鍵的步驟在於將 __index 方法覆寫掉,之後在建立 Point3D 物件時,就不會回頭查詢 Point 類別的方法。接著,我們建立 Point3D 類別新的 z 方法,這時候,Point 類別沒有 z 方法而 Point3D 有。透過此種方式,就可繼承父類別的方法,同時又擁有子類別獨有的方法。

理論上,我們也可以用一些手法模擬多重繼承 (multiple inheritance),但這種手法不易維護,且缺乏一定的標準,故我們不採用這項特性。

由於 Lua 是動態型別語言,我們不需要透過建立子型別 (subtyping) 的方式來達成多型 (polymorphism),只要能夠重覆使用程式碼即可達成繼承的目的。因 Lua 缺乏夠好的繼承機制,筆者甚少在撰寫 Lua 程式碼時使用繼承,通常會使用組合來代替繼承。

帶有私有屬性的物件

像是 Java 或 C# 等語言,相當注重物件的封裝,通常屬性是私有的 (private),再用公開的 (public) 方法存取該屬性。Lua 在設計上不注重封裝,在我們先前的範例中,其實都可以直接存取內部屬性;不過,可以存取不代表應該存取,實務上,還是使用方法呼叫取代屬性存取較佳。如果仍然想達到屬性私有化的效果,可以用閉包 (closure) 來模擬私有屬性,如下例:

local function Point(x, y)
    -- Private fields.
    local _x = x
    local _y = y

    local x = function ()
        return _x
    end

    local y = function()
        return _y
    end

    -- Public interface
    return {
        x = x,
        y = y
    }
end

do
    local p = Point(1, 2)

    assert(p.x() == 1)
    assert(p.y() == 2)

    -- We cannot access the private fields _x and _y.
    assert(p._x == nil)
    assert(p._y == nil)
end

有些讀者會發現這種方法和 JavaScript 建立物件的方式有些類似,因這兩種語言皆使用以 prototype 為主體的物件系統。

由於這種方法並不是主流,本範例僅供參考,我們之後的物件不會採用這種方式來建立。

關於作者

身為資訊領域碩士,美思認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

美思喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,美思將所學寫成文章,放在這個網站上和大家分享。