Давайте построим простой интерпретатор. Часть 10

четверг, мая 8, 2025 | 10 минут чтения

Давайте построим простой интерпретатор. Часть 10

Материалы ОП

Сегодня мы продолжим сокращать разрыв между тем, где мы находимся сейчас, и тем, где хотим быть: полностью функциональным интерпретатором для подмножества языка программирования Pascal.

alt text

В этой статье мы обновим наш интерпретатор, чтобы он мог разбирать и интерпретировать нашу самую первую полную программу на Pascal. Программа также может быть скомпилирована компилятором Free Pascal, fpc.

Вот сама программа:

PROGRAM Part10;
VAR
   number     : INTEGER;
   a, b, c, x : INTEGER;
   y          : REAL;

BEGIN {Part10}
   BEGIN
      number := 2;
      a := number;
      b := 10 * a + 10 * number DIV 4;
      c := a - - b
   END;
   x := 11;
   y := 20 / 7 + 3.14;
   { writeln('a = ', a); }
   { writeln('b = ', b); }
   { writeln('c = ', c); }
   { writeln('number = ', number); }
   { writeln('x = ', x); }
   { writeln('y = ', y); }
END.  {Part10}

Прежде чем мы начнем углубляться в детали, скачайте исходный код интерпретатора с GitHub и исходный код Pascal, указанный выше, и попробуйте его в командной строке:

$ python spi.py part10.pas
a = 2
b = 25
c = 27
number = 2
x = 11
y = 5.99714285714

Если я удалю комментарии вокруг операторов writeln в файле part10.pas, скомпилирую исходный код с помощью fpc, а затем запущу полученный исполняемый файл, вот что я получу на своем ноутбуке:

$ fpc part10.pas
$ ./part10
a = 2
b = 25
c = 27
number = 2
x = 11
y =  5.99714285714286E+000

Итак, давайте посмотрим, что мы сегодня рассмотрим:

  • Мы узнаем, как анализировать и интерпретировать заголовок Pascal PROGRAM
  • Мы узнаем, как анализировать объявления переменных Pascal
  • Мы обновим наш интерпретатор, чтобы использовать ключевое слово DIV для целочисленного деления и косую черту / для деления с плавающей точкой
  • Мы добавим поддержку комментариев Pascal

Давайте углубимся и сначала посмотрим на изменения в грамматике. Сегодня мы добавим несколько новых правил и обновим некоторые из существующих.

alt text alt text

Правило грамматики определения программы обновлено и включает зарезервированное ключевое слово PROGRAM, имя программы и блок, который заканчивается точкой. Вот пример полной программы на Pascal:

PROGRAM Part10;
BEGIN
END.

Правило блока объединяет правило declarations и правило compound_statement. Мы также будем использовать это правило позже в серии, когда добавим объявления процедур. Вот пример блока:

VAR
   number : INTEGER;

BEGIN
END

Вот еще один пример:

BEGIN
END

Объявления Pascal состоят из нескольких частей, и каждая часть является необязательной. В этой статье мы рассмотрим только часть объявления переменных. Правило declarations имеет либо подправило объявления переменных, либо оно пустое.

Pascal — это язык со статической типизацией, что означает, что каждой переменной требуется объявление переменной, которое явно указывает ее тип. В Pascal переменные должны быть объявлены до их использования. Это достигается путем объявления переменных в разделе объявления переменных программы с использованием зарезервированного ключевого слова VAR. Вы можете определить переменные следующим образом:

VAR
   number     : INTEGER;
   a, b, c, x : INTEGER;
   y          : REAL;

Правило type_spec предназначено для обработки типов INTEGER и REAL и используется в объявлениях переменных. В примере ниже

VAR
   a : INTEGER;
   b : REAL;

переменная «a» объявлена с типом INTEGER, а переменная «b» объявлена с типом REAL (float). В этой статье мы не будем применять проверку типов, но мы добавим проверку типов позже в серии.

Правило term обновлено для использования ключевого слова DIV для целочисленного деления и косой черты / для деления с плавающей точкой.

Раньше деление 20 на 7 с использованием косой черты давало целое число 2:

20 / 7 = 2 Теперь деление 20 на 7 с использованием косой черты даст REAL (число с плавающей точкой) 2.85714285714:

20 / 7 = 2.85714285714 Отныне, чтобы получить INTEGER вместо REAL, вам нужно использовать ключевое слово DIV:

20 DIV 7 = 2 Правило factor обновлено для обработки как целочисленных, так и вещественных (float) констант. Я также удалил подправило INTEGER, потому что константы будут представлены токенами INTEGER_CONST и REAL_CONST, а токен INTEGER будет использоваться для представления целочисленного типа. В примере ниже лексер сгенерирует токен INTEGER_CONST для 20 и 7 и токен REAL_CONST для 3.14:

y := 20 / 7 + 3.14;

Вот наша полная грамматика на сегодня:

    program : PROGRAM variable SEMI block DOT

    block : declarations compound_statement

    declarations : VAR (variable_declaration SEMI)+
                 | empty

    variable_declaration : ID (COMMA ID)* COLON type_spec

    type_spec : INTEGER | REAL

    compound_statement : BEGIN statement_list END

    statement_list : statement
                   | statement SEMI statement_list

    statement : compound_statement
              | assignment_statement
              | empty

    assignment_statement : variable ASSIGN expr

    empty :

    expr : term ((PLUS | MINUS) term)*

    term : factor ((MUL | INTEGER_DIV | FLOAT_DIV) factor)*

    factor : PLUS factor
           | MINUS factor
           | INTEGER_CONST
           | REAL_CONST
           | LPAREN expr RPAREN
           | variable

    variable: ID

В остальной части статьи мы пройдем тот же путь, что и в прошлый раз:

  • Обновим лексер
  • Обновим парсер
  • Обновим интерпретатор

Обновление лексера

Вот краткое изложение изменений в лексере:

  • Новые токены
  • Новые и обновленные зарезервированные ключевые слова
  • Новый метод skip_comment для обработки комментариев Pascal
  • Переименуем метод integer и внесем некоторые изменения в сам метод
  • Обновим метод get_next_token для возврата новых токенов

Давайте углубимся в изменения, упомянутые выше:

Чтобы обрабатывать заголовок программы, объявления переменных, целочисленные и вещественные константы, а также целочисленное и вещественное деление, нам нужно добавить несколько новых токенов — некоторые из которых являются зарезервированными ключевыми словами — и нам также нужно обновить значение токена INTEGER, чтобы он представлял целочисленный тип, а не целочисленную константу, такую как 3 или 5. Вот полный список новых и обновленных токенов:

  • PROGRAM (зарезервированное ключевое слово)
  • VAR (зарезервированное ключевое слово)
  • COLON (:)
  • COMMA (,)
  • INTEGER (мы меняем его, чтобы он означал целочисленный тип, а не целочисленную константу, такую как 3 или 5)
  • REAL (для типа Pascal REAL)
  • INTEGER_CONST (например, 3 или 5)
  • REAL_CONST (например, 3.14 и так далее)
  • INTEGER_DIV для целочисленного деления (зарезервированное ключевое слово DIV)
  • FLOAT_DIV для деления с плавающей точкой (косая черта /)

Вот полное сопоставление зарезервированных ключевых слов с токенами:

RESERVED_KEYWORDS = { ‘PROGRAM’: Token(‘PROGRAM’, ‘PROGRAM’), ‘VAR’: Token(‘VAR’, ‘VAR’), ‘DIV’: Token(‘INTEGER_DIV’, ‘DIV’), ‘INTEGER’: Token(‘INTEGER’, ‘INTEGER’), ‘REAL’: Token(‘REAL’, ‘REAL’), ‘BEGIN’: Token(‘BEGIN’, ‘BEGIN’), ‘END’: Token(‘END’, ‘END’), } Мы добавляем метод skip_comment для обработки комментариев Pascal. Метод довольно прост и все, что он делает, это отбрасывает все символы до тех пор, пока не будет найдена закрывающая фигурная скобка:

def skip_comment(self):
    while self.current_char != '}':
        self.advance()
    self.advance()  # the closing curly brace
Мы переименовываем метод integer в метод number. Он может обрабатывать как целочисленные константы, так и константы с плавающей точкой, такие как 3 и 3.14:

def number(self):
    """Return a (multidigit) integer or float consumed from the input."""
    result = ''
    while self.current_char is not None and self.current_char.isdigit():
        result += self.current_char
        self.advance()

    if self.current_char == '.':
        result += self.current_char
        self.advance()

        while (
            self.current_char is not None and
            self.current_char.isdigit()
        ):
            result += self.current_char
            self.advance()

        token = Token('REAL_CONST', float(result))
    else:
        token = Token('INTEGER_CONST', int(result))

    return token

Мы также обновляем метод get_next_token для возврата новых токенов:

def get_next_token(self):
    while self.current_char is not None:
        ...
        if self.current_char == '{':
            self.advance()
            self.skip_comment()
            continue
        ...
        if self.current_char.isdigit():
            return self.number()

        if self.current_char == ':':
            self.advance()
            return Token(COLON, ':')

        if self.current_char == ',':
            self.advance()
            return Token(COMMA, ',')
        ...
        if self.current_char == '/':
            self.advance()
            return Token(FLOAT_DIV, '/')
        ...

Обновление парсера

Теперь перейдем к изменениям в парсере.

Вот краткое изложение изменений:

  • Новые AST-узлы: Program, Block, VarDecl, Type
  • Новые методы, соответствующие новым правилам грамматики: block, declarations, variable_declaration и type_spec.
  • Обновления существующих методов парсера: program, term и factor

Давайте рассмотрим изменения по одному:

Начнем с новых AST-узлов. Есть четыре новых узла:

Узел Program AST представляет программу и будет нашим корневым узлом

class Program(AST):
    def __init__(self, name, block):
        self.name = name
        self.block = block

Узел Block AST содержит объявления и составной оператор:

class Block(AST):
    def __init__(self, declarations, compound_statement):
        self.declarations = declarations
        self.compound_statement = compound_statement

Узел VarDecl AST представляет объявление переменной. Он содержит узел переменной и узел типа:

class VarDecl(AST):
    def __init__(self, var_node, type_node):
        self.var_node = var_node
        self.type_node = type_node

Узел Type AST представляет тип переменной (INTEGER или REAL):

class Type(AST):
    def __init__(self, token):
        self.token = token
        self.value = token.value

Как вы, вероятно, помните, каждое правило из грамматики имеет соответствующий метод в нашем рекурсивном нисходящем парсере. Сегодня мы добавляем четыре новых метода: block, declarations, variable_declaration и type_spec. Эти методы отвечают за разбор новых языковых конструкций и построение новых AST-узлов:

def block(self):
    """block : declarations compound_statement"""
    declaration_nodes = self.declarations()
    compound_statement_node = self.compound_statement()
    node = Block(declaration_nodes, compound_statement_node)
    return node

def declarations(self):
    """declarations : VAR (variable_declaration SEMI)+
                    | empty
    """
    declarations = []
    if self.current_token.type == VAR:
        self.eat(VAR)
        while self.current_token.type == ID:
            var_decl = self.variable_declaration()
            declarations.extend(var_decl)
            self.eat(SEMI)

    return declarations

def variable_declaration(self):
    """variable_declaration : ID (COMMA ID)* COLON type_spec"""
    var_nodes = [Var(self.current_token)]  # first ID
    self.eat(ID)

    while self.current_token.type == COMMA:
        self.eat(COMMA)
        var_nodes.append(Var(self.current_token))
        self.eat(ID)

    self.eat(COLON)

    type_node = self.type_spec()
    var_declarations = [
        VarDecl(var_node, type_node)
        for var_node in var_nodes
    ]
    return var_declarations

def type_spec(self):
    """type_spec : INTEGER
                 | REAL
    """
    token = self.current_token
    if self.current_token.type == INTEGER:
        self.eat(INTEGER)
    else:
        self.eat(REAL)
    node = Type(token)
    return node

Нам также необходимо обновить методы program, term и factor, чтобы учесть изменения в нашей грамматике:

def program(self):
    """program : PROGRAM variable SEMI block DOT"""
    self.eat(PROGRAM)
    var_node = self.variable()
    prog_name = var_node.value
    self.eat(SEMI)
    block_node = self.block()
    program_node = Program(prog_name, block_node)
    self.eat(DOT)
    return program_node

def term(self):
    """term : factor ((MUL | INTEGER_DIV | FLOAT_DIV) factor)*"""
    node = self.factor()

    while self.current_token.type in (MUL, INTEGER_DIV, FLOAT_DIV):
        token = self.current_token
        if token.type == MUL:
            self.eat(MUL)
        elif token.type == INTEGER_DIV:
            self.eat(INTEGER_DIV)
        elif token.type == FLOAT_DIV:
            self.eat(FLOAT_DIV)

        node = BinOp(left=node, op=token, right=self.factor())

    return node

