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

в 10:57, , рубрики: 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, но сейчас не про них речь) можно задавать метатаблицу, описывающую поведение данной таблицы в особенных случаях. Такими случаями могут быть использование в арифметике (перегрузка операторов), конкатенация как строк и т.д. В качестве небольшого примера перегрузки операторов и приведения к строке:

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() используется для получения краткой информации о запрошенном уровне стека: 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. Если вы смогли это все прочитать, то, видимо, написано было не зря.

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

Автор: AterCattus

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js