Записи

Это вторая статья из цикла “Разра­ба­тываем свой язык програм­ми­ро­вания на Java”, первую статью можно прочитать по ссылке.

На текущем этапе у нас есть интер­пре­татор, способный выполнять команды нашего языка. Однако, этого недоста­точно, если мы хотим проверять код на наличие ошибок и понятным способом выводить их пользо­вателю. В данной статье мы рассмотрим, как добавить диагно­стику ошибок в язык. Прове­дение анализа ошибок в собственном языке програм­ми­ро­вания представляет собой важный этап в разра­ботке языка. Исполь­зо­вание мощных инстру­ментов, таких как ANTLR, позволяет в короткие сроки реали­зовать довольно эффек­тивные средства анализа кода, которые помогут выявить потен­ци­альные проблемы в программе на ранних стадиях разра­ботки, что способ­ствует улучшению качества программного обеспе­чения и повышению произ­во­ди­тель­ности разработчика.

Класси­фи­кация ошибок

Ошибки бывают разные, но в целом их можно разделить на три категории: синтак­си­ческиесеман­ти­ческие и ошибки времени испол­нения.

Синтак­си­ческие ошибки возникают из-за нарушения правил синтаксиса, установ­ленных для конкретного языка програм­ми­ро­вания. Синтак­си­ческие правила определяют, как должны быть органи­зованы инструкции и выражения в коде.

Пример синтак­си­ческой ошибки (отсут­ствует закры­вающая кавычка):

println("Hello, World!)

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

var a = 1 + 2 * 3

Ожидая, что переменная a будет равна 9, но на самом деле она будет равна 7. Это проис­ходит из-за того, что оператор умножения имеет более высокий приоритет, чем оператор сложения. Семан­ти­ческая ошибка обычно может быть обнаружена во время отладки или обширного тести­ро­вания программы.

Ошибки времени испол­нения, также известные как исклю­чения (Exceptions), возникают во время выпол­нения программы. Такие ошибки могут возникнуть из-за непра­вильного ввода данных, попытки доступа к несуще­ству­ющему файлу и многих других сценариев. Некоторые ошибки времени испол­нения могут быть обработаны в программе, но если этого не сделать, то обычно программа будет аварийно завершена.

Помимо ошибок, важно также обнару­живать потен­ци­альные проблемы или неоче­видные ситуации, которые не являются ошибками в строгом смысле, но могут привести к нежела­тельным послед­ствиям. Например, это может быть неисполь­зуемая переменная, исполь­зо­вание устаревших функций или бесмыс­ленная операция. На все подобные случаи пользо­вателю можно показывать преду­пре­ждения (Warnings).

JimpleBaseVisitor

Для выявления ошибок и преду­пре­ждений нам понадо­бится, знакомый из первой статьи абстрактный класс JimpleBaseVisitor (сгене­ри­рован ANTLR), который по-умолчанию реализует интерфейс JimleVisitor. Он позволяет обходить AST-дерево (Abstract Syntax Tree) и на основе анализа его узлов мы будем решать ошибка, преду­пре­ждение или нормальная часть кода. По сути, диагно­стика ошибок почти не отличается от интер­пре­тации кода, кроме случаев когда нам нужно выполнять ввод/вывод или обращаться к внешним ресурсам. Например, если выпол­няется команда вывода в консоль, то наша задача проверить допустимый ли тип данных передается в качестве аргумента, без непосред­ственного вывода в консоль.

Создадим класс JimpleDiagnosticTool, который наследует JimleBaseVisitor и будет инкап­су­ли­ровать в себе всю логику поиска и хранения ошибок:

class JimpleDiagnosticTool extends JimpleBaseVisitor<ValidationInfo> {
    private Set<Issue> issues = new LinkedHashSet<>();
}

record Issue(IssueType type, String message, int lineNumber, int lineOffset, String details) {}

Данный класс содержит в себе список типа Issue, который представляет инфор­мацию о конкретной ошибке.

Известно, что каждый метод данного класса должен возвращать значение опреде­ленного типа. В нашем случае мы будем возвращать инфор­мацию о типе узла в дереве — ValidationInfo. Также данный класс содержит инфор­мацию о возможном значении, это поможет нам выявлять некоторые семан­ти­ческие ошибки или ошибки времени выполнения.

record ValidationInfo(ValidationType type, Object value) {}

enum ValidationType {
    /**
     * Expression returns nothing.
     */
    VOID,

    /**
     * Expression is String
     */
    STRING,

    /**
     * Expression is double
     */
    DOUBLE,

    /**
     * Expression is long
     */
    NUMBER,

    /**
     * Expression is boolean
     */
    BOOL,

    /**
     * Expression contains error and analysing in another context no makes sense.
     */
    SKIP,

    /**
     * When object can be any type. Used only in Check function definition mode.
     */
    ANY,

    /**
     * Tree part is function declaration
     */
    FUNCTION_DEFINITION
}

Следует обратить внимание на значение ValidationType.SKIP. Оно будет исполь­зо­ваться в случае если в части дерева была найдена и уже зареги­стри­рована ошибка, и дальнейший анализ этого узла дерева не имеет смысла. Например, если в выражении суммы один аргумент содержит ошибку, то анализ второго аргумента выражения не будет проводиться.

ValidationInfo checkBinaryOperatorCommon(ParseTree leftExp, ParseTree rightExp, Token operator) {
    ValidationInfo left = visit(leftExp);
    if (left.isSkip()) {
        return ValidationInfo.SKIP;
    }
    ValidationInfo right = visit(rightExp);
    if (right.isSkip()) {
        return ValidationInfo.SKIP;
    }
    // code omitted
}

Listeners vs Visitors

Перед тем как двигаться дальше, давайте посмотрим на еще один сгене­ри­ро­ванный ANTLR-ом интерфейс JimpleListener (шаблон Observer), который тоже может быть исполь­зован, если нам нужно обходить AST-дерево. В чем разница между ними? Самое большое различие между этими механизмами в том, что методы listener вызываются ANTLR-ом для каждого узла всегда, тогда как методы visitor должны обходить свои дочерние элементы явными вызовами. И если программист не вызывает visit() на дочерних узлах, то эти узлы не посещаются, т.е. у нас есть возмож­ность управлять обходом дерева. Например, в нашей реали­зации тело функции посещается сначала один раз полностью (режим checkFuncDefinition==true) для выявления ошибок во всей функции (все блоки if и else), и несколько раз с конкретными значе­ниями аргументов:

@Override
ValidationInfo visitIfStatement(IfStatementContext ctx) {
    // calc expression in "if" condition
    ValidationInfo condition = visit(ctx.expression());

    if (checkFuncDefinition) {
        visit(ctx.statement());
        // as it's just function definition check, check else statement as well
        JimpleParser.ElseStatementContext elseStatement = ctx.elseStatement();
        if (elseStatement != null) {
            visit(elseStatement);
        }
        return ValidationInfo.VOID;
    }

    // it's not check function definition, it's checking of certain function call
    if (condition.isBool() && condition.hasValue()) {
        if (condition.asBoolean()) {
            visit(ctx.statement());
        } else {
            JimpleParser.ElseStatementContext elseStatement = ctx.elseStatement();
            if (elseStatement != null) {
                visit(elseStatement);
            }
        }
    }

    return ValidationInfo.VOID;
}

Шаблон Visitor работает очень хорошо, если нам необходимо спроеци­ровать опреде­ленное значение для каждого узла дерева. Это именно то, что нам нужно.

Отлов синтак­си­ческих ошибок

Для того чтобы найти в коде некоторые синтак­си­ческие ошибки, нам необходимо реали­зовать интерфейс ANTLRErrorListener. Данный интерфейс содержит четыре метода, которые будут вызываться (парсером и/или лексером) в случае ошибки или неопре­де­ленного поведения:

interface ANTLRErrorListener {
    void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e);
    void reportAmbiguity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, boolean exact, BitSet ambigAlts, ATNConfigSet configs);
    void reportAttemptingFullContext(Parser recognizer, DFA dfa, int startIndex, int stopIndex, BitSet conflictingAlts, ATNConfigSet configs);
    void reportContextSensitivity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, int prediction, ATNConfigSet configs);
} 

Название первого метода (syntaxError) говорит само за себя, он будет вызываться в случае синтак­си­ческой ошибки. Реали­зация довольно простая: нам нужно преоб­ра­зовать инфор­мацию об ошибке в объект типа Issue и добавить его в список ошибок:

@Override
void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
    int offset = charPositionInLine + 1;
    issues.add(new Issue(IssueType.ERROR, msg, line, offset, makeDetails(line, offset)));
}

Остальные три метода можно игнори­ровать. Также ANTLR сам реализует этот интерфейс (класс ConsoleErrorListener) и отправляет ошибки в стандартный поток ошибок (System.err). Чтобы отключить его и другие стандартные обработчики, нам необходимо вызвать метод removeErrorListeners у парсера и лексера:

    // убираем стандартные обработчики ошибок
    lexer.removeErrorListeners();
    parser.removeErrorListeners();

Другой тип синтак­си­ческих ошибок базируется на правилах конкретного языка. Например, в нашем языке функция иденти­фи­ци­руется по имени и количеству аргументов. Когда анали­затор встречает вызов функции, то он проверяет, существует ли функция с таким именем и количе­ством аргументов. Если нет, то он выдает ошибку. Для этого нам необходимо переопре­делить метод visitFunctionCall:

@Override
ValidationInfo visitFunctionCall(FunctionCallContext ctx) {
    String funName = ctx.IDENTIFIER().getText();
    int argumentsCount = ctx.expression().size();
    var funSignature = new FunctionSignature(funName, argumentsCount, ctx.getParent());
    // ищем функцию в контексте по сигнатуре (имя+количество аргументов)
    var handler = context.getFunction(funSignature);

    if (handler == null) {
        addIssue(IssueType.ERROR, ctx.start, "Function with such signature not found: " + funName);
        return ValidationInfo.SKIP;
    }

    // code omitted
}

Давайте проверим конструкцию if. Jimple требует, чтобы выражение в условии if было типа boolean:

@Override
ValidationInfo visitIfStatement(IfStatementContext ctx) {
    // visit expression
    ValidationInfo condition = visit(ctx.expression());
    // skip if expression contains error
    if (condition.isSkip()) {
        return ValidationInfo.SKIP;
    }

    if (!condition.isBool()) {
        addIssue(IssueType.WARNING, ctx.expression().start, "The \"if\" condition must be of boolean type only. But found: " + condition.type());
    }

    // code omitted
}

Внима­тельный читатель заметит, что в данном случае мы добавили преду­пре­ждение, а не ошибку. Это сделано из-за того, что наш язык является динами­ческим и нам не всегда известна точная инфор­мация о типе выражения.

Поиск семан­ти­ческих ошибок

Как уже было сказано ранее, семан­ти­ческие ошибки сложны в поиске и часто могут быть найдены только во время отладки или тести­ро­вания программы. Однако, некоторые из них можно выявить на этапе компи­ляции. Например, если мы знаем, что функция X всегда возвращает значение 0, то мы можем выдать преду­пре­ждение, если в выражении деления в качестве делителя исполь­зуется данная функция. Деление на ноль обычно считается семан­ти­ческой ошибкой, поскольку деление на ноль не имеет смысла в математике.

Пример детек­ти­ро­вания ошибки “Деление на ноль”: сраба­тывает в случае когда в качестве делителя исполь­зуется выражение, которое всегда возвращает значение 0:

ValidationInfo checkBinaryOperatorForNumeric(ValidationInfo left, ValidationInfo right, Token operator) {
    if (operator.getType() == JimpleParser.SLASH && right.hasValue() && ((Number) right.value()).longValue() == 0) {
        // if we have value of right's part of division expression and it's zero
        addIssue(IssueType.WARNING, operator, "Division by zero");
    }

    // code omitted
}

Ошибки времени исполнения

Ошибки времени испол­нения также тяжело или даже невоз­можно обнаружить на этапе компиляции/интерпретации. Однако, некоторые подобные ошибки всё же можно выявить. Например, если функция вызывает сама себя (напрямую или через другую функцию), то это может привести к ошибке перепол­нения стека (StackOverflow). Первое что нам нужно сделать – это объявить список (Set), где мы будем сохранять функции, которые находятся в процессе вызова в данной момент. Саму проверку можно (и нужно) разме­стить в методе handleFuncInternal обработки вызова функции. В начале этого метода находится проверка наличия текущего FunctionDefinitionContext (контекст объяв­ления функции) в списке уже вызванных функций, и если да, то регистрируем преду­пре­ждение (Warning) и прерываем дальнейшую обработку функции. Если нет, то добавляем текущий контекст в наш список, и далее следует остальная логика. При выходе из handleFuncInternal нужно удалить из списка текущий контекст функции. Здест следует обратить внимание, что в данном случае мы не только выявили потен­ци­альный StackOverflow, но и избавились от этой же ошибки во время проверки кода, а именно при выполении зацик­ли­вания метода handleFuncInternal.

Set<FunctionDefinitionContext> calledFuncs = new HashSet<>();

ValidationInfo handleFuncInternal(List<String> parameters, List<Object> arguments, FunctionDefinitionContext ctx) {
    if (calledFuncs.contains(ctx)) {
        addIssue(IssueType.WARNING, ctx.name, String.format("Recursive call of function '%s' can lead to StackOverflow", ctx.name.getText()));
        return ValidationInfo.SKIP;
    }
    calledFuncs.add(ctx);
    
    // other checkings

    calledFuncs.remove(ctx);

    // return resulting ValidationInfo
}

Анализ потока управления/данных

Для более глубокого иссле­до­вания программного кода, оптими­зации и выявления сложных ошибок также используют Анализ потока управ­ления (Control-flow analysis) и Анализ потока данных (Data-flow analysis).

Анализ потока управ­ления фокуси­руется на понимании того, какие части программы выпол­няются в зависи­мости от различных условий и управ­ляющих структур, таких как условные операторы (if-else), циклы и переходы. Он позволяет выявить пути выпол­нения программы и иденти­фи­ци­ровать потен­ци­альные ошибки, связанные с непра­вильной логикой управ­ления потоком. Например, недости­жимый код или потен­ци­альные точки зависания программы.

С другой стороны, анализ потока данных сосре­до­та­чи­вается на том, как данные распро­стра­няются и исполь­зуются внутри программы. Он помогает выявить потен­ци­альные проблемы с данными, такие как исполь­зо­вание неини­ци­а­ли­зи­ро­ванных переменных, зависи­мости данных и возможные утечки памяти. Анализ потока данных может также обнару­живать ошибки, связанные с непра­вильными опера­циями над данными, такими как исполь­зо­вание некор­ректных типов или непра­вильных (бессмыс­ленных) вычислений.

Резюме

В этой статье мы рассмотрели, как добавить диагно­стику ошибок и преду­пре­ждений в свой язык програм­ми­ро­вания. Узнали, какие инстру­менты из коробки предо­ставляет ANTLR для регистрации синтак­си­ческих ошибок. Реали­зовали обработку некоторых ошибок и потен­ци­альных проблем во время выпол­нения программы.

Весь исходный код интер­пре­татора можно посмотреть по ссылке.

Ссылки

Это первая статья из цикла “Разра­ба­тываем свой язык програм­ми­ро­вания на Java”, который на примере разра­ботки простого языка, покажет полный путь создания языка, а также написания и поддержки инстру­ментов для него. К концу данной статьи реализуем интер­пре­татор, с помощью которого можно будет выполнять программы на нашем языке.

Любой язык програм­ми­ро­вания имеет синтаксис, который необходимо преоб­ра­зовать в удобную для валидации, преоб­ра­зо­вания и испол­нения структуру данных. Как правило, такой струк­турой данных выступает абстрактное синтак­си­ческое дерево (abstract syntax tree, AST). Каждый узел дерева обозначает конструкцию, встре­ча­ю­щуюся в исходном коде. Исходный код разби­рается парсером и на выходе получается AST.

Языки разра­ба­ты­ваются давно, и поэтому на текущий момент мы имеем довольно много зрелых инстру­ментов, в том числе генераторы парсеров. Генераторы парсеров на вход принимают описание грамматики конкретного языка, а на выходе получаем парсеры, интер­пре­таторы и компиляторы.

В данной статье рассмотрим инструмент ANTLR. ANTLR – утилита, на вход которой подается грамматика в виде РБНФ, а на выходе получаем интерфейсы/классы (в нашем случае это java-код) для разбора программ. Список языков, на которых генери­руются парсеры, можно найти здесь.

Пример грамматики

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

  • ПЕРЕМЕННАЯ – это ИДЕНТИФИКАТОР
  • ЦИФРА – это один из символов 0 1 2 3 4 5 6 7 8 9
  • ЧИСЛО – это один или более элементов типа ЦИФРА
  • ВЫРАЖЕНИЕ – это ЧИСЛО
  • ВЫРАЖЕНИЕ – это ПЕРЕМЕННАЯ
  • ВЫРАЖЕНИЕ – это ВЫРАЖЕНИЕ ‘+’ ВЫРАЖЕНИЕ
  • ВЫРАЖЕНИЕ – это ‘(‘ ВЫРАЖЕНИЕ ’)’

Как видно из данного списка, языковая грамматика – это набор правил, которые могут иметь рекур­сивные связи. Каждое правило может ссылаться на себя или на любое другое правило. ANTLR в своем арсенале имеет множество опера­торов для описания таких правил.

: метка начала правила
; метка конца правила
| оператор альтернативы
.. оператор диапазона
~ отрицание
. любой символ
= присваивание
(...) подправило
(...)* повторение подправила 0 или более раз
(...)+ Повторение подправила 1 или более раз
(...)? подправило, может отсутствовать
{...} семантические действия (на языке, использующемся в качестве выходного – например, Java)
[...] параметры правила

Примеры правил на ANTLR

Следующий пример описывает правила для целых чисел и чисел с плавающей точкой:

NUMBER : [0-9]+ ;
FLOAT  : NUMBER '.' NUMBER ;

Очень важно понимать, что грамматика описывает только синтаксис языка, на основе которого будет генери­ро­ваться парсер. Парсер будет генери­ровать AST, используя который, можно будет реали­зовать семантику языка. В преды­дущем примере, мы задали правило для разбора целого числа, но не описали какой объем памяти занимает число (8 бит, 16, …), является ли число со знаком или без. Например, в некоторых языках програм­ми­ро­вания переменную можно начать исполь­зовать, не объявив ее. Также можно не объявлять тип переменной, в этом случае тип будет определен автома­ти­чески в рантайме. Все эти правила семантики языка не описы­ваются в грамматике, а реали­зуются в другой части языка.

Лексемы и выражения ANTLR

Грамматика ANTLR состоит из двух типов правил: лексем и выражений, которые исполь­зуются для опреде­ления структуры грамматики и разбора входных данных.

Лексемы (или токены) – это правила, которые определяют отдельные лекси­ческие элементы входного языка, такие как числа, иденти­фи­каторы, знаки операций и т.д. Каждой лексеме соответ­ствует опреде­ленный тип токена, который исполь­зуется для дальнейшей обработки синтак­си­ческим анали­за­тором. Лекси­ческий анали­затор сканирует входной текст, разбивает его на лексемы и создает после­до­ва­тель­ность токенов, которые затем передаются синтак­си­че­скому анали­затору. Записы­ваются в верхнем регистре (Например: NUMBERIDENTIFIER).

Выражения – это правила, которые определяют структуру грамматики входного языка. Они описывают, каким образом лексемы связаны между собой и как они могут быть объединены в более сложные конструкции. Выражения могут содержать ссылки на лексемы, а также на другие выражения. Записы­ваются в нотации сamelCase (Например: expressionfunctionDefinition).

Таким образом, разница между лексемами и выраже­ниями в ANTLR заклю­чается в том, что лексемы определяют отдельные лекси­ческие элементы входного языка и преоб­разуют их в токены, а выражения определяют структуру грамматики и описывают, каким образом лексемы связаны между собой в более сложные конструкции.

Требо­вания к языку

Перед тем как начать реали­зо­вывать язык необходимо опреде­литься с набором возмож­ностей, которые он должен поддер­живать. Для нашей задачи, в образо­ва­тельных целях, мы будем исполь­зовать простую грамматику. Язык будет поддер­живать следующие конструкции:

  • Переменные (типов StringLongDouble);
  • Оператор присва­и­вания (=);
  • Арифме­ти­ческие операции (+, -, *, /);
  • Операторы сравнения (>, <, >=, <=, ==, !=);
  • Условные операторы (if, else);
  • Функции;
  • Печать в консоль (встро­енный оператор println).

Грамматика

Ну и наконец, полное описание грамматики для языка:

grammar Jimple;

// корневое правило грамматики
program: (statement)* EOF;

// список возможных утверждений
statement: variableDeclaration
         | assignment
         | functionDefinition
         | functionCall
         | println
         | return
         | ifStatement
         | blockStatement
         ;

// список возможных выражений
expression: '(' expression ')'                                      #parenthesisExpr
          | left=expression op=(ASTERISK | SLASH) right=expression  #mulDivExpr
          | left=expression op=(PLUS | MINUS) right=expression      #plusMinusExpr
          | left=expression compOperator right=expression           #compExpr
          | IDENTIFIER                                              #idExp
          | NUMBER                                                  #numExpr
          | DOUBLE_NUMBER                                           #doubleExpr
          | STRING_LITERAL                                          #stringExpr
          | functionCall                                            #funcCallExpr
          ;

// описания отдельных выражений и утверждений
variableDeclaration: 'var' IDENTIFIER '=' expression ;

assignment: IDENTIFIER '=' expression ;

compOperator: op=(LESS | LESS_OR_EQUAL | EQUAL | NOT_EQUAL | GREATER | GREATER_OR_EQUAL) ;

println: 'println' expression ;

return: 'return' expression ;

blockStatement: '{' (statement)* '}' ;

functionCall: IDENTIFIER '(' (expression (',' expression)*)? ')' ;

functionDefinition: 'fun' name=IDENTIFIER '(' (IDENTIFIER (',' IDENTIFIER)*)? ')' '{' (statement)* '}' ;

ifStatement: 'if' '(' expression ')' statement  elseStatement? ;

elseStatement: 'else' statement ;

// список токенов
IDENTIFIER          : [a-zA-Z_] [a-zA-Z_0-9]* ;
NUMBER              : [0-9]+ ;
DOUBLE_NUMBER       : NUMBER '.' NUMBER ;
STRING_LITERAL      : '"' (~["])* '"' ;

ASTERISK            : '*' ;
SLASH               : '/' ;
PLUS                : '+' ;
MINUS               : '-' ;

ASSIGN              : '=' ;
EQUAL               : '==' ;
NOT_EQUAL           : '!=' ;
LESS                : '<' ;
LESS_OR_EQUAL       : '<=' ;
GREATER             : '>' ;
GREATER_OR_EQUAL    : '>=' ;

SPACE               : [ \r\n\t]+ -> skip;
LINE_COMMENT        : '//' ~[\n\r]* -> skip;

Как вы уже, наверное, догадались, наш язык называется Jimple (проис­ходит от Jvm Simple). Пожалуй, стоит объяснить некоторые моменты, которые могут быть неоче­видными при первом знакомстве с ANTLR.

Метки

При описании правил некоторых операций была исполь­зована метка op. Это позволяет нам в дальнейшем исполь­зовать эту метку в качестве имени переменной, которая будет содержать значение оператора. В принципе, можно было бы обойтись без указания меток, но в таком случае придется писать допол­ни­тельный код, чтобы достать значение оператора из дерева разбора.

compOperator: op=(LESS | LESS_OR_EQUAL | EQUAL | NOT_EQUAL | GREATER | GREATER_OR_EQUAL) ;

Имено­ванные альтер­нативы правил

В ANTLR при опреде­лении правила с несколькими альтер­на­тивами каждому из них можно дать имя, и тогда в дереве это будет отдельный узел обработки. Что очень удобно когда нужно вынести обработку каждого варианта правила в отдельный метод. Важно, что наиме­но­вания нужно задать либо всем альтер­на­тивам, либо ни одной из них. Следующий пример демон­стрирует, как это выглядит:

expression: '(' expression ')'  #parenthesisExpr
          | IDENTIFIER          #idExp
          | NUMBER              #numExpr

ANTLR сгене­рирует следующий код:

public interface JimpleVisitor<T> {
    T visitParenthesisExpr(ParenthesisExprContext ctx);

    T visitIdExp(IdExpContext ctx);

    T visitNumExpr(NumExprContext ctx);
}

Каналы

В ANTLR существует такая конструкция как канал (channel). Обычно каналы исполь­зуются для работы с коммен­та­риями, но так как в большинстве случаев нам не нужно проверять наличие коммен­тариев их необходимо отбросить с помощью -> skip, чем мы и восполь­зо­вались. Однако, бывают случаи, когда нам нужно интер­пре­ти­ровать значение коммен­тариев или других конструкций, тогда вы исполь­зуете каналы. В ANTLR уже есть встро­енный канал под названием HIDDEN, который вы можете исполь­зовать, или объявить свои каналы для опреде­ленных целей. Далее при разборе кода можно будет получить доступ к этим каналам.

Пример объяв­ления и исполь­зо­вания канала

channels { MYLINECOMMENT }

LINE_COMMENT : '//' ~[rn]+ -> channel(MYLINECOMMENT) ;

Фрагменты

На ряду с токенами (лексемами) в ANTLR присут­ствует такое понятие как фрагменты (fragment). Правила с префиксом фрагмента могут быть вызваны только из других правил лексера. Они не являются токенами сами по себе. В следующем примере мы вынесли во фрагменты опреде­ления чисел для разных систем счисления.

NUMBER: DIGITS | OCTAL_DIGITS | HEX_DIGITS;
fragment DIGITS: '1'..'9' '0'..'9'*;
fragment OCTAL_DIGITS: '0' '0'..'7'+;
fragment HEX_DIGITS: '0x' ('0'..'9' | 'a'..'f' | 'A'..'F')+;

Таким образом, число в любой системе счисления (например: «123», «0762» или «0xac1») будет рассмат­ри­ваться как токен NUMBER, а не DIGITS, OCTAL_DIGITS или HEX_DIGITS. В Jimple фрагменты не используются.

Инстру­менты

Перед тем как приступить к генерации парсера, нам нужно настроить инстру­менты для работы с ANTLR. Как известно, хороший и удобный инструмент — это половина успеха. Для этого нам понадо­бится скачать ANTLR библиотеку и написать скрипты для его запуска. Существуют также maven/gradle/IntelliJ IDEA плагины, которые мы не будем исполь­зовать в этой статье, но для продук­тивной разра­ботки они могут быть полезны.

Нам понадо­бятся следующие скрипты:

Скрипт antlr4.sh

java -Xmx500M -cp ".:/usr/lib/antlr-4.12.0-complete.jar" org.antlr.v4.Tool $@

Скрипт grun.sh

java -Xmx500M -cp ".:/usr/lib/antlr-4.12.0-complete.jar" org.antlr.v4.gui.TestRig $@

Генерация парсера

Сохраните грамматику в файле Jimple.g4. Далее запустите скрипт следующим образом:

antlr4.sh Jimple.g4 -package org.jimple.lang -visitor

Параметр -package позволяет указать java package, в котором будет сгене­ри­рован код. Параметр -visitor позволяет сгене­ри­ровать интерфейс JimpleVisitor, который реализует паттерн Visitor.

После успешного выпол­нения скрипта в текущей дирек­тории появятся несколько файлов: JimpleParser.javaJimpleLexer.javaJimpleListener.javaJimpleVisitor.java. Первые два файла содержат сгене­ри­ро­ванный код парсера и лексера соответ­ственно. Остальные два файла содержат интер­фейсы для работы с деревом разбора. В этой статье мы будем исполь­зовать интерфейс JimpleVisitor, а точнее JimpleBaseVisitor — это также сгене­ри­ро­ванный класс, который реализует интерфейс JimpleVisitor и содержит реали­зации всех методов. Это позволяет переопре­делить только те методы, которые нам нужны.

Реали­зация интерпретатора

Наконец-то мы добрались до самого интересного — реали­зации интер­пре­татора. Хотя в данной статье не будем затра­гивать вопрос проверки кода на ошибки, но ошибки интер­пре­тации все-таки будут реали­зованы. Для начала, создадим класс JimpleInterpreter с методом eval, на вход которого будет подаваться строка с кодом на Jimple. Далее нам нужно разобрать исходный код на токены с помощью JimpleLexer, затем создать дерево AST, используя JimpleParser.

public class JimpleInterpreter {
    public Object eval(final String input) {
        // разбираем исходный код на токены
        final JimpleLexer lexer = new JimpleLexer(CharStreams.fromString(input));
        // создаем дерево AST
        final JimpleParser parser = new JimpleParser(new CommonTokenStream(lexer));
        // создаем объект класса JimpleInterpreterVisitor
        final JimpleInterpreterVisitor interpreterVisitor = new JimpleInterpreterVisitor(new JimpleContextImpl(stdout));
        // запускаем интерпретатор
        return interpreterVisitor.visitProgram(parser.program());
    }
}

У нас есть синтак­си­ческое дерево. Давайте добавим семантики с помощью написанного нами класса JimpleInterpreterVisitor, который будет обходить AST, вызывая соответ­ствующие методы. Так как корневым правилом нашей грамматики является правило program (см. выше program: (statement)* EOF), то обход дерева начинается именно с него. Для этого вызываем реали­зо­ванный по умолчанию метод visitProgram у объекта JimpleInterpreterVisitor, на вход которого даем объект класса ProgramContext. Реали­зация ANTLR состоит из вызова метода visitChildren(RuleNode node), который обходит все дочерние элементы заданного узла дерева, вызывая для каждого из них метод visit.

// код сгенерирован ANTLR
public class JimpleBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements JimpleVisitor<T> {
    @Override public T visitProgram(JimpleParser.ProgramContext ctx) {
        return visitChildren(ctx);
    }

    // другие методы опущены для краткости
}

Как можно заметить, JimpleBaseVisitor — это generic-класс, для которого нужно определить тип обработки каждого узла. В нашем случае это класс Object, так как выражения могут возвращать значения разного типа. Обычно выражение (expression) должно вернуть какое-либо значение, а утвер­ждение (statement) ничего не возвращает. В этом и заклю­чается их различие. В случае утвер­ждения можем возвращать null. Однако, чтобы случайно не столк­нуться с NullPointerException, вместо null будем возвращать объект типа Object, который глобально определен в классе JimpleInterpreter:

public static final Object VOID = new Object();

Класс JimpleInterpreterVisitor расширяет класс JimpleBaseVisitor, переопре­деляя только интере­сующие нас методы. Рассмотрим реали­зацию встро­енного оператора println, который в грамматике описан как println: 'println' expression ;. Первое, что нужно сделать — это вычислить выражение expression, для этого нам нужно вызвать метод visit и передать в него объект expression из текущего контекста PrintlnContext. В методе visitPrintln нас совер­шенно не интересует, как вычис­ляется выражение, за вычис­ление каждого правила (контекста) отвечает соответ­ствующий метод. Например, для вычис­ления строкового литерала исполь­зуется метод visitStringExpr.

public class JimpleInterpreterVisitor extends JimpleBaseVisitor<Object> {
    @Override
    public Object visitPrintln(final JimpleParser.PrintlnContext ctx) {
        final Object result = visit(ctx.expression());
        System.out.println(result);
        return null;
    }

    @Override
    public Object visitStringExpr(final JimpleParser.StringExprContext ctx) {
        // возвращаем строковый литерал
        return cleanStringLiteral(ctx.STRING_LITERAL().getText());
    }

    private String cleanStringLiteral(final String literal) {
        // очистить строку от кавычек
        return literal.length() > 1 ? literal.substring(1, literal.length() - 1) : literal;
    }

    // другие методы опущены для краткости
}

Реали­зовав только лишь эти методы, интер­пре­татор уже поддер­живает println и строковые литералы, что позволяет нам выполнить код println "Hello, Jimple!".

Запуск интер­пре­татора

Для запуска интер­пре­татора нужно создать стандартный метод main, который после небольших проверок, используя класс JimpleInterpreter, будет запускать наш код:

public class MainApp {
    public static void main(String[] args) {
        if (args.length < 1) {
            System.out.println("usage: jimple input.jimple");
            System.exit(1);
        }

        Path path = Paths.get(args[0]);
        if (!Files.exists(path)) {
            System.err.println("File not found: " + path);
            System.exit(1);
        }

        new JimpleInterpreter().eval(path);
    }
}

Детали реали­зации

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

Как уже было упомянуто, интер­пре­татор построен на основе паттерна Visitor, который посещает узлы дерева AST и выполняет соответ­ствующие инструкции. В процессе выпол­нения кода в текущем контексте появляются новые иденти­фи­каторы (имена переменных и/или функций), которые нужно где-то хранить. Для этого напишем класс JimpleContext, который будет хранить не только эти иденти­фи­каторы, но и текущий контекст выпол­нения вложенных блоков кода и функций, так как локальная переменная и/или параметр функции должны быть удалены после выхода из области их видимости.

@Override
public Object handleFunc(FunctionSignature func, List<String> parameters, List<Object> arguments, FunctionDefinitionContext ctx) {
    Map<String, Object> variables = new HashMap<>(parameters.size());
    for (int i = 0; i < parameters.size(); i++) {
    variables.put(parameters.get(i), arguments.get(i));
    }
    // создаем новую область видимости параметров функции и помещаем ее в стек
    context.pushCallScope(variables);

    // выполнение выражений функций опущены для краткости

    // удаляем область видимости параметров функции из стека
    context.popCallScope();

    return functionResult;
}

В нашем языке переменная хранит значение типа, который опреде­ляется во время выпол­нения. Далее, в следующих инструкциях, этот тип может меняться. По сути, у нас получился язык с динами­ческой типизацией. Однако, некоторая проверка типов все же присут­ствует, в случаях когда, выпол­нение операции бессмыс­ленно. Например, число нельзя поделить на строку.

Зачем нужно два прохода?

Перво­на­чальная версия интер­пре­татора заклю­чалась в реали­зации метода для каждого правила. Например, если метод обработки объяв­ления функции находит функцию с таким именем (и количе­ством параметров) в текущем контексте, то выбра­сы­вается исклю­чение, иначе функция добав­ляется в текущий контекст. Таким же образом работает метод вызова функции. Если функция не найдена, то выбра­сы­вается исклю­чение, иначе функция вызывается. Такой подход работает, но он не позволяет вызывать функцию до её опреде­ления. Например, следующий код не будет работать:

var result = 9 + 10

println "Result is " + add(result, 34)

fun add(a, b) {
return a + b
}

В данном случае, у нас есть два подхода. Первый — это требовать определять функции до её исполь­зо­вания (не очень удобно для пользо­ва­телей языка). Второй — выполнять два прохода. Первый проход нужен для того, чтобы найти все функции, которые были определены в коде. А второй — непосред­ственно для выпол­нения кода. В своей реали­зации выбрал второй подход. Следует перенести реали­зацию метода visitFunctionDefinition в отдельный класс, который расширяет уже известный нам сгене­ри­ро­ванный класс JimpleBaseVisitor<T>.

// класс находит все функции в коде и регистрирует их в контексте
public class FunctionDefinitionVisitor extends JimpleBaseVisitor<Object> {
    private final JimpleContext context;
    private final FunctionCallHandler handler;

    public FunctionDefinitionVisitor(final JimpleContext context, final FunctionCallHandler handler) {
        this.context = context;
        this.handler = handler;
    }

    @Override
    public Object visitFunctionDefinition(final JimpleParser.FunctionDefinitionContext ctx) {
        final String name = ctx.name.getText();
        final List<String> parameters = ctx.IDENTIFIER().stream().skip(1).map(ParseTree::getText).toList();
        final var funcSig = new FunctionSignature(name, parameters.size());
        context.registerFunction(funcSig, (func, args) -> handler.handleFunc(func, parameters, args, ctx));
        return VOID;
    }
}

Теперь у нас есть класс, который можем исполь­зовать перед непосред­ственным запуском класса интер­пре­татора. Он будет наполнять наш контекст опреде­ле­ниями всех функций, которые будем вызывать в классе интерпретатора.

Как выглядит AST?

Для того, чтобы визуа­ли­зи­ровать AST, нужно восполь­зо­ваться утилитой grun (см. выше). Для этого следует запустить grun с параметрами Jimple program -gui (первый параметр – имя грамматики, второй – имя правила). В результате откроется окно с деревом AST. Перед выпол­нением этой утилиты важно скомпи­ли­ровать сгене­ри­ро­ванный ANTLR-ом код.

# сгенерировать парсер
antlr4.sh Jimple.g4
# скомпилировать сгенерированный код
javac -cp ".:/usr/lib/jvm/antlr-4.12.0-complete.jar" Jimple*.java
# запустить grun
grun.sh Jimple program -gui
# ввести код: "println "Hello, Jimple!"
# нажать Ctrl+D (Linux) или Ctrl+Z (Windows)

Для Jimple-кода println "Hello, Jimple!" сгене­ри­руется следующее AST:

 

Резюме

В данной статье, вы позна­ко­мились с таким понятиями как лекси­ческий и синтак­си­ческий анали­заторы. Исполь­зовали инструмент ANTLR для генерации таких анали­за­торов. Узнали, как писать грамматику ANTLR. В итоге смогли создать простой язык, а именно разра­ботали интер­пре­татор для него. В качестве бонуса смогли визуа­ли­зи­ровать AST.

Весь исходный код интер­пре­татора можно посмотреть по ссылке.

Ссылки