def factor(self):
    """factor : PLUS factor
              | MINUS factor
              | INTEGER_CONST
              | REAL_CONST
              | LPAREN expr RPAREN
              | variable
    """
    token = self.current_token
    if token.type == PLUS:
        self.eat(PLUS)
        node = UnaryOp(token, self.factor())
        return node
    elif token.type == MINUS:
        self.eat(MINUS)
        node = UnaryOp(token, self.factor())
        return node
    elif token.type == INTEGER_CONST:
        self.eat(INTEGER_CONST)
        return Num(token)
    elif token.type == REAL_CONST:
        self.eat(REAL_CONST)
        return Num(token)
    elif token.type == LPAREN:
        self.eat(LPAREN)
        node = self.expr()
        self.eat(RPAREN)
        return node
    else:
        node = self.variable()
        return node

Теперь давайте посмотрим, как выглядит абстрактное синтаксическое дерево с новыми узлами. Вот небольшая рабочая программа на Pascal:

PROGRAM Part10AST;
VAR
   a, b : INTEGER;
   y    : REAL;

BEGIN {Part10AST}
   a := 2;
   b := 10 * a + 10 * a DIV 4;
   y := 20 / 7 + 3.14;
END.  {Part10AST}

Давайте сгенерируем AST и визуализируем его с помощью genastdot.py:

$ python genastdot.py part10ast.pas > ast.dot && dot -Tpng -o ast.png ast.dot

alt text

На картинке вы можете увидеть новые узлы, которые мы добавили.

Обновление интерпретатора

Мы закончили с изменениями в лексере и парсере. Осталось добавить новые методы-посетители в наш класс Interpreter. Будет четыре новых метода для посещения наших новых узлов:

  • visit_Program
  • visit_Block
  • visit_VarDecl
  • visit_Type

Они довольно просты. Вы также можете видеть, что Interpreter ничего не делает с узлами VarDecl и Type:

def visit_Program(self, node):
    self.visit(node.block)

def visit_Block(self, node):
    for declaration in node.declarations:
        self.visit(declaration)
    self.visit(node.compound_statement)

def visit_VarDecl(self, node):
    # Do nothing
    pass

def visit_Type(self, node):
    # Do nothing
    pass

Нам также необходимо обновить метод visit_BinOp, чтобы правильно интерпретировать целочисленное деление и деление с плавающей точкой:

def visit_BinOp(self, node):
    if node.op.type == PLUS:
        return self.visit(node.left) + self.visit(node.right)
    elif node.op.type == MINUS:
        return self.visit(node.left) - self.visit(node.right)
    elif node.op.type == MUL:
        return self.visit(node.left) * self.visit(node.right)
    elif node.op.type == INTEGER_DIV:
        return self.visit(node.left) // self.visit(node.right)
    elif node.op.type == FLOAT_DIV:
        return float(self.visit(node.left)) / float(self.visit(node.right))

Давайте подытожим, что нам пришлось сделать, чтобы расширить интерпретатор Pascal в этой статье:

  • Добавить новые правила в грамматику и обновить некоторые существующие правила
  • Добавить новые токены и вспомогательные методы в лексер, обновить и изменить некоторые существующие методы
  • Добавить новые AST-узлы в парсер для новых языковых конструкций
  • Добавить новые методы, соответствующие новым правилам грамматики, в наш рекурсивный нисходящий парсер и обновить некоторые существующие методы
  • Добавить новые методы-посетители в интерпретатор и обновить один существующий метод-посетитель

В результате наших изменений мы также избавились от некоторых хаков, которые я представил в Части 9, а именно:

  • Наш интерпретатор теперь может обрабатывать заголовок PROGRAM
  • Переменные теперь можно объявлять с помощью ключевого слова VAR
  • Ключевое слово DIV используется для целочисленного деления, а косая черта / используется для деления с плавающей точкой

Если вы еще этого не сделали, то в качестве упражнения перепишите интерпретатор в этой статье, не глядя на исходный код, и используйте part10.pas в качестве входного тестового файла.

На сегодня это все. В следующей статье я более подробно расскажу об управлении таблицей символов. Оставайтесь с нами и до скорой встречи!

Литература