Записи

Это вторая статья из цикла “Разра­ба­тываем свой язык програм­ми­ро­вания на 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 для регистрации синтак­си­ческих ошибок. Реали­зовали обработку некоторых ошибок и потен­ци­альных проблем во время выпол­нения программы.

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

Ссылки