- PVSM.RU - https://www.pvsm.ru -

Возможности метатаблиц в Lua на примере реализации классов

В Lua ООП нет. И оно, в общем-то и не нужно: удобной модульности и функций первого класса достаточно для реализации многих вещей. На этом можно было бы и закончить, но пост не про это. В данном случае я распишу работу с метатаблицами, где в качестве примера шаг за шагом будет реализовываться системка по работе с классами в несколько таком python-стиле. Для понимания нужен хотя бы основной базис языка: таблицы, upvalues.

Вариант влоб

Начать можно с самого простого примера:

local Obj = {}
function Obj.spam()
    print 'Hello world'
end

--[[ Аналогично можно написать и так:
local Obj = {
    spam = function()
        print 'Hello world'
    end,
}
]]

Obj.spam()

-- Hello world

Мы получили таблицу с одним ключом, значением которого является функция. Однако внутри самой Obj.spam нельзя получить ссылку на сам Obj (кроме как по имени за счет upvalue), потому что пока нет никаких this/self и т.п. внутри функции.

Мы можем «реализовать» это сами:

local Obj = {}
function Obj.spam(self)
    print(self)
end

Obj.spam(Obj)

или предоставить это lua:

local Obj = {}
function Obj.spam(self)
    print(self)
end

function Obj:spam2()
    print(self)
end

Obj:spam()  -- эквивалентно Obj['spam'](Obj), т.е. не просто вызов метода, а сначала получение поля по имени, а затем его вызов, как функции.
Obj:spam2()
Obj.spam(Obj)

-- table: 0x417c7d58
-- table: 0x417c7d58
-- table: 0x417c7d58

Результатом работы будет одна и та же ссылка, т.к. все три self одинаковы.

Явное использование a:b вместо a.b(a) можно использовать, при желании, для визуального разграничения методов класса Obj.foo(cls) и методов инстанции a.foo(self).

Наивный вариант конструктора мог бы выглядеть так:

local Obj = {
    var = 0,
}

function Obj:new(val)
    self:set(val or 0)
    return self
end

function Obj:set(val)
    self.var = val
end

function Obj:print()
    print(self.var)
end

local a = Obj:new(42)
a:print()
local b = Obj:new(100500)
b:print()
a:print()

-- 42
-- 100500
-- 100500

Происходит переиспользование одной и той таблицы, что приводит к замене a.var внутри b.set. Для разделения нужно выдавать в new новую таблицу:

local Obj = {
    var = 0,
}

function Obj:set(val)
    self.var = val
end

function Obj:print()
    print(self.var)
end

function Obj:new(val)
    -- каждый раз создаем новую таблицу
    local inst = {}
    -- добавляем в эту таблицу все, что есть в Obj
    for k, v in pairs(self) do
        inst[k] = v
    end

    inst.new = nil -- для запрета создания инстанций из инстанций. можно и оставить :)

    inst:set(val or 0)
    return inst
end

local a = Obj:new(42)
a:print()
local b = Obj:new(100500)
b:print()
a:print()

-- 42
-- 100500
-- 42

Это работает, но уж больно криво, да и нужно повторять каждый раз.

Метатаблицы

В Lua для каждой таблицы (и userdata, но сейчас не про них речь) можно задавать метатаблицу, описывающую поведение данной таблицы в особенных случаях [1]. Такими случаями могут быть использование в арифметике (перегрузка операторов), конкатенация как строк и т.д. В качестве небольшого примера перегрузки операторов и приведения к строке:

local mt = {
    __add = function(op1, op2)
        local op1 = type(op1) == 'table' and op1.val or op1
        local op2 = type(op2) == 'table' and op2.val or op2

        return op1 + op2
    end,

    __tostring = function(self)
        return tostring(self.val)
    end,
}

local T = {
    val = 0,

    new = function(self)
        local inst = {}
        for k, v in pairs(self) do
            inst[k] = v
        end

        -- метатаблица не является явным полем таблицы, ее нужно назначать явно
        setmetatable(inst, getmetatable(self))

        return inst
    end,
}

setmetatable(T, mt)

local a = T:new()
a.val = 2
local b = T:new()
b.val = 3

print(a)
print(b)

print(a + b)
print(a + 10)
print(100 + b)

