Многие языки программирования позиционируют себя как почти естественные. Ruby не отстает и позиционирует себя как "natural to read and easy to write". Однако, первый же гайд по Rails (да, Ruby это всё еще Rails) предложит вам что–то такое: То есть это вот абсолютно естественный (natural) английский текст, правда? Кажется, нас обманули. Было бы здорово писать код на полностью естественном языке, например таком: Давайте попробуем запустить эту программу! Cкопируем код выше в файлик natural.rb и запустим его (irb ./natural.rb). Разумеется, программма должна упасть, однако ошибка, которую мы увидим, может показаться неожиданной: <main>: undefined method value for main:Object (NoMethodError). Почему интерпретатор не ругается на другие незнакомые ему слова, такие как assign или b? Дело в том, что код выше является абсолютно корректным с точки зрения парсера. К примеру, строку sum a with b можно прочитать так: происходит вызов метода b либо чтение значения переменной b; результат передается методу with в качестве аргумента; результат вызова with передается методу a в качестве аргумента; результат вызова a передается методу sum в качестве аргумента. Когда интерпретатор начинает выполнять этот код (справа налево), он падает с ошибкой: метод value нигде не определен. Давайте убедимся, что порядок вызовов и правда такой: Благодаря * (который принимает любые аргументы) наш код больше не падает, однако он и не делает ничего полезного. Давайте это исправим с помощью максимально топорной реализации: Запуск этой программы приведет к выводу значения 3 в консоль. Вам могло показаться, что язык, который мы пытаемся интерпретировать не очень–то натуральный. Я полностью разделяю ваше возмущение и хотел бы сделать что–то вроде assign 1 to variable a. Проблема в том, что такой код не является валидным с точки зрения парсера Ruby, который ожидает запятую либо конец строки после числа 🤷‍♂️ Давайте прочитаем код слева направо, так, как это делает интерпретатор. Сначала выполняется метод value, который принимает число и сохраняет его в глобальной переменной: Похоже, что все, что нам нужно сделать — это объявить метод для каждого слова, которое существует в нашем «языке»! Какое слово следующее? Это a, имя переменной, и похоже что объявление методов для всех возможных имен переменных — не лучшая идея. Однако, к нам на помощь придет method_missing. method_missing вызывается, когда объект получает сообщение, на которое он не может ответить (подробности тут) Давайте попробуем сохранять имя последней обнаруженной переменной в другую глобальную переменную: Теперь нам нужно реализовать метод variable. Мы могли бы принять в качестве аргумента результат вызова метода справа от variable, но это был method_missing, поэтому мы можем просто прочитать значение из глобальной переменной. Значение переменной будет храниться в глобальной переменной @variables: Наконец, метод assign (пока) ничего не делает, поэтому мы просто оставим его пустым. В результате работы первых двух строчек нашей программы (той, которая на естественном языке), в @variables будет храниться { a: 1, b: 2 }. Теперь кратко разберемся с тем, как работает sum: b заставляет method_missing присвоить значение :b переменной @unknown_token; with читает значение из @variables используя в качестве ключа значение переменной @unknown_token и сохраняет его в глобальной переменной @with; a заставляет method_missing присвоить значение :a переменной @unknown_token; sum читает значение из@variables используя в качестве ключа значение переменной  @unknown_token, складывает его со значением переменной @with и печатает результат. Здорово, правда? Безусловно! Однако, код, который мы написали не выглядит как что–то, что я хотел бы поддерживать: у нас есть неявные зависимости между вызовами методов (например, метод variable предполагает, что method_missing был вызван прямо перед ним). Что будет, если мы попробуем выполнить sum a? Мы получим непонятную ошибку, текст который никак не поможет нам ее исправить: method_missing: can't modify frozen NilClass: nil. С другой стороны, можем ли мы выполнить строчку variable a value 1? Да, но мы получим бессмысленный результат! Возможно вы заметили, что наш «интерпретатор» не способен обрабатывать все возможные фразы естественного языка. Это правда, мы сможем реализовать только небольшое подмножество, но это все равно должно быть весело! Наша следующая задача — создать явные зависимости между методами и данными: другими словами — использовать инкапсуляцию. Кроме того, хотелось бы помогать программисту на естественном языке исправлять ошибки в коде; для этого нам нужно переосмыслить то, как мы на них реагируем и реализовать более удобные сообщения об их возникновении. Вместо чтения и записи глобальных переменных попробуем другой подход. Самый левый метод на каждой строчке (т.е., assign и sum) будут запускать вычисление, которое будет построено в результате вызовов методов справа от него. Вычисление будет формироваться справа налево и храниться в некоторой структуре данных, а assign — читать из неё слева направо и выполнять действия. Вероятно вы уже догадались, что подходящая структура данных — это стек. Стек — структура данных, которая содержит список элементов и реализует две операции: push для добавление элемента наверх и pop для извлечения последнего добавленного элемента. Вот наша новая реализация: Начнем с трех структур для представления возможных объектов нашего языка: Value содержит значения, которые присвоены переменным; Token содержит то, что было поймано в method_missing (пока это могут быть только имена переменных); Keyword это ожидаемое в выражении ключевое слово, которое нужно для того, чтобы получившаяся фраза была осмысленной. Value и Keyword очень похожи, но представляют разные сущности: Keyword содержит имя некоторого метода, аValue хранит значение, которе было передано методу value в качестве аргумента. После этого мы создаем наследник класса Array для реализации стека. Он содержит два дополнительных метода: pop_if(expected_class) проверяет, что значение на стеке является объектом переданного класса и возвращает его, а в противном случае — выбрасывает исключение; pop_if_keyword(keyword_type) делает почти то же самое: проверяет, что значение на стеке это Keyword, содержащий переданное значение. Методы variable, value, method_missing и with не делают ничего кроме добавления соответствующих объектов в глобальную переменную @stack. К примеру, строчка assign variable a value 1 работает так (смотрите GIF): value 1 добавляет Value.new(1) в стек; a добавляет Token.new(:a) в стек; variable добавляет Keyword.new(:variable) в стек. assign пытается достать ожидаемыые данные из стека, и, если не возникло исключений — добавляет переменную в @variables: Попробуйте разобраться с тем как работает sum самостоятельно (отличия минимальны), а мы перейдем к следующей проблеме. Теперь у нас есть явные связи между командными методами (assign and sum) и их данными, однако у нас осталась куча шаблонного кода, и добавление новых команд будет довольно трудоемким занятием. Давайте используем метапрограммирование и добавим небольшой DSL! Как обычно, сначала посмотрим на реализацию целиком. Обратите внимание: я не привожу класс Stack, а так же методы variable, value, method_missing и with — они не изменились. Начyем с класса Command, содержащего данные команды. Каждая команда ожидает найти определенные объекты в стеке, поэтому нам нужно несколько методов для регистрации этих ожиданий. К примеру, так мы можем обозначить, что дальше мы ожидаем ключевое слово: Кроме этого, класс содержит метод для запоминания блока, содержащего логику команды: Когда настанет время выполнить команду, мы будем сравнивать ожидаемые значения со стеком и готовить аргументы для передачи в@execution_block. pop_if и pop_if_keyword позаботятся о случаях, когда ожидаемого значения в стеке не оказалось: Теперь давайте сделаем так, чтобы наш класс мог быть использован как часть DSL: Когда мы передаем блок в метод build, этот блок выполняется в контексте объекта этого класса с помощью метода instance_eval. В результате, все инстанс–методы становятся доступны внутри блока. Метод command создает объект класса Commandи создает новый метод, который заставляет команду выполнить её execution_block с аргументами: Наконец, нам нужно сконфигурировать наши команды с помощью нового DSL: Команда assign ожидает ключевое слово :variable, некоторый токен и некоторое значение. Токен и значение будут переданы в блок execute, внутри которого мы произведем присвоение. Давайте убедимся, что наш DSL поможет добавлять дополнительные фразы в наш язык. К примеру, вычитание можно реализовать так: Выглядит гораздо лучше! Теперь нам нужно разобраться с method_missing, который объявлен на верхнем уровне . Если вы запускали предыдущие примеры, то скорее всего видели предупреждение от интерпретатора. Проблема в том, что из–за него любой вызов метода на nil попадет в method_missing, что сильно усложнит отладку. Давайте попробуем инкапсулировать его в контейнер для исполнения кода. Взгляните на реализацию виртуальной машины для выполнения программы на естественном языке: Основное изменение — программа на естественном языке будет выполняться внутри блока VM.run do. Для того, чтобы это заработало, мы используем тот же трюк, что и в Command: все нужные методы (такие как variable) перемещаются в класс Vm и становятся доступными внутри блока благодаря instance_eval: @stack и @variables перестали быть глобальными и переместились в VM. В принципе, на этом можно было бы закончить, но давайте попробуем кое–что еще: что если наша виртуальная машина сможет запускать программы на разных естественных языках? Для создания языков мы сможем использовать специальный DSL, а затем передавать их в экземпляр VM для исполнения: Главное преимущество такого подхода — наши «примитивы» (variable, with, etc.) также будут определяться динамически, на основе синтаксиса языка. Как обычно, начнем с полного решения: КлассLang используется для настройки синтаксиса естественного языка. Обратите внимание, уже знакомый нам instance_eval снова в деле: command — единственный метод, который мы будем использовать внутри блока, переданного в define. Он принимает на вход название команды и её тело. Оба аргумента затем передаются в фабричный метод класса Command: После инициализации команды нам нужно добавить её в список команд, а также зарегистрировать ключевые слова, которые она содержит: Теперь мы можем создать язык, который содержит два ключевых слова (variable и with) и две команды (assign и sum): Теперь нам нужно внести изменения в класс VM. Описание языка переехалов в отдельный класс, поэтому все относящиеся к конкретному языку можно удалить. Давате внесем изменения в метод run чтобы добавлять их в виртуальную машину при запуске конкретного языка: Такое решение не идеально, так как эти методы останутся объявленными в экземпляре класса. Впрочем, для наших целей такое поведение допустимо, поэтому не будем усложнять 🙂 Еще одно важное изменение — появление низкоуровневых опереций в классе VM. Их можно использовать внутри тела команд: Последнее изменение касается method_missing: теперь метод может обрабатывать и токены и ключевые слова, как как список возможных ключевых слов есть в объекте класса Lang: Все готово! Давайте создадим новый язык с другим синтаксисом и убедимся что он работает: Таки работает! Вероятно вы думаете, что все, на что способен наш конструктор языков — это чтение и запись переменных. Попробую вас разубедить: сейчас мы создадим язык запросов, который сможет отвечать на вопросы о длительности маршрутов между городами. Вот как выглядит его синтаксис: Было бы здорово сделать синтаксис вида route from london to glasgow takes 22 hours но этот код не является валидным с точки зрения парсера 😞 Возможно ли реализовать такой язык? Конечно, но есть одна проблема: в текущей реализации мы не можем использовать takes как метод, который принимает значение, стоящее после него. Дело в том, что для этой цели у нас есть метод value и нет способа его переименовать. Давайте внесем изменение в класс Command так, чтобы его метод value принимал аргумент и использовал его в качестве имени метода для сохранения значения (полная версия тут): Теперь нам нужно поменять класс Lang так, чтобы он создавал соответствующий метод в классе VM: Всё, что нам осталось сделать — это создать наш язык запросов: Как вы видите, мы используем наши низкоуровневые методы VM#read_variable и VM#assign_variable для того, чтобы сохранять и читать расстояния. Мы могли бы даже реализовать алгоритм Дейкстры и искать кратчайший путь по графу, но это выходит за рамки этого поста 🙂 На этом все! С помощью метапрограммирования мы смогли заставить интерпретатор Ruby запускать программы на «почти естественном языке». Разумеется, возможности такого интерпретатора довольно ограничены. Если у вас осталось желание поэксперементировать еще — ниже пара идей, которые я решил не реализовывать в рамках статьи: Реализовать присвоение значения одной переменной из другой (e.g., assign variable b value a). Сделать DSL команд менее многословным: Подсказка: для реализации keyword(:variable).token.value можно использовать CPS.  Backend Engineer