Pull to refresh

Основы декларативного программирования на Lua

Reading time 12 min
Views 48K
Луа (Lua) — мощный, быстрый, лёгкий, расширяемый и встраиваемый скриптовый язык программирования. Луа удобно использовать для написания бизнес-логики приложений.

Отдельные части логики приложения часто бывает удобно описывать в декларативном стиле. Декларативный стиль программирования отличается от более привычного многим императивного тем, что описывается, в первую очередь, каково нечто а не как именно оно создаётся. Написание кода в декларативном стиле часто позволяет скрыть лишние детали реализации.

Луа — мультипарадигменный язык программирования. Одна из сильных сторон Луа — хорошая поддержка декларативного стиля. В этой статье я кратко опишу базовые декларативные средства, предоставлямые языком Луа.

Пример


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

function build_message_box(gui_builder)<br/>
  local my_dialog = gui_builder:dialog()<br/>
  my_dialog:set_title("Message Box")<br/>
 <br/>
  local my_label = gui_builder:label()<br/>
  my_label:set_font_size(20)<br/>
  my_label:set_text("Hello, world!")<br/>
  my_dialog:add(my_label)<br/>
 <br/>
  local my_button = gui_builder:button()<br/>
  my_button:set_title("OK")<br/>
  my_dialog:add(my_button)<br/>
 <br/>
  return my_dialog<br/>
end

В декларативном стиле этот код мог бы выглядеть так:

build_message_box = gui:dialog "Message Box"<br/>
{<br/>
  gui:label "Hello, world!" { font_size = 20 };<br/>
  gui:button "OK" { };<br/>
}

Гораздо нагляднее. Но как сделать, чтобы это работало?

Основы


Чтобы разобраться в чём дело, нужно знать о некоторых особенностях языка Луа. Я поверхностно расскажу о самых важных для понимания данной статьи. Более подробную информацию можно получить по ссылкам ниже.

Динамическая типизация


Важно помнить, что Луа — язык с динамической типизацией. Это значит, что тип в языке связан не с переменной, а с её значением. Одна и та же переменная может принимать значения разных типов:

= "the meaning of life" --> была строка,<br/>
= 42                    --> стало число

Таблицы


Таблицы (table) — основное средство композиции данных в Луа. Таблица — это и record и array и dictionary и set и object.

Для программирования на Луа очень важно хорошо знать этот тип данных. Я кратко остановлюсь лишь на самых важных для понимания деталях.

Создаются таблицы при помощи «конструктора таблиц» (table constructor) — пары фигурных скобок.

Создадим пустую таблицу t:

= { }

Запишем в таблицу t строку «one» по ключу 1 и число 1 по ключу «one»:

t[1] = "one"<br/>
t["one"] = 1

Содержимое таблицы можно указать при её создании:

= { [1] = "one"["one"] = 1 }

Таблица в Луа может содержать ключи и значения всех типов (кроме nil). Но чаще всего в качестве ключей используются целые положительные числа (array) или строки (record / dictionary). Для работы с этими типами ключей язык предоставляет особые средства. Я остановлюсь только на синтаксисе.

Во-первых: при создании таблицы можно опускать положительные целочисленные ключи для идущих подряд элементов. При этом элементы получают ключи в том же порядке, в каком они указаны в конструкторе таблицы. Первый неявный ключ — всегда единица. Явно указанные ключи при выдаче неявных игнорируются.

Следующие две формы записи эквивалентны:

= { [1] = "one"[2] = "two"[3] = "three" }<br/>
= { "one""two""three" }

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

При создании таблицы следующие две формы записи эквивалентны:

= { ["one"] = 1 }<br/>
= { one = 1 }

Аналогично для индексации при записи…

t["one"] = 1<br/>
t.one = 1

… И при чтении:

print(t["one"])<br/>
print(t.one)

Функции


Функции в Луа — значения первого класса. Это значит, что функцию можно использовать во всех случаях, что и, например, строку: присваивать переменной, хранить в таблице в качестве ключа или значения, передавать в качестве аргумента или возвращаемого значения другой функции.

Функции в Луа можно создавать динамически в любом месте кода. При этом внутри функции доступны не только её аргументы и глобальные переменные, но и локальные переменные из внешних областей видимости. Функции в Луа, на самом деле, это замыкания (closures).

function make_multiplier(coeff)<br/>
  return function(value)<br/>
    return value * coeff<br/>
  end<br/>
end<br/>
 <br/>
local x5 = make_multiplier(5)<br/>
print(x5(10)) --> 50

Важно помнить, что «объявление функции» в Луа — на самом деле синтаксический сахар, скрывающий создание значения типа «функция» и присвоение его переменной.

Следующие два способа создания функции эквивалентны. Создаётся новая функция и присваивается глобальной переменной mul.

С сахаром:

function mul(lhs, rhs) return lhs * rhs end

