Как заставить интерпретатор Ruby выполнить программу, написанную на естественном языке
Многие языки программирования позиционируют себя как почти естественные. Ruby не отстает и позиционирует себя как "natural to read and easy to write". Однако, первый же гайд по Rails (да, Ruby это всё еще Rails) предложит вам что–то такое:
class UserController < ApplicationController
end
То есть это вот абсолютно естественный (natural) английский текст, правда? Кажется, нас обманули. Было бы здорово писать код на полностью естественном языке, например таком:
assign variable a value 1
assign variable b value 2
sum a with b
Давайте попробуем запустить эту программу!
Запускаем первую программу
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
нигде не определен. Давайте убедимся, что порядок вызовов и правда такой:
def assign(*)
puts "assign"
end
def variable(*)
puts "variable"
end
def a
puts "a"
end
assign variable a
# => a
# => variable
# => assign
Благодаря *
(который принимает любые аргументы) наш код больше не падает, однако он и не делает ничего полезного. Давайте это исправим с помощью максимально топорной реализации:
@variables = {}
@unknown_token = nil
@current_value = nil
@with = nil
def assign(*); end
def variable(*)
@variables[@unknown_token] = @current_value
end
def value(value)
@current_value = value
end
def method_missing(m, *args, &block)
@unknown_token = m
end
def sum(*)
result = @variables[@unknown_token] + @with
print "#{result}\n"
end
def with(*)
@with = @variables[@unknown_token]
end
# Program
assign variable a value 1
assign variable b value 2
sum a with b
Запуск этой программы приведет к выводу значения 3
в консоль.
Вам могло показаться, что язык, который мы пытаемся интерпретировать не очень–то натуральный. Я полностью разделяю ваше возмущение и хотел бы сделать что–то вроде
assign 1 to variable a
. Проблема в том, что такой код не является валидным с точки зрения парсера Ruby, который ожидает запятую либо конец строки после числа ?♂️
Давайте прочитаем код слева направо, так, как это делает интерпретатор. Сначала выполняется метод value
, который принимает число и сохраняет его в глобальной переменной:
@current_value = nil
def value(value)
@current_value = value
end
Похоже, что все, что нам нужно сделать — это объявить метод для каждого слова, которое существует в нашем «языке»! Какое слово следующее? Это a
, имя переменной, и похоже что объявление методов для всех возможных имен переменных — не лучшая идея. Однако, к нам на помощь придет method_missing
.
method_missing
вызывается, когда объект получает сообщение, на которое он не может ответить (подробности тут)
Давайте попробуем сохранять имя последней обнаруженной переменной в другую глобальную переменную:
@unknown_token = nil
def method_missing(m, *args, &block)
@unknown_token = m
end
Теперь нам нужно реализовать метод variable
. Мы могли бы принять в качестве аргумента результат вызова метода справа от variable
, но это был method_missing
, поэтому мы можем просто прочитать значение из глобальной переменной. Значение переменной будет храниться в глобальной переменной @variables
:
@variables = {}
def variable(*)
@variables[@unknown_token] = @current_value
end
Наконец, метод 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 для извлечения последнего добавленного элемента.
Вот наша новая реализация:
@variables = {}
Value = Struct.new(:value)
Token = Struct.new(:name)
Keyword = Struct.new(:type)
class Stack < Array
def pop_if(expected_class)
return pop if last.is_a?(expected_class)
raise "Expected #{expected_class} but got #{last.class}"
end
def pop_if_keyword(keyword_type)
pop_if(Keyword).tap do |keyword|
unless keyword.type == keyword_type
raise "Expected #{keyword_type} but got #{keyword.type}"
end
end
end
end
@stack = Stack.new
def assign(*)
@stack.pop_if_keyword(:variable)
token = @stack.pop_if(Token)
assignment = @stack.pop_if(Value)
@variables[token.name] = assignment.value
end
def variable(*)
@stack << Keyword.new(:variable)
end
def value(value)
@stack << Value.new(value)
end
def method_missing(token, *args, &block)
@stack << Token.new(token)
end
def sum(*)
left = @stack.pop_if(Token)
@stack.pop_if_keyword(:with)
right = @stack.pop_if(Token)
print @variables[left.name] + @variables[right.name]
end
def with(*)
@stack << Keyword.new(:with)
end
# Program
assign variable a value 1
assign variable b value 2
sum a with b
Начнем с трех структур для представления возможных объектов нашего языка:
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
:
def assign(*)
@stack.pop_if_keyword(:variable)
token = @stack.pop_if(Token)
assignment = @stack.pop_if(Value)
@variables[token.name] = assignment.value
end
Попробуйте разобраться с тем как работает sum
самостоятельно (отличия минимальны), а мы перейдем к следующей проблеме. Теперь у нас есть явные связи между командными методами (assign
and sum
) и их данными, однако у нас осталась куча шаблонного кода, и добавление новых команд будет довольно трудоемким занятием. Давайте используем метапрограммирование и добавим небольшой DSL!
DSL для команд
Как обычно, сначала посмотрим на реализацию целиком. Обратите внимание: я не привожу класс Stack
, а так же методы variable
, value
, method_missing
и with
— они не изменились.
@variables = {}
@stack = Stack.new
# Command definition DSL
class Command
attr_reader :execution_block
def initialize(stack, variables)
@stack = stack
@variables = variables
@expectations = []
end
def build(&block)
self.tap { |command| command.instance_eval(&block) }
end
def args
@expectations.each_with_object([]) do |expectation, args|
if expectation.is_a?(Keyword)
@stack.pop_if_keyword(expectation.type)
else
args << @stack.pop_if(expectation)
end
end
end
private
def token
@expectations << Token
end
def value
@expectations << Value
end
def keyword(type)
@expectations << Keyword.new(type)
end
def execute(&block)
@execution_block = block
end
end
def command(command_name, &block)
command = Command.new(@stack, @variables).build(&block)
define_method(command_name) do |*|
command.execution_block.call(@variables, *command.args)
end
end
# Command definitions
command(:assign) do
keyword(:variable)
token
value
execute do |variables, token, value|
variables[token.name] = value.value
end
end
command(:sum) do
token
keyword(:with)
token
execute do |variables, left, right|
result = variables[left.name] + variables[right.name]
print "#{result}\n"
end
end
# Program
assign variable a value 1
assign variable b value 2
sum a with b
Начyем с класса Command
, содержащего данные команды. Каждая команда ожидает найти определенные объекты в стеке, поэтому нам нужно несколько методов для регистрации этих ожиданий. К примеру, так мы можем обозначить, что дальше мы ожидаем ключевое слово:
def keyword(type)
@expectations << Keyword.new(type)
end
Кроме этого, класс содержит метод для запоминания блока, содержащего логику команды:
def execute(&block)
@execution_block = block
end
Когда настанет время выполнить команду, мы будем сравнивать ожидаемые значения со стеком и готовить аргументы для передачи в@execution_block
. pop_if
и pop_if_keyword
позаботятся о случаях, когда ожидаемого значения в стеке не оказалось:
def args
@expectations.each_with_object([]) do |expectation, args|
if expectation.is_a?(Keyword)
@stack.pop_if_keyword(expectation.type)
else
args << @stack.pop_if(expectation)
end
end
end
Теперь давайте сделаем так, чтобы наш класс мог быть использован как часть DSL:
def build(&block)
self.tap { |command| command.instance_eval(&block) }
end
Когда мы передаем блок в метод build
, этот блок выполняется в контексте объекта этого класса с помощью метода instance_eval. В результате, все инстанс–методы становятся доступны внутри блока.
Метод command
создает объект класса Command
и создает новый метод, который заставляет команду выполнить её execution_block
с аргументами:
def command(command_name, &block)
command = Command.new(@stack, @variables).build(&block)
define_method(command_name) do |*|
command.execution_block.call(@variables, *command.args)
end
end
Наконец, нам нужно сконфигурировать наши команды с помощью нового DSL:
command(:assign) do
keyword :variable
token
value
execute do |variables, token, value|
variables[token.name] = value.value
end
end
Команда assign
ожидает ключевое слово :variable
, некоторый токен и некоторое значение. Токен и значение будут переданы в блок execute
, внутри которого мы произведем присвоение.
Давайте убедимся, что наш DSL поможет добавлять дополнительные фразы в наш язык. К примеру, вычитание можно реализовать так:
def from(*)
@stack << Keyword.new(:from)
end
command(:deduct) do
token
keyword :from
token
execute do |variables, left, right|
result = variables[right.name] - variables[left.name]
print "#{result}\n"
end
end
assign variable x value 12
assign variable y value 5
deduct y from x
Выглядит гораздо лучше!
Теперь нам нужно разобраться с method_missing
, который объявлен на верхнем уровне . Если вы запускали предыдущие примеры, то скорее всего видели предупреждение от интерпретатора. Проблема в том, что из–за него любой вызов метода на nil
попадет в method_missing
, что сильно усложнит отладку. Давайте попробуем инкапсулировать его в контейнер для исполнения кода.
Пишем виртуальную машину
Взгляните на реализацию виртуальной машины для выполнения программы на естественном языке:
class VM
attr_reader :variables, :stack
def initialize
@variables = {}
@stack = Stack.new
end
def run(&block)
instance_eval(&block)
end
class << self
def command(command_name, &block)
define_method(command_name) { |*| Command.build(&block).run(self) }
end
def run(&block)
new.run(&block)
end
end
# Commands: same as before
# command(:assign)
# command(:sum)
# Primitives: same as before
# def variable(*)
# def value(value)
# def method_missing(token, *args, &block)
# def with(*)
# def from(*)
end
# Program
VM.run do
assign variable a value 1
assign variable b value 2
sum a with b
end
Основное изменение — программа на естественном языке будет выполняться внутри блока VM.run do
. Для того, чтобы это заработало, мы используем тот же трюк, что и в Command
: все нужные методы (такие как variable
) перемещаются в класс Vm
и становятся доступными внутри блока благодаря instance_eval
:
def run(&block)
instance_eval(&block)
end
@stack
и @variables
перестали быть глобальными и переместились в VM
.
В принципе, на этом можно было бы закончить, но давайте попробуем кое–что еще: что если наша виртуальная машина сможет запускать программы на разных естественных языках? Для создания языков мы сможем использовать специальный DSL, а затем передавать их в экземпляр VM
для исполнения:
lang = Lang.define do
command :assign do
keyword :variable
token
value
execute { |vm, token, value| vm.assign_variable(token, value) }
end
command :sum do
token
keyword :with
token
execute do |vm, left, right|
result = vm.read_variable(left) + vm.read_variable(right)
print "#{result}\n"
end
end
end
VM.run(lang) do
assign variable a value 1
assign variable b value 2
sum a with b
end
Главное преимущество такого подхода — наши «примитивы» (variable
, with
, etc.) также будут определяться динамически, на основе синтаксиса языка.
DSL для создания «естественных» языков
Как обычно, начнем с полного решения:
class Lang
def self.define(&block)
new.tap { |lang| lang.instance_eval(&block) }
end
def command(command_name, &block)
command = Command.build(command_name, &block)
register_keywords(command)
commands[command_name] = command
end
def keywords
@keywords ||= []
end
def commands
@commands ||= {}
end
private
def register_keywords(command)
command.expectations
.filter { |expectation| expectation.is_a?(Keyword) }
.reject { |keyword| keywords.include?(keyword.type) }
.each { |keyword| keywords << keyword.type }
end
end
class VM
def self.run(lang, &block)
lang.commands.each do |command_name, command|
define_method(command_name) { |*| command.run(self) }
end
new(lang).run(&block)
end
attr_reader :variables, :stack
def initialize(lang)
@lang = lang
@variables = {}
@stack = Stack.new
end
def run(&block)
instance_eval(&block)
end
def assign_variable(token, value)
@variables[token.name] = value.value
end
def read_variable(token)
@variables[token.name]
end
def value(value)
@stack << Value.new(value)
end
def method_missing(unknown, *args, &block)
klass = @lang.keywords.include?(unknown) ? Keyword : Token
@stack << klass.new(unknown)
end
end
# Language definition
lang = Lang.define do
command :assign do
keyword :variable
token
value
execute { |vm, token, value| vm.assign_variable(token, value) }
end
command :sum do
token
keyword :with
token
execute do |vm, left, right|
result = vm.read_variable(left) + vm.read_variable(right)
print "#{result}\n"
end
end
end
# Program
VM.run(lang) do
assign variable a value 1
assign variable b value 2
sum a with b
end
КлассLang
используется для настройки синтаксиса естественного языка. Обратите внимание, уже знакомый нам instance_eval
снова в деле:
def self.define(&block)
new.tap { |lang| lang.instance_eval(&block) }
end
command
— единственный метод, который мы будем использовать внутри блока, переданного в define
. Он принимает на вход название команды и её тело. Оба аргумента затем передаются в фабричный метод класса Command
:
def command(command_name, &block)
command = Command.build(command_name, &block)
register_keywords(command)
commands[command_name] = command
end
После инициализации команды нам нужно добавить её в список команд, а также зарегистрировать ключевые слова, которые она содержит:
def register_keywords(command)
command.expectations
.filter { |expectation| expectation.is_a?(Keyword) }
.reject { |keyword| keywords.include?(keyword.type) }
.each { |keyword| keywords << keyword.type }
end
Теперь мы можем создать язык, который содержит два ключевых слова (variable
и with
) и две команды (assign
и sum
):
lang = Lang.define do
command :assign do
keyword :variable
token
value
execute { |vm, token, value| vm.assign_variable(token, value) }
end
command :sum do
token
keyword :with
token
execute do |vm, left, right|
result = vm.read_variable(left) + vm.read_variable(right)
print "#{result}\n"
end
end
end
Теперь нам нужно внести изменения в класс VM
. Описание языка переехалов в отдельный класс, поэтому все относящиеся к конкретному языку можно удалить. Давате внесем изменения в метод run
чтобы добавлять их в виртуальную машину при запуске конкретного языка:
def self.run(lang, &block)
lang.commands.each do |command_name, command|
define_method(command_name) { |*| command.run(self) }
end
new(lang).run(&block)
end
Такое решение не идеально, так как эти методы останутся объявленными в экземпляре класса. Впрочем, для наших целей такое поведение допустимо, поэтому не будем усложнять ?
Еще одно важное изменение — появление низкоуровневых опереций в классе VM
. Их можно использовать внутри тела команд:
def assign_variable(token, value)
@variables[token.name] = value.value
end
def read_variable(token)
@variables[token.name]
end
def value(value)
@stack << Value.new(value)
end
Последнее изменение касается method_missing
: теперь метод может обрабатывать и токены и ключевые слова, как как список возможных ключевых слов есть в объекте класса Lang
:
def method_missing(unknown, *args, &block)
klass = @lang.keywords.include?(unknown) ? Keyword : Token
@stack << klass.new(unknown)
end
Все готово! Давайте создадим новый язык с другим синтаксисом и убедимся что он работает:
another_lang = Lang.define do
command(:set) do
keyword :variable
token
keyword :to
value
execute { |vm, token, value| vm.assign_variable(token, value) }
end
command(:access) do
keyword :variable
token
execute do |vm, token|
result = vm.read_variable(token)
print "#{result}\n"
end
end
end
VM.run(another_lang) do
set variable a to value 42
access variable a # => 42
end
Таки работает!
Travel planning
Вероятно вы думаете, что все, на что способен наш конструктор языков — это чтение и запись переменных. Попробую вас разубедить: сейчас мы создадим язык запросов, который сможет отвечать на вопросы о длительности маршрутов между городами. Вот как выглядит его синтаксис:
VM.run(lang) do
route from london to glasgow takes 22
route from paris to prague takes 12
how long will it take to get from london to glasgow
end
Было бы здорово сделать синтаксис вида
route from london to glasgow takes 22 hours
но этот код не является валидным с точки зрения парсера ?
Возможно ли реализовать такой язык? Конечно, но есть одна проблема: в текущей реализации мы не можем использовать takes
как метод, который принимает значение, стоящее после него. Дело в том, что для этой цели у нас есть метод value
и нет способа его переименовать.
Давайте внесем изменение в класс Command
так, чтобы его метод value
принимал аргумент и использовал его в качестве имени метода для сохранения значения (полная версия тут):
class Command
attr_reader :execution_block, :value_method_names
# ...
def value_method_names
@value_method_names ||= []
end
private
def value(method_name)
value_method_names << method_name
expectations << Value
end
# ...
end
Теперь нам нужно поменять класс Lang
так, чтобы он создавал соответствующий метод в классе VM
:
class VM
def self.run(lang, &block)
lang.commands.each do |command_name, command|
define_method(command_name) { |*| command.run(self) }
command.value_method_names.each do |value_method_name|
define_method(value_method_name) do |value|
@stack << Value.new(value)
end
end
end
new(lang).run(&block)
end
# no changes, but `value` method is removed
end
Всё, что нам осталось сделать — это создать наш язык запросов:
lang = Lang.define do
command :route do
keyword :from
token
keyword :to
token
value :takes
execute do |vm, city1, city2, distance|
distances = vm.read_variable(:distances) || {}
distances[[city1, city2]] = distance
vm.assign_variable(:distances, Value.new(distances))
end
end
command :how do
keyword :long
keyword :will
keyword :it
keyword :take
keyword :to
keyword :get
keyword :from
token
keyword :to
token
execute do |vm, city1, city2|
distances = vm.read_variable(:distances) || {}
distance = distances[[city1, city2]].value
puts "Travel from #{city1.name} to #{city2.name} takes #{distance} hours"
end
end
end
Как вы видите, мы используем наши низкоуровневые методы VM#read_variable
и VM#assign_variable
для того, чтобы сохранять и читать расстояния. Мы могли бы даже реализовать алгоритм Дейкстры и искать кратчайший путь по графу, но это выходит за рамки этого поста ?
На этом все! С помощью метапрограммирования мы смогли заставить интерпретатор Ruby запускать программы на «почти естественном языке». Разумеется, возможности такого интерпретатора довольно ограничены. Если у вас осталось желание поэксперементировать еще — ниже пара идей, которые я решил не реализовывать в рамках статьи:
Реализовать присвоение значения одной переменной из другой (e.g.,
assign variable b value a
).Сделать DSL команд менее многословным:
command(:assign, keyword(:variable).token.value) do |vm, token, value|
vm.assign_variable(token, value)
end
Подсказка: для реализации keyword(:variable).token.value
можно использовать CPS.