 Ja, hallo und herzlich willkommen zur Vorlesung Programmierparadigmen. Wir sind hier im Themenblock Sündax, in dem es um die Sündax von Programmiersprachen geht. Und speziell sind wir hier in der dritten Einheit, in der wir uns mit dem Scanning beschäftigen wollen. Was macht so ein Scanner? Oder was ist das Scanning überhaupt? Wir hatten ja vorher gesehen, in der ersten Einheit zu Sündax, dass reguläre Ausdrücke verwendet werden, um zu beschreiben, welche Token-Style einer Programmiersprache sind. Was der Scanner jetzt macht, ist zu schauen, ob ein gegebener String, also eine gegebene Zeichenkette, tatsächlich ein legaler Token ist, also zu schauen, ob ein String einen regulären Ausdruck entspricht. Die Grundidee für so ein Scanner ist eigentlich ganz einfach. Was der Scanner macht, ist er nimmt den String und schaut sich ein Zeichen nach dem anderen davon an. Und wenn er feststellt, dass ein kompletter Token eingelesen wurde, dann wird dieser Token zurückgegeben. Falls der Scanner in den Zustand kommt, wo klar ist, dass die Zeichen, die da kommen, kein korrekter Token sein können, dann gibt er ein Fehler zurück. Und manchmal muss der Scanner, um diese Entscheidung treffen zu können, ist das jetzt das Ende des Tokens, bin ich vielleicht in dem Zustand, wo es gar nicht weitergeht. Einige Zeichen nach vorn schauen, zum Beispiel schon das nächste Zeichen anschauen, um dann entscheiden zu können, ob man jetzt noch weiter lesen möchte oder nicht. Um sein Scanner zu implementieren, gibt es im Grunde zwei Optionen, die in der Praxis häufig verwendet werden und die erste Option ist der sogenannte Ad Hoc Scanner. Ad Hoc steht einfach nur dafür, dass der Scanner manuell implementiert wird, wohin gegen der zweite Weg, den wir gleich anschauen werden, darauf passiert, dass man ein Tool hat, was einem hilft, diesen Scanner automatisch zu generieren. Beim Ad Hoc Scanner beschreibt man den Scanner tatsächlich selbst und sie werden in der ersten Übung auch mal Gelegenheit haben, das Ganze selbst einmal auszuprobieren. Die Grundidee ist, dass man im Prinzip, wie gerade eben schon beschrieben, ein Zeichen nach dem anderen einliest und dabei dann schaut, ob die eingelesenen Zeichen einen vollständigen Token ergeben und wenn ja, an diesen dann zurückgibt. Dabei behandelt man üblicherweise Tokens, die häufiger auftreten oder vielleicht einfacher zu erkennen sind als erstes, sodass der Scanner schlussendlich sehr effizient ist, weil er eben die Fälle, die häufig auftreten, auch als erstes bearbeitet. Das ist auch der Grund, warum Ad Hoc Scanner in vielen Compilern verwendet werden, die tatsächlich auch von Entwicklern tagtäglich benutzt werden, weil es eben zu sehr effizienten Pasking, Scanning führt und gleichzeitig der Code natürlich auch kompakt und einfach wartbar ist, denn er wurde ja von einem Menschen geschrieben. Die zweite Option, um ein Scanner zu implementieren, basiert auf der Tatsache, dass reguläre Ausdrücke und endliche Automaten ja mehr oder weniger das Gleiche ausdrücken können. Also wer sich da vielleicht aus der theoretischen Informatik nicht mehr so genau daran erinnern kann, reguläre Ausdrücke und endliche Automaten sind im Prinzip gleich mächtig und insbesondere kann man für jeden regulären Ausdruck einen dazu passenden endlichen Automaten finden, der genau die Zeichenketten erkennt oder aufnehmen kann, die von dem regulären Ausdruck beschrieben werden. Hier auf der Folie sehen wir mal ein kleines Beispiel, also der reguläre Ausdruck auf der linken Seite entspricht genau dieser Kombination aus A, Bs und Cs, die wir in einer der vorigen Einheiten schon gesehen haben und auf der rechten Seite sehen wir dann den regulären, den endlichen Automaten, der diesem regulären Ausdruck entspricht. Es gibt zwei Zustände, diesen Stadtzustand S1, den man daran erkennt, dass es eben diesen Pfeil hier gibt, der den Stadtzustand markiert und dann den Finalzustand S2, den man daran erkennt, dass er zwei Kreise drum hat, was für den Finalzustand steht. Und die Transition zwischen den Zuständen zeigen an, welche Zeichen eingelesen werden können. Und wenn man, nachdem man eine gewisse Anzahl Zeichen eingelesen hat, zum Beispiel nur das C oder vielleicht ein C, ein A und dann noch ein C, im Finalzustand rauskommt, dann sagt uns dieser Automat, dass das nun ein Token ist, der tatsächlich erkannt werden soll als Token, weil er dem regulären Ausdruck entspricht. Als kleine Erinnerung, was diese endlichen Automaten überhaupt sind, habe ich hier nochmal die hoffentlich bekannte Definition davon. Also ein endlicher Automat besteht aus fünf Komponenten, wobei die erste Komponente eine Menge von Zuständen ist, das ist das Q, in dem Beispiel gerade eben, wenn das also S1 und S2. Das zweite Element, das große Sigma, ist eine Menge von Input-Symbolen, in unserem Beispiel wären das A, B und C. Dann haben wir diese Transitionsfunktion Delta und was die Macht ist, die nimmt einen Zustand und einen Input-Symbol und mapt das Ganze wieder auf einen Zustand. Also im Prinzip sagt ihr uns einfach, wenn ich in diesem Zustand bin und jetzt dieses Input-Symbol sehe, dann gehe ich zu diesem anderen Zustand. Dann haben wir einen speziellen Zustand, nämlich den Q0, der unser Startzustand ist, da geht es los und dann haben wir noch diese Menge F von Zuständen, das ist eine Teilmenge der aller Zustände Q und genau das sind die Zustände, die ein Finalzustand sind, das heißt, wenn ich da bin, kann ich aufhören den Input weiter einzulesen. Es gibt zwei Arten von endlichen Automaten, die wir hier unterscheiden möchten, nämlich die deterministischen endlichen Automaten DFAS und die nicht-deterministischen endlichen Automaten NFAS. Der große Unterschied zwischen den beiden ist, dass wir bei einem deterministischen Automaten in jedem Zustand wissen, in welchen anderen Zustand wir gehen müssen, wenn ein bestimmtes Input- Symbol kommt. Das heißt, wenn wir in einem bestimmten Zustand sind und es kommt dieses Input-Symbol A zum Beispiel, dann wissen wir, aha, damit geht es immer in diesen einen bestimmten anderen Zustand oder vielleicht auch in keinen anderen Zustand, aber es gibt nicht mehrere Möglichkeiten, wo es hingehen könnte. Gleichzeitig gibt es auch keine Transitionen, die mit einem Epsilon, also dem leeren Wort, gelabelt sind, denn das würde ja bedeuten, ich könnte ohne etwas neu einzulesen, in einen anderen Zustand gehen. Das wäre also auch wieder so eine Option, die ich hätte und ich möchte einen deterministischen endlichen Automaten aber immer wissen, wo es als nächstes hingeht. Auf der anderen Seite gibt es die DFAS, die nicht-deterministischen endlichen Automaten, in denen eben all die Dinge, die ich gerade ausgeschlossen hab, möglich sind. Das heißt, wir können mehrere ausgehende Transitionen haben, die denselben dasselbe Zeichen akzeptieren und wir können auch Epsilon-Transitionen haben, also ohne ein weiteres Zeichen einzulesen, in einen anderen Zustand wechseln. Um jetzt mithilfe von endlichen Automaten feststellen zu können, ob ein gegebener String tatsächlich ein legaler Token unserer Programmiersprache ist, müssen wir den regulären Ausdruck, der beschreibt, wie Tokens aussehen, zunächst erst mal in einen endlichen Automaten umwandeln. Und genauer gesagt hätten wir da gern einen deterministischen endlichen Automaten. Wie das ganz genau geht, erkläre ich jetzt hier nicht ganz einfach, weil es ein Thema der theoretischen Informatik ist und in der entsprechenden Veranstaltung hoffentlich auch schon erklärt wurde. Falls nicht, oder falls das jemand vielleicht nicht mehr so ganz genau weiß, sei das Kapitel 2 des Programming Language Pragmatic Buches empfohlen, in dem das Ganze auch nochmal schön und aus einer Programmierspracheperspektive zusammengefasst ist. Ganz grob gefasst funktioniert das Ganze in drei Schritten. Zunächst nehmen wir den regulären Ausdruck und wandeln den in einen endlichen Automaten um, und zwar ein nicht deterministisch endlichen Automaten. Als nächstes wird dieser NFA in einen DFA umgewandelt, und zwar aus folgendem Grund, dass wir während wir den Input scanen, um festzustellen, ob das ein legaler Token ist, wenn wir einen NFA hätten, also einen nicht deterministischen endlichen Automaten. Manchmal hätten wir die Situation, dass wir nicht wissen, in welchem weiteren Zustand wir jetzt gehen sollen, eben weil es mehrere Möglichkeiten gibt. Das heißt, der Scanner müsste spekulativ in einen Zustand gehen, bloß um dann vielleicht irgendwann festzustellen, dass das der falsche Weg war und dann wieder zurückgehen und anschließend wieder den nächsten Weg ausprobieren, was nicht besonders effizient ist. Stattdessen wird der NFA in einen DFA umgewandelt und der Scanner kann sozusagen einen Schritt nach dem anderen entsprechend dem DFA ablaufen und wenn das nicht geht, wissen wir, dass der Token nicht, oder dass der String kein legaler Token ist. Schlussendlich wird als dritte Schritt der deterministische endliche Automat noch minimiert, also in einen minimalen DFA umgewandelt, und zwar aus dem einfachen Grund, dass das Scanning anschließend erleichtert. Ganz einfach, weil der Automat, an dem wir uns lang lang müssen, kleiner ist. Das heißt, der Scanner hat weniger Code und unter Umständen auch kein Code, der eigentlich gar nicht nötig ist. Insbesondere werden dabei Zustände rausgenommen, die eh nicht erreichbar sind, sowie Zustände, die genau gleich sind. Also manchmal hat man zwei Zustände, die eigentlich genau dasselbe meinen und von denen auch genau dieselben Transitionen rausgehen und diese kann man einfach miteinander verschmelzen und dann nur noch einen Zustand übrig haben. Wenn wir dann unseren minimierten DFA haben, können wir da aus Schlussendlich unseren Scanner generieren oder auch per Hand schreiben. Da gibt es zwei Wege, die ich hier ein bisschen genauer erklären will, nämlich einmal eine Implementierung, die im Grunde auf verschachtelten Switch Statements basiert und das ist, was man häufig in manuell geschriebenen Scanners verwendet. Die andere Alternative ist, dass wir den Scanner mit Hilfe einer Tabelle generieren. Das heißt, der eigentliche Scanner Code ist generisch und nicht speziell für den regulären Ausdruck, um den es sich handelt, sondern der Code läuft die gegebenen Zeichen entsprechend einer Tabelle ab, die im Prinzip diesen endlichen Automaten codiert. Das heißt, der Code wird einmal geschrieben und die Tabelle wird dann jeweils für den regulären Ausdruck oder den DFA, um den es geht, generiert. Schauen wir uns mal die erste Variante, also den Switch Statement basierten Scanner ein bisschen genauer an, und zwar im Beispiel von dem regulären Ausdruck beziehungsweise dem DFA, den wir vorhin schon gesehen haben, der diese Kombination aus As und Bs und Cs als Tokens erkennen soll. In der Implementierung eines solchen Scanners haben wir eine Variable, die den aktuellen Zustand codiert, die heißt hier State und die wird initialisiert mit dem Startzustand, also in unserem Beispiel S1. Außerdem haben wir eine Variable, die all die Tokens, die wir schon gelesen haben, enthält. Initial ist das erst mal gar nichts, weil wir noch kein Token gelesen haben, und dann haben wir diese Schleife, die in jeder Iteration einen weiteren Token einliest und in unserem Input Stream und unserem Input String einen Schritt weiter rückt. Was wir dann in der Schleife machen, ist, dass wir jeweils unterscheiden, in welchem Zustand wir sind. Also wir schauen, sind wir im Zustand S1 oder sind wir im Zustand S2 und innerhalb von jedem Zustand haben wir dann ein weiteres Switch Statement, in dem geschaut wird, was ist denn eigentlich der Buchstabe, der jetzt gerade als nächstes reinkommt und je nachdem, was das dann ist, behandeln wir den entsprechend. Wenn wir jetzt zum Beispiel im Zustand S1 ein C sehen, dann machen wir genau das, was der Automat hier auch sagt, nämlich wir gehen zum Zustand S2. Wenn wir hier aber jetzt zum Beispiel einen anderen Buchstaben sehen würden und da S1 aber keine anderen ausgehenden Transitionen hat, würden wir einen Fehler zurückgeben, der angibt, dass es hier nicht so, dass es hier nicht weitergeht, also dass der gegebenen String dann eben doch kein Valida Token entsprechend unseres regulären Ausdrucks ist. Genau das gleiche für S2, wo wir auch die verschiedenen Fälle unterscheiden, was hier noch anders ist, ist, dass wir, wenn wir einen leeren String an der Stelle sehen, wissen, dass der Token beendet ist und da S2 ein final Zustand ist, würden wir den Token dann so, wie er ist, entsprechend zurückgeben. Falls wir wie gesagt irgendwann an die Stelle kommen, wo kein weiteres Zeichen passt entsprechend des regulären Ausdrucks bzw. des DFAs, dann wird eben der eine Fehler melden zurückgegeben, die sagt, hey, das ist kein legaler Token in unserer Sprache. Anstatt den Scanner jetzt mithilfe dieser verhafteten Switch Statements zu implementieren, ist die zweite häufig genutzte Variante, dass wir eine Tabelle haben, die sogenannte Transition Table, die angibt, was wir in einem bestimmten Zustand, wenn ein bestimmter Input kommt, zu tun haben. Hier sehen wir mal das Beispiel für den selben regulären Ausdruck, wie gerade eben schon. Und was diese Tabelle zum Beispiel zeigt, ist, dass wenn wir im Zustand S1 sind und als nächstes Zeichen 1c sehen, dann sollen wir anschließend in den Zustand S2 wechseln. Oder so ähnlich, wenn wir in S2 sind und das Ende des aktuellen Tokens sehen, zum Beispiel bei einer Leerzeichen kommt, dann geben wir den Token zurück und wissen, dass dieser Token tatsächlich legal ist entsprechend unserer Programmiersprache. Wenn man die Tabelle hat, dann gibt es dazu ein generisches Programm, ein sogenanntes Driver-Programm, was nur einmal geschrieben wird und anschließend mit beliebigen regulären Ausdrücken funktioniert. Und was das eben macht ist, systematisch durch diese Tabelle durchlaufen, immer in die jeweilige nächsten Zustand gehen. Wir geben denfalls, wenn der Token vollständig ist, diesen Token zurückgeben und falls das nicht der Fall ist, einen Fehler ausgeben, um zu sagen, hey, dieser Token, der passt einfach nicht. Ein interessanter Spezialfall, den wir bis jetzt noch nicht angeschaut haben, ist, was eigentlich passiert, wenn ein Token, ein Prefix von einem anderen legalen Token ist. Nehmen wir mal an, wir haben eine Sprache, in der wir Zahlen als Tokens akzeptieren wollen, wie das so die meisten Programmiersprachen tun, dann könnte zum Beispiel ein Token 3.1, also 3,1 auf Deutsch und den anderen Token 3,141 existieren. Wenn der Scanner jetzt, wie gerade beschrieben, ein Zeichen nach dem anderen einlässt, ist also die Frage, nachdem die 1 eingelesen wurde, ob man jetzt noch weiterlesen sollte, um den möglichen Token 3,141 zu lesen oder ob man vielleicht nach 3,1 oder vielleicht auch schon nach 3 direkt aufhört. Und die häufig, oder am häufigsten oder eigentlich meistens verwendete Regel hier ist, die sogenannte Longest Possible Token Rule, die bedeutet, dass wir immer den längst möglichen Token akzeptieren. Das heißt, in diesem Beispiel würden wir 3,141 als Token einlesen, ganz einfach, weil wir eben noch weitergehen können und trotzdem noch einen illegalen Token erhalten. Eine ähnliche Frage ist, wie der Scanner eigentlich weiß, ob der Token jetzt noch weitergeht, also woher weiß ich, ob ich jetzt noch ein weiteres Zeichen einlesen muss und was die meisten Scanner in der Praxis hier machen, was wir jetzt in den Algorithmen, die ich gerade beschrieben habe, der einfache Teil aber nicht genau angeschaut haben, ist ein bisschen in die Zukunft zu schauen, also zumindest ein Zeichen, manchmal auch mehr Zeichen nach vorn zu schauen, um zu entscheiden, ob man jetzt noch weiter lesen muss oder eben nicht. So, um zu überprüfen, ob das Ganze jetzt alles Sinn gemacht hat, habe ich noch ein kleines Quiz zum Abschluss dieser Einheit. Das Ganze wird wieder in Ilias stattfinden, das heißt, bitte das Video in der Stelle mal kurz unterbrechen, pausieren und im Ilias das richtige Quiz raussuchen und abstimmen. Also was ich ja habe, sind vier Aussagen, von denen manche stimmen, manche auch nicht stimmen und die Frage ist, welche dieser Aussage ist denn wahr und welche dieser Aussage ist falsch. So, nachdem Sie jetzt hoffentlich alle abgestimmt haben, zeige ich mal die Lösung. Also der erste Satz ist wahr, denn ein Scanner produziert tatsächlich eine Sequenz von Tokens, das ist genau das, was Scanner machen. Der zweite Satz ist dementsprechend falsch, denn ein Scanner erstellt keinen Sündungsbaum, das ist das, was Pasa machen, die wir in der nächsten Einheit kennenlernen werden. Effiziente Scanner sind in der Regel eben nicht auf, ja, benutzen keine nicht deterministischen endlichen Automaten, aus dem einfachen Grund, dass in einem nicht deterministischen Automaten wir manchmal nicht wüssten, wo es als nächstes lang geht, der Scanner also den falschen Weg gegebenenfalls gehen würde und dann wieder zurückkehren muss, was natürlich nicht sehr effizient ist und dann noch die Frage, ob ein Scanner für C aus dieser Zeichensequenz If-Statement zwei Tokens machen würde oder nicht und die Antwort ist Nein, dann If-Statement, wenn man es so zusammenschreibt, wie es hier gemacht wurde, ist ein legaler Variabendename und nach der Regel, dass immer der längst mögliche Token eingelesen werden soll, wird der Token dann auch entsprechend eingelesen und das wäre es dann auch wieder. Ja, vielen Dank fürs zuhören in diese Einheit. Ich hoffe, Sie haben was zum Thema Scanner gelernt und wissen jetzt also, wie man feststellen kann, ob ein gegebener String tatsächlich ein Token in der Programmiersprache ist und wie man solche Scanner dann auch implementiert. Vielen Dank fürs zuhören und bis zum nächsten Mal.