Статьи

Лексеры, парсеры и AST, О, МОЙ !: Как работает Ruby

Мы считаем само собой разумеющимся, что ruby my_app.rb выполнит наш код. Но как это работает? Что происходит под капотом? Давайте пройдемся по внутренностям и узнаем.

лексер

Первый порт захода — это лексер. Лексер читает ваш код и разбивает его на «токены» (по этой причине вы можете услышать, что Tokenizer и Lexer используются взаимозаменяемо). Что за знак вы спрашиваете? Отличный вопрос! Давайте посмотрим на пример:

 5.times do |x| puts x end 

Когда вы запускаете ruby my_app.rb , Ruby открывает файл и последовательно читает каждый символ. Первым является целое число (5), поэтому лексер знает, что тип, с которым он работает, является целочисленным объектом. 0 также является целым числом (что интересно, также считается как целое число, потому что оно может обозначать число с плавающей запятой), но как только он достигнет значения t , лексер распознает, что числовая часть этого процесса завершена, возвращается и назначает тип tINTEGER для знак

После . Лексер встречается times . Это классифицируется как identifier который является техническим способом обозначения имени класса, переменной или метода. Затем он встречает do которое является зарезервированным словом, поскольку оно является ключевым словом в языке. Как лексер различает зарезервированное слово и идентификатор? Ну, сам Ruby должен знать, что является ключевым словом, а что нет, поэтому внутренне он хранит список зарезервированных слов. Вот пример — вы можете увидеть этот импортированный файл синтаксического анализа, который мы рассмотрим через минуту.

На самом деле я немного упрощаю вещи. Процесс лексирования и синтаксического анализа был усовершенствован в современном Ruby, чтобы быть более эффективным. На самом деле происходит то, что функция lexing вызывается как часть процесса синтаксического анализа. Это делается в файле правил грамматики parse.y — 11 000 строк сложной логики и низкоуровневая обработка вашего исходного файла. Видите этот 600+ строчный регистр? Это твой лексер.

Достаточно теории. Давайте перейдем к чему-то практичному! Начиная с Ruby 1.9, Ruby поставляется с API для взаимодействия со своим Lexer. Это называется Потрошитель, и это интересное упражнение. Итак, попробуем. irb отличную irb или irb и следуйте:

 [1] pry(main)> require 'Ripper' => true [2] pry(main)> to_lex = <<STR [2] pry(main)* 5.times do |n| [2] pry(main)* puts n end [2] pry(main)* STR => "5.times do |n|\nputs n end\n" [3] pry(main)> pp Ripper.lex(to_lex) [[[1, 0], :on_int, "5"], [[1, 2], :on_period, "."], [[1, 3], :on_ident, "times"], [[1, 8], :on_sp, " "], [[1, 9], :on_kw, "do"], [[1, 11], :on_sp, " "], [[1, 12], :on_op, "|"], [[1, 13], :on_ident, "n"], [[1, 14], :on_op, "|"], [[1, 15], :on_ignored_nl, "\n"], [[2, 0], :on_ident, "puts"], [[2, 4], :on_sp, " "], [[2, 5], :on_ident, "n"], [[2, 6], :on_sp, " "], [[2, 7], :on_kw, "end"], [[2, 10], :on_nl, "\n"]] => [[[1, 0], :on_int, "5"], [[1, 2], :on_period, "."], [[1, 3], :on_ident, "times"], [[1, 8], :on_sp, " "], [[1, 9], :on_kw, "do"], [[1, 11], :on_sp, " "], [[1, 12], :on_op, "|"], [[1, 13], :on_ident, "n"], [[1, 14], :on_op, "|"], [[1, 15], :on_ignored_nl, "\n"], [[2, 0], :on_ident, "puts"], [[2, 4], :on_sp, " "], [[2, 5], :on_ident, "n"], [[2, 6], :on_sp, " "], [[2, 7], :on_kw, "end"], [[2, 10], :on_nl, "\n"]] 

Достаточно просто — нам требуется Ripper, передать некоторый источник в виде heredoc и вызвать метод to_lex Ripper. Результатом является наш токенизатор в действии; числа слева имеют формат [line, position] и префикс :on_ обозначает тип токена. Давайте посмотрим на похожий пример:

 [5] pry(main)> pp Ripper.lex(to_lex) [[[1, 0], :on_int, "5"], [[1, 2], :on_period, "."], [[1, 3], :on_ident, "times"], [[1, 8], :on_sp, " "], [[1, 9], :on_kw, "do"], [[1, 11], :on_ignored_nl, "\n"], [[2, 0], :on_ident, "puts"], [[2, 4], :on_sp, " "], [[2, 5], :on_ident, "n"], [[2, 6], :on_sp, " "], [[2, 7], :on_kw, "end"], [[2, 10], :on_nl, "\n"]] => [[[1, 0], :on_int, "5"], [[1, 2], :on_period, "."], [[1, 3], :on_ident, "times"], [[1, 8], :on_sp, " "], [[1, 9], :on_kw, "do"], [[1, 11], :on_ignored_nl, "\n"], [[2, 0], :on_ident, "puts"], [[2, 4], :on_sp, " "], [[2, 5], :on_ident, "n"], [[2, 6], :on_sp, " "], [[2, 7], :on_kw, "end"], [[2, 10], :on_nl, "\n"]] 

В этом примере я удалил блок ( |x| ) из heredoc, поэтому он выглядит так:

 "5.times do\nputs n end\n" 

Это было бы синтаксической ошибкой в ​​рамках среды выполнения Ruby. Однако лексеру это безразлично, потому что его работа заключается в простой маркировке входных данных, поскольку проверки синтаксиса применяются на другом уровне. Enter: Парсер

Парсер

У нас есть наши токены, но мы все еще должны их выполнить. Как мы это делаем? Ну, нам нужно что-то понимать, и это работа генератора парсеров. Генератор синтаксических анализаторов принимает ряд определенных правил (в отличие от грамматики в языке) и проверяет входные данные на предмет их соответствия. Ruby использует генератор синтаксических анализаторов, называемый Bison, и правила, по которым определяется язык, можно найти в ранее упомянутом parse.y . Это буквально где Ruby как язык определен.

Алгоритмы разбора

Алгоритм парсинга — это процесс, используемый для разбора токенов. Существует много разных типов, но один из них, который мы будем касаться, — это «Взгляд вперед», «Слева направо», крайнее правое отклонение (или LALR для краткости) Мои ограничения по количеству слов не позволят мне углубиться в разработку компиляторов, но вот некоторые ресурсы, если вы хотите лучше ознакомиться с некоторыми терминами.

В двух словах, это то, что делает парсер:

  • Перемещается слева направо, обрабатывая токены
  • Берет текущий токен и помещает его в стек
  • Проверяет грамматические правила на совпадение; добавляет соответствующие правила в таблицу состояний и удаляет правила, которые больше не возможны
  • Помещает следующий токен в стек и применяет тот же процесс
  • Выполняет процесс, аналогичный уменьшению стека, если это применимо
  • Заглядывает в будущее, чтобы проверить, совместим ли следующий токен с остальными правилами в таблице состояний. Если это так, то это продолжается, а если нет, то эта часть синтаксического анализа завершена, и процесс перезапускается. Это LR часть LALR .
  • Он повторяет этот процесс до тех пор, пока сокращенное содержимое стека не совпадет с одним грамматическим правилом (или не выдаст ошибки из-за неправильного синтаксиса!)

Анализатор записывает как список примененных состояний, так и список потенциальных состояний, в которые он может перемещаться. Другими словами, синтаксический анализатор — это по сути просто сложный конечный автомат, который Bison поддерживает для вас. Если по какой-то причине вы хотели бы видеть переходы состояний Ruby, вы можете запустить свою программу с флагом -y , который выводит отладочную информацию:

 Starting parse Entering state 0 Reducing stack by rule 1 (line 939): lex_state: EXPR_NONE -> EXPR_BEG at line 940 -> $$ = nterm $@1 () Stack now 0 Entering state 2 lex_state: EXPR_BEG -> EXPR_END at line 7386 Reading a token: Next token is token tINTEGER () Shifting token tINTEGER () Entering state 41 ... 

Надеемся, что некоторые из этих tINTEGER теперь имеют смысл: tINTEGER — это тип токена, который tINTEGER назначает токену, состояния обрабатываются парсером, а Reducing и Shifting — это парсер, проходящий через поток токена.

Каков результат разбора? Абстрактное синтаксическое дерево

Абстрактные синтаксические деревья

Lisp ) пользователи будут на знакомой почве здесь. Абстрактное синтаксическое дерево (AST) состоит из дерева «символических выражений» (для sexpressions или sexprs ) и существует как финальная стадия перед тем, как наша программа Ruby преобразуется в байт-код. AST хорошо подходит для этого, потому что все возможные пути через программу отображаются, что делает генерирование байт-кода производным. Именно в этот момент в процессе компиляции наша программа проверяется на семантический анализ корректности), и если вы писали транспортер для изменения вашего кода Ruby на другой целевой язык (или, возможно, шаблонизатор для вашего приложения Rack), это это то место, где вы могли бы зацепиться

Давайте посмотрим на наш код, представленный в виде AST:

 [1] pry(main)> require 'Ripper' => true [2] pry(main)> to_ast = <<STR [2] pry(main)* 5.times do |n| [2] pry(main)* puts n end [2] pry(main)* STR => "5.times do |n|\nputs n end\n" [3] pry(main)> pp Ripper.sexp(to_ast) 

Это даст:

 [:program, [[:method_add_block, [:call, [:@int, "5", [1, 0]], :".", [:@ident, "times", [1, 3]]], [:do_block, nil, [[:command, [:@ident, "puts", [2, 0]], [:args_add_block, [[:vcall, [:@ident, "n", [2, 5]]]], false]]]]]]] 

Вы также можете получить эту информацию, запустив ruby --dump parsetree your_program.rb .

Как и прежде, числа являются номерами строк / символов. Вот как ваша трассировка стека может точно сказать вам, где в вашем источнике вы допустили ошибку. ident конечно же, короток для identifier и является ссылкой на идентификатор метода или переменной, который мы рассмотрели в разделе Lexer.

Из этого AST теперь мы можем запустить наш код! Ruby обходит AST, преобразовывая каждый узел в инструкции для своей виртуальной машины (еще одна виртуальная машина Ruby или YARV). На данный момент есть некоторые небольшие оптимизации, такие как пользовательские инструкции для часто используемых операций, таких как сложение. Как работает YARV — это целый пост в itselfe. Достаточно сказать, что приятный синтаксис Ruby был изменен на код, который наши компьютеры понимают и работает так, как написано. Процесс сборки на самом деле не так сложен, как может показаться на первый взгляд. Это просто конвейер, который принимает некоторые входные данные, применяет набор правил и возвращает объект — в данном случае исполняемый код, который мы можем считать само собой разумеющимся как работающий.