Dynamische Typinformation und statische Parametrisierung PDF
Document Details
Uploaded by FondDalmatianJasper
Technische Universität Wien
Tags
Summary
This document discusses dynamic type information and static parameterization, focusing on generics in programming languages like Java. It explains why generics are useful in terms of code reuse and efficiency compared to using traditional subtyping.
Full Transcript
4 Dynamische Typinformation und statische Parametrisierung Statisch typisierte prozedurale und funktionale Sprachen unterscheiden streng zwischen Typinformationen, die nur dem Compiler zum Zeitpunkt der Über- setzung zur Verfügung stehen und dynamischen Daten, die während der Pro- grammausführung...
4 Dynamische Typinformation und statische Parametrisierung Statisch typisierte prozedurale und funktionale Sprachen unterscheiden streng zwischen Typinformationen, die nur dem Compiler zum Zeitpunkt der Über- setzung zur Verfügung stehen und dynamischen Daten, die während der Pro- grammausführung verwendet werden. Es gibt in diesen Sprachen keine dynami- sche Typinformation. Im Gegensatz dazu wird in objektorientierten Program- miersprachen dynamische Typinformation (ähnlich wie sie auch in dynamisch typisierten prozeduralen und funktionalen Sprachen existiert) für das dyna- mische Binden zur Ausführungszeit benötigt. Viele objektorientierte Sprachen erlauben den direkten Zugriff darauf. In Java gibt es zur Laufzeit Möglichkei- ten, die Klasse eines Objekts direkt zu erfragen, zu überprüfen, ob ein Objekt Instanz eines bestimmten Typs ist, sowie zur überprüften Umwandlung des deklarierten Typs. Wir wollen in diesem Kapitel den Umgang mit dynami- scher Typinformation untersuchen. Wir behandeln auch statische Formen der Parametrisierung von Modularisierungseinheiten, gleich zu Beginn Generizität, gegen Ende des Kapitels Annotationen und aspektorientierte Programmierung. Oberflächlich betrachtet scheint es keinerlei Gemeinsamkeiten zwischen diesen Themenbereichen zu geben. Tatsächlich bestehen, insbesondere in Java, starke Verbindungen dazwischen. 4.1 Generizität Generische Klassen, Typen und Methoden enthalten Parameter, für die Typen eingesetzt werden. Andere Arten generischer Parameter unterstützt Java nicht. Daher nennen wir generische Parameter einfach Typparameter. Generizität ist ein statischer Mechanismus, der von Java erst ab Version 1.5 unterstützt wird. Dynamisches Binden wie bei Untertypen ist nicht nötig. Dieses wichtige Unterscheidungsmerkmal zu Untertypen verspricht einen effizienten Einsatz in vielen Bereichen, schränkt uns beim Programmieren aber manchmal auch auf unerwartete Weise ein. 4.1.1 Wozu Generizität? An Stelle expliziter Typen werden im Programm Typparameter verwendet. Das sind einfach nur Namen, die später konzeptuell (oft nur scheinbar) durch Typen ersetzt werden. Anhand eines Beispiels wollen wir zeigen, dass eine Verwendung von Typparametern und die spätere Ersetzung durch Typen sinnvoll ist: 177 4 Dynamische Typinformation und statische Parametrisierung Beispiel: Wir entwickeln Programmcode für Listen von Zeichenket- ten. Bald stellt sich heraus, dass wir auch Listen mit Elementen vom Typ Integer brauchen. Da der existierende Code auf Zeichen- ketten eingeschränkt ist, müssen wir eine neue Variante schreiben. Untertypen und Vererbung sind dabei wegen der Unterschiedlichkeit der Typen nicht hilfreich. Aber Typparameter können helfen: Statt für String schreiben wir den Code für Element. Dabei ist Element kein existierender Typ, sondern ein Typparameter. Den Code für String- und Integer-Listen könnten wir daraus erzeugen, indem wir alle Vorkommen von Element durch diese Typnamen ersetzen. Auf den ersten Blick schaut es so aus, als ob wir den gleichen Effekt auch er- zielen könnten, wenn wir im ursprünglichen String-Listencode alle Vorkommen von String durch Integer ersetzen würden. Leider gibt es dabei ein Problem: Der Name String kann auch für ganz andere Zwecke eingesetzt sein, beispiels- weise als Ergebnistyp der Methode toString(). Eine Ersetzung würde alle Vorkommen von String ersetzen, auch solche, die gar nichts mit Elementtypen zu tun haben. Daher wählen wir einen neutralen Namen wie Element, der im Code in keiner anderen Bedeutung vorkommt. Ein Programmstück kann auch mehrere Typparameter unterschiedlicher Bedeutungen enthalten. Natürlich spart es Schreibaufwand, wenn wir eine Kopie eines Programm- stücks anfertigt und darin alle Vorkommen eines Typparameters mit Hilfe eines Texteditors durch einen Typ ersetzen. Aber dieser einfache Ansatz bereitet Probleme bei der Wartung: Nötige Änderungen des kopierten Programmstücks müssen in allen Kopien gemacht werden, was einen erheblichen Aufwand ver- ursacht. Leichter geht es, wenn das Programmstück nur einmal existiert: Wir schreiben das Programmstück einmal und kennzeichnen Typparameter als sol- che. Statt einer Kopie verwenden wir nur den Namen des Programmstücks zusammen mit den Typen, die an Stelle der Typparameter zu verwenden sind. Erst der Compiler erzeugt nötige Kopien oder verwendet eine andere Technik mit ähnlichen Auswirkungen. Änderungen sind nach dem nächsten Überset- zungsvorgang überall sichtbar, wo das Programmstück verwendet wird. In Java erzeugt der Compiler keine Kopien der Programmstücke, sondern kann durch Typumwandlungen (Casts) den gleichen Code für mehrere Zwecke – etwa Listen mit Elementen unterschiedlicher Typen – verwenden; daher hängt Generizität (als rein statischer Mechanismus) mit dynamischer Typinformation zusammen. Generizität erspart damit nicht nur Schreibarbeit, sondern kann das übersetzte Programm auch kürzer und überschaubarer machen. 4.1.2 Einfache Generizität in Java Generische Klassen und Interfaces haben ein oder mehrere Typparameter, die in spitzen Klammern, durch Beistriche voneinander getrennt, deklariert sind. Innerhalb der Klassen und Interfaces sind diese Typparameter beinahe wie nor- male Referenztypen verwendbar. Das erste Beispiel in Java verwendet zwei ge- nerische Interfaces mit je einem Typparameter A: 178 4.1 Generizität public interface Collection { void add(A elem); // add elem to collection Iterator iterator(); // create new iterator } public interface Iterator { A next(); // get the next element boolean hasNext(); // further elements? } Mit diesen Definitionen ist Collection ein Interface, das durch Er- setzung aller Vorkommen des Typparameters A im Rumpf von Collection generiert wird. So enthält Collection die entsprechenden generierten Methoden void add(String elem) und Iterator iterator(), wo- bei Iterator die Methoden String next() und boolean hasNext() enthält. Der Typparameter kann durch Referenztypen ersetzt werden, aber nicht durch elementare Typen wie int, char oder boolean. Hier ist eine Implementierung von Collection als generische Liste: public class List implements Collection { private Node head = null, tail = null; public void add(A elem) { if (head == null) tail = head = new Node(elem); else tail.setNext(tail = new Node(elem)); } private class ListIter implements Iterator { private Node p = head; public boolean hasNext() { return p != null; } public A next() { if (p == null) throw new java.util.NoSuchElementException(); A elem = p.elem(); p = p.next(); return elem; } } public Iterator iterator() { return new ListIter(); } } class Node { private T elem; private Node next = null; public Node(T elem) { this.elem = elem; } public T elem() { return elem; } public Node next() { return next; } public void setNext(Node next) { this.next = next; } } Die generische Klasse List verwendet die Hilfsklasse Node für Listen- knoten und enthält die innere Klasse ListIter als Iterator-Implementierung. 179 4 Dynamische Typinformation und statische Parametrisierung Der Typparameter A der Liste ist auch in der inneren Klasse sichtbar und wie der Name eines Typs verwendbar. Da für die Listenknoten keine geschachtelte Klasse verwendet wird, ist dafür ein eigener Typparameter nötig. Wir könnten dafür ebenso A verwenden, aber auch jeden beliebigen anderen Namen. Wir ver- wenden T, damit der andere Gültigkeitsbereich als der von A deutlicher sichtbar wird. Durch interface Collection wird der Typparameter A eingeführt, der innerhalb dieses Interfaces sichtbar ist. Durch interface Iterator wird ein anderer Typparameter (der auch A heißt) eingeführt, der nur inner- halb des anderen Interfaces sichtbar ist. Ebenso wird durch class List ein weiterer Typparameter namens A eingeführt, der bis zum Ende der Klas- se sichtbar ist. Durch Node wird der Typparameter T eingeführt. An allen anderen Stellen im Beispiel, an denen A oder T vorkommt, werden keine Typ- parameter eingeführt, sondern zuvor eingeführte Typparameter verwendet. In implements Collection sowie in der Variablendeklaration Node head wird der durch class List eingeführte Typparameter verwendet. Wenn wir irgendwo im Programm den Typ List verwenden (wodurch der mit List eingeführte Typparameter durch String ersetzt wird), dann implemen- tiert List das Interface Collection und hat die Variable head vom Typ Node, also auch T wird durch String ersetzt. Mit dem Wort „ersetzt“ ist dabei eine Form der Parameterübergabe gemeint (String als Argument, A oder T als Parameter), genau so wie im λ-Kalkül die Parame- terübergabe durch Ersetzung erfolgt. Wir machen nichts falsch, wenn wir das Ersetzen einfach umgangssprachlich verstehen und uns nicht weiter um eine formale Definition kümmern. Konstruktoren haben wie in Node die üblich Syntax Node(...){...}; es werden keine Typparameter angegeben. Bei der Objekterzeugung müssen aber Typen angegeben werden, die die Typparameter der Klasse ersetzen, etwa new Node(...); dabei ist A der Typ der Listenelemente. Folgendes Programmstück zeigt den Umgang mit generischen Klassen: class ListTest { public static void main(String[] args) { List xs = new List(); xs.add(new Integer(0)); // oder xs.add(0); Integer x = xs.iterator().next(); List ys = new List(); ys.add("zerro"); String y = ys.iterator().next(); List zs = new List(); zs.add(xs); // zs.add(ys); !! Compiler meldet Fehler !! List z = zs.iterator().next(); } } 180 4.1 Generizität An ListTest fällt auf, dass statt einfacher Werte von int Objekte der Stan- dardklasse Integer verwendet werden müssen, da gewöhnliche Zahlen keine Referenzobjekte sind. In Java gibt es zu jedem elementaren Typ wie int, char oder boolean einen Referenztyp wie Integer, Character oder Boolean, weil in einigen Sprachkonstrukten nur Referenztypen erlaubt sind. Sie bieten die gleiche Funktionalität wie elementare Typen. Ein Nachteil ist der im Vergleich zu elementaren Werten weniger effiziente Umgang mit Objekten. Java unterstützt Autoboxing und Autounboxing. Dabei erfolgt die Umwand- lung zwischen Typen wie int und Integer bei Bedarf automatisch in beide Richtungen. Statt xs.add(new Integer(0)) schreiben wir einfach xs.add(0). Die automatische Umwandlung verringert nur den Schreibaufwand, nicht die dadurch bedingte Ineffizienz zur Laufzeit.1 Das Beispiel zeigt, dass Listen auch andere Listen enthalten können. Jedoch muss jedes Listenelement den durch den Typparameter festgelegten Typ ha- ben. Der Compiler ist klug genug, um List von List zu unterscheiden. Diese beiden Listentypen sind nicht kompatibel zueinander. Generizität bietet statische Typsicherheit. Bereits der Compiler garantiert, dass in ein Objekt von List nur Zeichenketten eingefügt werden kön- nen. Der Versuch, ein Objekt eines inkompatiblen Typs einzufügen, wird als Fehler gemeldet. Wer den Umgang mit Collections und Ähnlichem ohne Gene- rizität gewohnt ist, kennt die Probleme mangelnder statischer Typsicherheit, bei der Typfehler als Typkonvertierungsfehler zur Laufzeit auftreten. Generizität sorgt dafür, dass solche Typfehler schon zur Übersetzungszeit erkannt werden. Auch Methoden können generisch sein, wie das nächste Beispiel zeigt: public interface Comparator { int compare(A x, A y); // x < y if result < 0 // x == y if result == 0 // x > y if result > 0 } public class CollectionOps { public static A max(Collection xs, Comparator c) { Iterator xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (c.compare(w, x) < 0) w = x; } return w; } } 1 Es stimmt nicht, dass Autoboxing in jedem Fall ein neues Objekt erzeugt. Für häufig ver- wendete Zahlen in der Nähe von 0 stellt das Java-Laufzeitsystem schon vorher erzeugte Objekte bereit, die direkt übergeben werden. Das bedeutet jedoch, dass wir uns bei Au- toboxing nicht auf die Objektidentität verlassen dürfen. 181 4 Dynamische Typinformation und statische Parametrisierung Die Methode compare in Comparator vergleicht zwei Objekte des gleichen Typs und retourniert das Vergleichsergebnis als ganze Zahl. Unterschiedliche Komparatoren, also voneinander verschiedene Objekte mit einem solchen In- terface, werden unterschiedliche Vergleiche durchführen. Die statische Methode max in CollectionOps wendet Komparatoren wiederholt auf Elemente in einem Objekt von Collection an, um das größte Element zu ermitteln. Am vor dem Ergebnistyp von max eingefügten Ausdruck ist erkennbar, dass max eine generische Methode mit einem Typparameter A ist. Dieser Typparameter kommt sowohl als Ergebnistyp als auch in der Parameterliste und im Rumpf der Methode vor. In den spitzen Klammern können auch mehrere, durch Komma voneinander getrennte Typparameter deklariert sein. Generische Methoden haben den Vorteil, dass die für Typparameter zu ver- wendenden Typen nicht explizit angeben sein müssen: List xs =...; List ys =...; Comparator cx =...; Comparator cy =...; Integer rx = CollectionOps.max(xs, cx); String ry = CollectionOps.max(ys, cy); // Integer rz = CollectionOps.max(xs, cy); !Fehler! Der Compiler erkennt durch Typinferenz anhand der Typdeklarationen von xs und cx beziehungsweise ys und cy, dass beim ersten Aufruf von max für den Typparameter Integer und für den zweiten Aufruf String zu verwenden ist. Außerdem erkennt der Compiler statisch, wenn der Typparameter von List nicht mit dem von Comparator übereinstimmt. Hier ist ein Beispiel für die Implementierung eines einfachen Komparators: class IntComparator implements Comparator { public int compare(Integer x, Integer y) { return x.intValue() - y.intValue(); } } Aufgrund von Autounboxing kann die dritte Zeile auch einfach durch die Anwei- sung return x - y; ersetzt werden. Ein Komparator für Zeichenketten wird zwar etwas komplizierter, aber nach dem gleichen Schema aufgebaut sein. 4.1.3 Gebundene Generizität in Java Die einfache Form der Generizität ist zwar elegant und sicher, aber für einige Verwendungszwecke nicht ausreichend: Im Rumpf einer einfachen generischen Klasse oder Methode ist über den Typ, der den Typparameter ersetzt, nichts bekannt. Insbesondere ist nicht bekannt, ob Objekte dieses Typs bestimmte Methoden oder Variablen haben. 182 4.1 Generizität Schranken. Über manche Typparameter benötigen wir mehr Information, um auf Objekte der entsprechenden Typen zugreifen zu können. Gebundene Typ- parameter liefern diese Information: In Java kann für jeden Typparameter eine Klasse und beliebig viele Interfaces als Schranken angegeben werden. Nur Un- tertypen der Schranken dürfen den Typparameter ersetzen. Damit ist statisch bekannt, dass in jedem Objekt des Typs, für den der Typparameter steht, die in den Schranken festgelegten öffentlich sichtbaren Methoden und Variablen ver- wendbar sind. Objekte des Typparameters können wie Objekte der Schranken (jede Schranke ist ein Typ der Objekte) verwendet werden: public interface Scalable { void scale(double factor); } public class Scene implements Iterable { public void addSceneElement(T e) {... } public Iterator iterator() {... } public void scaleAll(double factor) { for (T e : this) e.scale(factor); }... } Die Klasse Scene hat einen Typparameter T mit einer Schranke. Jeder Typ, der T ersetzt, ist Untertyp von Scalable und unterstützt damit die Methode scale. Diese Methode wird in scaleAll aufgerufen (für jedes Element des aktuellen Objekts von Scene). Schranken stehen nach dem Schlüsselwort extends innerhalb der spitzen Klammern. Das Schlüsselwort dafür ist immer extends, niemals implements. Pro Typparameter sind als Schranken eine Klasse sowie beliebig viele Interfa- ces erlaubt, jeweils durch & voneinander getrennt. Nur solche Typen dürfen den Typparameter ersetzen, die alle diese Interfaces erweitern bzw. implementieren. Ist ein Typparameter ungebunden, das heißt, ist keine Schranke angegeben, wird Object als Schranke angenommen, da jede Klasse von Object abgeleitet ist. Die in Object definierten Methoden sind daher immer verwendbar. In obigem Beispiel erweitert Scene das in den Java-Bibliotheken vordefinierte Interface Iterable, welches die Methode iterator zur Erzeugung eines Iterators beschreibt. In Scene wird der Iterator benötigt, um einfach mittels for-Schleife über alle Elemente des aktuellen Objekts von Scene zu iterieren. Rekursion. Diese Beispiels-Variante verwendet Typparameter rekursiv: public interface Comparable { int compareTo(A that); // this < that if result < 0 // this == that if result == 0 // this > that if result > 0 } 183 4 Dynamische Typinformation und statische Parametrisierung public class Integer implements Comparable { private int value; public Integer(int value) { this.value = value; } public int intValue() { return value; } public int compareTo(Integer that) { return this.value - that.value; } } public class CollectionOps2 { public static A max(Collection xs) { Iterator xi = xs.iterator(); A w = xi.next(); while (xi.hasNext()) { A x = xi.next(); if (w.compareTo(x) < 0) w = x; } return w; } } Diese Klasse Integer ist eine vereinfachte Form der in Java standardmäßig vorhandenen gleichnamigen Klasse. Integer wird von Comparable abgeleitet. Der Name der Klasse kommt in der Schnittstelle vor, von der ab- geleitet wird. Auf den ersten Blick mag eine derartige rekursive Verwendung von Klassennamen eigenartig erscheinen, sie ist aber klar definiert, einfach ver- ständlich und in der Praxis sinnvoll. In der Schranke des Typparameters A von max in CollectionOps2 kommt eine ähnliche Rekursion vor. Damit wird eine Ableitungsstruktur wie die von Integer beschrieben. Diese Form der Generi- zität mit rekursiven Typparametern nennen wir F-gebundene Generizität nach dem formalen Modell, in dem solche Konzepte untersucht wurden: System F≤ , ausgesprochen „F bound“. Keine impliziten Untertypen. Generizität unterstützt keine impliziten Un- tertypbeziehungen. So besteht zwischen List und List keine Untertyp- beziehung wenn X und Y verschieden sind, auch dann nicht, wenn Y Untertyp von X ist oder umgekehrt. Natürlich gibt es die expliziten Untertypbeziehun- gen, wie beispielsweise die zwischen Integer und Comparable. Wir können Klassen wie üblich ableiten: class MyList extends List {... } Dann ist MyList ein Untertyp von List. Jedoch ist MyList kein Untertyp von List wenn Y möglicherweise ungleich List ist. Die Annahme impliziter Untertypbeziehungen ist ein häufiger Anfänger- fehler. Wir müssen stets bedenken, dass es weder in Java noch in irgendeiner anderen Sprache sichere implizite Untertypbeziehungen dieser Art geben kann. 184 4.1 Generizität In Java können bei Verwendung von Arrays Typfehler zur Laufzeit auftreten, da Arrays implizite Untertypbeziehungen unterstützen: class Loophole { public static String loophole(Integer y) { String[] xs = new String; Object[] ys = xs; // no compile-time error ys = y; // throws ArrayStoreException return xs; } } Diese Klasse wird unbeanstandet übersetzt, da in Java für jede Untertypbezie- hung auf Typen automatisch eine Untertypbeziehung auf Arrays von Elementen solcher Typen angenommen wird, obwohl Ersetzbarkeit verletzt sein kann. Im Beispiel nimmt der Compiler an, dass String[] Untertyp von Object[] ist, da String ein Untertyp von Object ist. Diese Annahme ist falsch. Generizität schließt solche Fehler durch das Verbot impliziter Untertypbeziehungen aus: class NoLoophole { public static String loophole(Integer y) { List xs = new List(); List ys = xs; // compile-time error ys.add(y); return xs.iterator().next(); } } Wildcards. Die Sicherheit durch Nichtunterstützung impliziter Untertypbe- ziehungen hat auch einen Nachteil. Zum Beispiel kann die Methode void drawAll(List p) {... // draws all polygons in list p } nur mit Argumenten vom Typ List aufgerufen werden, nicht aber mit Argumenten vom Typ List oder List (entsprechend dem Beispiel aus Abschnitt 3.2.3). Dies ist bedauerlich, da drawAll nur Ele- mente aus der Liste liest und nie in die Liste schreibt, Sicherheitsprobleme durch implizite Untertypbeziehungen wie bei Arrays aber beim Schreiben auf- treten. Für solche Fälle unterstützt Java gebundene Wildcards als Typen, die Typparameter ersetzen: void drawAll(List