Read-only модули

Задача:
Есть некий Lua модуль. Нужно сделать все его поля доступными только для чтения. При попытке добавить в модуль новое поле или изменить существующее генерируется ошибка или выводится предупреждение.

Решение:
Как сказал кто-то умный, любая сложная задача имеет простое, очевидное и неправильное решение. С него и начнем.
Очевидно, нам нужно установить поля __index и __newindex в метатаблице модуля. Создадим модуль, содержащий, например, одно строковое поле и одну функцию (файл Module1.lua):

 -- сохраняем ссылку на глобальное окружение
local base = _G
module('Module1')

-- поле
field1 = 'test1'

-- функция
function fun(param)
base.print('Module1 fun call', param)
end

Тест для проверки работы модуля (файл module_test.lua):

 require('Module1')

print('------Test Module1------')
print(Module1.field1)
Module1.fun()
print('Try to set new value to Module1.field1')
Module1.field1 = 'zzz'
print(Module1.field1)
print('------End Module1 test------')

В консоли мы должны увидеть:

 ------Test Module1------
test1
Module1 fun call nil
Try to set new value to Module1.field1
zzz
------End Module1 test------

Установим в модуле метатаблицу. Для этого в модуле создадим функцию setNewMetatable():

 function setNewMetatable()
local meta = {}
meta.__index = function(t, k)
base.print('Module1 __index', k)
return _M[k]
end

meta.__newindex = function(t, k, v)
base.print('Module1 __newindex', k, v)
-- ничего не делаем
end

base.setmetatable(_M, meta)
end

Эта функция должна быть вызвана в конце загрузки модуля, иначе после того, как эта функция выполнится, модуль не сможет добавить в себя требуемые поля. Убедимся в этом. Вызовем функцию в начале загрузки модуля. Результат не впечатляет:

 Module1 __newindex field1 test1
Module1 __newindex fun function: 0035F438
------Test Module1------
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
Module1 __index field1
lua.exe: C stack overflow
stack traceback:
[C]: in function 'print'
.\Module1.lua:8: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
...
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
.\Module1.lua:9: in function <.\Module1.lua:7>
module_test.lua:4: in main chunk

Два первых вызова это понятно что - создание полей field1 и fun во время загрузки модуля. Далее в строке 4 файла module_test.lua мы обращаемся к полю field1. Попадаем в функцию __index метатаблицы модуля, которая обращается к модулю посредством таблицы _M, у которой установлена метатаблица с функцией __index, которая... И так до "C stack overflow". После этого теста стали понятны две вещи:
1. Вызов setNewMetatable() должен идти в конце загрузки модуля, поскольку поля таблицы смогут быть проинициализированы только в том случае, если не установлена метатаблица с пустой функцией __newindex.
2. В методе __index обращаться к модулю вызовом _M[k] нельзя, поскольку это вызывает циклическую зависимость.
Исправим выявленные недостатки. Функция __index теперь использует вызов rawget(), при котором обращения к метатаблице не происходит:

 meta.__index = function(t, k)
base.print('Module1 __index', k)
return base.rawget(_M, k)
end

Вызов setNewMetatable() помещаем в конец загрузки модуля. Проверяем:

 ------Test Module1------
test1
Module1 fun call nil
Try to set new value to Module1.field1
zzz
------End Module1 test------

Упс! Ничего не произошло :(. Пробуем разобраться. Открываем книжку Иерусалимского Programming in Lua, Second Edition на странице 123. Там обнаруживаем, что метаметоды __index и __newindex вызываются только в случае обращения к отсутствующим полям таблицы. Теперь понятно, почему не произошло вызова функции __newindex при присвоении нового значения полю field1 - это поле уже существует! Значит, нужно сделать следующее - все данные хранить не в самом модуле, а в какой-нибудь специальной таблице внутри него, а через функции _index и __newindex метатаблицы модуля обращаться к этой специальной таблице. Пробуем (файл Module1.lua):

 -- сохраняем ссылку на глобальное окружение
local base = _G
module('Module1')

-- помещаем данные в специальную таблицу
local privateData = {
-- поле
filed1 = 'test1',

-- функция
fun = function (param)
base.print('Module1 fun call', param)
end,
}

function setNewMetatable()
local meta = {}
meta.__index = function(t, k)
base.print('Module1 __index', k)
-- обращаемся к специальной таблице
return privateData[k]
end

meta.__newindex = function(t, k, v)
base.print('Module1 __newindex', k, v)
end

base.setmetatable(_M, meta)
end

setNewMetatable()

Запускаем тест:

 ------Test Module1------
Module1 __index field1
test1
Module1 __index fun
Module1 fun call nil
Try to set new value to Module1.field1
Module1 __newindex field1 zzz
Module1 __index field1
test1
------End Module1 test------

В общем, это то, что нам нужно. Теперь нужно закрепить результат, и размножить его на другие модули, которые тоже могут захотеть быть read-only. Модули должны быть в традиционной форме, то есть пользователь не должен сам помещать данные в специальную таблицу, пусть за него все это сделает программа. Итак, Module1 выглядит так, как он выглядел в самом начале:

 -- сохраняем ссылку на глобальное окружение
local base = _G
module('Module1')

-- поле
field1 = 'test1'

-- функция
function fun(param)
base.print('Module1 fun call', param)
end

Создадим модуль ModuleLocker, который будет делать для модулей всю работу по защите их данных от изменения (файл ModuleLocker.lua):

 -- package.seeall устанавливает у модуля метатаблицу с полем __index, 
-- ссылающемся на глобальное окружение
module('ModuleLocker', package.seeall)

function lock(module)
-- убираем из модуля все поля,
-- чтобы вызывались методы __index и __newindex метатаблицы
-- данные сохраняем в таблицу t
local t = {}

for k, v in pairs(module) do
print(k, v)
t[k] = v

-- удаляем данные из модуля
rawset(module, k, nil)
end

-- создаем в модуле таблицу с защищенными данными
rawset(module, 'privateData_', t)

local meta = {
__index = function(t, k)
-- возвращаем данные из потайной таблицы
return t.privateData_[k]
end,

__newindex = function(t, k, v)
-- генерируем ошибку
error('access denied!')
end,
}

setmetatable(module, meta)
end

В файле Module1.lua нужно в конец добавить одну строчку:

 base.require('ModuleLocker').lock(_M)

Запускаем наш тест:

 _NAME Module1
_PACKAGE
_M table: 0037F650
field1 test1
fun function: 0037DBB0
------Test Module1------
test1
Module1 fun call nil
Try to set new value to Module1.field1
..\..\bin\x86\vc80.debug\lua.exe: .\ModuleLocker.lua:30: access denied!
stack traceback:
[C]: in function 'error'
.\ModuleLocker.lua:30: in function <.\ModuleLocker.lua:28>
module_test.lua:7: in main chunk
[C]: ?

Вначале напечатаны поля, которые мы удаляем из модуля. Первые три поля мы в модуль не заносили, это служебные поля и удалять их, пожалуй не стоит. Вызов __newindex сгенерировал ошибку и показал стек, так что видно, в каком месте эта ошибка произошла (при желании вызов error() можно заменить на что-нибудь более гуманное). Исправим ModuleLocker так, чтобы он не удалял служебные поля из модулей:

 function lock(module)
-- убираем из модуля все поля, кроме служебных,
-- чтобы вызывались методы __index и __newindex метатаблицы
-- данные сохраняем в таблицу t

local serviceFields = {
_NAME = true,
_PACKAGE = true,
_M = true,
}

local t = {}

for k, v in pairs(module) do
if not serviceFields[k] then
t[k] = v

-- удаляем данные из модуля
rawset(module, k, nil)
end
end

-- создаем в модуле таблицу с защищенными данными
rawset(module, 'privateData_', t)

local meta = {
__index = function(t, k)
-- возвращаем данные из потайной таблицы
return t.privateData_[k]
end,

__newindex = function(t, k, v)
-- генерируем ошибку
error('access denied!')
end,
}

setmetatable(module, meta)
end

Это, в общем, все. Однако, когда я стал применять ModuleLocker для работы, оказалось, что в модулях могут быть поля, которые пользователь может менять. Например:

 local base = _G
module('Text')

local ModuleLocker = base.require('ModuleLocker')

-- это не меняется
defaultFontSize = 12

-- путь к папке с темой
themePath = './'

function setThemePath(path)
themePath = path
end

...
...
...

ModuleLocker.lock(_M)

При попытке установить поле themePath вызовом setThemePath() генерируется ошибка 'access denied!'. Есть два способа решения проблемы - хороший и плохой. Сначала плохой способ:

 function setThemePath(path)
_M.privateData_['themePath'] = path
end

Фу... Это отвратительно! Мы исподтишка залезли в вотчину ModuleLocker и что-то там подкрутили. Тот, кто позже будет править ModuleLocker, может спокойно заменить privateData_ на что-то другое и в результате получит непонятно откуда появившуюся ошибку, или, хуже того, сначала ничего не произойдет, а сломается все позже у пользователей. Хороший способ такой - добавляем метод setValue() в модуль ModuleLocker:

 function setValue(module, key, value)
module.privateData_[key] = value
end

И теперь вызов из модуля Text выглядит так:

 function setThemePath(path)
ModuleLocker.setValue(_M, 'themePath', path)
end

Убедимся на практике :) Добавим поле field2 и функцию для его изменения в модуль Module1 (файл Module1.lua):

 field2 = 1

function setField2(val)
ModuleLocker.setValue(_M, 'field2', 2)
end

Изменим тест (файл module_test.lua):

 require('Module1')

print('------Test Module1------')
print(Module1.field1)
Module1.fun()
-- изменяем поле в read-only модуле
print('Module1.field2', Module1.field2)
Module1.setField2(2)
print('Module1.field2', Module1.field2)
print('Try to set new value to Module1.field1')
Module1.field1 = 'zzz'
print(Module1.field1)
print('------End Module1 test------')

Результат:

 ------Test Module1------
test1
Module1 fun call nil
Module1.field2 1
Module1.field2 2
Try to set new value to Module1.field1
..\..\bin\x86\vc80.debug\lua.exe: .\ModuleLocker.lua:38: access denied!
stack traceback:
[C]: in function 'error'
.\ModuleLocker.lua:38: in function <.\ModuleLocker.lua:36>
module_test.lua:10: in main chunk
[C]: ?

Исходные файлы можно взять здесь.
Исходник статьи в формате markdown здесь.
Благодарю за помощь Брезина Николая aka Jesus.

Hosted by uCoz