Без сахара:

mul = function(lhs, rhs) return lhs * rhs end

Вызов функции без круглых скобок


В Луа можно не ставить круглые скобки при вызове функции с единственным аргументом, если этот аргумент — строковый литерал или конструктор таблицы. Это очень удобно при написании кода в декларативном стиле.

Строковый литерал:

my_name_is = function(name)<br/>
  print("Use the force,", name)<br/>
end<br/>
 <br/>
my_name_is "Luke" --> Use the force, Luke

Без сахара:

my_name_is("Luke")

Конструктор таблицы:

shopping_list = function(items)<br/>
  print("Shopping list:")<br/>
  for name, qty in pairs(items) do<br/>
    print("*", qty, "x", name)<br/>
  end<br/>
end<br/>
 <br/>
shopping_list<br/>
{<br/>
  milk = 2;<br/>
  bread = 1;<br/>
  apples = 10;<br/>
}<br/>
 <br/>
--> Shopping list:<br/>
--> * 2 x milk<br/>
--> * 1 x bread<br/>
--> * 10 x apples

Без сахара:

shopping_list(<br/>
      {<br/>
        milk = 2;<br/>
        bread = 1;<br/>
        apples = 10;<br/>
      }<br/>
  )

Цепочки вызовов


Как я уже упоминал, функция в Луа может вернуть другую функцию (или даже саму себя). Возвращённую функцию можно вызвать сразу же:

function chain_print(...)<br/>
  print(...)<br/>
  return chain_print<br/>
end<br/>
 <br/>
chain_print (1) ("alpha") (2) ("beta") (3) ("gamma")<br/>
--> 1<br/>
--> alpha<br/>
--> 2<br/>
--> beta<br/>
--> 3<br/>
--> gamma

В примере выше можно опустить скобки вокруг строковых литералов:

chain_print (1) "alpha" (2) "beta" (3) "gamma"

Для наглядности приведу эквивалентный код без «выкрутасов»:

do<br/>
  local tmp1 = chain_print(1)<br/>
  local tmp2 = tmp1("alpha")<br/>
  local tmp3 = tmp2(2)<br/>
  local tmp4 = tmp3("beta")<br/>
  local tmp5 = tmp4(3)<br/>
  tmp5("gamma")<br/>
end

Методы


Объекты в Луа — чаще всего реализуются при помощи таблиц.

За методами, обычно, скрываются значения-функции, получаемые индексированием таблицы по строковому ключу-идентификатору.

Луа предоставляет специальный синтаксический сахар для объявления и вызова методов — двоеточие. Двоеточие скрывает первый аргумент метода — self, сам объект.

Следующие три формы записи эквивалентны. Создаётся глобальная переменная myobj, в которую записывается таблица-объект с единственным методом foo.

С двоеточием:

myobj = { a_ = 5 }<br/>
 <br/>
function myobj:foo(b)<br/>
  print(self.a_ + b)<br/>
end<br/>
 <br/>
myobj:foo(37) --> 42

Без двоеточия:

myobj = { a_ = 5 }<br/>
 <br/>
function myobj.foo(self, b)<br/>
  print(self.a_ + b)<br/>
end<br/>
 <br/>
myobj.foo(myobj, 37) --> 42

Совсем без сахара:

myobj = { ["a_"] = 5 }<br/>
 <br/>
myobj["foo"] = function(self, b)<br/>
  print(self["a_"] + b)<br/>
end<br/>
 <br/>
myobj["foo"](myobj, 37) --> 42

Примечание: Как можно заметить, при вызове метода без использования двоеточия, myobj упоминается два раза. Следующие два примера, очевидно, не эквивалентны в случае, когда get_myobj() выполняется с побочными эффектами.

С двоеточием:

get_myobj():foo(37)

Без двоеточия:

get_myobj().foo(get_myobj()37)

Чтобы код был эквивалентен варианту с двоеточием, нужна временная переменная:

do <br/>
  local tmp = get_myobj()<br/>
  tmp.foo(tmp, 37) <br/>
end

При вызове методов через двоеточие также можно опускать круглые скобки, если методу передаётся единственный явный аргумент — строковый литерал или конструктор таблицы:

foo:bar ""<br/>
foo:baz { }

Реализация


Теперь мы знаем почти всё, что нужно для того, чтобы наш декларативный код заработал. Напомню как он выглядит:

build_message_box = gui:dialog "Message Box"<br/>
{<br/>
  gui:label "Hello, world!" { font_size = 20 };<br/>
  gui:button "OK" { };<br/>
}

Что же там написано?


Приведу эквивалентную реализацию без декларативных «выкрутасов»:

do<br/>
  local tmp_1 = gui:label("Hello, world!")<br/>
  local label = tmp_1({ font_size = 20 })<br/>
 <br/>
  local tmp_2 = gui:button("OK")<br/>
  local button = tmp_2({ })<br/>
 <br/>
  local tmp_3 = gui:dialog("Message Box")<br/>
  build_message_box = tmp_3({ label, button })<br/>
