5 сент. 2007 г.

Реализация языков описания данных на основе языка Lua. Часть 3

Содержание

Как же можно добиться того, чтобы это работало?

Начнем с того, что в функции в Lua – значения первого класса (first class values). Это, в частности, значит, что функции можно присваивать переменным, передавать функциям в качестве параметров и использовать в качестве возвращаемых значений функций.

От своего предка, языка описания данных Sol, Lua унаследовал некоторые особенности синтаксиса, позволяющие этому коду выполняться. Здесь используется то, что Lua позволяет опускать скобки при передаче функции в качестве единственного параметра строкового литерала либо конструктора таблицы. Таким образом, с точки зрения языка, в коде, приведенном выше, вокруг каждой пары кавычек и каждой пары фигурных скобок стоят «неявные» круглые скобки.

Конструкция

foo “string” { key = “value” }
эквивалентна конструкции
foo(“string”)({ key = “value” })

Сначала вызывается функция foo, которой передается параметр “string”. Функция foo возвращает другую (анонимную) функцию, которая вызывается с параметром-таблицей { key = “value” }. Например, реализованная следующим образом foo в этом случае выведет на экран «string value»:

foo = function(str)
return function(tab) print(str, tab[“key”]) end
end

Перечисленные полезные особенности Lua предоставляют нам достаточно широкие возможности для реализации «самозагружающихся» описаний данных. Благодаря тому, что Lua позволяет компилировать программы в байт-код для виртуальной машины, мы можем «бесплатно» получить бинарное представление данных с еще меньшей избыточностью, к тому же, снимающее затраты на парсинг и компиляцию текста (впрочем в неэкстремальных случаях затраты на компиляцию и так достаточно невелики). Кроме того, текущая реализация языка Lua легко справляется с большими объемами данных – «пережевать» файл в несколько мегабайт для нее не большая проблема (конечно, при адекватной реализации пресловутой системы «самозагрузки»).

Рис. 3. Порядок вызовов при загрузке данных

Если при выполнении кода из листинга 1 в области действия будут находиться реализованные должным образом функции gui_layout, panel, button и checkbox, мы получим набор вызовов, показанный на рис. 3. Переменные modal и hidden могут содержать значения любого типа, главное, чтобы они были тоже видимы.

Фактически можно сказать, что мы видим здесь данные, записанные при помощи языка описания данных (data description language, DDL). Можно сказать, что перечисленные имена переменных – ключевые слова (keywords) в создаваемом нами языке описания пользовательского интерфейса. Нужно заметить, что наши данные носят ярко выраженный иерархический характер.

Поскольку описание данных – валидный код на Lua, мы имеем возможность, например, «разбавить» декларативное описание интерфейса кодом описания его функциональности – скажем, описать обработчики событий по клику на кнопку:

panel “mainmenu”
{
modal;
button ”newgame” { action = StartNewGame; };
button ”settings” { action = function() showpanel(“settings”) end; };
button “exit” { action = confirm(ExitGame); };
};

Конечно, если код обработчика пишется вручную, такой подход следует применять с осторожностью, всячески ограничивая API, доступный таким функциям, и проводя максимальную проверку на их корректность и безопасность.

Помимо этого, наличие функций непосредственно в описании данных несколько затрудняет вариант реализации DDL с промежуточной кодогенерацией (см. ниже) – такие обработчики придется как-то сохранять вместе со всеми данными. Например, их можно сохранять в виде байт-кода при помощи системной функции string.dump –накладываемое ограничение на отсутствие у таких функций ссылок на внешне переменные (upvalues) в данном случае не слишком существенно.

Как же получить загруженные данные? Можно реализовать функции – ключевые слова нашего DDL таким образом, чтобы они изменяли некую глобальную сущность. Однако это повышает связанность кода и уменьшает его гибкость.

Язык Lua позволяет явным образом возвращать значение (или даже несколько значений) при загрузке файла. Для этого достаточно в его конце написать выражение, использующее ключевое слово return так же, как если бы это была обычная функция. Удобно реализовывать корневую функцию иерархии (в нашем случае gui_layout) так, чтобы она возвращала собранные в процессе выполнения остального кода данные (либо сгенерированную функцию, выполняющую некие действия на основе этих данных). Тогда, если дописать в начало листинга 1 ключевое слово return, можно будет добраться до загруженных данных, используя примерно такую конструкцию:

local data = assert(loadfile(“myguilayout.lua”))()

В переменную data будет помещено значение, возвращенное корневой функцией gui_layout. Для большей изящности ключевое слово return можно «подклеивать» автоматически перед загрузкой данных. В этом случае вместо loadfile удобнее использовать loadstring.

Очевидно, помимо реализации необходимого набора ключевых слов, мы должны ограничить пользователя в написании кода на Lua в файлах с данными только этим набором. Без дополнительного разбора текста описания данных невозможно запретить использование в нем произвольного кода, но это и не обязательно. Главное – не дать такому коду доступа к окружающему миру, как говорится, выполнять его в «песочнице» (sandbox). Тогда «лишний» код может быть как минимум бесполезен (но не вреден), но как максимум его можно использовать во благо – например, для генерации данных на лету.

Строго говоря, еще нужно учитывать возможность подвесить загрузку данных, написав в файле с данными бесконечный цикл. Если требуется стопроцентная гарантия от вредоносного кода, необходимо контролировать процесс загрузки, например при помощи установки хуков на исполнение кода в функции debug.sethook, либо при помощи установки вотчдога в отдельном потоке.

К счастью, язык Lua предоставляет эффективное средство для создания такой «песочницы» – возможность явного задания таблицы глобального окружения (environment table) для функций при помощи стандартной функции setfenv. При загрузке данных в глобальное окружение необходимо поместить только функции – ключевые слова нашего DDL.

Функция loadfile возвращает функцию, содержащую весь код загружаемого файла. Глобальное окружение используется по умолчанию для всего этого кода. Заменив это окружение до вызова функции, можно заменить окружение, которое будет назначено коду в файле. Можно написать следующую функцию для загрузки файлов с данными:

load_ddl_file = function(name, ddl_keywords)
local loader = assert(loadfile(name))
setfenv(loader, ddl_keywords)
return loader()
end

Использовать эту функцию можно аналогично loadfile:

local data = assert(load_ddl_file(“myguilayout.lua”, ddl_keywords))

Подразумевается, что реализация ключевых слов языка находится в таблице ddl_keywords. Для большего контроля, можно запретить запись в эту таблицу и чтение из нее данных по отсутствующим ключам, переопределив в ее метатаблице методы __index и __newindex.

Используя при загрузке различные таблицы глобального окружения с реализациями ключевых слов, можно добиться различной интерпретации загружаемых данных, в контексте нашего примера – от генерации для художников спецификаций требуемых графических ресурсов, написанных естественным языком, до использования различных библиотек пользовательского интерфейса в зависимости от системы, на которой запускается приложение.

Учитывая все вышесказанное, можно, наконец, перейти к обсуждению путей реализации самих функций – ключевых слов для нашего примера.

Комментариев нет: