Ausarbeitung - Universität Münster

Werbung
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.
Herunterladen