-- 2
-- 3
-- 5
-- 12
-- 103

В данном случае нас интересует ключ __index, используемый при обращении к не существующему ключу таблицы, который внутри lua используется следующим образом:

  • Если значением ключа является функция, то та вызывается с передачей ей таблицы и искомого ключа. Результат работы функции используется как значение ключа. Повторное обращение по этому же ключу вновь вызывает функцию (никакого «кеширования»);
  • Если значением ключа является другая таблица, то ключ ищется в ней. Если ключ не находится, то у данной таблицы рекурсивно проверяется ее метатаблица, и так далее. Самый настоящий We need to go deeper.

Такой подход позволяет отделить описание класса от создания его экземпляра:

local T = {}

local T_mt = {
    __index = T, -- если у таблицы нет ключа, то следует посмотреть в другой таблице
}

function T.create()
    -- setmetatable возвращает свой первый параметр в качестве результата
    return setmetatable({}, T_mt)
end

function T:set(val)
    self.val = val or 0
end

function T:print()
    print(self.val)
end

local a = T.create()
a:set(42)
local b = T.create()
b:set(100500)

a:print()
b:print()
a:print()

-- поле инстанции
a.foo = 7
print(a.foo)
print(b.foo)

-- поле класса
T.bar = 7
print(a.bar)
print(b.bar)

Получаемые a и b являются пустыми таблицами, не имеющими ключей new, set и print. Данные методы хранятся в общей таблице T. При таком подходе вызов a:print() на самом деле разворачивается в (только итоговая ветвь исполения):

getmetatable(a).__index.print(a)

Внутри lua это выполняется очень быстро.

При необходимости получить значение только из таблицы, не задействуя магию метатаблиц, можно заменить a.bar на rawget(a, 'bar') / rawset(a, 'bar', value).

В качестве дополнительной приятной мелочи можно реализовать более привычный синтаксис конструкторов:

local T = {}

setmetatable(T, {
    __call = function(cls)
        return cls.create()
    end,
})

-- Все! Теперь вместо T.create() можно писать просто T():
local a = T()
local b = T()

Развитие идеи

Теперь можно попробовать собрать все это воедино в общий генератор классов, который будет выглядеть так:

local OOP = {}

function OOP.class(struct)
    -- магия
    return cls -- возвращаем класс, не инстанцию
end

-- создаем класс из описания публичных полей и методов инстанции
local A = OOP.class {
    val = 0,

    set = function(self, val)
        self.val = val or 0
    end,

    print = function(self)
        print(self.val)
    end,
}

-- создаем и используем
local a = A:create()
a:print()
a:set(42)
a:print()

Реализация в данном объеме весьма простая:

function OOP.class(struct)
    local struct = struct or {}

    local cls = {}

    local function _create_instance()
        local inst = {}

        for k, v in pairs(struct) do
            inst[k] = v
        end

        inst.__class = cls

        return inst
    end

    setmetatable(cls, {
        __index = {
            create = _create_instance, -- метод класса, не инстанции
        },
        __call = function(cls)
            return cls:create() -- сахар синтаксиса конструктора
        end,
    })

    return cls
end

Всего и делов-то.

Для методов класса можно сохранить ссылку на класс внутри таблицы инстанции и вопользоватся ей в последствии:

-- ...
    local function _create_instance()
        local inst = {}
        -- ...
        inst.__class = cls
        -- ...
    end
-- ...

A.clsMeth = function(cls)
    print('Hello')
end
-- ...

a.__class:clsMeth()
-- a.clsMeth() не доступно

Гораздо интереснее ситуация с наследованием. Пока разберем единичное:

-- метод исключительно ради красивого синтаксиса. необходимости в нем нет
function OOP.subclass(parent)
    return function(struct)
        return OOP.class(struct, parent)
    end
end

local A = OOP.class {
    -- ...
}

local B = OOP.subclass(A) { -- B является потомком A
    welcome = function(self)
        print('Welcome!')
        self:print() -- вызов метода потомка как своего
    end,
}

local b = B()
b:print()
b:set(100500)
b:welcome()

Для реализации нужно внести не так уж и много правок:

