Im folgendem wird eine Reihe von Fragen von allgemeingültigem Interresse
beantwortet. Sollten Sie ein Problem haben und hier keine Antwort dazu
finden, gehen Sie bitte wie hier beschrieben
vor.
-
Muß ich mich an die Vorgaben (Scanner, Environment)
halten?
-
Die Methode "toHtml()" ist optional. Muß ich
"format(int)" implementieren?
-
Wo fange ich am besten an?
-
Wie komme ich von der Grammatik zu den Knotentypen?
-
Warum gibt es pro Klasse eine statische "parse" Methode?
-
Warum nimmt die "parse" Methode ein Environment als
Argument?
-
Warum nehmen die "run" & "evaluate" Methoden ein
Environment als Argument?
-
Warum gibt es die Methode "typeCheck" obwohl sie nicht
gefordert wird?
-
Warum ist der Empfänger bei MessageSend
optional?
-
Warum gibt es für "Robot", "North", "move", "turnLeft",
usw. keine eigenen Tokentypen?
-
Wie kann man sich die Arbeit in der Gruppe sinnvoll
aufteilen?
-
Gibt es Aspekte an denen man sich Anfangs besser nicht
die Zähne ausbeissen sollte?
-
Wie kann ich das "return"-Statement realisieren?
-
Warum ergibt der Ausdruck 4 * 5 / 2 den Wert 8 und nicht 10?
-
Warum erwartet das Testwerkzeug einen erfolgreichen Typecheck, wenn das Programm fehlerhaft ist?
-
Ist der Testfall "parseErr1.task" fehlerhaft?
-
Ein nicht kompilierbares Programm erzeugt dennoch ein (natürlich falsches) Laufzeitsollergebnis. Warum?
-
Beim Einsatz des Testers unter UNIX tritt ein Fehler der Art cannot open file... auf.
-
Das Testat für das Praktikum wurde mir/uns verweigert. Was kann man da tun?
Muß ich mich an die Vorgaben
(Scanner, Environment) halten?
Nein. Ihre Aufgabe besteht darin alle öffentlichen und
später privaten verbindlichen Tests zu bestehen. Wie Sie das erreichen
ist Ihnen überlassen. Sie können also insbesondere den Scanner
verändern (obwohl dies nicht notwendig ist), eine andere Environmentklasse
benutzen, unsere Vorschläge zu einer "Value-Hierachie" (Klasse EnvValue)
ignorieren, usw.
Damit aber eine Testierung erfolgen kann müssen Sie die Schnittstellen
zum automatischen Testwerkzeug einhalten (siehe Klassen Interpreter, AbstractSyntaxNode
und ExecutableNode).
Hierbei können Sie selbstverständlich die Implementierung,
z.B., von Interpreter Methoden ändern, nur deren Signatur muß
erhalten bleiben.
Unter Umständen wird ein Tutor Sie besser beraten können,
wenn Sie den von uns vorgeschlagene Weg folgen, da er diesen im Gegensatz
zu anderen Ansätzen kennt und dazu Auskunft erteilen kann.
Die Methode "toHtml()"
ist optional. Muß ich "format(int)" implementieren?
Ja. Der PrettyPrint eines SJarel-Programms ist Teil der Aufgabe.
Dadurch, daß "toString()" (in Klasse AbstractSyntaxNode) die
Methode "format(int)" aufruft, erhalten Sie so gleichzeitig eine Möglichkeit
Ihre Knotenobjekte zu Testzwecken auszugeben.
Wo fange ich am
besten an?
Viele Wege führen nach Rom, aber in diesem Fall bietet
sich eine "bottom-up" Vorgehensweise an. In der Beispielklasse ProgramNode
können Sie nachvollziehen wie wir die Grammatik vereinfacht haben,
so daß nicht alle Sprachkonstrukte implementiert sein müssen,
bevor man eine Teillösung testen kann.
So ist z.B., die Teilmenge der Grammatik, die sich mit Ausdrücken
beschäftigt ist relativ komplex. Sio können also zunächst
einmal Expression :: = Literal annehmen, später dann Expression
:: = MultiplicativeExpression (aber z.B., ohne bereits alle Alternativen
für SimpleExpression zu verwirklichen), und so weiter.
Obwohl Ihr Interpreter während dieser Konstruktionsphase dann
möglicherweise kein einziges normal geformtes SJarel-Programm erkennen
kann (z.B., kann unsere ProgramNode Beispielklasse nur immer eine "task"-Methode
und nur ein Statement erkennen), können Sie so schrittweise Ihren
Fortschritt beobachten und erst zum Schluß echte SJarel-Programme
angehen.
Wie komme ich
von der Grammatik zu den Knotentypen?
Eine einfache Regel heißt: Jedes Nonterminal in der Grammatik
wird zu einem Knotentyp (siehe auch die Frage zur Aufteilung
der Arbeit). Die rechten Seiten der Nonterminal-Definitionen geben
Ihnen dabei Hinweis dazu welche Attribute die Knotentypen jeweils haben
müssen. Kommt auf der rechten Seite die beliebige Wiederholung ("{...}")
vor, so gibt es zwei Möglichkeiten:
-
Sie merken sich im entsprechendem Knoten eine Sequenz (=> Array, java.util.Vector,
List).
-
Sie definieren den Knoten so, daß er ein Element aus der Sequenz
enthalten kann und einen weiteren Knoten der gleichen Art (analog der List
Implementierung aus der Vorlesung => Klasse Node).
Kommen auf der rechten Seite nur Alternativen vor, dann werden Sie für
dieses Nonterminal nie Objekte erzeugen müssen, können aber eine
entsprechende Klasse als Fallunterscheider für das Parsen benutzen
(siehe Klasse Statement in ProgramNode.java). Es ist natürlich nicht
notwendig sich sklavisch an die "Nonterminal => Knotentyp" Regel zu halten.
Sie müssen z.B., für MethodDeclarator
keinen eigenen Knoten vorsehen sondern können die dort enthaltene
Information auch gleich in MethodDefinition
speichern. Entscheiden Sie sich für die Lösung, die Ihnen weniger
aufwendig erscheint, aber achten Sie dabei auch auf Übersichtlichkeit.
Warum gibt es
pro Klasse eine statische "parse" Methode?
Zunächst erscheint es logisch "parse(Environment)" genau
wie z.B., "typeCheck(Environment)" als "abstract" in der Klasse AbstractSyntaxNode
zu deklarieren, so daß Unterklassen automatisch dazu aufgefordert
werden "parse" zu implementieren. Dagegen sprechen aber folgenden Gründe:
-
In Java ist es nicht möglich den Rückgabetyp von Methoden bei
einer Redefinition zu ändern. Somit wäre das Ergebnis einer parse
Operation immer vom statischen Typ "AbstractSyntaxNode" und man müßte
unzählige "cast"-Operationen vornehmen, um den konkreteren dynamischen
Typ (z.B., WhileStatementNode) wieder zu erlangen.
-
Im Gegensatz zu "typeCheck(Environment)" oder "run(Environment)", die jeweils
einen Knoten als gegeben annehmen und für diesen eine Berechnung
durchführen, ist "parse(Environment)" eine Methode, die den Knoten
erst erzeugen soll. Wäre "parse(Environment)" also nicht statisch
deklariert, müssten sie zunächst immer erste einen leeren Knoten
erzeugen und dann "parse(Environment)" aufrufen um ihn zu füllen.
Idealerweise würde man einfach im Knotenkonstruktor den Tokenstrom
lesen und den Knoten entsprechend erzeugen (z.B., "WhileStatementNode(Scanner)").
Leider ist der Rückgabetyp von Konstruktoren in Java automatisch der
Klassentyp in dem der Konstruktor vorkommt. Es gibt aber Fälle, wo
man z.B,. ein "Statement" parsen möchte, dann aber ein "WhileStatement"
als Rückgabeknoten erhält. Der Konstruktor von "Statement" kann
aber leider kein Objekt vom Typ "WhileStatement" zurückliefern.
Warum nimmt die
"parse" Methode ein Environment als Argument?
Es ist auch möglich das Parsen völlig ohne Environment
durchzuführen. Für zwei Aspekte ist es jedoch hilfreich:
-
Man kann vorher im Environment nützliche Informationen ablegen, z.B.,
alle SJarel Konstanten und alle primitiven Typen, so daß man beim
Parsen das Environment als Auskunft für die Rolle von gefundenen Bezeichnern
benutzen kann.
-
Sie können das Environment beim Parsen erweitern, z.B., deklarierte
Variablen hinzunehmen, so daß Sie im folgenden überprüfen
können ob eine Variablenbenutzung auch eine entsprechende Deklaration
besitzt. Sie können also auf diese Art viele Programmfehler bereits
vor der Ausführung finden. Ein vollständiges Aufspüren aller
vor der Laufzeit entdeckbarer Fehler ist jedoch während des Parsens
nicht möglich. Da z.B., eine Methode aufgerufen werden kann bevor
sie definiert wurde, sind bestimmte Wohlgeformtheitsfehler erst durch "typeCheck()"
auffindbar.
Warum nehmen die
"run" & "evaluate" Methoden ein Environment als Argument?
Neben dem wie schon beim Parsen erwähnten optionalen Aspekt
der "Auskunftfunktion" des Environments ist es für diese Methoden
unerlälßlich während der Ausführung auf das Environment
zurückzugreifen. Das Environment fungiert hier als Speicher, in dem
Variablen eingetragen werden (Deklarationen), verändert werden (Zuweisungen,
u.a.,) und ausgelesen werden (Variablenzugriffe).
Warum gibt es
die Methode "typeCheck" obwohl sie nicht gefordert wird?
Es ist richtig, daß Sie das Minimalziel auch ohne Implementierung
von "typeCheck(Environment" (in Klasse AbstractSyntaxNode) erreichen können.
Auf der einen Seite bietet sich die Überprüfung des SJarel-Programs
auf Wohlgeformtheitsfehler schon vor der Ausführung als natürliche
Erweiterung einer Minimallösung an und auf der anderen Seite können
Sie hiermit den Ausführungsteil einfacher gestalten. Wenn die "run(Environment)"
Methode bereits ein wohlgeformtes Programm annehmen kann, muß auf
viele mögliche Fehlerfälle nicht mehr geachtet werden. Beispiele
für Fehlerfälle, die duch "typeCheck(Environment)" abgefangen
sind:
-
true < 10, falsche Typen für einen Operator
-
direction d = new Robot(1, 1, North, 0), falscher Variablentyp
-
Überprüfung ob alle benutzten Methoden auch definiert sind, usw.
Warum ist
der Empfänger bei MessageSend
optional?
Wie in Java auch gibt es sowohl die Möglichkeit Nachrichten
explizit an einen Empfänger zu schicken, z.B. "empfänger.nachricht()"
oder den Empfänger implizit zu lassen, z.B., "nachricht()". Letzterer
Fall ist eigentlich einen Abkürzung für "this.nachricht()", d.h.,
die Nachricht soll an das Objekt selbst geschickt werden. Im SJarel-Interpreter
ist das immer ein Objekt vom Typ "Robot", das auch die benutzerdefinierten
Methoden versteht. Beachten Sie deshalb für alle benutzerdefinierten
Methoden eine impliziten Empfänger vorraussetzen dürfen, jedoch
nicht für die "task"-Methode.
Warum gibt es
für "Robot", "North", "move", "turnLeft", usw. keine eigenen Tokentypen?
Diese Einheiten werden vom Scanner als "IdentifierToken" erkannt
und zurückgeliefert. Für diese Entscheidung gibt es folgende
Gründe:
-
Die Menge dieser Namen ist nicht so fix, wie z.B., die Menge der eingebauten,
primitiven Typen. Möchte man SJarel erweitern, so daß mehrere
(u.U., benutzerdefinierte) Robotertypen zulässig sind, muß nur
der Parser erweitert werden; der Scanner dagegen kann unverändert
bleiben.
-
Die Anzahl der benötigten Fallunterscheidungen im Parser ist auf diese
Weise niedriger. Der Selektor eines Methodenaufrufs (z.B., "empfänger.selector()")
muß für benutzerdefinierte Methoden ohnehin ein "Identifier"
sein. Wären "move", "turnLeft", usw. eigene Tokentypen zugeordnet,
dann müßten Sie an dieser Stelle immer entweder ein bestimmtes
Token oder einen "Identifier" lesen.
Wie kann man
sich die Arbeit in der Gruppe sinnvoll aufteilen?
Es bietet sich an die Struktur des Interpreters zur Arbeitsteilung
auszunutzen. Eine Implementierung besteht aus
-
den Statementknoten (Erben von ExecutableNode)
-
den Ausdrucksknoten (Erben von EvaluatableNode)
-
den deklarativen Knotentypen (Erben von AbstractSyntaxNode)
-
der Environment-Funktionalität inkl. der Werte-Klassen (Erben von
EnvValue)
Es bleibt Ihnen überlassen wie Sie diese Arbeitspakete angehen wollen,
z.B., jeweils in 2-er Teams oder als Einzelspezialisten.
Gibt es Aspekte
an denen man sich Anfangs besser nicht die Zähne ausbeissen sollte?
Manche Aspekte des Interpreters sind recht einfach umzusetzen,
andere wiederum sind interessanter. Wir schlagen vor, daß Sie sich
Anfangs mit einer "task"-methode in SJarel-Programmen begnügen sollten
und erst nach einigen Erfolgen die Herausforderung benutzerdefinierte Methoden
zu unterstützen angehen sollten.
Eine weitere interessante Aufgabe besteht auch darin das "return"-Statement
richtig zu implementieren (siehe auch diese spezielle
Frage). Falls Sie nicht gleich einen Lösungsweg sehen, fangen
Sie zunächst einfach mal ohne "return" an. Es gibt Lösungen,
die sich nachträglich ohne zusätzliche Arbeit in Interpreter
ohne "return"-Unterstützung einbauen lassen.
Wie kann ich das
"return"-Statement realisieren?
Das "return"-Statement bietet gleich zwei Herausforderungen:
-
i.A., gibt es einen Wert zurück, aber es scheint keinen offensichtlichen
Weg zu geben diesen Wert zurückzugeben. Die Methode "run" gibt bereits
ein Environment zurück und globale Variablen würden bei rekursiven
Aufrufen nicht funktionieren.
-
es ist das einzige Statement, daß den normalen Kontrollfluß
unterbrechen kann. Durch ein "return" können Methoden, Schleifen und
Anweisungsfolgen frühzeitig abgebrochen werden.
Wir kennen zwei ganz unterschiedliche Lösungsansätze, die jeweils
beide Herausforderungen "Hand-in-Hand" lösen:
Eine Lösung benutzt Java-Exceptions, nicht um Fehler zu behandeln,
sondern um den Kontrollfluß gemäß der return-Semantik
zu realisieren. Benutzt man eine Ausnahme, so hat man damit auch gleichzeitig
einen Weg gefunden den Rückgabewert zu transportieren.
Eine andere Lösung benutzt das Environment, um den Rückgabewert
"abzulegen". Somit gibt es, z.B. für Schleifen, ein Ort nachzusehen,
ob sich der Kontrollfluß ändern sollte.
Warum ergibt der Ausdruck 4 * 5 / 2 den Wert 8 und nicht 10?
In Java werden die
Operatoren links-assoziativ behandelt. In SJarel leider rechtsassoziativ. Das hat zur
Folge, dass in SJarel zunächst 5/2 berechnet wird (abgerundet auf 2) und dann mit 4
multipliziert wird.
Der Grund hierfür ist der Aufbau der SJarel Grammatik. Eine Grammatik, die zu
linksassoziativen Operatoren führen würde, wäre nicht mehr mit den hier
angstrebten Mitteln (rekursiver Abstiegsparser) handhabbar gewesen. Das naheliegende
Prinzip der Erkennung von Ausdrücken durch rekursive Aufrufe würde hier zu einer
Endlosrekursion führen. Es gibt andere Parsetechniken, die dieses Manko nicht haben,
deren Behandlung aber in diesem Rahmen zu weit führen würde.
Bei der Erzeugung von Testfällen müssen Sie auf diesen Unterschied zwischen
Java und SJarel achten, bzw. sich nicht über die Diskrepanzen wundern.
Warum erwartet das Testwerkzeug einen erfolgreichen Typecheck, wenn das Programm fehlerhaft ist?
Das Testwerkzeug war leider an dieser Stelle fehlerhaft. Bitte laden
Sie die neuen Version der Datei PureJavaTester.java herunter. Das
Archiv vorgaben.zip ist auch entsprechend aufgefrischt worden.
Für das Bestehen des Praktikums sind Abweichungen von erwarteten
Typchecks nicht relevant.
Ist der Testfall "parseErr1.task" fehlerhaft?
Dieser Testfall ist fehlerhaft. Er wurde von einer nicht ganz fehlerfreien
Version von PureJavaTester.java generiert. Eine neue Version von PureJavaTester.java
und der Datei "testsuite.ini" ist verfügbar und wurde auch dem Vorgabenarchiv
hinzugefügt. Bitte benutzen Sie die neue Testsuite Datei testsuite.ini für
Ihre Tests.
Ein nicht kompilierbares Programm erzeugt dennoch ein (natürlich falsches) Laufzeitsollergebnis. Warum?
Der PureJavaTester generiert für jede Taskdatei eine entsprechende RoboXXX.java Datei
(fortlaufende Nummerierung in XXX). Die entsprechende RoboXXX.class Datei wird zur Ausführung benutzt.
Sollte durch frühere Experimente bereits, z.B., eine Robo02.class erzeugt worden sein, dann wird
diese ausgeführt; auch dann wenn die aktuelle Version von Robo02.java einen Fehler enthält und
gar keine Robo02.class erzeugt hätte. In diesem Fall bitte einfach vorher die entsprechende
(oder einfach alle) RoboXXX.class Datei(en) löschen.
Beim Einsatz des Testers unter UNIX tritt ein Fehler der Art cannot open file... auf.
Das liegt an den Pfadtrennzeichen, die unter Windows so: \ und unter Unix so: /
aussehen. Ersetzen Sie in Ihren Testsuite-Dateien einfach jedes Auftreten von \ durch ein /.
Das Testat für das Praktikum wurde mir/uns verweigert. Was kann man da tun?
Wenn Ihr Tutor Ihnen beim Praktikum das Testat verweigert hat, und Sie
nochmals von dem jeweiligen Tutor in Anwesenheit eines Dozenten testiert
werden möchten, wenden Sie sich bitte bis zum 10. April per Email gleichzeitig an den
jeweiligen Tutor und an <inf1p@st.informatik.tu-darmstadt.de>.
Bitten Sie Ihren Tutor darum, sich wegen eines Termins mit uns in Verbindung zu setzen.