end

Интерфейс объекта gui


Как мы видим, всю работу выполняет объект gui — «конструктор» нашей функции build_message_box(). Теперь уже видны очертания его интерфейса.

Опишем их в псевдокоде:

gui:label(title : string)
  => function(parameters : table) : [gui_element]

gui:button(text : string)
  => function(parameters : table) : [gui_element]
   
gui:dialog(title : string) 
  => function(element_list : table) : function

Декларативный метод


В интерфейсе объекта gui чётко виден шаблон — метод, принимающий часть аргументов и возвращающий функцию, принимающую остальные аргументы и возвращающую окончательный результат.

Для простоты, будем считать, что мы надстраиваем декларативную модель поверх существующего API gui_builder, упомянутого в императивном примере в начале статьи. Напомню код примера:

function build_message_box(gui_builder)<br/>
  local my_dialog = gui_builder:dialog()<br/>
  my_dialog:set_title("Message Box")<br/>
 <br/>
  local my_label = gui_builder:label()<br/>
  my_label:set_font_size(20)<br/>
  my_label:set_text("Hello, world!")<br/>
  my_dialog:add(my_label)<br/>
 <br/>
  local my_button = gui_builder:button()<br/>
  my_button:set_title("OK")<br/>
  my_dialog:add(my_button)<br/>
 <br/>
  return my_dialog<br/>
end

Попробуем представить себе, как мог бы выглядеть метод gui:dialog():

function gui:dialog(title)<br/>
  return function(element_list)<br/>
 <br/>
    -- Наша build_message_box():<br/>
    return function(gui_builder) <br/>
      local my_dialog = gui_builder:dialog()<br/>
      my_dialog:set_title(title)<br/>
 <br/>
      for i = 1, #element_list do<br/>
        my_dialog:add(<br/>
            element_list[i](gui_builder)<br/>
          )<br/>
      end<br/>
 <br/>
      return my_dialog      <br/>
    end<br/>
 <br/>
  end<br/>
end

Ситуация с [gui_element] прояснилась. Это — функция-конструктор, создающая соответствующий элемент диалога.

Функция build_message_box() создаёт диалог, вызывает функции-конструкторы для дочерних элементов, после чего добавляет эти элементы к диалогу. Функции-конструкторы для элементов диалога явно очень похожи по устройству на build_message_box(). Генерирующие их методы объекта gui тоже будут похожи.

Напрашивается как минимум такое обобщение:

function declarative_method(method)<br/>
  return function(self, name)<br/>
    return function(data)<br/>
      return method(self, name, data)<br/>
    end<br/>
  end<br/>
end

Теперь gui:dialog() можно записать нагляднее:

gui.dialog = declarative_method(function(self, title, element_list)<br/>
  return function(gui_builder) <br/>
    local my_dialog = gui_builder:dialog()<br/>
    my_dialog:set_title(title)<br/>
 <br/>
    for i = 1, #element_list do<br/>
      my_dialog:add(<br/>
          element_list[i](gui_builder)<br/>
        )<br/>
    end<br/>
 <br/>
    return my_dialog      <br/>
  end<br/>
end)

Реализация методов gui:label() и gui:button() стала очевидна:

gui.label = declarative_method(function(self, text, parameters)<br/>
  return function(gui_builder) <br/>
    local my_label = gui_builder:label()<br/>
 <br/>
    my_label:set_text(text)<br/>
    if parameters.font_size then<br/>
      my_label:set_font_size(parameters.font_size)<br/>
    end<br/>
 <br/>
    return my_label<br/>
  end<br/>
end)<br/>
 <br/>
gui.button = declarative_method(function(self, title, parameters)<br/>
  return function(gui_builder) <br/>
    local my_button = gui_builder:button()<br/>
 <br/>
    my_button:set_title(title)<br/>
    -- Так сложилось, что у нашей кнопки нет параметров.<br/>
 <br/>
    return my_button<br/>
  end<br/>
end)

Что же у нас получилось?


Проблема улучшения читаемости нашего наивного императивного примера успешно решена.

В результате нашей работы мы, фактически, реализовали с помощью Луа собственный предметно-ориентированный декларативный язык описания «игрушечного» пользовательского интерфейса (DSL).

Благодаря особенностям Луа реализация получилась дешёвой и достаточно гибкой и мощной.

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

Например, если на нашем микро-языке будут писать пользователи, нам понадобится поместить выполняемый код в песочницу. Также, нужно будет серьёзно поработать над понятностью сообщений об ошибках.

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

Полностью работающий пример можно посмотреть здесь.

Дополнительное чтение

Tags:
Hubs:
+59
Comments 18
Comments Comments 18

Articles