Westfälische Wilhelms-Universität Münster Ausarbeitung Übersetzung objektorientierter Sprachen im Rahmen des Seminars „Übersetzung künstlicher Sprachen“ André Christ Themensteller: Prof. Dr. Herbert Kuchen Betreuer: Dipl.-Wirt. Inform. Christian Hermanns Institut für Wirtschaftsinformatik Praktische Informatik in der Wirtschaft Inhaltsverzeichnis 1 Einführung ................................................................................................................. 1 2 Objektorientierte Konzepte........................................................................................ 2 3 2.1 Grundlagen........................................................................................................ 2 2.2 Vererbung ......................................................................................................... 3 2.3 Polymorphie...................................................................................................... 4 Übersetzung ............................................................................................................... 7 3.1 Reale und abstrakte Maschinen ........................................................................ 7 3.2 Klassen und Methoden...................................................................................... 9 3.3 Vererbung ....................................................................................................... 12 3.3.1 3.3.2 3.4 3.4.1 3.4.2 3.4.3 Einfachvererbung........................................................................................ 12 Mehrfachvererbung..................................................................................... 14 Parametrisierung ............................................................................................. 16 Kopierende Übersetzung............................................................................. 16 Homogene Übersetzung.............................................................................. 18 Echte generische Übersetzung .................................................................... 20 4 Zusammenfassung & Fazit ...................................................................................... 22 A Anhang..................................................................................................................... 23 Literaturverzeichnis ........................................................................................................ 24 II Kapitel 1: Einführung 1 Einführung Zur Erstellung komplexer Software haben sich mit Modularisierung, Wiederverwendung, Erweiterbarkeit, Abstraktion und Kapselung Anforderungen an den Software-Entwurf herauskristallisiert. Getrieben wurden diese Überlegungen durch die sog. Software-Krise der 60er Jahre, in der das Scheitern großer Softwareprojekte in zeitlicher, monetärer und funktionaler Hinsicht aufgrund von wachsender Komplexität zu beobachten war. Als Antwort entwickelten sich mit dem objektorientierten Paradigma neue Ansätze für Analyse (OOA), Entwurf (OOD) und Programmierung (OOP). Der Ansatz der objektorientierten Programmierung geht auf die Programmiersprache Simula67 zurück, die durch die Einführung eines Klassenkonzepts grundlegend für die meisten nachfolgenden objektorientierten Programmiersprachen ist. Die Sprache Smalltalk, die u. a. von KAY, INGALLS und GOLDBERG am Forschungszentrum Xerox Palo Alto entwickelt wurde, verfolgt die konsequente Umsetzung des objektorientierten Prinzips und gilt daher als Musterbeispiel einer objektorientierten Sprache [Lo94]. Zu den Vertretern moderner objektorientierter Sprachen gehören C++, Java und C#. Der Träger des Turing-Preises und Erfinder von Smalltalk, Dr. ALAN KAY, prägte den Begriff objektorientierte Programmierung, um die von ihm mitentwickelte Art der Programmierung zu beschreiben, bei der Nachrichten zwischen Objekten ausgetauscht werden. Die ISO-Definition (ISO/IEC 2382-15) von „Objektorientierung“ umfasst mit der Vererbung eine weitere fundamentale Eigenschaft. Gegenstand der vorliegenden Arbeit ist die Untersuchung der Übersetzung einer objektorientierten Programmiersprache (Quellsprache) in eine maschinennahe Programmiersprache (Zielsprache). Da objektorientierte Sprachen auf dem Fundament imperativer Sprachen fußen, steht hier die Umsetzung objektorientierter Konzepte wie Vererbung und Polymorphie mit seinen Ausprägungen im Vordergrund. Kapitel 2 führt in die Grundlagen der Objektorientierung ein. Hieraus ergeben sich die Anforderungen an die Übersetzung. Nach der Einordnung in den Übersetzungsprozess wird in Kapitel 3 die Übersetzung der vorgestellten Bestandteile einer objektorientierten Sprache behandelt. Durchweg dienen kurze Programmbeispiele in Java, C++ und C# der Illustration der Konzepte. Die Arbeit schließt mit einer Zusammenfassung und einem Fazit. 1 Kapitel 2: Objektorientierte Konzepte 2 Objektorientierte Konzepte Es werden zunächst die Begriffe Objekt, Methode, Nachricht und Klasse eingeführt, die die Grundlagen objektorientierter Programmiersprachen ausmachen. Darauf aufbauend erfolgt die Beschreibung von Vererbung und Polymorphie. 2.1 Grundlagen Objekte als grundlegende Einheiten von objektorientierten Programmiersprachen zeichnen sich durch einen bestimmten Zustand, ein bestimmtes Verhalten und eine Objektidentität aus. Objekte verwalten ihren Zustand in Instanzvariablen, ihr Verhalten wird durch Methoden implementiert. Bei der Erzeugung eines Objekts, der sog. Instanz, wird dessen Identität festgelegt [BH98, S. 16 f.], [WM97, S. 174 f.]. Ein Objekt besteht aus einer Menge von Instanzvariablen (auch Attribute oder Felder) und den dazugehörigen Methoden. Methoden ähneln den aus imperativen Sprachen bekannten Funktionen (oder Prozeduren), mit der Ausnahme, dass sie auf die Instanzvariablen des Objekts zugreifen können. Abhängig von der Programmiersprache steht daher im Methodenrumpf ein Sprachkonstrukt zur Verfügung, um auf das der Methode zugehörige Objekt zuzugreifen (meist durch das Schlüsselwort this). Eine Nachricht an ein Objekt ist als Anfrage zu verstehen, eine bestimmte Operation auf dem Objekt auszuführen, wobei erst zur Laufzeit die entsprechende Implementierung, d. h. die auszuführende Methode, ausgewählt wird. Es ist zwischen dem Aufruf einer Methode (Nachricht senden) und dem Ausführen einer Methode (Implementierung auswählen) zu unterscheiden [BH98, S. 24]. Gemäß des Geheimnisprinzips greifen Nachrichten somit nur auf die Schnittstelle eines Objekts zu, nicht aber auf die dahinterliegende Implementierung. Bzgl. der Lebenszeit eines Objekts existieren spezielle Methoden, die bei bestimmten Ereignissen automatisch aufgerufen werden: Der Konstruktor wird bei der Erzeugung einer Instanz einer Klasse aufgerufen. Am Ende der Lebenszeit eines Objekts wird der Destruktor aufgerufen [BH98, S. 21]. Für Nachrichtenaufruf und Zugriff auf Instanzvariablen existieren je nach Programmiersprache verschiedene Notationen. Z.B. die Punktnotation obj.m() (u.a. Java) oder die Pfeilnotation obj->m() (u.a. C++). 2 Kapitel 2: Objektorientierte Konzepte Eine Klasse beschreibt eine Menge von Objekten mit gleicher Struktur (d. h. gleichen Instanzvariablen) und gleichem Verhalten (d. h. gleichen Methoden). Die Klassendefinition führt zur Übersetzungszeit einen neuen Datentyp ein und enthält die Deklarationen der Instanzvariablen sowie die Methodenimplementierungen. Es wird zwischen einem Schnittstellenteil und einem Implementierungsteil unterschieden, der je nach Programmiersprache getrennt oder gemeinsam vorliegt [BH98, S. 20]. Durch die Instanziierung werden aus Klassen Objekte erzeugt, die nur zur Laufzeit des Programms existieren. Mittels Variablen vom Datentyp der Klasse lässt sich auf die konkreten Objekte zugreifen. In diesem Fall enthält die Variable eine Referenz auf ein Objekt des entsprechenden Datentyps. 2.2 Vererbung Kernidee der Vererbung ist, dass gemeinsames Verhalten von Objekten geteilt wird. Vererbung dient der Strukturierung von Programmen, indem die gesamte Funktionalität auf unterschiedliche Abstraktionsebenen aufgespalten wird. Vererbung trägt entscheidend zur Wiederverwendung und Erweiterbarkeit von Software bei. Aus einmal gelösten Problemstellungen in Klassen-Bibliotheken lassen sich durch Vererbung ohne redundanten Code neue Varianten bilden [WM97, S. 176, S. 181]. Vererbung bedeutet, dass alle Instanzvariablen und Methoden einer Klasse A auch in einer Klasse B enthalten sind, so dass gilt “B erbt von A” (auch: B ist von A abgeleitet). Von außen betrachtet stellt die Unterklasse B sämtliche Funktionalität bereit, die auch in der Superklasse A enthalten ist, ohne diese jedoch zu duplizieren. Die Unterklasse B kann neue Instanzvariablen und Methoden hinzufügen oder die Implementierung geerbter Methoden verändern [BH98, S. 32 ff.]. Dieses Vorgehen wird unter dem Begriff Spezialisierung zusammengefasst. Bei der invarianten Spezialisierung (auch ReDefinition) wird die Implementierung einer Methode bei gleichbleibender Signatur in der Unterklasse überschrieben [BH98, S. 41.]. Aus den Vererbungsrelationen ergibt sich der Vererbungsgraph, an dessen Wurzel die Basisklassen stehen. Je tiefer eine Klasse im Vererbungsgraph steht (d. h. weiter weg von den Basisklassen), desto höher ist der Grad ihrer Spezialisierung [BH98, S. 39]. Man spricht von einfacher Vererbung, wenn eine Unterklasse genau von einer Superklasse erbt. Für den Fall der einfachen Vererbung ist der Vererbungsgraph ein Baum [BH98, S. 30 ff.]. Bei der mehrfachen Vererbung ist es möglich, dass eine 3 Kapitel 2: Objektorientierte Konzepte Klasse von zwei oder mehr Superklassen erbt. Der Vererbungsgraph ist dann ein gerichteter, azyklischer Graph [BH98, S. 42 ff.]. Für die Zuweisung im Kontext der Vererbung gilt: Die Zuweisung der Form a = b mit a und b als Variablen der Datentypen A und B (Klassen) ist gültig, falls A und B identisch sind oder B eine Unterklasse von A ist. Im Falle der Vererbung steht über die Variable a nur der Zugriff über die durch A definierte Schnittstelle zur Verfügung (A-Sicht auf Klasse B). Diese Unterklassen-Beziehung gilt weiterhin auch für Eingabe-Parameter und Rückgabewert von Methoden (Teiltypregel [WM97, S. 187] bzw. Unterklassenregel [BH98, S. 40]). 2.3 Polymorphie Bei der Realisierung von Programmen spielt die Namensgebung eine wichtige Rolle. Der sauberen Strukturierung ist es dienlich, konzeptionell verwandte Operationen unter einem gemeinsamen Namen bereitzustellen [Lo94, S. 327]. Das in Kapitel 2.1 eingeführte Prinzip der Nachrichten bereitet die Grundlage dafür, dass eine Nachricht, die an unterschiedliche Objekte gesandt wird, unterschiedliche Ergebnisse liefern kann. Dieses Prinzip ist unter dem Begriff Polymorphie bekannt (griechisch „Vielgestaltigkeit“). Im Gegensatz zu monomorphen Sprachen können Variablen, Datenobjekte (z. B. Objekte und Attribute) sowie Argument- und Rückgabewerte von Methoden in polymorphen Sprachen mehr als einen Datentyp annehmen [CW85, S. 4]. Aufgrund des Bezugs zu Datentypen ist das Prinzip der Polymorphie nicht auf objektorientierte Sprachen beschränkt. Es findet sich insbesondere auch bei funktionalen Programmiersprachen wieder. Die folgende Klassifizierung von CARDELLI, WEGNER beruht auf der Weiterentwicklung der ursprünglich durch STRACHEY eingeführten Arten von Polymorphie [CW85], [St67]. Überladen Überladen (engl. Overloading) bezeichnet die Verwendung von Methoden gleichen Namens, die sich jedoch in ihrer Signatur unterscheiden [BH98, S. 54].1 Die Unterschiede in der Signatur beziehen sich auf Anzahl und Typen der Argumente (vgl. Listing 1). 1 Analog gilt das Überladen auch für Operatoren, wie z.B. +, - oder *. 4 Kapitel 2: Objektorientierte Konzepte public class Util { public String toString(int i) {…} public String toString(float f) {…} public String toString(float complex, float imaginary) {…} } Listing 1: Überladen von Methoden Subklassen-Polymorphie Bei der Subklassen-Polymorphie (auch Inklusions-Polymorphie) kann ein Objekt als Element eines Vererbungsgraphs in Abhängigkeit seiner Verwendung unterschiedlichen Klassen zugeordnet werden [BH98, S. 53]. Diese unterschiedlichen Klassen liegen auf dem Pfad der Klasse des Objekts bis zur Wurzel des Vererbungsgraphs. Ein Objekt einer Unterklasse B von A kann beispielsweise wie folgt im Superklassenkontext von A verwendet werden: Eine Methode m, die in der Unterklasse B überschrieben wird, muss auch dann ausgeführt werden, wenn das Objekt von B in einer Variablen vom Datentyp A vorliegt (Methoden-Auswahl Regel [WM97, S. 179]). In beiden Fällen wird die Implementierung der Klasse B ausgeführt (vgl. Listing 2). public class A obj1 = new B obj2 = new obj1.m(); // obj2.m(); // B extends A {…} B(); B(); Ruft B::m() auf Ruft B::m() auf Listing 2: Subklassen-Polymorphie Parametrische Polymorphie Das Konzept der Parametrisierung (auch Generizität) hat ihren Ursprung in der funktionalen Sprache ML [CW85, S. 6]. Bei der Implementierung größerer Programmpakete wird die gleiche Funktionalität nicht selten für mehrere Datentypen realisiert, z. B. Kellerspeicher (Stack) für Integer oder Strings. Insbesondere die Verwendung von Datenbehältern (Collections) ohne Parametrisierung führt zu potenziellen Fehlern, da nur eine dynamische Typüberprüfung zur Laufzeit möglich ist [WM97, S. 179]. Listing 3 demonstriert die Typumwandlung eines Elements des Stacks von der Basisklasse Object zur speziellen Klasse String. Ein solcher Downcast setzt Elemente vom Typ String im Stack voraus und führt zu einem Laufzeitfehler, falls dies nicht der Fall ist. Stack oldStack = new Stack(); oldStack.push(new Integer(2)); String top = (String) oldStack.pop(); // Downcast Object -> String Listing 3: Unsichere Typumwandlung ohne Parametrisierung 5 Kapitel 2: Objektorientierte Konzepte Das Konzept der Parametrisierung liefert Abhilfe. Parametrisierung ermöglicht die Definition von Klassen, die sich mit verschiedenen Datentypen instanziieren lassen. Dadurch ist eine statische Typüberprüfung möglich, die Fehler bereits zur Übersetzungszeit aufdeckt. In der Objektorientierung wird die Parametrisierung durch sog. generische Klassen ermöglicht.2 BALZERT definiert wie folgt: "Eine generische Klasse (parameterized class, template) beschreibt eine Familie von Klassen mit einem oder mehreren formalen Parametern. Parameter einer generischen Klasse sind Typ-Parameter oder Konstanten-Parameter. Ein Typ-Parameter ist ein Bezeichner, der innerhalb der Klasse wie ein gewöhnlicher Typ verwendet werden kann." [Ba00, S. 815] Die Instanziierung der generischen Klasse mit aktuellen Parametern resultiert in einer neuen Klassendefinition. Generische Klassen haben mit Version 5.0 als Generics Einzug in Java gehalten [SUN03, S. 178 f.]. Listing 4 illustriert die Definition eines generischen Stacks mit dem formalen Parameter T: public class Stack<T> { public void push(T element) {…} public T pop() {…} } Listing 4: Definition eines generischen Stacks Im folgenden Beispiel erfolgt die Instanziierung des generischen Stacks durch den aktuellen Parameter String: Stack stringStack = new Stack<String>(); stringStack.push("Hello World"); String top = stringStack.pop(); Listing 5: Instanziierung des generischen Stacks Es stehen Mechanismen bereit, um die Parametrisierung weitergehend zu steuern. Mit Hilfe von Parameterrestriktionen lassen sich Anforderungen an den aktuellen Parameter stellen. Unter Bezug auf die Vererbungshierarchie können sog. Bounds die möglichen Parameter auf eine Untermenge reduzieren (Number in Listing 6). Falls nicht explizit angegeben (vgl. Listing 4), wird als Bound die Basisklasse Object angenommen. public class Stack<T extends Number> {[…]} Listing 6: Definition eines generischen Stacks 2 Das Konzept der Parametrisierung ist analog zu generischen Klassen auch auf Methoden anwendbar. 6 Kapitel 3: Übersetzung 3 Übersetzung Programme in modernen objektorientierten Programmiersprachen wie Java oder C# werden nicht direkt in ausführbaren Maschinencode sondern in einen Zwischencode übersetzt, der zur Laufzeit des Programms auf einer virtuellen Maschine ausgeführt wird. Zunächst wird eine Abgrenzung zwischen realen und abstrakten Maschinen vorgenommen, wobei letztere als direkte Vorlage für virtuelle Maschinen dienen. Auf dieser Basisarchitektur aufbauend erfolgt die Beschreibung der Übersetzung von Klassen und Methoden, wobei Methodenaufrufe insbesondere im Kontext der Vererbung untersucht werden. Das Kapitel schließt mit der Vorstellung von drei unterschiedlichen Strategien zur Übersetzung von Parametrisierung. 3.1 Reale und abstrakte Maschinen Die Übersetzung transformiert den Programmtext einer Sprache in ein Zielprogramm, das unter Einbeziehung von Eingaben auf einem Rechner ausführbar ist. Während dieses Übersetzungsprozesses wird das Programm analysiert, ohne die konkreten Eingabedaten zu berücksichtigen [WM97, S. 3]. Innerhalb dieser Analyse werden beispielsweise Zuweisungen und Methodenaufrufe auf die korrekte Verwendung der Variablen- und Argumenttypen kontrolliert. Durch diese statische Typüberprüfung lassen sich bestimmte Programmfehler bereits zur Übersetzungszeit identifizieren. Eine Möglichkeit ist, das Zielprogramm so zu generieren, dass es auf einem Rechner, d. h. einer realen Maschine, ausführbar ist. Eine reale Maschine ist durch ihre Hardware und insbesondere durch den Prozessor, der die Instruktionen des Zielprogramms verarbeitet, bestimmt. Zwei verbreitete Prozessorarchitekturen sind CISC (Complex Instruction Set Computer) und RISC (Reduced Instruction Set Computer). [OV00, S. 253ff. und S. 289ff.], (Vgl. [WM97, S. 569ff.] für eine Betrachtung der Codeerzeugung). Da die Hardware heutiger Rechner aufgrund ihrer Registerorientierung an imperative Sprachen angepasst ist, bietet sich für Programme dieser Sprachenfamilie die direkte Übersetzung in Maschinencode an [WM97, S.4]. Dahingegen führt die Übersetzung für eine abstrakte Maschine eine Abstraktionsschicht zwischen dem übersetzten Programm und dem realen Rechner ein. Da die abstrakte Maschine einen für die zu übersetzende Sprache geeigneten Befehlssatz bereitstellt, vereinfacht sie die Implementierung des Übersetzers. In einem 7 Kapitel 3: Übersetzung davon unabhängigen Schritt ist die Implementierung der abstrakte Maschine auf realer Hardware umzusetzen. Die Vorteile einer solchen zweischichtigen Architektur offenbaren sich, wenn die Sprache auf verschiedenen Hardware-Plattformen zu realisieren ist. Zwar ist eine spezifische Implementierung der abstrakten Maschine notwendig, ihre Definition und Schnittstellen und der aufwendige Prozess der Übersetzung bleibt für alle Ziel-Plattformen identisch. Im heutigen schnellen Wandel von Prozessorarchitekturen und -befehlssätzen ist dadurch die Zukunftssicherheit eines Übersetzungssystems gewahrt [WM97, S. 4f.]. Moderne Programmiersprachen wie Java oder C# nutzen eine Ausprägung einer abstrakten Maschine zur Ausführung der Programme. Eine solche virtuelle Maschine, z.B. die Java Virtual Maschine (JVM) oder die Common Language Runtime (CLR) in .NET, übersetzt den Zwischencode in den Maschinencode der Ziel-Plattform. Dies geschieht teilweise erst dann, wenn eine Methode aus dem Programm aufgerufen wird (sog. Just-in-time Compilierung JIT) [AW02, S. 17]. Die Laufzeitumgebung einer idealisierten abstrakten Maschine für objektorientierte Sprachen umfasst die Komponenten Befehlsinterpreter, Programmspeicher, Stack (Keller) und Heap (Halde) [BH98, S. 57]. Abbildung 1: Laufzeitumgebung einer abstrakten Maschine. Der Programmspeicher enthält den Zwischencode, der in Klassendeskriptoren und Methodenrümpfe unterteilt ist. Während die Klassendeskriptoren die Klassendefinition repräsentieren, wird die Implementation der Methoden, d. h. die Methodenrümpfe, unabhängig abgespeichert. Zuständig für die Ausführung des Zwischencodes ist der Befehlsinterpreter. Unter Zuhilfenahme des Befehlszählers arbeitet der Interpreter die Befehle ab, die im 8 Kapitel 3: Übersetzung Programmspeicher vorliegen. Dabei zeigt der Befehlszähler auf den abzuarbeitenden Befehl, der im Rumpf einer Methode steht. Der Framepointer verweist auf denjenigen Frame auf dem Stack, der passend zum Befehlszähler die Inkarnation der gerade ausgeführten Methode enthält. Der Stack ist eine Datenstruktur, die nach dem LIFOPrinzip (Last in First Out) funktioniert. Eine Inkarnation einer Methode umfasst sämtliche lokalen Variablen einer Methode in einem gesonderten Speicherbereich (Frame). Jeder Methodenaufruf führt zu einer neuen Inkarnation, so dass lokale Variablen zwischen den Inkarnationen nicht geteilt werden. Ein Methodenaufruf erzeugt einen Frame der Inkarnation, der oben auf den Stack gelegt wird. Nach Abarbeitung des Methodenrumpfes wird der Frame vom Stack entfernt. Dieser Mechanismus ist insbesondere für die Schachtelung von Methodenaufrufen innerhalb von Rekursion von Bedeutung. Auf dem Heap werden die Instanzen der Klassen, die Objekte, in einer zusammengehörigen Struktur, die sich aus ihren Instanzvariablen und einem Pointer auf die Methodentabelle zusammensetzen, abgespeichert. Die Methodentabelle wird benötigt um die Methodenimplementation auszuwählen, wenn eine Nachricht an das Objekt gesendet wird. Der Heap ist als linear angeordnete Menge von Zellen organisiert (Liste), deren Größe fest oder variabel sein kann. Während der gesamten Lebensdauer befindet sich das Objekt auf dem Heap und kann von anderen Objekten oder Variablen in Methodeninkarnationen referenziert werden. Das Ende der Lebensdauer eines Objekts ist dadurch bestimmt, dass es nicht mehr referenziert wird. Bei den existierenden objektorientierten Programmiersprachen werden die manuelle und automatische Speicherverwaltung unterschieden. Im manuellen Fall (z. B. C++) ist in der Programmlogik sicherzustellen, dass die Speicherbereiche explizit freigegeben werden. Moderne Sprachen (z. B. Java oder C#) befreien den Programmierer von dieser Aufgabe, in dem sie geeignete Algorithmen für die sog. Garbage Collection in der Laufzeitumgebung bereitstellen [BH98, S. 165 ff.]. 3.2 Klassen und Methoden In Kapitel 3.1 wurde der Programmspeicher eingeführt, in dem die Klassendefinitionen in Form von Klassendeskriptoren vorgehalten werden. Die folgende Detaillierung bezieht sich auf Smalltalk-80. Die gewählte Abstraktion lässt jedoch eine Anwendung auf andere objektorientierte Programmiersprachen wie z. B. Java zu [BH98, S. 75]. 9 Kapitel 3: Übersetzung In Abb. 2 ist die Datenstruktur sowohl in allgemeiner Form als auch für einen Stack dargestellt, der durch den Klassendeskriptor repräsentiert wird. Die wesentlichen Bestandteile des Klassendeskriptors sind der Verweis auf die Methodentabelle sowie Angaben über die Anzahl und Datentypen der Instanzvariablen der Klasse. Im Vorgriff auf die Umsetzung von Vererbung ist festzuhalten, dass der Klassendeskriptor, je nachdem ob Einfach- oder Mehrfachvererbung vorliegt, einen oder mehrere Verweise auf die übergeordneten Superklassen enthält. Die Methodentabelle ist eine indizierte Datenstruktur, der die Anzahl aller in dieser Klasse definierten Methoden voransteht. Jeder Index der Tabelle ist einem Methodennamen zugewiesen, dem sog. Methodenselektor [BH98, S. 77]. Abbildung 2: Detaillierung des Klassendeskriptors Für Java findet sich die beschriebene Struktur der Klassendeskriptoren in dem vom Java-Compiler erzeugtem Bytecode wieder. Zu jeder Klasse im Quelltext eines JavaProgramms korrespondiert genau eine class-Datei [SUN99, Kapitel 4]. In ihr, vgl. Ausschnitt in Listing 7, sind u.a. die in Abb. 2 dargestellten Informationen enthalten. ClassFile { u2 super_class; u2 fields_count; field_info fields[field_count]; u2 methods_count; method_info methods[methods_count]; […] } // // // // // Referenz auf Superklasse Anzahl Instanzvariablen Name u. Typ d. Instanzvar. Anzahl der Methoden Methodentabelle Listing 7: Ausschnitt einer Java class-Datei Der Name einer Methode in der Methodentabelle verweist auf einen entsprechenden Eintrag im Methodenarray. Der Eintrag im Methodenarray setzt sich aus einem Header, einem Literal-Frame und dem Zwischencode der Methode, bei Java und Smalltalk-80 dem Bytecode, zusammen. Der Header nimmt Informationen über Argumente und 10 Kapitel 3: Übersetzung lokale Variablen auf. Der Literal-Frame stellt Verweise auf Objekte, Konstanten und Methodenselektoren bereit, die im Methodenrumpf verwendet werden [BH98, S. 82]. Die in Kapitel 1 eingeführten Gemeinsamkeiten zwischen objektorientierten und imperativen Sprachen schlagen sich hauptsächlich im Programmcode von Methoden nieder. In diesem werden die aus imperativen Sprachen bekannten Konstrukte wie z.B. Variablen, Schleifen oder Verzweigungen zur Manipulation von Objekten verwendet. Für deren Übersetzung wird daher an dieser Stelle auf umfassende Literatur zur Übersetzung imperativer Sprachen verwiesen [WM97, S. 7 – 62], [ALU86]. Darüber hinaus bringen objektorientierte Sprachen neue Sprachkonstrukte mit sich, um auf Instanzvariablen zuzugreifen oder Nachrichten an Objekte zu senden (vgl. Punktund Pfeilnotationen Kapitel 2.1). Die Auswahl des auszuführenden Methodenrumpfes wird im nachfolgenden Kapitel im Kontext der Vererbung erläutert. Dahingegen ist die Ausführung des Methodenaufrufs mit dem Laden der Argumente auf einen Stack und der anschließenden Abarbeitung des Methodenrumpfes eng an die Vorgehensweise des Funktionsaufrufs einer imperativen Sprache angelehnt. [BH98, S. 97ff.]. Der Unterschied ergibt sich daraus, dass Methoden unmittelbar auf die Merkmale ihres Objekts zugreifen können (this-Objekt). Dies wird dadurch realisiert, indem eine Referenz auf das Empfänger-Objekt einer Nachricht der aufgerufenen Methode intern als zusätzliches Argument übergeben wird [WM97, S. 183]. Es findet damit bei der Übersetzung von Methoden eine Abbildung auf das Konzept der Funktionen imperativer Sprachen statt: Eine Methode m einer Klasse K mit der Signatur <retval> m(<arglist>) wird übersetzt in eine Funktion der Signatur <retval> Km(K this,<arglist>). Über das Argument this wird eine Referenz auf das Objekt übergeben, dass als Empfänger der Nachricht m diente. Dementsprechend muss das Senden von Nachrichten, d. h. der Aufruf einer Methode von o.m(<arglist>) in Km(o,<arglist>) umgewandelt werden [WM97, S. 183]. Für die generierten Funktionsnamen hat der Compiler sicherzustellen, dass es nicht zu Konflikten im Namensraum3 der Funktionen kommt, d.h. sich die Funktionen in ihrem Namen unterscheiden. Aufgrund des in Kapitel 2.3 beschrieben Prinzips des Überladens 3 Unter einem Namensraum wird ein Kontext verstanden, in dem ein Bezeichner ein Objekt wie z.B. eine Funktion oder eine Variable identifiziert. 11 Kapitel 3: Übersetzung ist es erforderlich die Typen der Argumentliste in den Funktionsnamen zu codieren [WM97, S. 184]. An der folgenden Gegenüberstellung der durch den GNU Compiler g++ 3.0 gebildeten Funktionsnamen lässt sich das Codierungsschema _ZN#<Klasse>#<Methode>E<Typ>* ableiten, wobei # für die Anzahl der Zeichen des folgenden Bezeichners steht: Stack::push(int element) Stack::push(float element) Stack::push(float comp,float imag) _ZN5Stack4pushEi _ZN5Stack4pushEf _ZN5Stack4pushEff Listing 8: Codierung von Methodennamen in Funktionen in C++ 3.3 Vererbung Vererbung gehört, wie durch Kapitel 2.2 motiviert, zu den wichtigsten Konzepten der Objektorientierung. Hinsichtlich der Übersetzung trägt sie maßgeblich dazu bei, dass in einer objektorientierten Sprache stärker zwischen statischen und dynamischen Aspekten der Übersetzungs- und Laufzeit unterschieden werden muss. Im Kontext der Einfachvererbung wird daher zunächst der Begriff des dynamischen Bindens erörtert. Die Behandlung der Mehrfachvererbung schließt sich daran an. 3.3.1 Einfachvererbung Die Realisierung eines Methodenaufrufs in einer objektorientierten Sprache unterscheidet sich grundlegend von der Realisierung eines Funktionsaufrufs (oder Prozeduraufrufs) in einer imperativen Sprache. Dort wird der Funktionsaufruf bereits zur Übersetzungszeit der Definition der Funktion fest zugeordnet. Nach erfolgreicher Typüberprüfung der Argumente kann der Übersetzer bereits die (relative) Speicheradresse festlegen, an dem der auszuführende Code der Funktion während der Programmausführung liegen wird. Diese Schritte des Übersetzers werden statisches Binden genannt, da sie unabhängig von der Programmausführung sind [WM97, S. 35]. Wie bereits an der Subklassen-Polymorphie in Kapitel 2.3 demonstriert wurde, erlaubt das Prinzip der Nachrichten im Zusammenhang mit Vererbung keine statische Bindung. Es liegt in der Natur der Polymorphie, dass erst zur Laufzeit bestimmbar ist, welche Instanz die Verarbeitung einer Nachricht übernimmt, d. h. welche Methodenimplementierung ausgeführt wird. Die dynamische Bindungsregel besagt daher: „Überschreibt eine Klasse B eine Methode ihrer Superklasse A und wird eine 12 Kapitel 3: Übersetzung Nachricht m an ein Objekt geschickt, dessen Klassenzugehörigkeit zur Übersetzungszeit nicht bekannt ist, so muss die Methodenimplementierung zur Laufzeit an das Objekt gebunden werden.“ [BH98, S.41] Die dynamische Bindungsregel findet sich in der Umsetzung der Vererbung wieder. Die konkrete Umsetzung der Vererbung wird durch die in C++ benutzten virtuellen Funktionstabellen (auch vtable) beschrieben.4 Sog. virtuelle Methoden lassen sich in C++ in einer Unterklasse überschreiben (Sie müssen in der Oberklasse durch virtual gekennzeichnet werden). Für jede Klasse, die virtuelle Methoden enthält oder überschreibt, wird eine vtable angelegt [BH98, S. 104]. Abb. 3 visualisiert den Vererbungsbaum der Objekte f, k, r und q der Klassen Figur, Kreis, Rechteck und Quadrat mit den dazugehörigen vtables. Die Klassen Kreis und Rechteck überschreiben die geerbte Methode und führen neue Methoden ein. Die Klasse Quadrat überschreibt die geerbten Methoden nicht, so dass die Einträge in der vtable auf die Superklasse verweisen, in der die Implementation steht. Abbildung 3: Objekte einer Einfachvererbung mit virtueller Funktionstabelle Während der Instanziierung erhält das Objekt einen Zeiger, der auf die vtable seiner Klasse verweist. Eine Nachricht, z. B. q->flaeche(), wird ausgeführt, indem in der vtable die Adresse für den entsprechenden Funktionscode nachgeschlagen wird, z. B. Rechteck::Flaeche(). Eine effiziente Implementation ist durch die standardisierte Indizierung der vtable möglich: Bereits zur Übersetzungszeit können die Methodenaufrufe als C-Funktionszeiger auf Elemente der vtable umgesetzt werden, aus q->flaeche() würde so bspw. (*(q->vtable[1]))() [Eck00, S. 636 ff.]. 4 Das Konzept virtueller Funktions- bzw. Methodentabellen findet sich auch in weiteren objektorientierten Sprachen (u.a. C#) in dieser oder ähnlicher Form wieder. 13 Kapitel 3: Übersetzung Im Rahmen der Subklassen-Polymorphie (vgl. Methoden-Auswahl-Regel in Kapitel 2.3) wurde bereits die Notwendigkeit von Sichten diskutiert, die über ein Objekt gelegt werden müssen, wenn es im Superklassenkontext verwendet wird. Z. B. müsste für den Programmcode in Listing 9 eine Figur-Sicht des Objekts r vom Typ Rechteck gelten, da es als Figur verwendet wird. Figur* figur = new Rechteck(); figur->Flaeche(); // Figur-Sicht auf Rechteck // Aufruf von Rechteck::Flaeche() Listing 9: Verwendung eines Objekts im Superklassenkontext Aufgrund des aufsteigenden Aufbaus der vtable entlang des Vererbungsbaums (neue Methoden werden unten angefügt), werden Sichten mittels Offsets vom Beginn der vtable angegeben. In Abb. 3 sind diese Sichten durch geschweifte Klammern neben den vtables angedeutet. 3.3.2 Mehrfachvererbung Im Gegensatz zum vorherigen Kapitel stellt die Mehrfachvererbung größere Herausforderungen an die Sprachdefinition, die Übersetzung sowie an den Entwurf des Vererbungsgraphs. Die Ausarbeitung soll die dafür verantwortlichen Problemstellungen rund um das sog. Diamant-Problem aufdecken. Für eine tiefgreifende Betrachtung dieses Themenkomplexes sei auf die umfangreiche Behandlung in [WM97, S. 189 ff.] verwiesen. Wie im vorherigen Kapitel beziehen sich die Ausführungen auf die Sprache C++, die Mehrfachvererbung unterstützt. Der einfachste Fall einer wiederholten mehrfachen Vererbung lässt sich mithilfe von zwei erbenden Klassen abbilden, von denen gemeinsam eine weitere Klasse erbt. Abb. 4a visualisiert den Vererbungsgraphen in Form eines Diamanten. Abbildung 4: Wiederholte mehrfache Vererbung: Graph (a) und Übersetzung (b) 14 Kapitel 3: Übersetzung Das Klassenmodell aus Abb. 3 wurde um die Klassen GUIObjekt und Linie erweitert. Besonderer Augenmerk liegt auf der Methode Zeichne() die durch GUIObjekt definiert und von Linie überschrieben wird, während auf dem anderen Vererbungspfad erst eine konkrete Figur, z. B. Kreis die Methode überschreibt. Ein weiterer Fokus liegt auf der Methode Skalieren(), die jeweils in Figur und in 5 Linie eingeführt wird. Es ergeben sich zwei Probleme aus Sicht von Rechteck [WM97, S. 190]: 1. Wiederholte Beerbung: Als direkte Unterklassen erhalten Figur und Linie Methoden und Instanzvariablen, die von beiden Klassen weiter vererbt werden. 2. Uneindeutigkeit wegen doppelter Methodennamen oder Instanzvariablen (z. B. Skalieren()) der Klassen Figur und Linie. Zu 1: Für die wiederholte Beerbung existieren zwei Lösungsansätze, die beide ihre Berechtigung haben [BH98, S. 45 f.]. Im ersten Fall werden alle Komponenten von GUIObjekt jeweils für Figur und Linie übernommen (vgl. Abb. 4b.1). Nachteilig hierbei ist, dass für jeden Zugriff auf Instanzvariablen oder Methoden von GUIObjekt aus Rechteck immer genau definiert werden muss, welche Kopie (z. B. farbtiefe in Figur oder in Linie) benutzt werden soll. Eine andere Möglichkeit ist, die Komponenten aus GUIObjekt nur einfach zu vererben (vgl. Abb. 4b.2). Nach den Regeln der Polymorphie kann es dabei zum Aufruf einer Methode kommen, die in einem anderen Pfad der Vererbungshierarchie definiert wurde. Im folgenden Beispiel wird ein Objekt vom Typ Rechteck im Superklassenkontext von Figur verwendet und die Nachricht zeichne() gesendet. Ausgeführt wird der in der Klasse Linie implementierte Methodenrumpf. Figur* f = new Rechteck(); f->Zeichne(); // ruft Linie::Zeichne() auf Listing 10: Polymorphie bei (virtueller) Mehrfachvererbung Besonders an diesem Beispiel ist, dass das Senden einer Nachricht im Pfad des Vererbungsgraphs GUIObjekt <– Figur <– Rechteck gemäß der Polymorphie 5 Das Modell ist in Anlehnung an [WM97, S. 177] entwickelt worden, um die Besonderheiten der Mehrfachvererbung zu verdeutlichen und erhebt daher nicht den Anspruch einer optimalen Modellierung, vgl. auch Anhang A: Beispiel Mehrfachvererbung in C++. 15 Kapitel 3: Übersetzung zum Aufruf einer Methode führt, die in einem parallelen Pfad, nämlich GUIObjekt <– Linie <– Rechteck, überschrieben wurde [BH98, S. 112]. Falls die Implementierung des parallelen Pfades nicht offen liegt (z.B. aufgrund von Teamarbeit oder Bibliotheken) hätte der Programmierer erwarten können, dass der Methodenrumpf der Oberklasse GUIObjekt gemäß Vererbung ausgeführt werden müsste. Zu 2: Eine Nachricht Skalieren() an ein Objekt vom Typ Rechteck würde zunächst zu einem Übersetzungsfehler führen, da die zu benutzende Implementierung nicht eindeutig identifizierbar ist. Als Lösung existiert in C++ eine Zuordnungsmöglichkeit, die auf die Methode der gewählten Superklasse (hier Figur::Skalieren) verweist [WM97, S. 190]: class Rechteck : public Figur, public Linie { public: using Figur::Skalieren; // Verweis auf Methode in Superklasse } Listing 11: Verweis auf Superklasse in C++ Um derartigen Mehrdeutigkeiten im Kontext der Mehrfachvererbung aus dem Weg zu gehen, bietet Java bspw. nur eine eingeschränkte Mehrfachvererbung an. So ist Mehrfachvererbung nur mit Superklassen erlaubt, die keinen Implementierungsteil enthalten, sondern ausschließlich Schnittstellen definieren, vgl. Java Interfaces [SUN03, S. 186]. Dadurch ist sichergestellt, dass es nicht mehrere Methodenimplementierungen oder Instanzvariablen auf verschiedenen Pfaden des Vererbungsgraphs gibt. 3.4 Parametrisierung Die Konzepte der Parametrisierung, die in Kapitel 2.3 eingeführt wurden, haben in den meisten objektorientierten Sprachen einen gemeinsamen Nenner gefunden. Ihre Umsetzung variiert jedoch deutlich. Nach der Klassifikation von BAUER, ORDERSKY und WADLER werden die Strategien für C++, Java und C# vorgestellt. 3.4.1 Kopierende Übersetzung Eine Möglichkeit Parametrisierung zu übersetzen besteht darin, die generische Klasse als Vorlage zu benutzen. Bei der Instantiierung mit aktuellen Parametern wird die Klassendefinition kopiert und die formalen durch aktuelle Parameter ersetzt. BAUER und HÖLLERER klassifizieren den Ansatz als kopierende Sicht, da der Übersetzer dem Programmierer die Aufgaben des Kopierens und Änderns abnimmt [BH98, S. 113]. 16 Kapitel 3: Übersetzung ODERSKY und WADLER nennen dies auch heterogene Übersetzung, da für jede Instanziierung eine spezielle Klasse erzeugt wird. Die Sprache C++ realisiert Parametrisierung durch sog. Templates. Die Syntax einer Template-Klasse ist eng an die in Kapitel 2.3 eingeführte Schreibweise für Generics in Java angelehnt. Der C++ Übersetzer generiert bei Bedarf, d. h. bei Instanziierung einer generischen Klasse und Zugriff auf eine Methode den entsprechenden Programmcode. Die generische Definition wird zur Übersetzungszeit zu konkretem Programmcode expandiert. Wie in den vorigen Kapiteln vorgestellt, werden Methoden durch Funktionen mit speziellen Namen realisiert. Am Beispiel eines generischen Stacks soll dies demonstriert werden (vgl. Listing 10). Stack<int> intStackA; Stack<int> intStackB; Stack<float> floatStack; intStackA.push(1); intStackB.push(2); floatStack.push(1); floatStack.pop(); Listing 12: Instantiierung von Template-Klassen in C++ Der C++-Compiler, hier der GNU Compiler g++ 3.0, generiert genau für diejenigen Methoden spezifischen Code, die tatsächlich aufgerufen werden. Folgende symbolische Namen des kompilierten Programms korrespondieren zu den Methoden: _ZN5StackIiE4pushEi _ZN5StackIfE4pushEf _ZN5StackIfE3popEv Stack<int>::push() Stack<float>::push() Stack<float>::pop() Listing 13: Codierung von Methodennamen bei Template-Klassen Die Methode Stack<int>::pop() wird nicht vom Compiler expandiert, da sie nicht aufgerufen wird. Darüber hinaus wird die Methode auch nicht vollständig in den Übersetzungsprozess einbezogen. G++ führt zwar eine syntaktische Analyse durch, die Semantik ist jedoch nicht Gegenstand der Validierung. Ein weiterer Schwachpunkt des heterogenen Ansatzes wird darin gesehen, dass die Programmgröße bei intensivem Gebrauch generischer Klassen aufgrund der Kopien drastisch anwächst [WM97, S. 218]. Das obige Beispiel demonstriert, dass die Umsetzung von Parametrisierung mit Templates nicht im Sprachkern stattfindet, sondern auf bestehende Sprachmerkmale aufbaut. In gewissem Sinne ist die Übersetzung mit der Expansion von Makros durch 17 Kapitel 3: Übersetzung einen Präprozessor vergleichbar, der dem eigentlichen Übersetzungsprozess vorangestellt ist (vgl. Übersetzung von C-Programmen [Eck00, S. 79 ff.]). Gleichermaßen verfolgt der Einsatz von Makros das Ziel, bestimmte Programmblöcke automatisch zu generieren, um die Anzahl des zu schreibenden Programmtextes zu reduzieren oder Kopieren und Ändern zu vermeiden. Daher bringt die Realisierung generischer Klassen in C++ Probleme mit sich, die aus der Makroverarbeitung eines Präprozessors bekannt sind. Aufgrund des Makrocharakters ist die Typprüfung zwar statisch zur Übersetzungszeit, jedoch nur im Kontext der Instanziierung, d. h. dort, wo generische Klassen verwendet werden, und nicht innerhalb der generischen Klasse selbst möglich. Fehlermeldungen die sich auf Template-Klassen beziehen sind aus diesem Grund meist nur schwer interpretierbar [OW97, S. 2]. 3.4.2 Homogene Übersetzung In der ursprünglichen Sprachdefinition von Java wurde das Konzept der Parametrisierung nicht berücksichtigt. Die Generics Implementation von Java 5.0 geht auf ein Projekt von ODERSKY und WADLER mit dem Namen Pizza zurück, das später in Generic Java (GJ) umbenannt wurde und im Java Specification Request (JSR) 14 mündete [JSR14]. Eine Kernbedingung an Pizza war, dass generische Klassen keine Modifikationen an der JVM hervorrufen sollten (Abwärtskompatibilität) [OW97], [ORW98]. Darüber hinaus wurde in GJ die Anforderung erweitert, dass alte Java Programme mit neuen parametrisierten Klassen, insbesondere den Collections, zusammenarbeiten sollten (Aufwärtskompatibilität) [BOW98]. Zwar wurde nur die letzte Bedingung in die JSR aufgenommen, dennoch beeinflussten beide die endgültige Umsetzung stark. Eine generische Klasse wird im Gegensatz zu der Umsetzung der C++ Templates nicht in mehrere, sondern nur in eine Klasse im Zwischencode (in Java Bytecode) übersetzt. Die nach ODERSKY, WADLER gewählte Bezeichnung als homogene Übersetzung rührt daher, dass dieser Code universell für alle aktuellen Parameter verwendbar ist [ORW98, S. 117]. Die Vorgehensweise, die zu dieser universellen Klasse führt, wird als Erasure („Ausradieren“) bezeichnet. Im ersten Schritt überprüft der Java-Compiler die Verwendung von Instanzen generischer Klassen unter Einbeziehung der aktuellen Typparameter. Durch diese statische Typüberprüfung werden generische Klassen und 18 Kapitel 3: Übersetzung deren Parameter in das Typsystem von Java einbezogen. Falls die Benutzung der generischen Klasse hinsichtlich der Typen korrekt ist, werden die formalen Parameter vollständig durch ihren Bound, d. h. ihre mögliche Superklasse, ersetzt und an den benötigten Stellen automatisch Typkonvertierungen eingeführt. Der modifizierte, temporäre Quelltext wird anschließend in Bytecode für die JVM übersetzt [BOW98, S. 5]. Listing 10 stellt den originalen Quelltext sowie den temporären Quelltext für das Beispiel des Stacks gegenüber, der als Ausgangspunkt für die Erzeugung des Bytecodes dient. public class Stack<T> { public void push(T element) {…} public T pop() {…} } public class Stack { public void push(Object element) {…} public Object pop() {…} } Stack<String> st = new Stack<String>(); st.push("Hello World"); Stack st = new Stack(); st.push("Hello World"); String top = st.pop(); String top = (String) st.pop(); Listing 14: Generische Klasse Stack vor und nach Erasure Da der modifizierte Code, auch der Raw Type, frei von generischen Instruktionen ist, erfüllt er die Anforderung an Aufwärts- und Abwärtskompatibilität. Dementsprechend enthält der Bytecode zur Laufzeit nur noch reguläre Typen und keine Informationen mehr über generischen Typen [BOW98, S. 13]. Die Möglichkeiten zur Laufzeit Informationen über die generischen Instanzen zu bekommen (sog. Reflection) sind deshalb sehr eingeschränkt. So ist eine Typüberprüfung auf formelle Parameter oder generische Klassen wie z. B. element instanceof T oder stack instanceof Stack<String> nicht möglich. Damit hängt zusammen, dass selbst zur Laufzeit nicht wie üblich überprüft werden kann, ob eine Typumwandlung möglich ist: Stack<String> strStack = new Stack<String>(); strStack.push("Test"); Object tmp = strStack; Stack<Integer> intStack = (Stack<Integer>) tmp; // Unchecked cast Integer intVal = intStack.pop(); // CastException later in code Listing 15: Ungesicherte Typumwandlung eines generischen Typs Eine solche ungesicherte Typumwandlung (sog. unchecked cast) auf Stack<Integer> kann erst an späteren Stellen im Programm zu Fehlern führen, die nur schwer auf ihren Ursprung zurückzuführen sind. 19 Kapitel 3: Übersetzung Eine weitere wichtige Besonderheit ist, dass primitive Datentypen wie z. B. int oder float nicht als Parameter einer generischen Klasse eingesetzt werden können. Dies ist damit begründet, dass diese Typen keine Objekte sind und damit auch nicht von der Basisklasse Object erben. Eine Zusammenstellung weiterer Besonderheiten der gewählten Implementation von Generics findet sich unter [IBM05]. 3.4.3 Echte generische Übersetzung Wie in Java war parametrische Polymorphie nicht Bestandteil der ersten Version von .NET und dessen neuer Sprache C#. Analog zum Pizza Projekt entwickelte das Gyro Projekt einen Vorschlag, der in .NET 2.0 integriert wurde. Die wichtigste Forderung umfasste die vollständige Integration von parametrischer Polymorphie in das Typsystem, so dass z. B. zur Laufzeit Informationen über generischen Klassen ermittelt werden können [MS01]. .NET ist durch ein gemeinsames Typsystem (Common Type System CTS) in der Laufzeitumgebung (CLR) gekennzeichnet, welches die Ausführung und Interoperabilität verschiedener Programmiersprachen erlaubt [MS06]. Diese Tatsache stellte die Herausforderung, Generizität in die Gesamtarchitektur von .NET zu integrieren, so dass die Zwischensprache (MS Intermediate Language IL) und damit die CLR erweitert werden musste. BAUER und HÖLLERER bezeichnen diese vollständige Integration in die Sprache als echte generische Übersetzung von Parametrisierung [BH98, S. 121f.]. Die Umsetzung in .NET stützt sich darauf, die Ansätze heterogener und homogener Übersetzung zu kombinieren. Dabei steht im Vordergrund, den übersetzten Code so gut wie möglich zwischen verschiedenen Instanzen generischer Klassen zu teilen. Ob sich zwei Instanzen generischer Klassen, also z. B. Stack<string> und Stack<object>, ihre Methodenimplementation teilen können, hängt von ihrer Kompatibilität ab. So sind zwei Klasseninstanzen kompatibel, falls die Datenstrukturen und die darauf ausgeführten Funktionen der aktuellen Parameter im übersetzten Code identisch sind. Beispielsweise sind alle Referenztypen, d. h. alle Objekte, zueinander kompatibel, weil sie gegenüber der Laufzeitumgebung als 32-bit-Pointer angesehen werden. Sämtliche primitiven Datentypen, z. B. int oder float, sind hingegen untereinander und zu Referenztypen inkompatibel [MS01, S. 7]. 20 Kapitel 3: Übersetzung Die Verwendung gemeinsamer Code-Teile für verschiedene Klasseninstanzen steht im Konflikt dazu, spezielle Informationen über die einzelne Instanz zur Laufzeit zu erhalten. Im Gegensatz zu Java ist per Design eine Typüberprüfung wie z.B. stack instanceof Stack<string> zu gewährleisten. Die Problemstellung wird durch eine Kopie der virtuellen Methodentabelle für jede Klasseninstanz gelöst, welche die Typinformationen zur Laufzeit bereitstellt (vgl. Abbildung 5) [MS01, S. 8]. Zueinander kompatible Klasseninstanzen verweisen wiederum auf geteilten Code. Abbildung 5: Umsetzung parametrischer Polymorphie in .NET Folgender Programmtext führt zu der in Abbildung 5 dargestellten Repräsentation: Stack<string> Stack<string> Stack<object> Stack<int> s1 s2 s3 s4 = = = = new new new new Stack<string>(); Stack<string>(); Stack<object>(); Stack<int>(); // // // // vtable und code anlegen vtable existiert code existiert, neue vtable vtable und code anlegen Listing 16: Erzeugung virtueller Methodentabellen für Klasseninstanzen Während der Laufzeit entscheidet die CLR für jede Instanziierung einer generischen Klasse, ob bereits eine kompatible Instanziierung bzw. eine zugehörige virtuelle Methodentabelle existiert und erstellt diese, falls sie nicht vorhanden sind (vgl. Kommentare in Listing 16). Durch geeignete Suchmechanismen (vtable Dictionary) wird eine effiziente Ausführung sichergestellt [MS01, S. 9]. Die Umsetzung generischer Klassen in .NET erfüllt die Anforderungen, so dass zur Laufzeit vollständige Informationen über die generischen Datentypen vorliegen. Darüber hinaus wird für die jeweiligen aktuellen Parameter möglichst effizienter Maschinencode ausgeführt. Der Verzicht auf die bei der Java benötigten Downcasts wirkt sich vorteilhaft auf die Ausführungsgeschwindigkeit aus, da zur Laufzeit keine Typüberprüfungen und -konversionen mehr durchgeführt werden müssen. 21 Kapitel 4: Zusammenfassung & Fazit 4 Zusammenfassung & Fazit Nach der Vorstellung der grundlegenden Begrifflichkeiten wurden mit der Vererbung und den unterschiedlichen Ausprägungen der Polymorphie die wesentlichen Konzepte objektorientierter Programmiersprachen erläutert. Dabei wurde auch auf die Anforderungen wie z.B. Abstraktion, Wiederverwendung und Erweiterbarkeit eingegangen und erklärt, durch welche Bestandteile einer objektorientierten Sprache sich die Erstellung komplexer Software unterstützen lässt. Im Anschluß daran widmete sich der Hauptteil dieser Ausarbeitung der Übersetzung der vorgestellten objektorientierten Konzepte. Die Definition einer abstrakten Maschine für eine objektorientierte Sprache diente zur Verdeutlichung, wie Klassen, Methoden und Objekte zur Laufzeit eines Programms repräsentiert werden. Insbesondere anhand der Übersetzung von Methoden konnten Gemeinsamkeiten aber auch wesentliche Unterschiede zwischen der Übersetzung imperativer und objektorientierter Programmiersprachen ausgemacht werden. Das Prinzip des späten Bindens beim Methodenaufruf ist als wesentliche Voraussetzung für die Übersetzung von Vererbung und Polymorphie hervorzuheben. Moderne Sprachen wie Java oder C# verzichten auf echte Mehrfachvererbung um die Komplexität des daraus entstehenden Programmcodes zu reduzieren. In der für den Rahmen der Ausarbeitung möglichen Tiefe wurden die aus der Übersetzung von Mehrfachvererbung entstehenden Probleme beleuchtet. Die späte Integration von Parametrisierung in Java (Generics in J2SE 5) hat viele Diskussionen hervorgerufen, die sich oftmals auf die Art der Umsetzung von Templates in C++ beziehen [SUN04]. Aus diesem Grund galt der Übersetzung von Parametrisierung in C++, Java und C# ein besonderer Augenmerk. Es wurde festgestellt, dass die Kritik an der Umsetzung der C++ Templates berechtigt ist. Da Templates dem eigentlichen Übersetzungsprozess vorangeschaltet sind, entziehen sie sich dem Typsystem und der damit einhergehenden Überprüfbarkeit des Codes durch den Compiler. Die Umsetzung von Java Generics scheint besser gelungen, jedoch erreicht nur die Realisierung generischer Klassen in C# die vollständige Integration in das Typsystem der Sprache. Sie wird daher als die geeignetste Übersetzung bewertet. Die durch diese Ausarbeitung gewonnenen Kenntnisse tragen dazu bei, ein tieferes Verständnis für das objektorientierte Paradigma im Allgemeinen und für die Realisierung in konkreten objektorientierten Sprachen zu gewinnen. 22 Anhang A A Anhang Beispiel Mehrfachvererbung in C++ #include <cstdio> class GUIObjekt { public: int farbtiefe; virtual void Zeichne() { printf("GUIObjekt::Zeichne\n"); } }; class Figur : public virtual GUIObjekt { public: virtual void Flaeche() { printf("Figur::Flaeche\n"); } virtual void Skalieren() { printf("Figur::Skalieren\n"); } }; class Linie : public virtual GUIObjekt { public: void Zeichne() { printf("Linie::Zeichne\n"); } virtual void Skalieren() { printf("Linie::Skalieren\n"); } }; class Rechteck : public virtual Figur, public virtual Linie { public: using Figur::Skalieren; void flaeche() { printf("Rechteck::Flaeche\n"); } }; int main() { Rechteck* r = new Rechteck(); r->Skalieren(); // Figur::Skalieren Figur* f = new Rechteck(); f->Zeichne(); // Linie::Zeichne } 23 Literaturverzeichnis [ALU06] Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman, Compilers. Principles, Techniques, and Tool, Amsterdam 1986. [AW02] Tom Archer, Andrew Whitechapel, Inside C#, 2. Aufl., Microsoft Press 2002. [Ba00] Helmut Balzert, Lehrbuch der Software-Technik, Bd. 1 Software Entwicklung, 2. Aufl., Heidelberg, Berlin 2000. [BH98] Bernhard Bauer, Riitta Höllerer: Übersetzung objektorientierter Programmiersprachen, Springer Verlag, 1998. [BOW98] Gilad Bracha, Martin Odersky, David Stoutamire, and Philip Wadler, Making the future safe for the past: Adding Genericity to the Java Programming Language, OOPSLA Vancouver 1998, Url: http://homepages.inf.ed.ac.uk/wadler/papers/gj-oopsla/gj-oopsla-letter.pdf (Abruf 18.11.2006). [CW85] Luca Cardelli, Peter Wegner, On Understanding Types, Data Abstraction, and Polymorphism, Computer Surveys, Vol. 17 n. 4, Seite 471-522, 1985. [Eck00] Bruce Eckel, Thinking in C++ Second Edition, Volume One: Introduction to Standard C++, Prentice Hall, 2000. [JSR14] Java Community Process, Java Specification Request 14, Url: http://jcp.org/en/jsr/detail?id=014 (Abruf: 18.11.2006). [Lo94] Keneth C. Louden: Programmiersprachen, The MIT Press, 1994. [MS01] Microsoft Research: Andre Kennedy, Don Syme, Design and Implementation of Generics for the .NET Common Runtime, 2001, Url: http://research.microsoft.com/projects/clrgen/generics.pdf (Abruf: 18.11.2006). [MS06] Microsoft Research: Erik Meijer, John Gough, Technical Overview of the Common Language Runtime, Url: http://research.microsoft.com/~emeijer/Papers/CLR.pdf (Abruf: 18.11.2006). [ORW98] Martin Odersky, Enno Runne, Philip Wadler, Two Ways to Bake Your Pizza – Translating Parameterized Types into Java in Generic Programming ’98, LNCS 1766, Seite 114–132, 2000, Url: http://pizzacompiler.sourceforge.net/doc/pizza-translation.pdf (Abruf: 18.11.2006). [OV00] Walter Oberschelp, Gottfried Vossen, Rechneraufbau und Rechnerstrukturen, 8. Aufl., Oldenbourg Verlag, 2000. [OW97] Martin Odersky and Philip Wadler, Pizza into Java: Translating theory into practice, 4th ACM Symposium on Principles of Programming Languages, Paris, January 1997, Url: http://pizzacompiler.sourceforge.net/doc/pizzalanguage-spec.pdf (Abruf: 18.11.2006). [St67] Christopher Strachey: Fundamental concepts in programming languages, lecture notes for the International Summer School in Computer Programming, Copenhagen, 1967. [SUN99] Sun Microsystems: Tim Lindholm, Frank Yellin, The Java Virtual Machine Specification, Second Edition, Url: http://java.sun.com/docs/books/vmspec/ (Abruf: 18.11.2006), 1999. [SUN03] James Gosling, Bill Joy, Guy Steele, Gilad Bracha, The Java Language Specification, Third Edition, Url: http://java.sun.com/docs/books/jls/ (Abruf: 18.11.2006), 2003. [SUN04] Sun Microsystems, Developer Forum Core APIs - Generics, Url: http://forum.java.sun.com/forum.jsp?forum=316 (Abruf: 18.11.2006). [WM97] Reinhard Wilhelm, Dieter Maurer: Übersetzerbau – Theorie, Konstruktion, Generierung, 2. Aufl., Springer Verlag, 1997.