Dies ist der zweite Artikel in der Serie „Entwicklung unserer Programmiersprache in Java“. Der erste Artikel kann hier gelesen werden.
In der aktuellen Phase haben wir einen Interpreter, der in der Lage ist, die Befehle unserer Sprache auszuführen. Dies reicht jedoch nicht aus, wenn wir den Code auf Fehler überprüfen und diese dem Benutzer auf eine klare Weise anzeigen wollen. In diesem Artikel werden wir die Hinzufügung von Fehlerdiagnosen zur Sprache betrachten. Die Durchführung einer Fehleranalyse in Ihrer eigenen Programmiersprache ist ein wichtiger Schritt in der Sprachentwicklung. Die Verwendung leistungsstarker Werkzeuge wie ANTLR ermöglicht es Ihnen, in kurzer Zeit recht effiziente Codeanalyse-Tools zu implementieren, die Ihnen helfen, potenzielle Probleme in einem Programm in frühen Entwicklungsstadien zu erkennen, was die Softwarequalität verbessert und die Produktivität der Entwickler steigert.
Klassifizierung von Fehlern
Es gibt verschiedene Arten von Fehlern, aber im Allgemeinen können sie in drei Kategorien unterteilt werden: Syntax-, Semantik- und Laufzeitfehler.
Syntaxfehler treten aufgrund der Verletzung der Syntaxregeln einer bestimmten Programmiersprache auf. Syntaxregeln definieren, wie Anweisungen und Ausdrücke im Code organisiert sein sollten.
Beispiel für einen Syntaxfehler (fehlendes abschließendes Anführungszeichen):
println("Hello, World!)
Semantische Fehler treten auf, wenn ein Programm kompiliert und sogar ausgeführt wird, das Ergebnis jedoch anders ist als erwartet. Diese Art von Fehler ist die schwierigste. Semantische Fehler können durch das Missverständnis des Programmierers bezüglich der Sprache oder der anstehenden Aufgabe verursacht werden. Zum Beispiel, wenn ein Programmierer ein schlechtes Verständnis der Operatorrangfolge hat, könnte er folgenden Code schreiben:
var a = 1 + 2 * 3
Er könnte erwarten, dass die Variable a gleich 9 ist, aber tatsächlich wird sie gleich 7. Dies geschieht, weil der Multiplikationsoperator eine höhere Priorität als der Additionsoperator hat. Ein semantischer Fehler kann normalerweise während des Debuggens oder durch umfangreiche Tests des Programms entdeckt werden.
Laufzeitfehler, auch bekannt als „Exceptions“, treten während der Programmausführung auf. Solche Fehler können aufgrund falscher Dateneingabe, dem Versuch, auf eine nicht vorhandene Datei zuzugreifen, und in vielen anderen Szenarien auftreten. Einige Laufzeitfehler können in einem Programm gehändelt werden, aber wenn dies nicht geschieht, stürzt das Programm normalerweise ab.
Neben Fehlern ist es auch wichtig, potenzielle Probleme oder nicht offensichtliche Situationen zu erkennen, die im strengen Sinne keine Fehler sind, aber zu unerwünschten Konsequenzen führen können. Zum Beispiel könnte es sich um eine ungenutzte Variable handeln, die Verwendung veralteter Funktionen oder eine überflüssige Operation. In all diesen Fällen können dem Benutzer Warnungen angezeigt werden.
JimpleBaseVisitor
Um Fehler und Warnungen zu identifizieren, benötigen wir die abstrakte Klasse JimpleBaseVisitor
(erzeugt von ANTLR), die uns bereits aus dem ersten Artikel bekannt ist und standardmäßig das JimleVisitor
-Interface implementiert. Sie ermöglicht es uns, den AST-Baum (Abstract Syntax Tree) zu durchlaufen, und basierend auf der Analyse seiner Knoten wird entscheiden, ob es sich um einen Fehler, eine Warnung oder einen normalen Teil des Codes handelt. Im Wesentlichen ist die Diagnose von Fehlern fast nicht anders als das Interpretieren von Code, außer wenn wir I/O durchführen oder auf externe Ressourcen zugreifen müssen. Zum Beispiel, wenn ein Konsolenausgabebefehl ausgeführt wird, dann ist es unsere Aufgabe zu überprüfen, ob der als Argument übergebene Datentyp gültig ist, ohne es direkt an die Konsole auszugeben.
Lassen Sie uns die Klasse JimpleDiagnosticTool
erstellen, die JimleBaseVisitor
erbt und die gesamte Logik des Findens und Speicherns von Fehlern kapselt:
class JimpleDiagnosticTool extends JimpleBaseVisitor<ValidationInfo> {
private Set<Issue> issues = new LinkedHashSet<>();
}
record Issue(IssueType type, String message, int lineNumber, int lineOffset, String details) {}
Diese Klasse enthält eine Liste vom Typ Issue
, welcher Informationen über einen spezifischen Fehler enthält.
Es ist bekannt, dass jede Methode einer gegebenen Klasse einen Wert eines bestimmten Typs zurückgeben muss. In unserem Fall werden wir Informationen über den Typ des Knotens im Baum zurückgeben – ValidationInfo
. Diese Klasse enthält auch Informationen über den möglichen Wert, dies wird uns helfen, einige semantische oder Laufzeitfehler zu identifizieren.
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
}
Sie sollten auf den Wert von ValidationType.SKIP
achten. Er wird verwendet, wenn ein Fehler gefunden und bereits in einem Teil des Baums registriert wurde und eine weitere Analyse dieses Baumknotens nicht sinnvoll ist. Wenn zum Beispiel ein Argument in einem Summenausdruck einen Fehler enthält, wird das zweite Argument des Ausdrucks nicht analysiert.
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
}
Listers vs Visitors
Before moving on, let’s take a look at another ANTLR-generated interface (), which can also be used if we need traverse the AST tree. What is the difference between them? The biggest difference between these mechanisms is that listener methods are always called by ANTLR on a per-node basis, whereas visitor methods must bypass their child elements with explicit calls. And if the programmer does not call on child nodes, then these nodes are not visited, i.e. we have the ability to control tree traversal. For example, in our implementation, the function body is first visited once in its entirety (mode ) to detect errors in the entire function (all if and else blocks), and several times with specific argument values:
Bevor wir fortfahren, werfen wir einen Blick auf ein weiteres von ANTLR generiertes Interface, JimpleListener
(pattern Observer), das ebenfalls verwendet werden kann, wenn wir den AST-Baum durchlaufen müssen. Was ist der Unterschied zwischen ihnen? Der größte Unterschied zwischen diesen Mechanismen ist, dass Listener-Methoden immer von ANTLR auf einer pro-Knoten-Basis aufgerufen werden, während Besucher-Methoden ihre Kind-Elemente mit expliziten Aufrufen umgehen müssen. Und wenn der Programmierer nicht visit()
auf Kindknoten aufruft, dann werden diese Knoten nicht besucht, d.h. wir haben die Möglichkeit, die Baumdurchquerung zu steuern. In unserer Implementierung wird beispielsweise der Funktionskörper
zunächst einmal in seiner Gesamtheit (Modus checkFuncDefinition==true
) besucht, um Fehler in der gesamten Funktion (alle if- und else-Blöcke) zu erkennen, und dann mehrmals mit spezifischen Argumentwerten:
@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;
}
Das „Visitor“ Muster eignet sich sehr gut, wenn wir für jeden Baumknoten einen bestimmten Wert zuordnen müssen. Das ist genau das, was wir brauchen.
Abfangen von syntaktischen Fehlern
Um einige Syntaxfehler im Code zu finden, müssen wir die Schnittstelle ANTLRErrorListener
implementieren. Diese Schnittstelle enthält vier Methoden, die (vom Parser und/oder Lexer) im Falle eines Fehlers oder undefinierten Verhaltens aufgerufen werden:
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);
}
Der Name der ersten Methode (syntaxError
) spricht für sich selbst; sie wird im Falle eines Syntaxfehlers aufgerufen. Die Implementierung ist recht einfach: Wir müssen die Fehlerinformationen in ein Objekt des Typs Issue
umwandeln und es der Liste der Fehler hinzufügen:
@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)));
}
Die verbleibenden drei Methoden können ignoriert werden. ANTLR implementiert dieses Interface auch selbst (siehe Klasse ConsoleErrorListener
) und sendet Fehler an den standard error stream
(System.err). Um es und andere Standard-Handler zu deaktivieren, müssen wir die Methode removeErrorListeners
am Parser und Lexer aufrufen:
// remove default error handlers
lexer.removeErrorListeners();
parser.removeErrorListeners();
Ein anderer Typ eines Syntaxfehlers basiert auf den Regeln einer bestimmten Sprache. Zum Beispiel wird in unserer Sprache eine Funktion durch ihren Namen und die Anzahl der Argumente identifiziert. Wenn der Analysator auf einen Funktionsaufruf trifft, überprüft er, ob eine Funktion mit demselben Namen und der gleichen Anzahl an Argumenten existiert. Wenn nicht, dann wird ein Fehler ausgelöst. Um dies zu tun, müssen wir die Methode visitFunctionCall
überschreiben:
@Override
ValidationInfo visitFunctionCall(FunctionCallContext ctx) {
String funName = ctx.IDENTIFIER().getText();
int argumentsCount = ctx.expression().size();
var funSignature = new FunctionSignature(funName, argumentsCount, ctx.getParent());
// find a function in the context by signature (name+number of arguments)
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
}
Prüfen wir die if-Konstruktion. Jimple setzt voraus, dass der Ausdruck in der if
-Bedingung vom Typ boolean
ist:
@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
}
Der aufmerksame Leser wird feststellen, dass wir in diesem Fall eine Warnung und keinen Fehler hinzugefügt haben. Dies geschieht aufgrund der Tatsache, dass unsere Sprache dynamisch ist und wir nicht immer die genauen Informationen über den Typ des Ausdrucks kennen.
Identifizieren semantischer Fehler
Wie bereits erwähnt, sind semantische Fehler schwer zu finden und können oft nur beim Debuggen oder Testen des Programms gefunden werden. Einige davon können jedoch bereits in der Kompilierungsphase identifiziert werden. Wenn wir beispielsweise wissen, dass die Funktion zurückgibt, können wir eine Warnung anzeigen, wenn ein Divisionsausdruck diese Funktion als Divisor verwendet. Die Division durch null wird in der Regel als semantischer Fehler betrachtet, da die Division durch null in der Mathematik keinen Sinn ergibt.
Ein Beispiel für die Fehlererkennung „Division by zero“: wird ausgelöst, wenn ein Ausdruck als Divisor verwendet wird, der immer den Wert 0
zurückgibt.
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
}
Laufzeitfehler
Laufzeitfehler sind ebenfalls schwer oder sogar unmöglich in der Kompilierungs-/Interpretierungsphase zu erkennen. Dennoch können einige solcher Fehler identifiziert werden. Zum Beispiel, wenn eine Funktion sich selbst aufruft (entweder direkt oder durch eine andere Funktion), kann dies zu einem Stapelüberlauf-Fehler (StackOverflow) führen. Das Erste, was wir tun müssen, ist eine Liste (Set) zu deklarieren, in der wir die Funktionen speichern, die gerade aufgerufen werden. Die Überprüfung selbst kann (und sollte) in der Methode handleFuncInternal zur Verarbeitung des Funktionsaufrufs platziert werden. Zu Beginn dieser Methode überprüfen wir, ob der aktuelle FunctionDefinitionContext (Funktionsdeklarationskontext) auf der Liste der bereits aufgerufenen Funktionen steht, und falls dies der Fall ist, protokollieren wir eine Warnung und unterbrechen die weitere Verarbeitung der Funktion. Wenn nicht, dann fügen wir den aktuellen Kontext zu unserer Liste hinzu, und der Rest der Logik folgt. Beim Verlassen von handleFuncInternal muss der aktuelle Funktionskontext aus der Liste entfernt werden. Hier sollte angemerkt werden, dass wir in diesem Fall nicht nur einen potenziellen StackOverflow identifiziert haben, sondern auch denselben Fehler beim Diagnostizieren von Fehlern vermieden haben, nämlich beim Schleifen der Methode 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
}
Kontroll-/Datenflussanalyse
Für eine tiefere Untersuchung des Programmcodes, Optimierung und Identifizierung komplexer Fehler werden auch die Kontrollflussanalyse und Datenflussanalyse verwendet.
Die Kontrollflussanalyse konzentriert sich darauf zu verstehen, welche Teile eines Programms in Abhängigkeit von verschiedenen Bedingungen und Steuerstrukturen wie bedingten (if-else) Anweisungen, Schleifen und Verzweigungen ausgeführt werden. Sie ermöglicht es, die Ausführungspfade des Programms zu identifizieren und potenzielle Fehler zu erkennen, die mit einer fehlerhaften Steuerlogik zusammenhängen. Zum Beispiel unerreichbarer Code oder potenzielle Programmaufhänger.
Die Datenflussanalyse hingegen konzentriert sich darauf, wie Daten innerhalb eines Programms verteilt und verwendet werden. Sie hilft, potenzielle Datenprobleme wie die Verwendung von nicht initialisierten Variablen, Datenabhängigkeiten und mögliche Speicherlecks („memory leaks“) zu identifizieren. Die Datenflussanalyse kann auch Fehler erkennen, die mit falschen Datenoperationen zusammenhängen, wie die Verwendung falscher Typen oder inkorrekter (überflüssiger) Berechnungen.
Zusammenfassung
In diesem Artikel haben wir den Prozess des Hinzufügens von Fehler- und Warndiagnosen zu Ihrer Programmiersprache untersucht. Wir haben gelernt, was ANTLR von Haus aus für die Protokollierung von Syntaxfehlern bietet. Außerdem haben wir eine Behandlung einiger Fehler und potenzieller Probleme während der Programmausführung implementiert.
Der gesamte Quellcode des Interpreters kann eingesehen werden unter link.