function OOP.class(struct, parent) -- 1. передаем данные по родителю
    local struct = struct or {}

    local cls = {}

    local function _create_instance()
        local base = parent and parent:create() or nil -- 2. при создании инстанции создаем ее предка

        local inst = {}

        -- 3. берем из родителя все его публичные поля
        if base then
            for k, v in pairs(base) do
                inst[k] = v
            end
        end

        for k, v in pairs(struct) do
            inst[k] = v
        end

        inst.__class = cls

        return inst
    end

    setmetatable(cls, {
        __index = setmetatable( -- 4. метатаблица получает собственную метатабалицу
            {
                create = _create_instance,
            }, {
                -- если чего нет у текущего класса, то ищем у предка
                __index = function(_, key)
                    if parent then
                        return parent[key]
                    end
                end,
            }
        ),
        __call = function(cls)
            return cls:create()
        end,
    })

    return cls
end

Для создания собственных явных конструкторов опишем метод new и будем его вызывать при создании инстанции:

-- ...
    setmetatable(cls, {
        -- ...
        __call = function(cls, ...)
            local inst = cls:create()

            -- если есть конструктор - вызываем его
            local new = inst.new
            if new then
                new(inst, ...)
            end

            return inst
        end,
    })
-- ...

local A = OOP.class {
    new = function(self, text)
        text = text or ''
        print('Hello ' .. text)
    end,
}

local B = OOP.subclass(A) {
}

A('world')
B('traveler')

-- Hello world
-- Hello traveler

Автоматического вызова конструктора (да и вообще любого другого метода) предка мы не реализовывали, соотвественно

local B = OOP.subclass(A) {
    new = function(self, text)
        print('B says ' .. tostring(text))
    end,
}

B('spam')

не приведет к вызову A.new. Для этого опять нужно лишь внести небольшое дополнение в логику работы, реализовыв метод инстанции super :)

local B = OOP.subclass(A) {
    new = function(self, text)
        print('B says ' .. tostring(text))
        self:super('from B')
    end,
}
-- ...

local function super_func(self, ...)
    local frame = debug.getinfo(2)
    local mt = getmetatable(self)
    assert(mt and mt.__base, 'There are no super method')

    local func = mt.__base[frame.name]
    return func and func(self, ...) or nil
end

-- ...
    local function _create_instance()
        -- ...

        -- вместо явного объявления inst.super выносим метод в метатаблицу, чтобы он не выглядел как частью структуры.
        -- но это позволяет объявить одноименный метод/поле.
        -- можно добавить проверку имени при обходе pairs(struct), если необходимо. но от a.super = x это не спасет.
        local inst = setmetatable({}, {
            __base = base,
            __index = {
                super = super_func,
            },
        })
-- ...

super вызывается без указания имени вызываемого метода. Для его получения используется модуль debug.
Если не хочется его использовать (или lua запущена без него), то можно явно передавать имя метода.
debug.getinfo() [2] используется для получения краткой информации о запрошенном уровне стека: 0 — текущий (super_func), 1 — уровень, где вызвали super_func,… Нам нужно имя функции, из которой была вызвана super, т.е. поле name второго уровня стека.
Теперь можно вызывать любые родительские методы, не только конструктор :)

Для реализации private полей и методов можно использовать подход на основе соглашения об именовании как в python, или воспользоваться истинным сокрытием через область видимости модуля, или вообще через upvalues:

local A = OOP.class((function()

    -- нет прямого доступа из потомка
    local function private(self, txt)
        print('Hello from ' .. txt)
    end

    return {
        val = 0,

        public = function(self)
            private(self, 'public')
        end,
    }
end)())

Ну тут вариантов много. Меня вполне устраивает вариант с соглашением по именованию.

Вот такие возможности предоставляют метатаблицы в Lua. Если вы смогли это все прочитать, то, видимо, написано было не зря.

Полный и чуть более навороченный вариант реализации можно увидеть тут [3].

Автор: AterCattus

Источник [4]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/lua/63661

Ссылки в тексте:

[1] особенных случаях: http://lua-users.org/wiki/MetatableEvents

[2] debug.getinfo(): http://www.lua.ru/doc/5.9.html

[3] можно увидеть тут: https://github.com/AterCattus/estrela/blob/dev/lib/estrela/oop/single.lua

[4] Источник: http://habrahabr.ru/post/228001/