Как стать автором
Обновить

Примеры использования языкоориентированного программирования

Время на прочтение 5 мин
Количество просмотров 1.9K
Идея 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 только небольшое кол-во разработчиков. Надеюсь эта статья подтолкнёт вас к подробному изучению вопроса.
Теги:
Хабы:
+33
Комментарии 18
Комментарии Комментарии 18

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн