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