Идея language oriented programming (LOP), состоит в том, что во время разработки программы, постоянно создаются миниязыки. Они могут как расширять основной язык разработки, так и быть отдельными языками. Лучшим языком для LOP является Common Lisp с его макросами, но здесь речь пойдёт не о нём. Примеры использования LOP с Common Lisp советую посмотреть в замечательной книге Peter Seibel Practical Common Lisp. Я считаю, что LOP один из самых простых и эффективных способов программирования. Мы описываем задачу и предметную область на самом подходящем для этого языке, а потом стараемся его реализовать.

Я разрабатываю браузерные игры на Ruby, поэтому часто использую LOP, как для расширения языка и встроенных DSL (Ruby позволяет делать это очень хорошо), так и для создания миниязыков связанных со сложной игровой механикой. В этой статье я рассмотрю простое расширение основного языка, встроенный мини-DSL и два не встроенных языка. Буду приводить примеры в близкой мне тематике, надеюсь они будут вполне понятны.



Статью написал хабраюзер CrazyPit, но ему не хватает кармы для опубликования.

Простое расширение языка



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

У игрока есть несколько им составленных дек (набора карт, которыми игрок использует в коллекционной карточной игре). Нужно выбрать одну из дек — активировать. Для этого я создаю отдельный класс, реализующий алгоритм активации.

Deck::Activator = Struct.new(:deck)
class Deck::Activator

   def activate!
     ......
   end 

   private
   <some helper methods and exceptions>
end


Можно этот модуль использовать так:

Deck::Activator.new(some_deck).activate!



Но гораздо красивее:

deck.activator.activate!

# или вот так

deck.activate!


Для этого нужно добавить метод в класс Deck:

class Deck

  def activate!
     Deck::Activator.new(some_deck).activate!
  end

end


Но поскольку таких алгоритмов достаточно много, я сделал небольшое улучшение, это класс-метод strategy (не имеет прямого отношение к одноимённому паттерну). Теперь я делаю так:

class Deck < ActiveRecord::Base
  strategy :activator
end

Deck.find(:first).activator.activate!



или:

class Deck < ActiveRecord::Base
  strategy :activator, :delegate => [:activate!]
end

Deck.find(:first).activate!



Имя класса алгоритма по умолчанию это <имя класса где вызывается метод>::<имя задаваемое в strategy>. Но может быть задано вручную (:class => BrainDestructor).

Таких расширений много, как в самом руби, так и в RoR. Думаю каждый, кто много программировал на Ruby, делал что-то подобное.

Встроенный DSL для обозначения ограничений



В игре есть различные комнаты, куда пускают не всех, а только если игрок соответствует определённым правилам. Например его уровень больше 10, или кол-во карт в деке не больше 8. Такие ограничению комбинируются. Есть виды ограничений, например «Уровень игрока >= N» и есть конкретные ограничения «Уровень игрока >= 13».

Для задания видов ограничений можно использовать DSL define_constraint, и потом хранить ограничение и их комбинации в базе данных.


  define_constraint "deck_sum_between", "Сумма уровней карт между %N и %M" do
    (arguments['N'].to_i..arguments['M'].to_i).include?(context.deck.sum_of_card_levels)
  end


  define_constraint "deck_without_duplicates", "Дека без дублей" do
    !context.deck.has_duplicates?
  end

  define_constraint "user_level_ge", "Уровень игрока %X  или выше" do
    context.level >= arguments['X'].to_i
  end



В каждом виде ограничение мы задаём его имя (deck_sum_between), описание «Сумма уровней карт между %N и %M», из которого потом, на основание параметров, получается описание конкретного ограничения. И конечно реализацию ограничения, которая должна возвращать true, если игрок или другой объект подходит под ограничение. Система универсальна поэтому не user а context.
В итоге ограничения можно записать так deck_sum_between(N=>10, M=> 20) или хранить имя и параметры в разных свойствах объекта.

Язык логическое выражение из ограничений



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

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

Например: уровень игрока > 10 И размер деки <= 8 карт И (карты игроки из кланов 1,2,3 ИЛИ карты игрока из кланов 4,5,).
Создан язык выражений, которое это задаёт (тут использован слегка другой вид базовых ограничений, чем в предыдущем разделе):

(AND user_level_ge(12) 
     deck_size_le(8)
     (OR deck_has_only_clans(1,2,3) 
         deck_has_only_clans(4,5,6)))


Я использовал немного lisp-style чтобы было удобнее со списками ограничений включающих большой список И.

Далее сохраняем это строчку в модели Room (room.restrictions_string). Во время, когда нам нужно вычислить ограничение парсим строку, вычисляем все базовые ограничения а также общий результат и отдаём клиенту. Игрок видит, необходимые условия и какие из них он не прошёл.

Язык описания правил бустера



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

Каждое из правил генерации карт можно описать текстом:

rarity(1) — плохая карта (кол-во задаётся вне правила, об этом позже)
rarity(1)|clans(6,7,8) — одна плохая карта из кланов 6,7,8. "|" здесь символизирует конвейер unix, а не логическое или.

Также возможны правила с вероятностью:

clans(1,2,3)|expectance(1,60,2,38,3,2) — карты из клана 1, 2 или 3; с вероятностью 60% — плохая, с вероятностью 38% — средняя, с вероятностью 3% — хорошая.

Каждое правило реализуются на основе ActiveRecord механизма scope примерно так:

  def rule_clans(scope, ids)
    scope.scoped_by_clan_id(ids)
  end
  
  def rule_expectance(scope, params)
    scope.scoped_by_rarity(Expectance.for_expectance(Hash[*params.map(&:to_i)]))
  end

Кстати, здесь тоже можно было расширить язык и сделать описание чуть более лаконичным.

Правил объединяются reduce:

  def generate_card_original
    rules_scope = rules.reduce(Card::Original) do |scope, rule|
      rule.add_scope(scope)
    end
    rules_scope.randomly.find(:first)
  end


В итоге у нас получается один запрос на одну карту, в не зависимости от сложности правила.

Остаётся вопрос как генерировать скажем 5 карт по одному правилу и 2 по другому. Есть несколько вариантов, я использовал такое класс Booster has_many generators. В объекте класса Generator храниться кол-во карт и правило, по которому каждая из карт генерируются. Но можно усложнить базовый язык и записывать все правила бустера одной строкой:
5[rarity(1,2)], 2[clans(1,2)|expectance(1,60,2,40)]


Заключение



Я привёл примеры использования LOP в повседневной практике. Многие используют DSL даже не подозревая об этом (XML-задание интерфейсов например). Но создают свои DSL только небольшое кол-во разработчиков. Надеюсь эта статья подтолкнёт вас к подробному изучению вопроса.