thesisverhandeling: Java Generics

advertisement
Faculteit Toegepaste Wetenschappen
Vakgroep Informatietechnologie
Voorzitter: Prof. Dr. Ir. P. Lagasse
Onderzoeksgroep Software Engineering
Java Generics – een nieuwe kijk
op generiek programmeren in
Java
door David Ooms en Steven Stappaerts
Promotoren: Prof. Dr. Ir. G. Hoffman, Prof. Dr. Ir. H. Tromp
Scriptie ingediend tot het behalen van de academische graad van burgerlijk
ingenieur in de computerwetenschappen
Scriptie ingediend tot het behalen van de academische graad van licentiaat in
de informatica
Academiejaar 2003–2004
Dankwoord
Het opbouwen van deze thesisverhandeling zou wellicht niet zo vlot verlopen
zijn zonder de handige LATEX-template van de hand van Peter Lambert (vakgroep Elektronica en Informatiesystemen) of zonder de waardevolle tips van
Koen Van Boxstael met betrekking tot het opnemen van codelistings in de
tekst. Onze dank gaat verder ook uit naar Bram Adams voor zijn bemerkingen
in verband met Java Generics, naar Andy Verkeyn voor zijn basisimplementatie van een bibliotheekapplicatie in C++, en tenslotte ook naar Wim Backers
voor het formuleren van een aantal verbeteringen voor deze teksten.
Toelating tot bruikleen
De auteurs geven de toelating deze scriptie voor consultatie beschikbaar te
stellen en delen van de scriptie te kopiëren voor persoonlijk gebruik. Elk ander gebruik valt onder de beperkingen van het auteursrecht, in het bijzonder
met betrekking tot de verplichting de bron uitdrukkelijk te vermelden bij het
aanhalen van resultaten uit deze scriptie.
31 mei 2004
i
Java Generics – een nieuwe kijk op generiek
programmeren in Java
door
David Ooms en Steven Stappaerts
Scriptie ingediend tot het behalen van de academische graad van
burgerlijk ingenieur in de computerwetenschappen
Scriptie ingediend tot het behalen van de academische graad van
licentiaat in de informatica
Academiejaar 2003–2004
Universiteit Gent
Faculteit Toegepaste Wetenschappen
Vakgroep Informatietechnologie
Voorzitter: Prof. Dr. Ir. P. Lagasse
Onderzoeksgroep Software Engineering
Promotoren: Prof. Dr. Ir. G. Hoffman, Prof. Dr. Ir. H. Tromp
Samenvatting
Reeds meer dan vijf jaren terug werd door de belangrijkste bezielers achter
de objectgeoriënteerde programmeertaal Java een voorstel gedaan om Java
uit te breiden met wat heet “parametrisch polymorfisme”: de mogelijkheid
om klassen, interfaces en methoden te voorzien van expliciete typeparameters.
De voornaamste doelstelling hiervan is te kunnen beschikken over generieke
containerklassen die tegelijkertijd typeveilig zijn, om op die manier generieke
programmacode beter leesbaar en onderhoudbaar te maken. In 2001 werd de
studie van dit voorstel met gunstig gevolg afgerond, en de nieuwe specificatie
is intussen stilaan naar zijn definitieve vorm gegroeid. Tevens werden reeds
proefimplementaties van “Java Generics” voor het grote publiek beschikbaar
gemaakt.
Het doel van deze thesisverhandeling is dan ook een eerste exploratie van
de werking, mogelijkheden en beperkingen van deze technologie. We zullen de
meest belangrijke praktische voordelen van Java Generics illustreren, en dit
voornamelijk in de context van het primaire toepassingsgebied voor de nieuwe
taaluitbreiding: het Java Collection Framework. Ook de keerzijde van de medaille komt echter aan bod. Er zal blijken dat de geldende beginvoorwaarden
en het uiteindelijke implementatieschema ervoor hebben gezorgd dat de slagkracht van het nieuwe mechanisme eerder beperkt is gebleven in vergelijking
met de mogelijkheden die templates in de programmeertaal C++ bieden.
Trefwoorden: Java, Java Generics, generiek programmeren, collecties
ii
Inhoudsopgave
1 Inleiding
1
2 Motivaties voor Generics
5
2.1
Een definitie voor generiek programmeren . . . . . . . . . . . .
5
2.2
Technieken voor generiek programmeren . . . . . . . . . . . . .
6
2.3
Java Generics: een noodzaak? . . . . . . . . . . . . . . . . . . .
8
2.3.1
Situatieschets . . . . . . . . . . . . . . . . . . . . . . . .
8
2.3.2
Probleemstelling . . . . . . . . . . . . . . . . . . . . . . 11
2.4
Doelstellingen van Java Generics
. . . . . . . . . . . . . . . . . 15
3 Java Generics: syntax en semantiek
16
3.1
Gebruikte terminologie . . . . . . . . . . . . . . . . . . . . . . . 17
3.2
Generieke types en methoden . . . . . . . . . . . . . . . . . . . 18
3.3
Wildcards . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.4
Meer wildcards: bounds . . . . . . . . . . . . . . . . . . . . . . 28
3.4.1
Upper bounds: typisch gebruik . . . . . . . . . . . . . . 28
3.4.2
Lower bounds: typisch gebruik . . . . . . . . . . . . . . . 29
3.4.3
Bounds: algemene vorm en beperkingen . . . . . . . . . 30
3.4.4
Bounds en de subtype–supertype relatie . . . . . . . . . 31
3.5
Het gebruik van methoden met typeparameters . . . . . . . . . 31
3.6
Generics: een eerste evaluatie . . . . . . . . . . . . . . . . . . . 34
iii
4 Implementatie-aspecten van Java Generics
35
4.1
Inleiding . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
4.2
Diverse vormen van compatibiliteit . . . . . . . . . . . . . . . . 36
4.3
Precondities . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.4
Aanvullende doelstellingen . . . . . . . . . . . . . . . . . . . . . 39
4.5
Onder de motorkap: erasure . . . . . . . . . . . . . . . . . . . . 42
4.5.1
Praktische werkwijze . . . . . . . . . . . . . . . . . . . . 42
4.5.2
Een brug te ver . . . . . . . . . . . . . . . . . . . . . . . 48
4.5.3
Vertalingsregels . . . . . . . . . . . . . . . . . . . . . . . 51
4.6
De gevolgen van erasure: voor- en nadelen . . . . . . . . . . . . 60
4.7
Interactie met legacy code . . . . . . . . . . . . . . . . . . . . . 65
4.7.1
Het gebruik van legacy code in Generics-code . . . . . . 66
4.7.2
Het gebruik van Generics-code in legacy code . . . . . . 68
4.8
Het omzeilen van typeveiligheid . . . . . . . . . . . . . . . . . . 68
4.9
Besluit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
5 Andere uitbreidingen van Java
71
5.1
Een for-lus voor iteratie over collecties . . . . . . . . . . . . . . 72
5.2
Autoboxing/unboxing
5.3
Het Java Collection Framework opnieuw bekeken . . . . . . . . 73
5.4
Uitgebreide mogelijkheden voor reflectie . . . . . . . . . . . . . 74
. . . . . . . . . . . . . . . . . . . . . . . 73
6 Java Generics en templates in C++: een korte vergelijking
78
7 Java Generics: evolutie of revolutie?
81
7.1
Een evaluatie . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
7.2
De huidige status van J2SE 1.5.0 “Tiger” . . . . . . . . . . . . . 84
7.3
Alternatieven: een blik op de toekomst? . . . . . . . . . . . . . 84
Bibliografie
87
iv
Listings
2.1
Enkele fragmenten uit de klasse LinkedList . . . . . . . . . . . 10
2.2
De interface Comparable en fragmenten uit de klassen Object
en Integer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.3
Het oproepen van methoden op containerelementen . . . . . . . 13
3.1
De klasse LinkedList in Generics-syntax . . . . . . . . . . . . . 20
3.2
Het matchen van arbitraire collecties – eerste poging . . . . . . 23
3.3
De subtype–supertype relatie en collecties . . . . . . . . . . . . 24
3.4
De subtype–supertype relatie en arrays . . . . . . . . . . . . . . 25
3.5
Het matchen van arbitraire collecties met typevariabelen . . . . 25
3.6
Het matchen van arbitraire collecties met wildcards . . . . . . . 26
3.7
De wildcard en het toevoegen van elementen aan collecties . . . 27
3.8
De methode Collections.max . . . . . . . . . . . . . . . . . . . 28
3.9
De methode Collections.max met een upper bound . . . . . . 29
3.10 De methode Collections.max met upper en lower bounds . . . 30
3.11 De methode Collections.max – actueel uitzicht . . . . . . . . . 30
3.12 Het oproepen van polymorfe methoden . . . . . . . . . . . . . . 32
4.1
Klassen en methoden met typeparameters voor erasure . . . . . 45
4.2
Klassen en methoden met typeparameters na erasure . . . . . . 46
4.3
Interactie met ruwe types en typecorrectheid . . . . . . . . . . . 47
4.4
Interactie met ruwe types en typecorrectheid bij uitvoering . . . 48
4.5
Bijzondere gevallen bij het invoegen van brugmethoden . . . . . 49
4.6
Bijzondere gevallen bij het invoegen van brugmethoden na erasure 50
4.7
Mogelijke gevallen voor brugmethoden . . . . . . . . . . . . . . 53
v
4.8
Mogelijke gevallen voor brugmethoden na erasure . . . . . . . . 54
4.9
Covariante resultaattypes . . . . . . . . . . . . . . . . . . . . . . 55
4.10 Covariante resultaattypes na erasure . . . . . . . . . . . . . . . 56
4.11 Erasure en ongeldige methodedeclaraties – voorbeeld 1 . . . . . 56
4.12 Erasure en ongeldige methodedeclaraties – voorbeeld 2 . . . . . 57
4.13 Erasure en ongeldige methodedeclaraties – voorbeeld 3 . . . . . 57
4.14 De noodzaak voor het invoegen van casts . . . . . . . . . . . . . 59
4.15 Erasure en het toevoegen van casts . . . . . . . . . . . . . . . . 59
4.16 Verschillende instanties van hetzelfde generieke type delen dezelfde runtime klasse . . . . . . . . . . . . . . . . . . . . . . . . 61
4.17 Erasure en instanceof . . . . . . . . . . . . . . . . . . . . . . . 62
4.18 Erasure en casts . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
4.19 Typevariabelen en arrays . . . . . . . . . . . . . . . . . . . . . . 64
4.20 Wildcards en arrays . . . . . . . . . . . . . . . . . . . . . . . . . 64
4.21 Typevariabelen en het aanmaken van arrays . . . . . . . . . . . 64
4.22 Een legacy package . . . . . . . . . . . . . . . . . . . . . . . . . 66
4.23 Generics en legacy code
. . . . . . . . . . . . . . . . . . . . . . 67
4.24 Lekken in de beveiliging at runtime . . . . . . . . . . . . . . . . 68
4.25 Beveiliging at runtime . . . . . . . . . . . . . . . . . . . . . . . 69
5.1
Reflectieve informatie over typebinding . . . . . . . . . . . . . . 76
5.2
Het gebruik van Class<T> . . . . . . . . . . . . . . . . . . . . . 76
5.3
De methode newInstance . . . . . . . . . . . . . . . . . . . . . 77
vi
Hoofdstuk 1
Inleiding
C++, templates en Java
Het concept van de templates vormt ondertussen reeds ruim een decennium
lang een onderdeel van de programmeertaal C++. Toch is dit mechanisme,
dat generiek programmeren vanuit de taal zelf mogelijk maakt, nog steeds stof
tot discussie. Een belangrijke factor hierin is dat het ontegensprekelijk één
van de meer geavanceerde concepten in C++ is, en dat daardoor zeker niet
alle facetten ervan altijd even goed begrepen, laat staan gebruikt worden. Andere redenen zijn dat templates ook nu nog niet door alle courante compilers
in voldoende mate ondersteund worden, en dat de standaardspecificatie van
C++ wat dit onderwerp betreft op een aantal punten misschien nog enige verduidelijking of aanvullingen verdient. Anderzijds kan niet ontkend worden dat
templates krachtige hulpmiddelen zijn in de ontwikkeling van snellere, intelligentere en beter geschreven software. Naast hun toepassing in de creatie van
generieke en typeveilige containerklassen zijn de templates met de jaren dan
ook een ware hoeksteen geworden van verschillende nieuwe programmeertechnieken in C++.
Hoewel Java gedeeltelijk op C++ gebaseerd is, was een soortgelijk concept
echter niet opgenomen in het eerste platform voor deze programmeertaal toen
dat in 1995 uitgebracht werd. Dit gegeven kan men zeker kaderen in het streven
van de taalontwerpers naar het aanbrengen van een aantal vereenvoudigingen
ten opzichte van C++. Het feit dat in Java al de objecttypes impliciet deel
1
uitmaken van dezelfde overervingshiërarchie met één enkel gemeenschappelijk
basistype – de filosofie van de object-gebaseerde hiërarchie die uit de programmeertaal Smalltalk werd overgenomen – maakte het ontwerpen van generieke
collecties reeds op natuurlijke wijze mogelijk, zonder dat daarvoor aanvullende
mechanismen vereist waren. Keerzijde van de medaille was wel dat hierdoor
deze containerklassen niet typeveilig waren. Omdat het standaard aanbod collectieklassen in de beginjaren van Java veel beperkter was dan de faciliteiten
waarover programmeurs in de C++ STL-bibliotheek toen al konden beschikken, werd dit niet meteen als een grote hinderpaal beschouwd.
Java, collecties en Generics
Ondertussen is echter gebleken dat dit toch niet de meest gelukkige beslissing
is geweest. Software-ontwikkelaars die van C++ naar Java migreerden, ondervonden dat de standaardbibliotheek ernstig tekort schoot in ondersteuning
voor datastructuren en algoritmen. Enkel klassen voor dynamische lijsten en
hashtabellen waren aanwezig, andere structuren zoals hashmaps en gebalanceerde bomen ontbraken – maar konden zeker aangemaakt worden, wat velen
dan ook zelf deden. Deze situatie veranderde grondig bij de introductie van het
Java 2 Standard Edition platform (J2SE) in 1998 met het toevoegen van een
uitgebreid Java Collection Framework. De nieuwe Application Program Interfaces (API’s) werkten het toenemende gebruik van de collecties in de hand, en
dit had dan ook tot gevolg dat de type-onveiligheid van de containerklassen
meer en meer als een nadeel werd ervaren. Niet alleen waren bij het ophalen
van objectreferenties uit een container dus steevast cast-operaties nodig, fouten
tegen de typering ten gevolge van vergissingen van de programmeur konden
ook niet door de compiler opgepikt worden, maar moesten door uitvoeren en
testen van de geschreven code worden opgespoord.
Het traceren van programmeerfouten in software door te proberen uitzonderingstoestanden uit te lokken tijdens de uitvoering ervan, is een allesbehalve
eenvoudige of aangename bezigheid. Bovendien is er ook geen enkele sluitende
garantie dat met deze werkwijze alle aanwezige “bugs” gelokaliseerd worden.
Het is dus belangrijk programmeerfouten in een zo vroeg mogelijk stadium van
het ontwikkelingsproces te detecteren en te verhelpen. In dit kader werden we
2
in Java dus met een verre van ideale situatie geconfronteerd. Het kan dan ook
geen verbazing wekken dat een oplossing voor deze problematiek vrij snel is
uitgegroeid tot één van de meest gevraagde taaluitbreidingen1 voor Java.
Aan deze vraag gaven de bezielers achter Java gehoor door in 1999 bij het
Java Community Process (JCP) een zogenaamd Java Specification Request
(JSR) in te dienen. Het JCP, dat in 1998 opgericht werd in de schoot van Sun
Microsystems, kan gezien worden als een open denktank waarin verscheidene
belangrijke spelers uit de computerindustrie participeren, met als doel het
toevoegen van nieuwe technologieën aan de standaard van Java en het bewaren
van de stabiliteit en compatibiliteit op de verschillende gebruiksplatformen.
Een JSR is een document dat door één of meerdere leden wordt ingediend en
dat voorstellen bevat voor het ontwikkelen van een nieuwe specificatie of voor
een grondige herziening van een bestaande specificatie.
De studie van de aanvraag voor het toevoegen van types en methoden met
typeparameters aan Java, opgenomen in JSR 14 [1], werd in 2001 afgerond.
De nieuwe technologie werd voornamelijk gebaseerd op een reeds vroeger ontwikkelde uitbreiding voor Java, met name GJ (zie [7] en [8]). De nieuwe standaard zal onder de naam “Java Generics” uiteindelijk geı̈ntroduceerd worden
in release 1.5.0 van het J2SE platform (codenaam “Tiger”), waarvan de eerste
beta-versie in het voorjaar van 2004 werd uitgebracht.
1
Sun Microsystems houdt naast een “Bug Parade” ook een rangschikking bij van de meest
populaire RFE’s (“Request For Enhancements”). Beide kunnen teruggevonden worden op
http://bugs.sun.com/bugdatabase/.
3
Java Generics en deze thesis
In deze thesisverhandeling zullen we – na het schetsen van een aantal algemene
technieken voor generiek programmeren – op zoek gaan naar concrete motivaties voor het toevoegen van het Generics-mechanisme aan Java. Vervolgens
worden syntax en semantiek toegelicht. In heel deze bespreking zal de nadruk
vooral liggen op het behandelen van objectreferenties die in containers ondergebracht worden en er later weer uit worden opgevraagd, omdat dit ook het
primaire toepassingsgebied is waarvoor de Java Generics ontwikkeld werden.
In het daarop volgende hoofdstuk wordt bekeken welke implementatievoorwaarden moesten worden gerespecteerd om de nieuwe taaluitbreiding te realiseren. Tevens zal de werking van het erasure-procédé, dat achter de schermen
toegepast wordt, verklaard worden.
Naast de wijzigingen in het Java Collection Framework zelf komen vervolgens ook een aantal uitbreidingen van de taal en de standaardbibliotheken in
de marge van Java Generics aan bod. We sluiten af met een korte bespreking
van het template-mechanisme in C++ en een vergelijking met Java Generics.
Om het gebruik van de Java Generics te testen, werd ook een eenvoudige
voorbeeldapplicatie geschreven in drie versies: een implementatie in C++, een
equivalente versie in Java zonder Generics, en een versie waarin met Generics
is gewerkt. Een aantal – maar daarom niet alle – praktische aspecten van
Generics en van enkele andere uitbreidingen in “Tiger” komen erin aan bod.
4
Hoofdstuk 2
Motivaties voor Generics
Java Generics worden ingevoerd om generiek programmeren in Java te ondersteunen. Het algemene concept van generiek programmeren zullen we dan
ook eerst kort behandelen. Vervolgens gaan we op zoek naar de redenen om
hiervoor een expliciet mechanisme toe te voegen aan de programmeertaal Java.
2.1
Een definitie voor generiek programmeren
Vooraleer we dieper ingaan op het thema van generiek programmeren, is het
uiteraard nuttig dit begrip nader te bepalen. Het blijkt echter moeilijk te
zijn een universele en door iedereen aanvaarde definitie te formuleren. In de
literatuur komen vele varianten voor, de ene al specifieker dan de andere. Een
greep uit het aanbod:
• het programmeren met generieke parameters
• het vinden van de meest algemene representatie voor abstracte concepten
en efficiënte algoritmen
• programmeren met concepten, waarbij een concept een familie is van abstracties die verbonden zijn door een gemeenschappelijke set vereisten
• ...
5
In de context van C++ wordt generiek programmeren soms ook wel gewoon omschreven als “programmeren met templates”. Zelfs Bjarne Stroustrup
geeft in [13] alleen een heel algemene omschrijving van het generieke programmeerparadigma. Uiteindelijk is één van de meer volledige definities die we
teruggevonden hebben deze van Czarnecki en Eisenecker, die door Nicolai M.
Josuttis en David Vandevoorde geciteerd wordt in [10]:
Generic programming is a subdiscipline of computer science that
deals with finding abstract representations of efficient algorithms,
data structures and other software concepts, and with their systematic organization. Generic programming focuses on representing
families of domain concepts.
2.2
Technieken voor generiek programmeren
In sterk getypeerde programmeertalen zoals Java en C++ dienen we in principe altijd concrete types te gebruiken wanneer we variabelen declareren in
functies, in klassen of in andere entiteiten. Dit is soms echter een eerder ongewenste of zelfs moeilijk te vervullen vereiste. In het bijzonder, bij het aanmaken van implementaties voor abstracte datastructuren (zoals binaire bomen of
wachtrijen), of voor algemene algoritmen (sorteer- en zoekalgoritmen vormen
hiervan hét voorbeeld bij uitstek), willen we het structuur- en gedragsmodel
kunnen uitschrijven zonder rekening te moeten houden met specifieke types:
het type van de elementen die gestockeerd worden in de datastructuur, of het
type van de argumenten waarop het algoritme inwerkt1 . Het is immers een feit
dat de code van dergelijke datastructuren en algoritmen er voor verschillende
types, op de typevermeldingen zelf na, veelal volledig identiek uitziet. Het is
ook niet erg waarschijnlijk dat een programmeur die een stapelstructuur voor
karakters uitschrijft, de aangemaakte container nooit voor andere doeleinden
en types zal willen gebruiken. Een stapel is immers een algemeen concept,
onafhankelijk van de notie van karakters, en bijgevolg zou het stapelconcept
ook onafhankelijk ervan voorgesteld moeten kunnen worden.
1
In de praktijk dienen meestal toch zekere eisen opgelegd te worden aan de nog onbekende
types. Voor het toepassen van een sorteeralgoritme moeten hun instanties bijvoorbeeld
vergelijkbaar zijn.
6
De geschetste problematiek van genericiteit treedt voornamelijk op de voorgrond bij het implementeren van standaardbibliotheken, omdat deze, in vergelijking met de meeste andere software, een hogere graad van algemeenheid en
flexibiliteit moeten bezitten. Generieke types blijken in de praktijk dermate
belangrijk te zijn, dat zelfs programmeertalen waarin de mechanismen ontbreken om dergelijke types expliciet te manipuleren ontworpen kunnen zijn om
hen te simuleren. Hiervoor heeft men de beschikking over een aantal alternatieven. Drie ervan worden in [10] opgesomd.
Een eerste mogelijkheid is om, voor elk type waarvoor we een zelfde gedrag
willen bekomen, dit gedrag telkens opnieuw te implementeren, bijvoorbeeld
door de manuele “copy-and-paste” aanpak te gebruiken en de programmeur
telkens zelf de gewenste types te laten invullen. Het is duidelijk dat we zo als
het ware telkens opnieuw het wiel uitvinden. Op deze manier wordt ook in
wezen bijna identieke code onnodig gedupliceerd en op diverse locaties in programma’s verspreid. Een eerste nadeel dat hieruit voortvloeit, is de toename
van de omvang van de broncode, waardoor het ontwerp minder overzichtelijk en onderhoudbaar wordt. Indien we bovendien iets aan de implementatie
van het algoritme of de datastructuur in kwestie willen wijzigen, moet deze
aanpassing dan ook meerdere keren op verschillende plaatsen in de code doorgevoerd worden. Dit is uiteraard een bijzonder vervelende situatie waar het
het opsporen en herstellen van fouten betreft. Het vereist bovendien dat op
één of andere manier bijgehouden wordt waar alle kopieën van een particulier
codefragment opgenomen zijn.
Een volgend alternatief is het uitschrijven van de algemene functionaliteit
van algoritmen en datastructuren voor een verzameling types in termen van
een gemeenschappelijk basistype, zoals Object of void*. Keerzijde van de
medaille is dat door het gebruik van dit “generic idiom” de voordelen van
typecontrole verloren gaan. Bovendien kan het, afhankelijk van de taal waarin
gewerkt wordt, nodig zijn alle klassen voor dit doel af te leiden van een eerder
artificieel ingevoerde basisklasse, zodat het ontwerp minder logisch wordt.
De laatste en eigenlijk wel minst gewenste oplossing is het gebruik van
eventueel beschikbare preprocessormechanismen. Preprocessoren, zoals die in
C of C++ voorhanden zijn, gebruiken echter de methode van “domweg tekst
vervangen”, en houden in geen enkel opzicht rekening met scope en typering.
7
Templates in C++ en Generics in Java bieden een oplossing voor de hier
beschreven problemen. Ze laten toe klassen en methoden uit te schrijven in
termen van één of meerdere nog niet nader bepaalde types. Wanneer dan van
deze klassen en methoden instanties aangemaakt worden – dit proces noemen
we “instantiëring” – moeten (impliciet of expliciet) concrete types als argumenten vastgelegd worden. Omdat deze mechanismen in de taal zelf ingebouwd
zijn, is er bovendien volledige ondersteuning voor typecontrole en scoping.
2.3
2.3.1
Java Generics: een noodzaak?
Situatieschets
Dit is het geschikte moment om de programmeertaal Java nu eens nauwkeuriger
onder de loep te nemen. We hebben dit hoofdstuk op bladzijde 5 geopend
met de bewering dat Generics ingevoerd worden om generiek programmeren
in Java te ondersteunen. Eigenlijk ontbrak in deze zin het woord “beter ”.
Generiek programmeren in Java is immers al mogelijk sinds het ontstaan van
deze programmeertaal in 1995. Wie enigszins vertrouwd is met de werking
van de mechanismen van overerving en polymorfisme in Java, zal zich bij de
lectuur van de voorgaande paragraaf 2.2 wellicht de bedenking hebben gemaakt
dat de tweede vermelde techniek voor generiek programmeren inderdaad de
populaire strategie is om in Java abstractie te maken over alle referentietypes.
Deze manier van werken wordt mogelijk gemaakt door twee aspecten van de
basisfilosofie die achter Java schuilgaat.
1. De Java-programmeur heeft niet de keuze om objecten hetzij rechtstreeks, hetzij onrechtstreeks via een pointervariabele te manipuleren,
zoals dit in C++ het geval is: alle objecten worden in Java impliciet via
een referentie doorgegeven.
2. Alle referentietypes in Java, zowel de standaard gedefinieerde als de zelf
aangemaakte, erven impliciet over van één enkel gemeenschappelijk basistype Object, dat in zekere zin de meest algemene entiteit voorstelt.
8
We kunnen hier een parallel trekken met de werking van polymorfe pointers
in C++: een pointervariabele T* ptr kan men laten wijzen naar elk object
dat een type heeft dat behoort tot de lokale overervingshiërarchie met T als
basistype. Zonder hier verder in te gaan op het template-mechanisme en de
standaard collectieklassen in C++, kunnen we stellen dat dit schema gebruikt
kan worden om deze containers, die van nature homogeen zijn en dus voor een
particuliere instantiëring slechts elementen van één enkel type kunnen meedragen, heterogeen te maken. Immers, door ervoor te zorgen dat de container
instanties van een pointertype stockeert en dan het effect van de polymorfe
pointers uit te buiten, kan men toch een koppeling realiseren tussen een container en een aantal objecten waarvan het meest specifiek type verschillend is.
Dit principe is in Java in feite tot het uiterste doorgetrokken. Wanneer men in
Java een object manipuleert, manipuleert men eigenlijk een referentie, en alle
referentietypes erven over van hetzelfde gemeenschappelijke basistype Object.
Als we dus een variabele van dit basistype Object gebruiken, kan het actuele
type van het object waarnaar gerefereerd wordt om het even wat zijn. Deze
techniek is inderdaad niet meer dan de toepassing van het “generic idiom”.
Laten we, om de hier beschreven concepten te illustreren, eens naar een
aantal klassen en interfaces in de bestaande versie van het Java Collection
Framework kijken. Al de containers en algoritmen die hierin opgenomen zijn,
werden in termen van het type Object uitgeschreven, zodat de referenties
die in de containers opgenomen worden elk mogelijk referentietype kunnen
hebben. In listing 2.1 vinden we bijvoorbeeld enkele fragmenten uit de klasse
LinkedList terug.
De getoonde aanpak lijkt even eenvoudig als doeltreffend. Het volstaat bij
het uitschrijven van de interface en de implementatie van generieke containers
ervoor te zorgen dat de attributen die de referenties naar op te nemen objecten
moeten bevatten, variabelen zijn van het meest algemene type Object. In methoden om elementen toe te voegen aan de collectie of eruit op te vragen, is het
type van de relevante argumenten respectievelijk de resultaatwaarde eveneens
Object. Wanneer we ons consequent aan dit implementatieschema houden, is
het mogelijk zonder verdere beperkingen referenties van om het even welk type
in een dergelijke container te plaatsen. Dit houdt tevens in dat we objecten
uit onderscheiden deelbomen van de typehiërarchie, die enkel de basisklasse
9
Listing 2.1: Enkele fragmenten uit de klasse LinkedList
public class LinkedList
extends AbstractSequentialList
implements List, Cloneable, java.io.Serializable {
private transient Entry header = new Entry(null, null, null);
public boolean contains(Object o) { ... }
public boolean add(Object o) { ... }
public Object[ ] toArray() { ... }
public Object[ ] toArray(Object a[ ]) { ... }
private static class Entry {
Object element;
Entry next;
Entry previous;
...
}
private class ListItr implements ListIterator {
public Object next() { ... }
...
}
...
}
10
Object gemeenschappelijk hebben, naast elkaar in dezelfde collectie-instantie
kunnen plaatsen2 .
Ook de interfaces die de declaratie van een aantal algemene basismethoden bevatten, dienen uiteraard in termen van Object-referenties uitgeschreven te worden, opdat hun methoden in willekeurige klassen zouden kunnen
geı̈mplementeerd worden. Listing 2.2 geeft delen van de basisklasse Object
weer, en laat tevens een dergelijke interface Comparable en fragmenten uit de
klasse Integer zien, zoals die totnogtoe zonder Generics uitgeschreven waren.
We zien dat ook de methode equals is uitgeschreven in functie van het type
Object, zodat deze methode in principe toepasbaar is op instanties van om het
even welk referentietype. Hetzelfde geldt voor de declaratie van de methode
compareTo in de interface Comparable en voor haar implementaties, bijvoorbeeld in de klasse Integer. Bemerk echter dat, als we in de vergelijkingstest
members van specifieke types willen betrekken, we in de implementatie van
compareTo wel een “downcast” moeten inlassen, in dit geval naar het type
Integer.
2.3.2
Probleemstelling
Als we dit allemaal reeds konden realiseren in de bestaande taalstandaard,
waarom hebben we dan nog iets als Java Generics nodig? Het antwoord op deze
vraag is dat de containers uit het Java Collection Framework wel generiek zijn,
maar niet typeveilig. We kunnen weliswaar referenties van om het even welk
type in een collectie plaatsen, maar hierbij vindt een impliciete “upcast” naar
het basistype Object plaats als gevolg van het gebruik van het “generic idiom”.
Wanneer we op een later ogenblik deze referenties ophalen uit de container,
krijgen we dan ook referenties terug met als statisch type Object en niet het
type dat de bewuste referenties hadden bij het toevoegen aan de collectie.
Zolang we op de verkregen referenties enkel methoden willen oproepen die in
de basisklasse Object aanwezig zijn (bijvoorbeeld de methode toString), is
dit geen bezwaar. Door polymorfisme zal bij uitvoering automatisch de meest
specifieke versie van deze methode opgeroepen worden indien de implementatie
uit de basisklasse ergens overschreven zou worden.
2
Dit is niet meteen een aan te raden praktijk, zoals uit het vervolg zal blijken.
11
Listing 2.2: De interface Comparable en fragmenten uit de klassen Object en
Integer
public class Object {
public String toString() { ... }
public boolean equals(Object obj) {
return (this == obj);
}
...
}
public interface Comparable {
public int compareTo(Object o);
}
public final class Integer extends Number implements Comparable {
private int value;
// implementatie voor de methode uit de interface Comparable
public int compareTo(Object o) {
return compareTo((Integer)o);
}
public int compareTo(Integer anotherInteger) { ... }
// meer gespecialiseerde implementaties voor methoden uit
// de klasse Object
public String toString() { return String.valueOf(value); }
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
// implementatie voor een abstracte methode uit de
// superklasse Number
public int intValue() { return value; }
...
}
12
Listing 2.3: Het oproepen van methoden op containerelementen
// collectie bedoeld voor opslag van Integer-referenties
List myIntList = new LinkedList();
myIntList.add(new Integer(0));
...
// geen cast nodig, toString is een methode uit de interface van de
// klasse Object (en wordt overschreven in de klasse Integer)
String s = myIntList.iterator().next().toString();
// cast verplicht, intValue is een methode uit de klasse Number
// (waarvan Integer overerft, een cast naar Number zou dus ook
// gewerkt hebben)
int i = ((Integer)myIntList.iterator().next()).intValue();
Gebruikers hebben echter niet de mogelijkheid te bepalen welke methoden
opgenomen worden in de klasse Object. Wanneer we een methode willen oproepen die niet aanwezig is in de interface van dit basistype maar wel voor een
afgeleid type gedefinieerd is, worden we met een onaangename verplichting geconfronteerd: er is eerst een expliciete downcast naar het gepaste type nodig.
Als programmeur moeten we op dat ogenblik voor ogen houden wat het type
van de elementen van de beschouwde collectie was, en deze kennis vervolgens
toepassen door het invoegen van een cast-operatie zodat de code correct gecompileerd kan worden. Statements zoals deze op de laatste lijn van listing 2.3
zullen dan ook veelvuldig voorkomen in Java-code waarin met collecties wordt
gewerkt.
Er is echter meer aan de hand dan wat extra tikwerk voor de cast-operatie.
De collectieklassen zijn heterogeen, dus niets verhindert ons om – accidenteel
– in de gelinkte lijst myIntList ook instanties van andere klassen dan Integer
op te nemen. Wanneer we het statement myIntList.add(new Integer(0));
bijvoorbeeld vervangen door myIntList.add(new String());, dan zal dit codefragment nog altijd foutloos compileren, maar bij het uitvoeren falen met
een ClassCastException op de laatste lijn, omdat we een object waarvan het
meest specifiek type String is als een instantie van de klasse Integer proberen te behandelen. De compiler kan de correctheid van deze code niet in
voldoende mate nagaan omdat het statisch type van de elementen Object is en
13
er in het algemeen geen manier bestaat om at compile time de correctheid at
runtime van de cast-operatie na te gaan. Dit is een bijzonder nadelige situatie.
Wanneer we als gevolg van een programmeerfout verkeerdelijk een String toevoegen aan een collectie die enkel voor Integer-instanties is bestemd, zouden
we in ideale omstandigheden mogen verwachten dat de compiler ons meldt dat
de code niet typecorrect is. Hier is dit echter onmogelijk, pas in een latere fase
van het ontwikkelingsproces kan het probleem eventueel aan de oppervlakte
komen. We zullen programmacode die dergelijke fragmenten bevat dan ook
uitvoerig moeten testen om vergissingen van deze soort op te sporen, hetgeen
dan nog geen enkele garantie inhoudt dat we ze ook allemaal zullen kunnen
traceren.
Een minder technische manier om dit fenomeen te bekijken, is te stellen
dat vele van dergelijke casts vanuit een hoog-niveau perspectief op de code
overbodig zouden moeten zijn. De programmeur heeft immers dikwijls de
intentie om de inhoud van een collectie te beperken tot instanties van types
die behoren tot een welbepaalde deelboom van de totale overervingshiërarchie.
Dit is meta-informatie over programma’s waarvoor er in de bestaande Javastandaard helaas geen syntax beschikbaar is om deze kennis in de broncode
op te nemen, laat staan dat de compiler deze restricties voor ons zou kunnen
nagaan en afdwingen.
14
2.4
Doelstellingen van Java Generics
Hiermee hebben we meteen de voornaamste motivatie voor het invoeren van
het nieuwe Generics-mechanisme in Java geı̈dentificeerd. Veel bestaande Javacode is al intrinsiek generiek, maar omdat de mogelijkheid ontbreekt om op
een expliciete manier met genericiteit om te gaan, zijn dergelijke programma’s
niet typeveilig: het is niet uitgesloten dat de generieke code probleemloos
compileert en bij het uitvoeren toch faalt door fouten tegen de typering. De
cast-operaties die door de programmeur ingevoegd moeten worden, willen we
zowel vanuit praktisch als vanuit een logisch standpunt schrappen.
De hoofddoelstellingen van Java Generics kunnen we dan ook als volgt
samenvatten:
• het voorzien van de mogelijkheid om expliciete type-informatie toe te
voegen aan generieke klassen, interfaces en methoden
• het wegwerken of impliciet maken van cast-operaties in de broncode
• het verbeteren van de statische typecontrole
• meer algemeen het verbeteren van de typeveiligheid, de leesbaarheid en
de onderhoudbaarheid van generieke Java-code
15
Hoofdstuk 3
Java Generics: syntax en
semantiek
Zoals we in hoofdstuk 2 geı̈llustreerd hebben, zijn de standaard Java-collecties
dus wel generiek, maar niet typeveilig. Ook al heeft de software-ontwikkelaar
de intentie om de inhoud van een container te beperken tot referentietypes
die behoren tot een specifieke subboom van de totale overervingshiërarchie,
de syntax om dit in de broncode vast te leggen ontbreekt, zodat de compiler
deze eis ook niet kan afdwingen. De cast-operaties die gebruikt worden bij
het ophalen van referenties uit containers, geven enigszins de bedoeling weer,
maar in feite zijn het niet meer dan uitingen van hetgeen de programmeur
voor waar aanneemt op dat punt in de code. Hun correctheid kan de compiler al evenmin garanderen. Ook in een aantal andere situaties, zoals bij het
implementeren van de compareTo-methode uit de interface Comparable, kunnen casts optreden die problemen veroorzaken bij het uitvoeren van de code.
Er is dus duidelijk nood aan een mogelijkheid om op een meer expliciete en
gecontroleerde manier om te kunnen gaan met generieke types in Java, en
dit op taalniveau zodat typefouten zoveel mogelijk at compile time kunnen
gedetecteerd worden.
16
Dat was dan ook het doel van Java Specification Request 14 [1]: komen tot
een specificatie voor het toevoegen van “parametrisch polymorfisme” of types
en methoden met typeparameters aan de programmeertaal Java. Om de uitbreidingen te kunnen integreren in de taal, diende de Java Language Specification
[6] dus uitgebreid te worden. Waar nodig, moest het class-bestandsformaat
licht aangepast worden om de taaluitbreidingen te kunnen implementeren, hetgeen op zijn beurt ook updates in de Java Virtual Machine Specification [11]
met zich meebracht. De bespreking van implementatiedetails wordt echter
grotendeels uitgesteld tot in hoofdstuk 4. Ook de uiteenzetting over syntax
en semantiek hier zullen we tamelijk beknopt houden. Voor een meer formele
behandeling, inclusief BNF-beschrijvingen, verwijzen we naar [4].
3.1
Gebruikte terminologie
Wanneer we het in deze teksten hebben over klassen, interfaces en methoden
waaraan door middel van parameters extra type-informatie kan toegevoegd
worden, zullen we meestal de termen “generieke types” en “generieke methoden” of “polymorfe methoden” gebruiken. Types en methoden die geen
expliciete type-informatie kunnen opnemen, zullen we bestempelen als “nietgenerieke types” en ”niet-generieke methoden”. Deze benamingen zijn niet
volledig accuraat omdat ook types uit de laatste categorie – zoals bijvoorbeeld
de bestaande collectietypes die steunen op het “generic idiom” – in feite generiek kunnen zijn. Deze terminologie wordt dan ook alleen maar voor de
eenvoud en de bondigheid gebruikt. Verder zullen we het proces waarin uit
generieke types particuliere versies worden gecreëerd door voor elk van de gedeclareerde formele typeparameters een actueel type-argument op te geven,
“instantiëring” noemen. Voor de entiteiten die uit dit proces ontstaan, zullen
we de benamingen “invocaties” en “instanties” hanteren. Deze laatste term
kan in het kader van objectgeoriënteerde programmeertalen wellicht voor enige
verwarring zorgen, maar de context zal meestal duidelijk maken welke betekenis bedoeld wordt. De termen “typevariabele” en “typeparameter” tenslotte
zullen we als synoniemen voor elkaar gebruiken.
17
3.2
Generieke types en methoden
Uit de bespreking in het vorige hoofdstuk is gebleken dat syntax om extra
type-informatie mee te geven aan klassen, interfaces en methoden, ons een
stap dichter bij de oplossing van het geschetste probleem kan brengen. Vanuit
een conceptueel standpunt bekeken zullen deze klassen en interfaces dan elk
een set van types voorstellen: één type voor elke mogelijke instantiëring van
de set typeparameters met concrete argumenten. Analoog vormen polymorfe
methoden dan elk een model voor een familie van aanverwante methoden,
één voor elke mogelijke binding van de formele typeparameters met actuele
argumenten.
Dit is dan ook de benadering waarop Java Generics steunen: klassen, interfaces en methoden kunnen voorzien worden van een set typeparameters,
genoteerd tussen < en >, waarbij elke typeparameter symbolisch wordt voorgesteld door een typevariabele. De declaratie van een dergelijke klasse of interface heeft dan als algemene vorm C<T1 , ..., Tn >, waarbij C het type van
de klasse of interface is, en alle Ti typevariabelen zijn. Methoden kunnen op
analoge wijze voorzien worden van een lijst typeparameters, met dat verschil
dat deze lijst niet achter de methodenaam wordt geplaatst maar wel vóór het
resultaattype van de methode. Bovendien moeten bij methoden enkel die typevariabelen nog vermeld worden die niet voorkomen in de lijst typeparameters
van de omhullende klasse of interface1 .
Elk van de typevariabelen die als typeparameter dienst doet, is een ongekwalificeerde identifier, waarvan de scope de volledige declarerende klasse,
interface of methode is. Deze typevariabelen kunnen met andere woorden ook
intern gebruikt worden om het nog niet gekende type van attributen en variabelen aan te geven of om het type van methode-argumenten en resultaatwaarden
van functies voor te stellen. Een belangrijke restrictie op het vrije gebruik van
deze typevariabelen is echter dat de typeparameters van een klasse niet mogen
gebruikt worden om het type aan te geven van static members. De redenen
voor deze beperking zullen pas volledig duidelijk worden wanneer we dieper
1
Wanneer men bij een methode toch expliciet een typeparameter opgeeft waarvan de
naam overeenkomt met de naam van een typeparameter van de omhullende klasse of interface, dan wordt voor de methode een afzonderlijke nieuwe typevariabele met dezelfde naam
gecreëerd.
18
ingaan op de implementatie-aspecten van Java Generics in hoofdstuk 4.
Bij het instantiëren van dergelijke klassen of interfaces, moet een actueel
argument worden opgegeven voor elk van de formele typeparameters uit de
declaratie van de klasse of de interface. Bij methoden kan men dit ook doen,
maar het is geen verplichting: zoals ook bij functietemplates in C++ gebeurt,
zal er getracht worden de “type arguments” voor de typeparameters af te leiden
uit de types van de “call arguments” van de methode, voor zover dit mogelijk
is uiteraard.
Een concreet voorbeeld kan op dit ogenblik wellicht al het een en ander
verduidelijken. In listing 3.1 worden delen van de klasse LinkedList getoond
zoals deze er uitziet in de standaardbibliotheek van het J2SE platform versie
1.5.0. Tevens wordt een voorbeeld gegeven van de manier waarop men deze
generieke klasse kan gebruiken.
Bemerk dat, in tegenstelling tot wat bij vele C++ compilers het geval is,
het optreden van de karaktersequenties >> of >>>, veroorzaakt door het nesten
van invocaties van generieke types, in Java geen verwarring oplevert met de
right-shift operatoren, zodat geen extra spaties moeten worden tussengevoegd.
De grammatica die gebruikt wordt om Java-broncode te parsen, werd hiervoor
speciaal uitgebreid, zodat de verschillende gevallen goed uit elkaar kunnen gehouden worden. Het feit dat deze generieke klasse LinkedList een interface
Queue implementeert die in listing 2.1 niet voorkwam, is hier bijkomstig en
negeren we dus. Wat van belang is, is dat aan deze klasse een typeparameter
E is toegevoegd. Deze typevariabele E wordt geünificeerd met de typeparameter die optreedt in de declaratie van de klasse AbstractSequentialList
waarvan overgeërfd wordt, en daarnaast eveneens met de typeparameter in
een aantal interfaces die geı̈mplementeerd worden. Een korte beschrijving van
de belangrijkste aandachtspunten volgt hieronder.
• De methode add heeft als argument nu ook een referentie van het voorlopig onbepaalde type E, waar dit in listing 2.1 nog Object was. Aan
een instantie LinkedList<Integer>, die gecreëerd kan worden door aan
de LinkedList-constructoroproep het argument <Integer> voor de typevariabele E toe te voegen, kunnen we alleen nog maar instanties van
het type Integer of van subtypes hiervan toevoegen.
19
Listing 3.1: De klasse LinkedList in Generics-syntax
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Queue<E>, Cloneable,
java.io.Serializable {
private transient Entry<E> header
= new Entry<E>(null, null, null);
public boolean contains(Object o) { ... }
public boolean add(E o) { ... }
public Object[ ] toArray() { ... }
public <T> T[ ] toArray(T[ ] a) { ... }
private static class Entry<E> {
E element;
Entry<E> next;
Entry<E> previous;
...
}
private class ListItr implements ListIterator<E> {
public E next() { ... }
...
}
...
}
// nesting, er is geen spatie nodig in >> (i.t.t. C++ syntax)
LinkedList<Pair<String,String>> l1;
// illegaal: geen primitieve types als type-argument
LinkedList<int> l2;
LinkedList<Integer> list = new LinkedList<Integer>();
list.add(new Integer(0));
...
int i = list.iterator().next().intValue(); // geen cast nodig!
list.add(new String()); // compile time error!
20
• De methode contains heeft, ondanks het toevoegen van de typeparameter aan de klasse LinkedList, nog steeds Object als argumenttype.
Dit is echter geen bezwaar aangezien de implementatie van deze methode niets toevoegt aan de inhoud van de container waarop ze opgeroepen
wordt, maar enkel met behulp van de methode equals tracht te achterhalen of het opgegeven object al dan niet aanwezig is in de collectie.
Om flexibiliteit te behouden, heeft men er dus voor gekozen de typevariabele E niet in deze methode contains te gebruiken. Hetzelfde geldt
trouwens ook voor een aantal andere methoden die niet getoond werden,
bijvoorbeeld remove.
• Ook de methode toArray() blijft een array teruggeven waarvan het elementtype Object is, in plaats van E. Dit is mede een gevolg van de
implementatie, die het gebruik van typevariabelen in new-expressies en
als het elementtype van arrays beperkt. We komen hier verder nog op
terug.
De methode toArray(T[ ]) daarentegen geeft een array terug waarin
alle elementen uit de gelinkte lijst opgenomen zijn, in volgorde. Hierbij
is het runtimetype van de resulterende array deze van de array die als
argument wordt doorgegeven. Wanneer het runtimetype van deze modelarray geen supertype is van elk van de referentietypes die aanwezig
zijn in de gelinkte lijst, dan zal een ArrayStoreException opgeworpen
worden. Deze controle is mogelijk omdat arrays hun elementtype at
runtime meedragen, in tegenstelling tot collecties, zoals nog zal blijken.
Deze methode bevat een typevariabele T die niet is opgenomen in de
lijst typeparameters van de omhullende klasse, en deze wordt dan ook
afzonderlijk vermeld vóór het resultaattype van de methode.
• De typevariabele E, gedeclareerd door de klasse LinkedList, wordt wel
gebruikt in de geneste klassen Entry en ListItr. Deze laatste implementeert de interface Iterator, eveneens met deze typevariabele E. De
klasse Entry, die de elementen modelleert waaruit de gelinkte lijst wordt
opgebouwd, bevat een attribuut element van type E, dat de referentie
zal bevatten naar een object dat in de gelinkte lijst opgenomen wordt.
In listing 2.1, waar het “generic idiom” werd gebruikt, was dit attribuuttype nog Object. Een gevolg van het gebruik van generieke types en
21
methoden is dus dat het type van klassemembers nu niet meer noodzakelijk vast is, maar kan afhangen van de argumenten die voor zekere
typeparameters opgegeven worden.
De methode next van de iterator geeft nu ook een referentie van type E
terug. Het actuele type van deze resultaatwaarde zal worden afgeleid uit
het type-argument voor de typevariabele van de LinkedList-container
waaruit de iterator wordt aangemaakt. Er is dus geen cast meer nodig
bij het ophalen van referenties uit de gelinkte lijst.
• Tenslotte vermeldt het voorbeeld nog dat in Java primitieve types zoals
int of char nooit in aanmerking komen als type-argument, dit in tegenstelling tot C++ waarin dit wel mogelijk is. Ook “non-type parameters”
die toelaten dat C++ templates afhankelijk worden gemaakt van een
zekere constante integrale waarde, hebben geen equivalent in Java.
Bij het instantiëren van het generieke type LinkedList dienen we exact
één referentietype als argument op te geven voor de typeparameter E. Wanneer we nog eens teruggrijpen naar listing 2.3 en vergelijken met de laatste
regels in listing 3.1, dan zien we dat de Integer-cast als het ware verplaatst is
naar de declaratie van de container. Op deze manier maakt de programmeur
zijn intenties duidelijk in de broncode, zodat de compiler de correctheid van
de typering beter kan nagaan. Een nuttig gevolg hiervan is dat we bij het
ophalen van referenties uit generieke containers met behulp van de methode
next, aangeroepen op een iterator, meteen een referentie van het gewenste type, i.c. Integer bekomen zonder dat een downcast nodig is. Bovendien zullen
pogingen om aan de gelinkte lijst list een instantie van de klasse String toe
te voegen, nu door de compiler als foutief afgevlagd worden op basis van de
extra beschikbare kennis aangaande de inhoud van de container.
Tot dusver lijkt het gebruik van generieke types in Java, op de vermelde beperkingen na, misschien niet echt fundamenteel te verschillen van het
template-mechanisme in C++. In de volgende paragrafen zullen echter nieuwe facetten opduiken die Java Generics wel degelijk onderscheiden van C++
templates. Eén van de aspecten die geen tegenhanger in C++ heeft, is bijvoorbeeld de wildcard, die ook in [5] uitgebreid toegelicht wordt.
22
3.3
Wildcards
Vóór de introductie van de Generics in de programmeertaal Java was een
instantie van Collection gewoon een collectie en niet een collectie van elementen van een welbepaald type. Om het gebruik van het Java Collection
Framework op een veiligere manier te laten verlopen, hebben Java Generics
de syntax uitgebreid zodat containers kunnen voorzien worden van een typeparameter dat het elementtype weergeeft. Objecten die aan een particuliere
instantie van een dergelijke generieke collectie toegevoegd worden, moeten dan
instanties zijn van het type-argument van de invocatie. En ook de referenties
die opgehaald worden uit de container, hebben dit type, zodat onveilige casts
vermeden kunnen worden. In vele gevallen is dit een verbetering tegenover
het ruwe gebruik van het “generic idiom”, maar het maakt het ook moeilijker
om een collectie gewoon als collectie te behandelen, onafhankelijk van het elementtype. Dit laatste zou handig kunnen zijn in methoden waarin we alleen
geı̈nteresseerd zijn in de gemeenschappelijke kenmerken van alle onderscheiden
invocaties van een zelfde generiek type.
Beschouw, om de gedachten te vestigen, bijvoorbeeld een methode die de
inhoud van een arbitraire collectie moet uitprinten in de terminal, waarbij het
elementtype van deze collectie volledig irrelevant is. Een eerste poging om de
signatuur en implementatie van deze methode vast te leggen, kan er als volgt
uitzien:
Listing 3.2: Het matchen van arbitraire collecties – eerste poging
public static void printCollection(Collection<Object> c) {
for (Object e : c) { System.out.println(e); }
}
Bemerk tevens dat we hier gebruik hebben gemaakt van een andere nieuwe
taaluitbreiding die in J2SE 1.5.0 aanwezig zal zijn: een speciale for-lus die
ons toelaat zonder het expliciet aanmaken van een iterator over de volledige
inhoud van collecties (en arrays) te itereren. Het aspect dat we willen illustreren is echter dat deze code te beperkt is en niet kan opgeroepen worden
voor arbitraire collecties met willekeurig elementtype: argumenten van het
type Collection<Object> of Vector<Object> kunnen doorgegeven worden,
23
maar geen Collection<String> of Set<Integer> referenties. Om types met
parameters te kunnen opnemen in de taal, diende immers de definitie van de
subtype–supertype relatie aangepast te worden. Zoals trouwens ook bij de
collecties in C++ het geval is, bestaat er standaard geen enkele dergelijke relatie tussen invocaties van hetzelfde generieke type. Het feit dat T een subtype
is van U, impliceert dus niet dat C<T> een subtype zou zijn van C<U>. Meer
nauwkeurig definieert men:
Een invocatie A van een generiek type is een subtype van een invocatie B van een generiek type als en slechts als de argumenten voor
overeenkomstige typeparameters dezelfde zijn en het ruwe type van
A een subtype is van het ruwe type van B.
Een “raw type” is niets meer dan het type dat verkregen wordt door
(invocaties van) een generiek type te ontdoen van de extra type-informatie
(formele of actuele parameters). Het ruwe type van Collection<T> is dus
Collection, dat van ArrayList<String> is ArrayList. Merk op dat expliciete subtype–supertype relaties die aangegeven worden met de sleutelwoorden
extends en implements volgens de gegeven definitie wel degelijk blijven bestaan: Collection<Object> is dus een supertype van TreeSet<Object>.
Deze uitgebreide definitie verzekert dat de typeveiligheid van Java Generics niet omzeild wordt. Beschouw bijvoorbeeld het volgende (illegale) codeframentje:
Listing 3.3: De subtype–supertype relatie en collecties
LinkedList<String> xs = new LinkedList<String>();
LinkedList<Object> ys = xs; // compile time error
ys.add(new Integer(0));
Indien dit zou toegelaten worden, kunnen we een collectie van instanties
van Object gebruiken als alias voor een collectie String-instanties en op die
manier de typeveiligheid doorbreken door Integer-referenties via ys te introduceren in de String-collectie. Het is trouwens bijzonder instructief deze code
te vergelijken met het analogon voor arrays.
24
Listing 3.4: De subtype–supertype relatie en arrays
String[ ] xs = new String[1];
Object[ ] ys = xs;
ys[0] = new Integer(0); // runtime ArrayStoreException
Deze code is voor de compiler legaal, maar werpt een ArrayStoreException
bij het uitvoeren. Zoals eerder al werd vermeld, dragen arrays hun elementtype
mee bij uitvoering zodat deze controle mogelijk is. In hoofdstuk 4 zal duidelijk
worden waarom dit niet het geval is voor generieke collecties.
Nu weten we ook waarom de signatuur van de methode printCollection
in listing 3.2 niet algemeen genoeg was. Een manier om met Generics-syntax
meer flexibiliteit toe te voegen, is het invoeren van een expliciete typevariabele
om het elementtype van het collectie-argument symbolisch voor te stellen.
Hoewel we dan in de for-lus in principe ook gebruik kunnen maken van deze
typevariabele T om het elementtype aan te geven, kunnen we in dit voorbeeld
evengoed de typevariabele volledig negeren in de body van de methode en
Object gebruiken, op de volgende manier:
Listing 3.5: Het matchen van arbitraire collecties met typevariabelen
public static <T> void printCollection(Collection<T> c) {
for (Object e : c) { System.out.println(e); }
}
Hoewel een expliciete typeparameter moest worden gebruikt in de signatuur van de methode, wordt in de implementatie toch nergens gebruik gemaakt
van deze typevariabele. Dit is geen zuivere oplossing, maar deze methode zal
uitstekend werken. Meer problematische situaties treden echter op wanneer
een klasse een attribuut nodig heeft waarvan het type een collectie is en het
elementtype onbelangrijk. Dit is in het bijzonder een probleem in generieke
klassen die veel functionaliteit bezitten die onafhankelijk is van de actuele typeparameters, zoals bijvoorbeeld het geval is voor de klasse java.lang.Class.
Zonder extra mechanismen kan dit niet algemeen uitgedrukt worden in Java
Generics.
De oplossing die hiervoor aangereikt wordt, is het gebruik van een zogenaamde wildcardvariabele ? in plaats van een typevariabele wanneer het
25
actuele type niet relevant is. We zouden onze methode printCollection met
behulp van deze wildcard op de volgende manier kunnen uitschrijven:
Listing 3.6: Het matchen van arbitraire collecties met wildcards
public static void printCollection(Collection<?> c) {
for (Object e : c) { System.out.println(e); }
}
Dit drukt uit dat het methode-argument een arbitraire collectie is waarvan
het elementtype irrelevant is. In dit voorbeeld is de wildcard eigenlijk gewoon
“syntactic sugar”, een verkorte en meer eenvoudige manier van notatie, die
bovendien niet verkeerdelijk suggereert dat een gedeclareerde typevariabele
in de interne implementatie van belang zou zijn. Op dezelfde manier kan
een klasse-attribuut bijvoorbeeld gedeclareerd worden als een List van om
het even welk elementtype: List<?> list. Het type List<?> is dus in feite
het supertype van List<T> voor elke mogelijke T, wat betekent dat elk type
lijstinvocatie kan toegewezen worden aan de variabele list. Bij uitbreiding is
Collection<?> dan ook het supertype van alle standaard Java-collecties.
Wildcards vormen een taaluitbreiding die ontworpen werd om de flexibiliteit van het objectgeoriënteerde typesysteem, waarin expliciete generieke types
opgenomen zijn, te verhogen. Wildcards laten een typeveilige abstractie over
verschillende invocaties van generieke types toe, door een ? te gebruiken om
onbepaalde type-argumenten aan te geven. Op die manier kan men de gemeenschappelijke kenmerken van de verschillende instanties blijven uitbuiten,
zoals bijvoorbeeld het opvragen van de lengte van een lijst of het leegmaken
ervan. Een wildcard is in dat opzicht een speciaal type-argument dat loopt
over alle mogelijke specifieke type-argumenten, zodat List<?> het type is van
alle lijsten, ongeacht hun elementtype. In essentie verenigen ze dus onderscheiden families van klassen die ontstaan door het mechanisme van parametrisch
polymorfisme. De wildcard kan gebruikt worden in het type van referenties,
maar niet in de oproep van constructoren of bij het aanmaken van arrays. Een
wildcard mag dus niet opgevat worden als een specifiek type. Bijvoorbeeld, de
twee optredens van ? in Pair<?,?> worden niet verondersteld voor hetzelfde
type te staan, en over assignaties heen kan een ? ook op verschillende types
duiden. Een speciaal mechanisme dat luistert naar de naam “wildcard capture”
26
laat toe in zeer specifieke omstandigheden generieke methoden op te roepen
in situaties waarin het type-argument niet afgeleid kan worden omdat het een
wildcardtype is.
Ook de flexibiliteit van wildcards kent echter grenzen. Omdat het actuele
type dat achter de wildcard schuilgaat onbekend is en eigenlijk om het even
wat kan zijn, kunnen we bij collectiereferenties waarin een wildcardargument is
gebruikt ook niets aannemen over het type van de containerelementen. Wat we
in Java echter altijd zeker weten, is dat we instanties voorgeschoteld krijgen van
een type dat van de basisklasse Object overerft wanneer we uit een dergelijke
collectie lezen. Daarom waren we verplicht in listing 3.6 als type in de for-lus
Object op te geven.
De gevolgen hiervan voor het opnemen van elementen in dergelijke containers reiken nog verder: omdat we niet weten wat het actuele type is, kunnen
we helemaal geen referenties toevoegen aan dergelijke collecties2 .
Listing 3.7: De wildcard en het toevoegen van elementen aan collecties
Collection<?> c = new ArrayList<Object>();
c.add(new Integer(0)); // compile time error
De code in listing 3.7 zal dan ook niet compileren en de volgende foutmelding opleveren:
add(?) in java.util.Collection<?>
cannot be applied to (java.lang.Object)
Omdat in situaties zoals deze in het algemeen niet gegarandeerd kan worden
dat deze toevoeging aan de collectie niet in strijd is met het principe van
typeveiligheid – dat we door het invoeren van de Generics-syntax juist proberen
te versterken – wordt niet toegelaten dat objectreferenties worden toegevoegd
aan collecties die worden gemanipuleerd via een referentie waarvan het type
een wildcardvariabele bevat. Deze collecties zijn dus als het ware “immutable”
geworden. Een andere manier om dit fenomeen te bekijken, is de bedenking
te maken dat in de verschillende collectietypes de typevariabele ook altijd het
type aangeeft van attributen die referenties zullen bevatten naar objecten die
2
Een pathologische maar niet bijzonder nuttige uitzondering op deze regel zijn nullreferenties, omdat deze tot elk type behoren.
27
in de collectie opgenomen worden. Wanneer we deze typevariabele unificeren
met de wildcard,, wordt het type van deze attributen het wildcardtype, hetgeen
voor de compiler een indicatie is dat de opgeroepen methode in het algemeen
niet geldig kan zijn.
3.4
3.4.1
Meer wildcards: bounds
Upper bounds: typisch gebruik
In het voorbeeld van listing 3.1 hebben we geı̈llustreerd hoe bij de invocatie van generieke collecties de inhoud van collecties kan beperkt worden tot
referenties waarvan het type behoort tot een welbepaalde subboom van de
overervingshiërarchie. Bij het uitschrijven van de implementatie zelf van deze
generieke containerklassen, willen we iets soortgelijks kunnen doen: zoals in
hoofdstuk 2 op bladzijde 6 al vermeld werd in een voetnoot, zijn in sommige
situaties de eigenschappen van de actuele types waarmee een typeparameter
in een generieke klasse, interface of methode geı̈nstantieerd wordt, niet helemaal irrelevant. Beschouw bijvoorbeeld de statische methode max uit de klasse
java.util.Collections van het Java Collection Framework, die het grootste
element in een gegeven collectie bepaalt.
Listing 3.8: De methode Collections.max
public static Object max(Collection coll) { ... }
public static <T> T max(Collection<T> coll) { ... }
Zowel de oude versie zonder als een nieuwe versie mét typeparameter worden weergegeven. In de generieke versie is het resultaattype het elementtype
van het containerargument. Dit is echter niet het einde van het verhaal. Om
deze methode correct te kunnen uitvoeren, wordt de stilzwijgende veronderstelling gemaakt dat de elementen van de container die doorgegeven wordt,
onderling vergelijkbaar zijn. Het elementtype van de collectie moet dus de
interface Comparable implementeren. Deze eis is niet expliciet in de broncode
opgenomen, zodat de compiler deze ook niet kan nagaan. Deze methode is
28
dan ook een potentiële bron van runtime fouten. Wanneer we bijvoorbeeld de
methode max loslaten op een collectie van Boolean-instanties, zal dit scenario
optreden, omdat de klasse Boolean de interface Comparable niet implementeert (althans niet in J2SE 1.4.2, in versie 1.5.0 is dit wel het geval).
Ook hiervoor dienen Java Generics de programmeur dus de mogelijkheid
te geven extra kennis via de broncode aan de compiler mee te delen. Daartoe
kunnen we typevariabelen en wildcards uitrusten met “bounds”, bijvoorbeeld
op de volgende manier:
Listing 3.9: De methode Collections.max met een upper bound
public static <T extends Comparable<T>>
T max(Collection<T> coll) { ... }
Met de bovengrens Comparable<T> geven we aan dat het type dat voor T
ingevuld wordt, een type moet zijn dat de generieke interface Comparable implementeert voor zichzelf, zodat instanties van dit type onderling vergelijkbaar
zijn. Bemerk tevens dat altijd het sleutelwoord extends wordt gebruikt, ook
als het opgegeven supertype een interfacetype is.
Dezelfde techniek kunnen we ook gebruiken om aan het collectie-argument
opgelegde voorwaarden te relaxeren: niet alleen collecties van T-instanties kunnen verwerkt worden, maar meer algemeen ook alle collecties van subtypes van
T: dit kunnen we met een wildcard aangeven als Collection<? extends T>.
Grenzen zijn daarnaast ook bruikbaar voor het declareren van referentievariabelen in combinatie met wildcards. Aan de variabele l die gedeclareerd is als
List<? extends Number> kan men dus een instantie van Vector<Integer>
assigneren, maar geen ArrayList<String>. Ook de “bounds” zelf kunnen
invocaties zijn van een generiek type, en ze mogen ook typevariabelen bevatten die elders in de parameterlijst voorkomen, zoals in listing 3.9 geı̈llustreerd
wordt. Recursie of mutuele recursie tussen typeparameters is toegelaten –
Generics ondersteunen “F-bounded” polymorfisme.
3.4.2
Lower bounds: typisch gebruik
Zelfs met de aanpassingen uit de vorige paragraaf ontbreekt er nog iets aan de
methode max. Het type T moet de interface Comparable namelijk implemente29
ren voor zichzelf. Ook dit gegeven is te restrictief. In termen van comparatoren
kunnen we instanties van String vergelijken met een comparator voor String,
maar aangezien String overerft van Object, kan een comparator voor Object
even goed volstaan. Wanneer we instanties van een bepaald type moeten vergelijken, kunnen we dus een comparator specifiek voor dit type gebruiken, maar
een comparator voor een willekeurig supertype kan in principe ook volstaan.
Om deze keuzemogelijkheid open te laten, kunnen we aan de typevariabele T in
de bovengrens Comparable<T> een “lower bound” toevoegen. Omdat het specifieke supertype waarvoor de interface Comparable geı̈mplementeerd wordt
niet van belang is, gebruiken we ook hier een wildcard.
Listing 3.10: De methode Collections.max met upper en lower bounds
public static <T extends Comparable<? super T>>
T max(Collection<? extends T> coll) { ... }
We kunnen nog opmerken dat ook dit nog niet nauwkeurig het uitzicht
weergeeft van de methode Collections.max zoals deze in de J2SE 1.5.0 API’s
is opgenomen.
3.4.3
Bounds: algemene vorm en beperkingen
Meer formeel heeft de bound van een typeparameter de vorm T & I1 & ...&
In , waarbij T een klassetype of interfacetype is en I1 , . . . , In een eventueel
ledige set andere interfacetypes. Een bound is optioneel. Wanneer geen bovengrens voor een typeparameter opgegeven is, wordt automatisch de uiterste
bovengrens java.lang.Object verondersteld. Als illustratie tonen we hier het
actuele uitzicht van de Collections.max-methode die we eerder al in voorbeelden hebben gebruikt.
Listing 3.11: De methode Collections.max – actueel uitzicht
public static <T extends Object & Comparable<? super T>>
T max(Collection<? extends T> coll) { ... }
De reden waarom de eerste bovengrens op T het basistype Object is, zal in
hoofdstuk 4 verduidelijkt worden.
30
Het gebruik van bounds is nog aan een aantal andere beperkingen onderworpen. Zo moeten de ruwe types van alle samenstellende bounds van dezelfde typevariabele paarsgewijs verschillen. Verder, wanneer een typevariabele
X meer dan één bound heeft en wanneer verschillende van deze boundtypes
memberfuncties hebben met dezelfde signatuur maar een verschillend resultaattype, dan is elke referentie naar zulke member vanuit een object van type
X dubbelzinnig en dus een compile time error, tenzij een expliciete cast wordt
toegevoegd. Wanneer de typevariabele slechts één bound heeft, zijn deze restricties natuurlijk niet van toepassing.
3.4.4
Bounds en de subtype–supertype relatie
Zoals eerder al vermeld werd in paragraaf 3.3, is het overervingsgedrag door
het invoeren van expliciete generieke types gewijzigd. De formele behandeling
van deze materie valt echter buiten het bereik van deze tekst, en daarvoor
verwijzen we dan ook naar [4]. We formuleren hier echter nog een aantal
algemene bemerkingen.
• Invocaties van generieke types met naar boven toe begrensde wildcards
zijn op covariante manier gerelateerd in subtype–supertype relaties met
betrekking tot het type van de bovengrens: alle instanties van List<?
extends Integer> zijn dus ook instanties van List<? extends Number>.
• In tegenstelling tot de extends-bounds geven super-bounds aanleiding
tot contravariant subtype-gedrag: Comparator<? super Number> is een
subtype van Comparator<? super Integer>.
3.5
Het gebruik van methoden met typeparameters
Dit onderwerp is al zijdelings aan bod gekomen in de voorbeelden van de vorige
paragrafen. Zoals getoond, is er geen speciale syntax nodig om polymorfe methoden te kunnen oproepen. Expliciete argumenten voor de typeparameters
van een dergelijke methode worden in de praktijk bijna altijd weggelaten: de
31
Listing 3.12: Het oproepen van polymorfe methoden
public class Sort {
static <Elem> void swap(Elem[ ] a, int i, int j) {
Elem temp = a[i];
a[i] = a[j];
a[j] = temp;
}
static <Elem extends Comparable<Elem>> void sort(Elem[ ] a) {
for (int i = 0; i < a.length; i++)
for (int j = 0; j < i; j++)
if (a[j].compareTo(a[i]) < 0)
Sort.<Elem>swap(a, i, j);
}
}
Integer[ ] ints = {new Integer(0), new Integer(1), new Integer(2)};
Sort.swap(ints, 0, 2); // zonder expliciet type-argument
Sort.<Integer>swap(int, 0, 2); // met expliciet type-argument
argumenten worden, voor zover dat mogelijk is, afgeleid uit het type van de
call-argumenten van de methode. De details van deze afleidingsprocedure kunnen in verschillende bronnen teruggevonden worden, onder andere in [4]. Toch
kan het in zeldzame gevallen nuttig zijn expliciet actuele type-argumenten door
te geven aan de methode, eerder dan te steunen op het ingebouwde inferentiemechanisme. In dit geval moeten evenveel argumenten opgegeven worden als er
formele typeparameters zijn in de declaratie van de methode – er zijn met andere woorden geen “default argumenten” zoals in C++ – en elk type-argument
moet uiteraard een subtype zijn van de bovengrens van de corresponderende
typeparameter.
Volgens de specificatie moeten de expliciete type-argumenten van een methode aangegeven worden vóór de methodenaam. Dit stemt overeen met de
vorm die gehanteerd wordt in de declaratie van deze methoden, en vermijdt
ambiguı̈teitsproblemen bij het parsen van de code.
In het kader van methodes met typeparameters passen tevens nog een aantal andere wijzigingen in de taalspecificatie.
32
• Het overschrijven van methoden uit klassen in afgeleide klassen: een
klasse of interface C<A1 , ..., An > kan een declaratie bevatten voor
een methode die dezelfde naam en argumenttypes heeft als een methodedeclaratie in één de supertypes van C<A1 , ..., An >. De declaratie in
C overschrijft dan de declaratie in het supertype. De nieuwe specificatie
vereist dat het resultaattype van een methode een subtype is van het
resultaattype van alle methoden die het overschrijft (“covariant return
types”). Dit is een versoepeling ten opzichte van de voorgaande specificaties van de programmeertaal Java, die vereisten dat de resultaattypes
identiek waren. De manier waarop hiervoor een implementatieschema
kan worden voorzien, lichten we verder nog toe.
• Het overladen van methoden: wanneer meerdere toegankelijke membermethodes in aanmerking komen voor een methode-oproep, dan moet één
ervan gekozen worden (“overload resolution”). De programmeertaal Java
gebruikt hiervoor als algemene regel dat de meest specifieke methode de
voorkeur krijgt. Dit begrip moest echter uitgebreid worden in de context
van Java Generics.
• Afleiden van typeparameters: als algemene regel geldt dat voor de oproep van een methode met typeparameters altijd het meest specifieke type wordt genomen dat een geldige call oplevert en waarbij de “bounds”
gerespecteerd worden. Wanneer de meest specifieke keuze niet uniek
is, genereert de compiler een foutmelding. Dit gebeurt bijvoorbeeld bij
het oproepen van een methode met typeparameter A waarbij beide argumenten met type A gedeclareerd zijn en de argumenten geünificeerd
worden met String en Integer: in dit geval zijn zowel Comparable als
Serializable evenwaardige kandidaten, dus de keuze is niet eenduidig
en de afleiding faalt. Situaties waarin er geen argumenttypes zijn die een
typevariabele bevatten stellen problemen, alsook het optreden van een
null-waarde voor een argument aangezien dit een instantie is van elk
type.
Het nauwkeurig beschrijven van deze topics zou er echter toe leiden dat
we hier een bijna letterlijke vertaling van de draft specificatie moeten geven,
hetgeen zeker niet de bedoeling kan zijn. Voor meer details verwijzen we dan
33
ook naar deze specificatie en naar [9]. Een aantal aspecten, onder andere
het overschrijven van methoden en covariante resultaattypes, komen in het
volgende hoofdstuk nog aan bod in een andere context.
3.6
Generics: een eerste evaluatie
In dit hoofdstuk hebben we geı̈llustreerd hoe Java Generics de expressiviteit
van Java uitgebreid hebben en hoe de nieuwe mogelijkheden praktisch gebruikt
kunnen worden. Het expliciet manipuleren van generieke types geeft de programmeur dus de mogelijkheid algemene functionaliteit te voorzien die met
verschillende types kan worden gebruikt en die toch at compile time afdoende
kan worden gecontroleerd op typefouten.
34
Hoofdstuk 4
Implementatie-aspecten van
Java Generics
4.1
Inleiding
Tot hiertoe werden alleen syntax en semantiek toegelicht, met een paar korte
verwijzingen naar de onderliggende implementatie. De nieuwe mogelijkheden
op taalniveau steunen uiteraard op mechanismen in de compiler en in de runtime omgeving, en in het geval van Java Generics kan het bijzonder nuttig
zijn dat de software-ontwikkelaar ook in zekere mate op de hoogte is van wat
achter de schermen gebeurt om Java Generics ten volle te kunnen benutten.
Het uitbreiden van de standaard van een bestaande programmeertaal is
uiteraard lang geen onvoorwaardelijk proces. Niet alleen willen de ontwerpers
van de taal dikwijls een zekere basisfilosofie in ere houden, ook compatibiliteitsvereisten dwingen randvoorwaarden op. Meestal is een fundamentele eis dat
reeds vroeger geschreven code geldig moet blijven: compilatie en uitvoering
van deze code moeten ook in de nieuwe omgeving mogelijk zijn, en hetzelfde
geldt in het geval van Java voor bytecode die door vroegere versies van de
compiler gegenereerd werdt. Dit is het zogenaamde “generic legacy problem”:
wat gebeurt er met “legacy code”1 die het generieke idioom uitbuit wanneer
1
Met de term “legacy code” bedoelen we in deze thesis geenszins COBOL-code uit de
jaren ’60, maar wel Java-code die geschreven werd volgens de pre-Generics standaard.
35
expliciete ondersteuning voor generieke types wordt toegevoegd aan de taal?
En wat gebeurt er met code die geschreven is in termen van oudere versies
van de standaardbibliotheken? Een andere implicatie is bijvoorbeeld dat het
toevoegen van nieuwe gereserveerde woorden aan de programmeertaal zoveel
mogelijk vermeden dient te worden, aangezien deze in oude code mogelijk als
identifiers gebruikt worden. Op een dieper gelegen niveau spelen prestatieeisen dan weer een belangrijke rol: de overhead die geı̈ntroduceerd wordt door
nieuwe mechanismen moet beperkt worden, en de invloed op de prestaties van
oude code in de uitgebreide omgeving moet minimaal blijven. Met deze en andere overwegingen moest dan ook rekening gehouden worden bij het opstellen
van een specificatie voor het toevoegen van generieke types aan Java.
4.2
Diverse vormen van compatibiliteit
Conceptueel kunnen we in deze discussie drie compatibiliteitsniveaus onderscheiden. Elk niveau is een uitbreiding van onderliggende vormen van compatibiliteit.
1. taalcompatibiliteit: alle programma’s geschreven in de bestaande taalversie moeten geldig blijven
2. platformcompatibiliteit: alle programma’s die konden uitvoeren op de
bestaane platformversies moeten dit ook kunnen op het nieuwe platform
3. migratiecompatibiliteit: bestaande broncode kan aangepast worden voor
het gebruik van de nieuwe mogelijkheden
Taalcompatibiliteit alleen is niet voldoende. Al wat dan gegarandeerd kan
worden, is dat oude programma’s nog altijd dezelfde betekenis hebben. Wanneer programma’s echter platformbibliotheken gebruiken en deze bibliotheken
veranderingen ondergaan, zijn de garanties in de praktijk meestal waardeloos.
Er zal dus een nieuwe versie van de code geschreven moeten worden, wat niet
eenvoudig is als migratie moeilijk is, tenzij men de oude versie van de bibliotheken kan blijven gebruiken. Taalcompatibiliteit is dus nuttig als theoretische
36
notie, maar praktisch alleen nuttig als eerste stap naar meer bruikbare vormen
van compatibiliteit.
Ook platformcompatibiliteit volstaat niet. Softwareproducenten die willen
migreren naar Generics, zouden nog altijd verplicht zijn hun bibliotheken te
dupliceren, en dit is zelfs niet mogelijk wanneer niet alle andere componenten
waarop gesteund wordt gemigreerd zijn. We kunnen ons dus in het beste geval
verwachten aan gemiste deadlines, het dupliceren van code en onderhoudsmoeilijkheden. Cyclische afhankelijkheden dwingen bovendien aan iedereen
een strakke coördinatie van de migratie op.
Nog een trap hoger vinden we tenslotte het concept van migratiecompatibiliteit terug dat deze nadelen niet heeft. Er moet geen code gedupliceerd
worden en er is geen strakke coördinatie nodig, iedereen migreert op eigen
tempo. Het hoeft daarom niet te verbazen dat deze vorm van compatibiliteit
ook veruit de zwaarste eisen stelt aan de implementatie.
4.3
Precondities
Een aantal eerder strenge beperkingen vinden we ondermeer terug in de Java
Specification Request 14 [1] die door het Java Community Process2 gepubliceerd werd. Omwille van hun impact op de manier waarop de Java Generics
uiteindelijk geı̈mplementeerd werden, sommen we ze hier even op, voorzien van
de nodige bemerkingen.
1. Opwaartse compatibiliteit met bestaande bytecode: dit houdt niet alleen de opwaartse compatibiliteit van het class-bestandsformaat in, maar
tevens ook de mogelijkheid om vroeger geschreven applicaties te laten samenwerken met “geparametrizeerde versies” van bestaande bibliotheken,
in het bijzonder de standaard platformbibliotheken.
Deze beperking heeft in sterke mate het uiteindelijke implementatieontwerp bepaald, zoals verder zal blijken.
2
http://www.jcp.org
37
2. Opwaartse compatibiliteit van de broncode: het zou mogelijk moeten
blijven al de bestaande (en correct geschreven) Java-programmacode te
compileren op het nieuwe systeem.
Elk correct geschreven Java-programma is nog steeds legaal, en blijft
dezelfde betekenis behouden in de uitgebreide taal.
3. Eenvoud: het herziene systeem zou geı̈mplementeerd moeten kunnen
worden in een aanvaardbaar tijdsbestek, het aanpassen van virtuele machines, ontwikkelingsomgevingen (IDE’s) en compilers mag niet onnodig
gecompliceerd worden. Aangezien de hoeveelheid in Java geschreven code gestaag toeneemt, neemt ook de omvang van de inspanningen toe die
nodig zijn om deze code aan te passen aan de gereviseerde taal. Het is
dus in het belang van alle leden van de Java-gemeenschap dat nieuwe
uitbreidingen zo vlug mogelijk beschikbaar worden gemaakt.
We kunnen echter opmerken dat in het document niet omschreven wordt
wat er precies onder “aanvaardbaar tijdsbestek” verstaan mag worden.
4. Ondersteuning voor migratie van bestaande Application Program Interfaces (API’s): het zou mogelijk moeten zijn op relatief eenvoudige manier
bestaande API’s van typeparameters te voorzien, in het bijzonder dient
men over een duidelijk procédé te beschikken om de API’s uit het Java
Collection Framework om te zetten naar versies die gebruik maken van
Java Generics.
De manier waarop dit kan uitgevoerd worden, zullen we met een aantal
praktische voorbeelden illustreren. In de praktjk blijkt dat migratie van
oude API’s niet altijd even eenvoudig te realiseren is omdat er verborgen kennis en impliciete veronderstellingen mee geassocieerd is die nu
zichtbaar moet worden gemaakt.
5. Randeffecten in de taal: voor zover dit mogelijk is, mag de introductie
van generieke types geen vergezochte en weinig voor de hand liggende
veranderingen in andere delen van de taal veroorzaken.
6. Behoud van de prestaties van bestaande code: de prestaties van programma’s die geschreven werden in de programmeertaal Java volgens de
38
pre-Generics standaard, zouden slechts in minieme mate mogen worden
beı̈nvloed door de Generics-extensie.
Als aanvaardbare richtlijn wordt een prestatieverlies van 1 à 2% in tijd
of ruimte vermeld.
7. Behoud van de basisfilosofie van de programmeertaal Java: de generieke
taaluitbreiding zou op een naadloze manier moeten aansluiten bij het
bestaande ontwerp van de taal.
8. Goede prestaties van de generieke code: code die gebruik maakt van
generieke types, mag niet significant trager zijn dan niet-generieke code,
en ook het gebruik van additionele geheugenruimte moet beperkt blijven.
Voor dit criterium worden geen precieze richtcijfers gegeven: het is echter duidelijk dat een prestatieverlies van 10% eventueel geaccepteerd kan
worden, maar een verdubbeling van rekentijd of geheugengebruik zeker
niet! Het implementatie-schema dat in dit hoofdstuk toegelicht zal worden, introduceert slechts beperkte overhead met het toevoegen van een
aantal extra methoden en casts. Bovendien zal als gevolg van dit schema
geen type-informatie at runtime gebruikt worden, hetgeen betekent dat
Generics-code sterk lijkt op Java-code die voor hetzelfde doel door een
programmeur geschreven wordt, en even efficient.
4.4
Aanvullende doelstellingen
Al de beperkingen die in de voorgaande paragraaf vermeld werden, zijn strikt
en moeten absoluut gerespecteerd worden. JSR 14 somt daarnaast nog een
aantal extra doelstellingen op. Doelstellingen zijn geen beperkingen, hun realisatie is geen echte noodzaak. Bovendien moeten de verschillende doelstellingen
tegen elkaar afgewogen worden, en is het ook mogelijk dat de doelstellingen
niet volledig in overeenstemming zijn met de opgelegde restricties.
1. Goede ondersteuning voor collecties: de standaard API’s uit het Java
Collection Framework vormen het voornaamste toepassingsgebied voor
het nieuwe mechanisme, het is dus essentieel dat hun goede werking verzekerd wordt.
39
Het is eerder merkwaardig te noemen dat dit expliciet vermeld wordt als
aanvullende doelstelling, en niet als harde vereiste, gezien het feit dat
het toevoegen van een uitgebreide containerbibliotheek aan het Java 2
platform een directe aanleiding is geweest om de taal nu uit te breiden
met generieke types, met als belangrijkste doel het typeveilig maken
van deze containers. We nemen hier dan ook aan dat deze primaire
doelstelling reeds voortvloeit uit de eerste en de vierde vereiste in de
voorgaande paragraaf.
2. Eliminatie van overbodige casts en verbeterde statische typecontrole.
Ook dit objectief verdient enige duiding. Bij de motivatie voor het indienen van JSR 14 kunnen we daarover onder andere het volgende lezen:
“The Java programming language lacks the ability to specify generic types. As a result, programs are unnecessarily hard to read and maintain,
and are more likely to fail with runtime type errors.” De runtime typefouten waarover hier gesproken wordt, zijn de instanties van de klasse
ClassCastException die geworpen worden wanneer cast-operaties falen. Deze cast-operaties willen we dan ook op de één of andere manier
elimineren om de typeveiligheid van onze programma’s te verbeteren,
tenminste op het niveau van de broncode. We zouden dit dan ook eerder een harde vereiste dan een vrijblijvende of facultatieve doelstelling
willen noemen. In het vorige hoofdstuk hebben we besproken hoe dit
met additionele syntax verwezenlijkt kan worden. Verder in dit hoofdstuk zal geı̈llustreerd worden dat achter de schermen de compiler terug
cast-operaties toevoegt aan de code, deze keer met de garantie dat deze
niet zullen falen at runtime.
3. Ondersteuning voor parameters in throws-uitdrukkingen: het zou mogelijk moeten zijn type-parameters te gebruiken om abstractie te maken
van het exceptietype.
4. Eenvoud: maak het mechanisme eenvoudig voor de gebruiker (maar niet
noodzakelijk voor de programmeurs die het systeem moeten implementeren), en verberg de complexiteit in de virtuele machine en de compiler.
Dit is een aanvulling op de derde basisvereiste. We vermelden hier al dat,
omwille van compatibliteitseisen, men getracht heeft de aanpassingen aan
40
de specificatie van de virtuele machine zo beperkt mogelijk te houden.
5. “The Principle of Least Astonishment: don’t surprise the user...”
6. Types met parameters moeten “first-class” types zijn. Hiermee bedoelen we dat de types met parameters op dezelfde manier kunnen gebruikt
worden als bestaande type-expressies. Het moet in het bijzonder mogelijk
zijn een waarde die het resultaat is van een expressie te casten naar één
van deze types, en te testen of een welbepaald object een instantie is van
een dergelijk type. Dit houdt o.a. in dat:
• instanties van types met parameters (bijvoorbeeld List)“first-class”
moeten zijn;
• type-parameters (zoals T) “first-class” moeten zijn (zodat ook List
dit wordt);
• het mechanisme van reflectie generieke typedefinities moet herkennen, en accurate informatie moet kunnen geven over formele typeparameters, en dit in klassen, interfaces en methoden;
Deze doelstelling is niet volledig gerealiseerd: de uiteindelijke implementatie beperkt het vrij gebruik van typeparameters in zekere mate. Reflectie zal wel informatie kunnen verschaffen over formele typeparameters en
hun bounds, maar niet over de actuele binding tussen typevariabelen en
actuele argumenten.
7. Minimaal ontwerprisico: de impact van het ontwerp op bruikbaarheid,
compatibiliteit, prestaties en geschiktheid voor implementatie moeten goed
begrepen worden.
Voor de de volledigheid vermelden we tenslotte nog uitdrukkelijk dat niet
geëist wordt dat:
1. voorzien wordt in binaire neerwaartse compatibiliteit;
De class-bestanden die gegenereerd worden uit Java-bronbestanden met
Generics-syntax, zijn dus niet bestemd om uit te voeren op vroegere
versies van het J2SE platform, onafhankelijk van het feit of deze versie
41
al dan niet Generics ondersteunt. De implementatie, die in de volgende paragraaf toegelicht wordt, zou kunnen suggereren dat men dit toch
op eenvoudige manier zou kunnen realiseren. Om een aantal andere
taaluitbreidingen te kunnen implementeren – we denken hier bijvoorbeeld aan metadata – heeft men in de virtuele machine en het classbestandsformaat eveneens enkele aanpassingen moeten aanbrengen die
niet achterwaarts compatibel zijn, hoewel berichten op diverse nieuwsgroepen en fora erop wijzen dat tools die de omzetting kunnen realiseren
in de toekomst toch beschikbaar zouden kunnen zijn.
2. ondersteuning aanwezig is voor het gebruikt van primitieve types als
type-argument: het onderscheid tussen primitieve types en de referentietypes is een kenmerkende eigenschap van de programmeertaal Java, en
deze eigenschap moet bewaard worden.
We hebben al eerder vermeld dat dit dan ook niet mogelijk is in Java
Generics.
4.5
Onder de motorkap: erasure
Een implementatiemethode die op een natuurlijke manier aan vele van de
gestelde eisen voldoet, is de techniek van erasure. Dit mechanisme kan men
vanuit semantisch standpunt (bijna) bekijken als een source-to-source vertaling
van de code mét extra type-informatie naar een versie waarin alle generieke
type-informatie is uitgeveegd (“erased”). De nieuwe taalconstructies worden
omgezet naar constructies in de niet-uitgebreide taal die zich bij het uitvoeren
equivalent gedragen.
4.5.1
Praktische werkwijze
Onder deze transformatie wordt elk type vervangen door zijn erasure, het
resultaat is een ruw type:
• de erasure van een type met parameters wordt verkregen door de typeparameters tussen < en > te verwijderen, dus List<A> wordt omgezet in
42
List, hetzelfde geldt voor instanties van types met parameters waarbij
het type-argument wordt weggelaten;
• de erasure van een type zonder parameters (een ruw type) is het type
zelf, dus Byte blijft gewoon Byte;
• de erasure van een typeparameter is de erasure van de eerste upper
bound, of Object wanneer geen bovengrens is opgegeven;
Ook in methoden worden typeparameters weggeveegd. Tevens worden door
de compiler cast-operaties toegevoegd op plaatsen waar na het uitvoeren van
het erasure-procédé de code niet meer typecorrect is, in het bijzonder wanneer
het resultaattype van een methode een typevariabele is, of bij toegang tot een
klasse-attribuut waarvan het type een typevariabele is. De cast is gebaseerd
op de erasure van de typeparameter in kwestie. Het verschil met de casts die
we als programmeur zelf zouden toevoegen in code conform de pre-Generics
standaard, is dat gegarandeerd kan worden dat elke cast die de compiler invoegt nooit zal falen bij het uitvoeren van de code3 . Het implementeren van
methoden uit interfaces, het in subklassen overschrijven van methoden uit
superklassen, en het toelaten van covariante resultaattypes veroorzaakt tenslotte ook nog speciale gevallen die expliciet behandeld moeten worden. Het
resultaat lijkt vrij sterk op wat we overeenkomstig de pre-Generics standaard
zouden schrijven om het “generic idiom” op een zo degelijk mogelijk manier
te implementeren, wanneer we een aantal uitzonderingsgevallen even buiten
beschouwing laten.
Het voorbeeld in listing 4.1 kan dit proces wellicht het best aanschouwelijk
illustreren. We zien een interface Comparable<A> voor objecten die kunnen
vergeleken worden met andere objecten van type A (of subtypes ervan). De
klasse Byte implementeert deze interface met zichzelf als argument voor de
typeparameter, bijgevolg zijn instanties van Byte onderling vergelijkbaar. De
klasse Collections definieert een methode max die het grootste element uit
een niet-ledige collectie vergelijkbare elementen teruggeeft. Zoals geı̈llustreerd
3
Caveat: deze garantie verdwijnt wanneer de compiler een “unchecked warning” genereert. Dit kan in het bijzonder optreden wanneer legacy code en code met typeparameters
samen gebruikt worden. Compatibiliteitsvereisten kunnen aanleiding geven tot constructies
waarvan de correctheid niet kan verzekerd worden.
43
werd in het vorige hoofdstuk, ziet deze methode er in werkelijkheid nog anders uit. We komen op dit punt verder nog terug, we redeneren hier op de
vereenvoudigde versie.
De erasure-vertaling van de code in listing 4.1 geeft aanleiding tot de versie
die in listing 4.2 wordt weergegeven. Wat we bekomen, is code die we ook
zouden kunnen neerschrijven in de oude versie van de taal. We willen wel
benadrukken dat dit enkel een conceptuele voorstelling van de werkelijke gang
van zaken is: in realiteit wordt de vertaling uitgevoerd bij het genereren van
bytecode, de programmeur krijgt de omzetting niet te zien, behalve wanneer hij
de door de compiler aangemaakte class-bestanden zou decompileren uiteraard.
Wanneer we het origineel en het resultaat eens overlopen, kunnen we meteen een aantal vaststellingen doen.
• Eerst en vooral blijkt dat de typeparameter A in het type Comparable
inderdaad verdwenen is. Er was echter geen bound voor deze typeparameter opgegeven, dus het optreden van A in de declaratie van de methode
compareTo wordt vervangen door de impliciete bovengrens Object. De
resulterende methode heeft de signatuur die men ook aantreft wanneer
men de bestaande API’s van het Java Collection Framework bekijkt.
• De klasse Byte implementeert de interface Comparable<Byte>, waarbij de typeparameter A in de generieke interface Comparable gebonden
wordt aan het actuele type-argument Byte. De klasse bevat dan ook een
implementatie voor de methode compareTo uit deze interface, met als
argument een Byte-referentie.
Na erasure wordt de interface Comparable<Byte> afgebeeld op de interface Comparable door het type-argument Byte te laten vallen. Het
probleem dat hierbij optreedt, is dat de erasure van compareTo dan een
methode oplevert die als argument een Object heeft, terwijl de erasure
van de gelijknamige methode in de klasse Byte een versie oplevert die
als argument een Byte heeft. Omdat het correct implementeren van methoden uit interfaces vereist dat de signaturen van de declaratie in de
interface en de implementatie overeenstemmen, moet een extra “bridge
method ” toegevoegd worden aan de vertaling van de klasse Byte. Deze
44
Listing 4.1: Klassen en methoden met typeparameters voor erasure
interface Comparable<A> {
public int compareTo(A that);
}
class Byte implements Comparable<Byte> {
private byte value;
public Byte(byte value) {
this.value = value;
}
public byte byteValue() {
return value;
}
public int compareTo(Byte that) {
return this.value - that.value;
}
}
class Collections {
public static <A implements Comparable<A>>
A max(Collection<A> xs) {
Iterator<A> xi = xs.iterator();
A w = xi.next();
while (xi.hasNext()) {
A x = xi.next();
if (w.compareTo(x) < 0) w = x;
}
return w;
}
}
45
Listing 4.2: Klassen en methoden met typeparameters na erasure
interface Comparable {
public int compareTo(Object that);
}
class Byte implements Comparable {
private byte value;
public Byte(byte value) {
this.value = value;
}
public byte byteValue() {
return value;
}
public int compareTo(Byte that) {
return this.value - that.value;
}
public int compareTo(Object that) {
return this.compareTo((Byte)that);
}
}
class Collections {
public static Comparable max(Collection xs) {
Iterator xi = xs.iterator();
Comparable w = (Comparable)xi.next();
while (xi.hasNext()) {
Comparable x = (Comparable)xi.next();
if (w.compareTo(x) < 0) w = x;
}
return w;
}
}
46
bridge heeft wel de correcte signatuur, en doet niet meer dan elke oproep, na het uitvoeren van een cast van het Object-argument naar type
Byte, doorgeven aan de andere methode. Omwille van het mechanisme
van function overloading kunnen beide methoden naast elkaar blijven
bestaan in dezelfde klasse.
In het algemeen is een dergelijke bridge method nodig wanneer een klasse
een typevariabele in een superklasse of interface instantieert. Bemerk
dat deze techniek ook op broncodeniveau gebruikt werd in de bestaande
bibliotheken, zoals listing 2.2 aangeeft.
• Tenslotte zien we dat de typevariabele A in de methode max een bovengrens Comparable<A> heeft. A wordt dus afgebeeld op de erasure van
deze bovengrens, Comparable. Verder werden bij de oproepen van de
methode next op de iterator cast-operaties ingevoegd omdat het resultaattype van deze methode een typevariabele is.
Dit eenvoudige voorbeeld illustreert uitstekend de interne werking van Java
Generics: code met extra type-informatie wordt via een aantal idiomen eenvoudigweg vertaald naar code typeparameters. Bij het uitvoeren krijgen we
dan ook precies het gedrag zoals we dat van deze code verwachten. Beschouw
bijvoorbeeld het volgende fragment:
Listing 4.3: Interactie met ruwe types en typecorrectheid
public String loophole(Integer x) {
List<String> ys = new LinkedList<String>();
List xs = ys;
xs.add(x); // compile-time unchecked warning
return ys.iterator().next();
}
We hebben een instantie van het ruwe type List gebruikt als alias voor
een List met String-objecten, als voorbeeld van het mengen van generieke
en ruwe types. We komen verder nog uitgebreid terug op de interactie tussen
Generics en legacy code. Wat hier van belang is, is dat de compiler deze code
aanvaardt maar een “unchecked warning” genereert omdat de correctheid van
de typering niet kan gegarandeerd worden. Om de interactie tussen oude en
47
nieuwe code mogelijk te maken, moet men inderdaad in een aantal gevallen de
keuze maken tussen “unsoundness” en “failure”. Deze waarschuwing is in dit
geval volkomen terecht: we voegen immers een Integer toe aan de container
en proberen dit object daarna op te halen als een String. Wanneer we de
waarschuwing negeren, gedraagt deze code zich bij het uitvoeren zoals zijn
erasure:
Listing 4.4: Interactie met ruwe types en typecorrectheid bij uitvoering
public String loophole(Integer x) {
List ys = new LinkedList();
List xs = ys;
xs.add(x);
return (String)ys.iterator().next(); // run time error
}
Bemerk de cast-operatie die de compiler in het return-statement heeft toegevoegd. Wanneer we het element uit de lijst ophalen en trachten het te behandelen als een instantie van de klasse String, zullen we een ClassCastException
krijgen. De erasure, die de Generics-code naar een versie zonder typeparameters omzet, zorgt er dus voor dat de typeveiligheid en de integriteit van de Java
virtuele machine nooit op het spel worden gezet, ook niet in de aanwezigheid
van “unchecked warnings”.
4.5.2
Een brug te ver
In de meeste gevallen is het toevoegen van brugmethoden relatief eenvoudig.
Er doet zich echter een uitzonderingsgeval voor wanneer we een methode overschrijven of implementeren die wel een typeparameter heeft, maar waarbij
deze typeparameter enkel optreedt in het resultaattype en niet in de lijst
methode-argumenten. Deze situatie doet zich bijvoorbeeld voor in klassen
die de Iterator-interface implementeren.
Hier geeft de methode next van de klasse Interval een Integer terug
en komt dus overeen met de particuliere instantiëring van de typeparameter
Iterator<Integer>. Zoals verwacht moet in de erasure-vertaling een “bridge
method” toegevoegd worden aan de klasse Interval.
48
Listing 4.5: Bijzondere gevallen bij het invoegen van brugmethoden
interface Iterator<A> {
public A next();
public boolean hasNext();
}
class Interval implements Iterator<Integer> {
private int i, n;
public Interval(int l, int u) {
i = l; n = u;
}
public boolean hasNext() {
return (i <= n);
}
public Integer next() {
return new Integer(i++);
}
}
Het invoeren van een brugmethode zou in dit geval aanleiding geven tot
illegale Java-broncode, aangezien beide versies van next niet van elkaar onderscheiden kunnen worden omdat ze een identieke signatuur hebben. Daarom
maakt de getoonde code onze intentie duidelijk door het toevoegen van de
suffixen /*1*/ en /*2*/ in de declaraties en de oproep van deze methoden.
Gelukkig kunnen beide versies wel van elkaar onderscheiden worden door de
Java Virtuele Machine, die methoden identificeert op basis van een descriptor
die ook het resultaattype van de methode bevat (zie paragrafen 4.3.3, 4.6 en
7.7 van de Java Virtual Machine Specification [11]). Dit is één van de zeldzame
gevallen waarin de vertaling van code met typeparameters naar code zonder
type-informatie gedefinieerd dient te worden in termen van een rechtstreekse
omzetting naar bytecode.
49
Listing 4.6: Bijzondere gevallen bij het invoegen van brugmethoden na erasure
interface Iterator {
public boolean hasNext();
public Object next();
}
class Interval implements Iterator {
private int i, n;
public Interval(int l, int u) {
i = l; n = u;
}
public boolean hasNext() {
return (i <= n);
}
public Integer next/*1*/() {
return new Integer(i++);
}
// bridge
public Object next/*2*/() {
return next/*1*/();
}
}
50
4.5.3
Vertalingsregels
De beschrijving van het erasure-mechanisme hebben we tot hiertoe vrij informeel behandeld. Daarin gaan we nu verandering brengen. Hierbij zullen nog
een aantal eigenschappen aan bod komen die nog niet eerder vermeld werden.
De vertaling van types
Als onderdeel van het vertalingsproces zal de compiler elk type met parameter
afbeelden op zijn erasure. Deze erasure is een mapping van types die mogelijk
uitgeschreven zijn in termen van één of meerdere typevariabelen naar types
die geen dergelijke variabelen meer bevatten. We noteren de erasure van type
T als |T |. De mapping wordt dan gedefineerd als volgt:
• De erasure van het geparametrizeerde type T hT1 , . . . , Tn i is |T |.
• De erasure van een genest type T.C is |T |.C.
• De erasure van een arraytype T [ ] is |T |[ ].
• De erasure van een typevariabele is zijn meest linkse grens (bovengrens),
of Object.
• De erasure van elk ander type is dit type zelf.
De vertaling van methoden
Iedere methode T m(T1 , . . . , Tn ) throws S1 , . . . , Sm wordt omgezet naar
een methode met dezelfde naam waarvan het type van de terugkeerwaarde,
de argumenttypes en types van geworpen excepties de erasure zijn van de
corresponderende types in de oorspronkelijke methode. Bovendien, wanneer
een methode m in een klasse of interface C wordt overgeërfd in een klasse
D, kan het nodig zijn een extra brugmethode toe te voegen in D. De regels
hiervoor luiden als volgt:
• Wanneer C.m rechtstreeks overschreven wordt door een methode D.m
in D, en de erasure van het resultaattype of de argumenttypes van D.m
51
verschillen van de erasure van de corresponderende types in C.m, dan
moet een “bridge method” worden gegenereerd.
• Een “bridge method” moet ook toegevoegd worden wanneer C.m niet
meteen overschreven wordt in D, behalve wanneer C.m abstract is.
Het type van de brugmethode is het erasuretype van de methode in de
basisklasse of interface C. In de body van deze methode worden alle argumenten gecast naar hun type-erasures in de uitbreidende klasse D, waarna de
oproep gewoon doorgestuurd wordt naar de overschrijvende methode D.m (indien deze bestaat) of naar de originele methode C.m (in het andere geval). Er
is geen speciale behandeling nodig voor de verandering in erasure van geworpen types, de throws-clauses worden trouwens ook niet gecontroleerd door de
virtuele machine bij het inladen van bytecode of bij het uitvoeren ervan (zie
paragraaf 4.9.5 van [11]).
Deze code wordt na erasure omgezet naar de code in listing 4.8.
Het resultaat van het implementeren van een methode uit een generieke
interface is eerder al aan bod gekomen. Hier zien we wat er gebeurt met methoden uit een superklasse die al dan niet overschreven worden in een afgeleide
klasse, en hoe er wordt omgegaan met abstracte methoden. Bemerk dat het
voorzien van een brugmethode in D voor een niet-geı̈mplementeerde abstracte methode uit de basisklasse C zinloos is, deze methode heeft immers geen
implementatie in de klasse C.
Zoals al eerder besproken werd, kan dit vertalingsschema binnen dezelfde
klasse methoden produceren met dezelfde naam en argumenttypes, maar verschillende resultaattypes. Een compiler zou dergelijke code verwerpen omwille
van de meervoudige declaratie. De representatie van deze klasse in bytecode is
echter legaal, omdat de bytecode altijd verwijst naar een methode door middel
van de volledige signatuur en daardoor het onderscheid kan maken tussen de
verschillende versies.
Dezelfde techniek wordt overigens gebruikt voor het overschrijven van methoden waarbij de resultaattypes covariant zijn4 . Een methode die een metho4
Covariante resultaattypes werden reeds in overweging genomen in de vroege ontwikkelingsstadia van Java, maar werden uiteindelijk dan toch maar uit de standaard gehaald.
52
Listing 4.7: Mogelijke gevallen voor brugmethoden
abstract class C<A> {
abstract A id1(A x);
abstract A id2(A x);
A id3(A x) {
return x;
}
A id4(A x) {
return x;
}
}
abstract class D extends C<String> {
String id1(String x) {
return x;
}
String id3(String x) {
return x;
}
}
53
Listing 4.8: Mogelijke gevallen voor brugmethoden na erasure
abstract class C {
abstract Object id1(Object x);
abstract Object id2(Object x);
Object id3(Object x) {
return x;
}
Object id4(Object x) {
return x;
}
}
abstract class D extends C {
String id1(String x) {
return x;
}
Object id1(Object x) {
return id((String)x);
}
String id3(String x) {
return x;
}
Object id3(Object x) {
return id3((String)x);
}
Object id4(Object x) {
return super.id4((String)x);
}
}
54
Listing 4.9: Covariante resultaattypes
class C implements Cloneable {
public C copy() {
return (C)this.clone();
}
}
class D extends C implements Cloneable {
public D copy() {
return (D)this.clone();
}
}
de uit een superklasse overschrijft, mag dus een resultaattype hebben dat een
subtype is van het resultaattype van de overschreven methode, waar er vroeger
een exacte overeenkomst moest zijn. Het volgende (niet-generieke) voorbeeld
in listing 4.9 illustreert dit nieuwe principe.
Ook hier hebben we onze intentie aangegeven door de methoden in de klasse
D te decoreren met de suffixen /*1*/ en /*2*/.
Omdat de vertaling de typevariabelen uitwist, is het mogelijk dat verschillende methoden met identieke naam maar verschillende typevariabelen in de
lijst met argumenten afgebeeld worden op methoden met dezelfde erasure. Ook
deze situatie verdient bijzondere aandacht. De compiler zal een foutmelding
genereren wanneer een typedeclaratie T een methode m1 bevat en er eveneens
een methode m2 bestaat in T of in een supertype van T zodat al de volgende
condities vervuld zijn:
• m1 en m2 hebben dezelfde naam;
• m2 is zichtbaar in T ;
• m1 en m2 hebben een verschillende signatuur;
• m1 of een methode die door m1 overschreven wordt (rechtstreeks of onrechtstreeks) heeft dezelfde erasure als m2 of een methode die door m2
overschreven wordt (direct of indirect).
55
Listing 4.10: Covariante resultaattypes na erasure
class C implements Cloneable {
public C copy() {
return (C)this.clone();
}
}
class D extends C implements Cloneable {
public D copy/*1*/() {
return (D)this.clone();
}
public C copy/*2*/() {
return this.copy/*1*/();
}
}
Een formele benadering van deze situatie valt buiten de scope van deze
thesisverhandeling, maar kan in [4] teruggevonden worden. Deze voorwaarden
sluiten ook de volgende code uit.
Listing 4.11: Erasure en ongeldige methodedeclaraties – voorbeeld 1
class D<A,B> {
A id(A a) {
return A;
}
B id(B b) {
return B;
}
}
De compiler rapporteert terecht een conflict. Dit is uiteraard een zeer
eenvoudig voorbeeld. Ook in de volgende situaties treden conflicten op.
De code in listing 4.12 is ongeldig omdat D.id(Object) een methode is in
D en C<String>.id(String) gedeclareerd is in een supertype van D en:
• beide methoden dezelfde naam id hebben;
56
Listing 4.12: Erasure en ongeldige methodedeclaraties – voorbeeld 2
class C<A> {
A id (A x) { ... }
}
class D extends C<String> {
Object id(Object x) { ... }
}
Listing 4.13: Erasure en ongeldige methodedeclaraties – voorbeeld 3
class C<A> {
A id (A x) { ... }
}
interface I<A> {
A id(A x);
}
class D extends C<String> implements I<Integer> {
String id(String x) { ... }
Integer id(Integer x) { ... }
}
57
• C<String>.id(String) zichtbaar is in D;
• beide methoden een verschillende signatuur hebben;
• beide methoden dezelfde erasure hebben (in tegenstelling tot wat in sommige versies van de draft specificatie vermeld staat).
Evenzo is listing 4.13 foutief omdat D.id(String) een member is van D,
D.id(Integer) gedeclareerd is in D en:
• beide methoden dezelfde naam id hebben;
• beide methoden een verschillende signatuur hebben;
• D.id(Integer) zichtbaar is in D;
• D.id(String) de methode C<String>.id(String) overschrijft en tevens D.id(Integer) de methode I.id(Integer) overschrijft en de twee
overschreven methoden dezelfde erasure hebben.
Ook situaties waarin een klasse (rechtstreeks of onrechtstreeks via overerving) twee keer dezelfde interface implementeert voor een ander type-argument,
worden door de compiler afgevlagd.
De vertaling van expressies
Expressies worden vertaald in overeenstemming met de bestaande Java Language Specification, met uitzondering van extra casts die toegevoegd worden.
Deze cast-operaties zijn noodzakelijk in de volgende twee situaties:
• bij toegang tot een attribuut van een klasse of object waarbij het type
van het attribuut een typevariabele is;
• bij het oproepen van een methode, waarbij het type van de terugkeerwaarde een typeparameter is.
Bijvoorbeeld:
58
Listing 4.14: De noodzaak voor het invoegen van casts
public class Cell<A> {
public A value;
public A getValue();
}
String f(Cell<String> cell) {
return cell.value;
}
String x = cell.getValue();
Omdat de erasure van cell.value gelijk is aan Object maar de functie f
een String teruggeeft, dient het return-statement aangepast te worden. Ook
de oproep van de getValue-methode dient gedecoreerd te worden met een cast.
Listing 4.15: Erasure en het toevoegen van casts
String f{Cell cell) {
return (String)cell.value;
}
String x = (String)cell.getValue();
Aanpassingen van het classfile bestandsformaat
Het erasure-procédé wist weliswaar in eerste instantie alle typeparameters uit
in de gegenereerde bytecode, maar toch moet deze generieke informatie in de
class-bestanden terechtkomen. Dit is nodig voor debugging en reflectie, maar
ook om de compiler in staat te stellen typecontrole uit te voeren op code zonder dat van alle klassen, interfaces en methoden waarop gesteund wordt de
broncode beschikbaar moet zijn. De class-bestanden moeten gegevens over
typevariabelen en grenzen dus afzonderlijk meedragen, en dit bovendien op
een manier die achterwaarts compatibel is. Daartoe werd een nieuw attribuut
Signature geı̈ntroduceerd in de bytecode. Ook de “bridge methods”, die niet
in de broncode aanwezig zijn maar door de compiler worden toegevoegd, worden afzonderlijk gemarkeerd in de bytecode. Deze voorziening is er enkel voor
59
de compiler, de extra attributen worden genegeerd door de class loader. At
runtime wordt dus geen generieke type-informatie gebruikt door de virtuele machine, de standaard Java bytecode die resulteert uit het erasure-proces
wordt uitgevoerd.
Deze bespreking zou kunnen suggereren dat het op bijna triviale wijze mogelijk is bytecode voor oudere virtuele machines te genereren uit code met
typeparameters. Het zou volstaan na het uitvoeren van de typecontrole door
de compiler en het toepassen van erasure het Signature-attribuut met de generieke informatie als het ware weg te knippen. Dit zou ons toelaten gebruik
te maken van de Java Generics om op een veiligere manier broncode te schrijven, maar toch code te genereren voor een oudere virtuele machine, omdat
we als software-ontwikkelaar weinig zeggenschap over de versie van de virtuele machine die door klanten en gebruikers gedraaid wordt. In de realiteit is
dit echter minder vanzelfsprekend dan op het eerste zicht zou kunnen lijken.
Omwille van een aantal andere taalvernieuwingen – we denken bijvoorbeeld
aan metadata – heeft men immers nog een aantal andere wijzigingen in het
class-bestandsformaat moeten doorvoeren. Er zijn in onze ogen echter geen restricties die een dergelijke werkwijze a priori volledig uitsluiten, ook al zal men
in dat geval onvermijdelijk een aantal toegevingen moeten doen. Omdat de
bytecode ontdaan wordt van de Signature-attributen, kunnen compilers bij
het verder uitbreiden van de software alleen betrouwbare typecontrole uitvoeren wanneer de broncode beschikbaar blijft, anders wordt men geconfronteerd
met alle nadelen die eigen zijn aan de interactie van generieke code met legacy
code.
4.6
De gevolgen van erasure: voor- en nadelen
We hebben gezien dat erasure broncode waarin extra type-informatie is verweven omzet naar bytecode waarin de type-informatie afzonderlijk is opgeslagen,
en dat bij het uitvoeren de type-info niet gebruikt wordt. Deze info kan dus ook
niet zorgen voor extra runtime overhead. In de bytecode is alleen extra ruimte vereist voor het markeren van brugmethoden en de Signature-attributen,
die de gegevens over formele typeparameters en bounds onder de vorm van
60
een string opslaan. De invloed van deze toevoegingen op de omvang van de
bytecode blijft doorgaans beperkt en zorgt zeker niet voor een verdubbeling
in omvang. Bovendien geeft elke generieke typedeclaratie slechts aanleiding
tot één enkel class-bestand. Ook bij uitvoering van de code wordt slechts één
enkele instantie ingeladen. We kunnen dus stellen dat de laatste ontwerpdoelstelling wel degelijk gerealiseerd werd. Ook de compatibiliteit met legacy code
wordt door erasure optimaal verzekerd.
Het nieuwe mechanisme is dan ook voornamelijk een taak van de compiler,
door de erasure-aanpak konden de aanpassingen in de JVM tot het minimum
beperkt blijven. Elke generieke klasse geeft na compilatie aanleiding tot één
enkel class-bestand, in tegenstelling tot de aanpak die in C++ wordt gebruikt,
waarbij elke particuliere set van argumenten voor de typeparameters van een
generiek type in principe aanleiding geeft tot het genereren van specifieke code.
Het gevaar dat “code bloat” optreedt, is dus veel minder een probleem bij Java
Generics. Anderzijds zijn er wel een aantal specifieke gevolgen.
Een generieke klasse wordt gedeeld door al zijn invocaties
Het volgende codefragmentje moet dus succesvol compileren en bij uitvoering
true als resultaat geven5 , dit omdat alle instanties van dezelfde generieke
klasse dezelfde runtime klasse hebben, ongeacht de actuele argumenten voor
de typeparameters.
Listing 4.16: Verschillende instanties van hetzelfde generieke type delen dezelfde
runtime klasse
List<String> l1 = new ArrayList<String>();
List<Integer> l2 = new ArrayList<Integer>();
System.out.println(l1.getClass() == l2.getClass());
Wat een klasse dus generiek maakt, is dat de klasse hetzelfde gedrag vertoont voor veel verschillende type-argumenten: dezelfde klasse is als het ware
een representatie voor vele verschillende types.
5
Door een bug in de eerste beta-release van de J2SE 1.5.0 SDK compileert deze code
momenteel niet.
61
Een consequentie hiervan is dat ook statische variabelen en methoden gedeeld worden door alle invocaties van dezeflde generieke klasse. Dit is de reden waarom het niet toegelaten wordt naar typevariabelen van een omhullende
klasse of interface te verwijzen in een statische methode of in de declaratie en
initializering van een statische variabele. Indien dit wel toegelaten was, zou de
statische variabele vanuit verschillende instanties gezien meerdere onderscheiden types hebben. Het heeft dus ook geen zin bij de toegang tot een statische
variabele van een klasse de klassenaam van type-argumenten te voorzien.
Typevariabelen en new-expressies
Een new-expressie waarin het type een typevariabele is, is illegaal. Dus, new
A() is illegaal wanneer A een typevariabele is. Zulke expressies kunnen niet
uitgevoerd worden omdat typeparameters niet beschikbaar zijn at runtime. Er
is dus geen manier om er achter te komen welk type bedoeld wordt, laat staan
dat geweten is dat dit type een publiek toegankelijke defaultconstructor zou
hebben. Als alternatief hiervoor kan men at runtime een object aanspreken
met een gepaste methode voor het aanmaken van nieuwe instanties (ook wel
“factory object” genoemd).
Casts en instanceof
Een ander gevolg van het feit dat een generieke klasse wordt gedeeld door al
zijn instanties, is dat het in het algemeen ook zinloos is om at runtime van een
welbepaalde instantie op te vragen of het een instantie is van een particuliere
invocatie van een generiek type.
Listing 4.17: Erasure en instanceof
Collection cs = new ArrayList<String>();
if (cs instanceof Collection<String>) { ... }
Evenzo voor casts. Typevariabelen bestaan niet at runtime. Dit betekent
ook dat we ze niet in alle omstandigheden op betrouwbare manier kunnen
gebruiken in casts omdat het runtime systeem dergelijke casts niet voor ons
kan controleren. Slechts in een beperkt aantal gevallen zal de compiler de
62
statische correctheid van een cast kunnen nagaan. Zo zal de volgende code,
naast één foutmelding, ook “unchecked warnings” opleveren.
Listing 4.18: Erasure en casts
class Dictionary<K,V> extends Object { ... }
class Hashtable<K,V> extends Dictionary<K,V> { ... }
Dictionary<String,Integer> d;
Object o;
// legaal, de cast naar Hashtable kan gecontroleerd worden at
// runtime en compile time constraints garanderen dat
//typeparameters matchen
Hashtable<String,Integer> h1 = (Hashtable<String,Integer>)d;
// legaal, omzetting tussen raw types
Hashtable h2 = (Hashtable)o;
// illegaal, compile time error
Hashtable<Float,Double> h3 = (Hashtable<Float,Double>)d;
// unchecked warning
Hashtable<String,Integer> h4 = (Hashtable<String,Integer>)o;
Constructies zoals in de laatste lijn worden niettemin toch toegelaten omdat deze bijvoorbeeld kunnen optreden in de implementatie van de methode
equals.
Typevariabelen en arrays
Het elementtype van een array mag geen instantie van een generiek type zijn,
behalve wanneer het een (onbegrensde) wildcardtype betreft. Deze beperking
is nodig om de volgende situaties te vermijden:
Indien arrays van instanties van generieke types toegelaten zouden worden,
zou de bovenstaande code zonder foutmelding compileren maar niettemin falen
bij het uitvoeren. Het voorzien van typeveiligheid was echter de primaire
doelstelling bij het invoeren van de Java Generics. In het bijzonder is de
nieuwe taaluitbreiding zodanig ontworpen dat gegarandeerd kan worden dat,
63
Listing 4.19: Typevariabelen en arrays
List<String>[ ] lsa = new List<String>[10]; // niet echt toegelaten
Object o = lsa;
Object[ ] oa = (Object[ ]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // problematisch, maar komt door de runtime store check
String s = lsa[1].get(0); // ClassCastException at run time
als een volledig geheel code gecompileerd kan worden met de compilervlag
-source 1.5 zonder dat “unchecked warnings” gerapporteerd worden, deze
software tyeveilig is. Wanneer we wildcars gebruiken in dit voorbeeld, vervalt
de restrictie.
Listing 4.20: Wildcards en arrays
List<?>[ ] lsa = new List<?>[10]; // OK, niet-begrensd wildcardtype
Object o = lsa;
Object[ ] oa = (Object[ ]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // correct
String s = (String)lsa[1].get(0);
Ook deze code zal falen met een runtime error, maar hier is de cast expliciet
omdat de methode get die gedefinieerd is op het type List<?> een referentie
van het type Object teruggeeft.
Een andere gelijkaardige beperking is dat we wel arrays kunnen declareren
waarvan het elementtype een typevariabele is, maar dat we geen dergelijke array kunnen aanmaken. Een poging om dit toch te doen levert een foutmelding
van de compiler op.
Listing 4.21: Typevariabelen en het aanmaken van arrays
<T> T[ ] makeArray(T t) { return new T[100]; } // error
Het is aan te raden in deze situaties eerder een instantie van de klasse
Vector of ArrayList te gebruiken, of een array van het gepaste type als model door te geven at runtime. Wie [9] erop naleest, zal zien dat dit in GJ wel
64
toegelaten wordt (weliswaar met een “unchecked warning”), onder andere omdat gesteld wordt dat een constructie om arrays van een typevariabele nodig is
voor de interne implementatie van de klasse Vector zelf. Dit is helemaal waar:
het volstaat dat deze interne array gedeclarareerd wordt als Object[ ] en dat
de methoden om elementen toe te voegen aan deze array uitgeschreven zijn in
termen van de typevariabele om een typeveilige container te bekomen. Omdat typeveiligheid prioritair was in Java Generics, wordt dergelijke constructie
afgevlagd door de compiler.
Nogmaals, typevariabelen bestaan niet meer at runtime, er is dan ook geen
manier om te bepalen wat het actuele type dan wel zou zijn. Ook constructies
zoals A a = new A(); zijn volstrekt zinloos: hoe zouden we kunnen bepalen
wat de actuele binding van typevariabele A is, laat staan dat dit type een dergelijke toegankelijke constructor heeft. Een manier om dergelijke beperkingen
te omzeilen, is het gebruik van klasseliteralen als runtime typetokens.
Excepties met typeparameters
Invocaties van exceptietypes met parameters worden toegelaten in throwsclauses, maar niet in catch-clauses om onderscheid te maken tussen verschillende invocaties. Nogmaals, bij uitvoering is geen generieke type-informatie
beschikbaar, er is dan ook geen manier om dit onderscheid te maken.
4.7
Interactie met legacy code
Tot nu toe hebben we in al onze voorbeelden een ideale wereld verondersteld,
waarin iedereen ofwel de oude taalversie gebruikt ofwel de laatste versie met
ondersteuning voor Generics. Dit is helaas niet het geval in de realiteit. Miljoenen lijnen code werden conform de vorige standaard geschreven, en die kunnen
uiteraard niet allemaal op korte tijd geconverteerd worden, als we dat al zouden willen. In deze paragraaf zullen we ons dan ook twee vragen stellen: hoe
kunnen we generieke code gebruiken binnen een omgeving van legacy code, en
hoe moeten we het omgekeerde realiseren?
65
Listing 4.22: Een legacy package
package legacy;
public interface Part { ... }
public class Inventory {
// stilzwijgende veronderstelling: parts bevat alleen
// instanties van types die de interface Part implementeren
public static void
addAssembly(String name, Collection parts) { ... }
public static Assembly getAssembly(String name) { ... }
}
public interface Assembly {
Collection getParts(); // geeft een collectie met Parts terug
}
Het antwoord is migratiecompatibiliteit. De implementatie met behulp
van een erasure-mechanisme werd in de eerste plaats gekozen om dit toe te
laten. In dat opzicht vormen ruwe types de brug tussen beide versies van de
taalstandaard.
4.7.1
Het gebruik van legacy code in Generics-code
Beschouw bijvoorbeeld de code in listing 4.22, die een deel van een oude bibliotheek voorstelt.
Veronderstel dat we nu nieuwe code zouden willen schrijven die gebruik
maakt van dit package legacy. Het zou nuttig zijn als we hierbij de verzekering zouden hebben dat de methode addAssembly altijd met de correcte
argumenten wordt opgeroepen en dat de collectie die wordt doorgegeven inderdaad alleen instanties van Part bevat. Dit kunnen we uiteraard met het
gebruik van Generics realiseren.
Bij het oproepen van addAssembly wordt als tweede argument een instantie
van het ruwe type Collection verwacht. Het actuele argument dat in 4.23
wordt opgegeven, is Collection<Part>. Dit kan men laten werken door het
66
Listing 4.23: Generics en legacy code
import legacy.*;
public class Blade implements Part { ... }
public class Main {
public static void main(String[ ] args) {
Collection<Part> c = new ArrayList<Part>();
c.add(new Blade()) ;
Inventory.addAssembly(thingee, c);
Collection<Part> k
= Inventory.getAssembly(thingee).getParts();
}
}
type Collection op te vatten als een collectie met onbepaald elementtype,
net zoals Collection<?>.
In dat geval zou de oproep naar getParts niet mogen werken in listing 4.23.
Deze methode geeft immers een referentie naar een instantie van Collection
terug, en deze referentie proberen we te assigneren aan een variabele k die
als type Collection<Part> heeft. Als het resultaat van deze oproep een
Collection<?> is, dan zou deze assignatie in theorie een foutmelding van
de compiler moeten opleveren. Om toe te laten dat legacy code op een flexibele manier gecombineerd wordt met generieke code, wordt de toekenning in de
praktijk aanvaardt, maar de compiler genereert een unchecked warning omdat
de correctheid van het statement niet kan gegarandeerd worden: er is geen
algemene manier om na te gaan of wat teruggegeven wordt inderdaad een collectie van Part-instanties is. Het type dat gebruikt wordt, is Collection, en
in een instantie daarvan kunnen alle soorten objecten opgenomen worden. Het
is aan de gebruiker om na te gaan of de assignatie correct is.
Ruwe types lijken dus sterk op wildcardtypes, met dat verschil dat ze niet
even streng gecontroleerd worden. Dit is het resultaat van het afwegen van
ontwerpbeslissingen om toe te laten dat Generics interageren met legacy code.
Het oproepen van legacy code vanuit generieke code is dus inherent gevaarlijk,
omdat alle garanties die Generics bieden waardeloos kunnen worden.
67
Listing 4.24: Lekken in de beveiliging at runtime
public class Channel { ... }
public class SecureChannel extends Channel { ... }
public class C {
public LinkedList<SecureChannel> l;
...
}
4.7.2
Het gebruik van Generics-code in legacy code
Beschouwen we nu even het omgekeerde geval: het package legacy wordt
geconverteerd volgens de Generics-syntax, zodat het parts-argument van de
methode addAssembly nu van type Collection<Part> is evenals het resultaattype van getParts. Verder werd de clientcode die dit package importeert
geschreven vóór de invoering van Generics, zodat alle containers die gebruikt
worden instanties zijn van ruwe types. In dit geval zal een unchecked warning
gegenereerd worden op de plaats waar we een gewone Collection doorgeven aan de methode addAssembly die Collection<Part> verwacht. Voor een
specifieke beschrijving van de gevallen waarin dergelijke warnings gegenereerd
worden, verwijzen we ondermeer naar [4].
4.8
Het omzeilen van typeveiligheid
Sommige auteurs argumenteren dat een potentieel veiligheidslek at runtime
onstaat omdat de homogene erasure-vertaling de type-informatie van de code
scheidt. Beschouw bijvoorbeeld de code in listing ??.
Omdat erasure ervoor zorgt dat het type van l in de bytecode LinkedList
is, kan een aanvaller, die geen gebruik maakt van Generics maar van de bestaande Java-taal of van bytecode, ervoor zorgen dat bijvoorbeeld een instantie
van Channel in de lijst terechtkomt die geen SecureChannel is. Dit zou een
manier kunnen zijn om informatie te laten lekken uit een beveiligde omgeving,
omdat compiler noch runtime omgeving een violatie van het typesysteem zullen
68
Listing 4.25: Beveiliging at runtime
public class SecureChannelList extends LinkedList<SecureChannel> {
SecureChannelList() { super(); }
}
public class C {
SecureChannelList l;
...
}
detecteren.
Om dit probleem op te lossen, moeten we een manier vinden om ervoor te
zorgen dat generieke type-informatie niet verloren gaat bij erasure. Dit kan gerealiseerd worden door een klasse SecureChannelList de klasse LinkedList<SecureChannel
te laten uitbreiden. De afgeleide klasse erft alle attributen en methoden van
de superklasse over, en de constructor laten we gewoon de constructor van de
basisklasse oproepen, bijvoorbeeld zoals in listing 4.25.
In tegenstelling tot LinkedList<SecureChannel> wordt SecureChannelList onder erasure naar zichzelf vertaald zodat er geen kennis over types verloren gaat. Bovendien zorgt het erasure-vertalingsschema ervoor dat in de klasse
SecureChannelList brugmethoden toegevoegd waardoor argumenttypes tijdens cast-conversies at runtime de nodige controles zullen ondergaan. Dit
schema kan echter niet gebruikt worden voor publieke attributen van types
met parameters, vermits de toegang daarvoor niet via methoden en brugmethoden verloopt.
Dergelijke typespecialisatie is een algemene methode om type-informatie at
runtime te behouden waar deze anders verloren zou gaan door erasure. Omdat een heterogene vertalingsschema dergelijke specialisatie ook daadwerkelijk
uitvoert voor elke particuliere instantiëring, lijkt het er dus op dat homogene
vertaling minder geschikt is vanuit een beveiligingsperspectief.
69
4.9
Besluit
We hebben aangetoond dat Java Generics de expressieve kracht van de programmeertaal Java uitbreiden, terwijl toch zoveel als mogelijk compatibiliteit
met de bestaande specificatie voor de virtuele machine werd nagestreefd. Omdat de code met typeparameters wordt omgezet naar klassieke bytecode, blijven alle eigenschappen van de Java Virtuele Machine behouden, zelfs in de
aanwezigheid van “unchecked warnings”. Ook de meeste beperkingen vloeien echter uit de implementatie voort, voornamelijk wat het vrije gebruik van
typevariabelen betreft.
70
Hoofdstuk 5
Andere uitbreidingen van Java
In het voorgaande hoofdstuk 4 is wellicht duidelijk geworden dat wat we met
Java Generics in bytecode kunnen uitdrukken, op de type-informatie in het
Signature-attribuut na niet fundamenteel meer is dan wat we konden realiseren in de pre-Generics standaard. Achter de schermen vormt het “generic
idiom” nog steeds de werkingsbasis voor genericiteit in Java. Wat echter telt, is
dat we nu op broncodeniveau op een veel striktere en veiligere manier kunnen
omgaan met dit implementatieschema, zodat het foutloos ontwikkelen van software eenvoudiger wordt. Hierin staan de Java Generics niet alleen. Dezelfde filosofie gaat ook schuil achter een aantal taaluitbreidingen die eveneens in versie
1.5.0 van het J2SE platform hun opwachting zullen maken. Hun gemeenschappelijk kenmerk is dat ze vertrekken van veelvoorkomende programma-idiomen
die frequent met fouten gepaard gaan, en daarvoor taal- en dus ook compilerondersteuning voorzien. De verantwoordelijkheid voor het uitschrijven van
zekere fragmenten “boilerplate code” wordt op die manier uit de handen van
de menselijke programmeur gehaald en overgeheveld naar de compiler. Omdat
deze laatste (bijna) nooit vergissingen begaat, is het veel waarschijnlijker dat
de resulterende code vrij is van bugs. Bovendien “vervuilen” de boilerplatefragmenten de broncode dan niet meer. Door deze verschillende mechanismen
in combinatie met Java Generics te gebruiken, krijgen we dus nog krachtiger werkinstrumenten aangereikt. In dit hoofdstuk zullen we dan ook enkele
van deze nieuwe mechanismen toelichten. Uitbreidingen en aanpassingen van
bestaande API’s in het kader van Java Generics komen eveneens aan bod.
71
5.1
Een for-lus voor iteratie over collecties
Een typische situatie waarin we boilerplate code moeten schrijven, is bijvoorbeeld het itereren over collecties. Om dit te verwezelijken, moeten we eerst
expliciet bij de collectie in kwestie een referentie naar een iteratorobject opvragen, en vervolgens door het beurtelings oproepen van de methoden hasnext
en next alle elementen van de container overlopen. In de code die deze lus
implementeert, zal de naam van de iteratorvariabele dus maar liefst drie keer
voorkomen. Wat dus wil zeggen dat we twee gelegenheden hebben om een fout
te maken, bijvoorbeeld door de “copy-and-paste” broncode-recyclagetechniek
toe te passen op de code voor een andere iterator in dezelfde scope en dan te
vergeten alle identifiers aan te passen. Aangezien in de praktijk de iterator
meestal toch alleen maar gebruikt wordt om tijdens het itereren over de container de elementen uit te lezen, zou het dus handiger en vooral veiliger zijn
als de compiler het aanmaken van de iterator voor z’n rekening zou nemen.
Daartoe wordt in Java nu een speciale for-lus geı̈ntroduceerd, die toelaat
te itereren over een volledige collectie. Het gebruik van deze constructie is
reeds in vroegere voorbeelden aan bod gekomen, onder andere in listing 3.2.
De code wordt compacter en beter leesbaar – er staat precies wat er bedoeld
wordt – en bevat geen expliciete iteratormanipulaties meer. Ook voor arrays
is deze for bruikbaar, de lus vervangt dan eerder de index van de array dan
een iterator, maar de voordelen blijven dezelfde.
Bemerk tenslotte dat niet in alle gevallen de for-constructie de aangewezen
oplossing is. Sommige algoritmen vereisen bijvoorbeeld dat na het doorlopen
van een deel van de collectie de iteratie gestopt en opnieuw vanaf het eerste
element hernomen wordt1 . In dat geval is een expliciete iterator soms handiger.
1
In het hoofdprogramma van onze console-applicatie treedt een dergelijke situatie op.
72
5.2
Autoboxing/unboxing
De strikte scheiding in Java tussen referentietypes en primitieve types speelt
ons bij het werken met collecties eveneens parten. In tegenstelling tot hun
tegenhangers in C++ kunnen de containerklassen in Java niet rechtstreeks
instanties van ingebouwde types zoals int opnemen. Via een omweg lukt het
wel, als we de primitieve waarde eerst verpakken in een “wrapper”, in dit
geval een instantie van de klasse Integer. Het uitvoeren van deze conversie
en van de omgekeerde omzetting wanneer we de collectie uitlezen, vereist extra
statements en methode-oproepen in de broncode. Ook hier zouden we liever
zien dat de compiler dit automatisch voor ons afhandelt. Dit is dan ook precies
wat autoboxing en auto-unboxing voor ons doen.
5.3
Het Java Collection Framework opnieuw
bekeken
Omdat een primaire ontwerpdoelstelling van Java Generics het typeveilig maken van de standaard Java-collecties was, is het niet verwonderlijk dat de
interfaces en containerklassen in het Java Collection Framework een ingrijpende herziening hebben ondergaan. De drie besproken taaluitbreidingen hebben
hiertoe in belangrijke mate bijgedragen: Generics voegen statische typeveiligheid toe aan de collecties, de speciale for-constructie vermijdt expliciete
iteratormanipulatie bij het overlopen van containers, en autoboxing omzeilt
de tweeledige typestructuur van Java.
Op de eerste plaats hebben de verschillende interfaces en klassen één of
meerdere typeparameters gekregen. Deze typevariabelen treden uiteraard ook
op in de membermethoden voor het toevoegen van referenties aan containers of
het ophalen ervan. Andere methoden, zoals contains of remove zijn echter nog
altijd uitgedrukt in termen van Object, omdat dit enerzijds meer flexibiliteit
toelaat en anderzijds de typeveiligheid toch niet schaadt.
Daarnaast heeft men van de gelegenheid gebruik gemaakt om ook nieuwe
interfaces, klassen en methoden toe te voegen. Nieuwkomers zijn ondermeer
73
een interface en bijhorende implementaties voor Queue-structuren. De hulpklasse Arrays bevat voor verschillende aanwezige methoden, zoals bijvoorbeeld
toString(Object[ ]), nu ook een versie die recursief inwerkt op geneste of
meerdimensionale arrays: deepToString(Object[ ]). Verder bevat de klasse Collections eveneens een aantal nieuwe generieke algoritmen: zo telt de
statische methode frequency(Collection<?> c, Object o) het aantal optredens van het gegeven object in de collectie. Tenslotte is er voor gezorgd dat
de klasse Boolean nu ook de Comparable-interface implementeert.
De laatste, maar in het kader van Java Generics tevens belangrijkste toevoeging, is een nieuwe familie “wrappers” voor generieke collecties. Methoden zoals static <E> Collection<E> checkedCollection(Collection<E>
c, Class<E> type) geven een view op de meegegeven collectie terug die at
runtime de typeveiligheid van de toegang tot de onderliggende collectie nagaat. Wanneer clients proberen elementen van een ongeldig type toe te voegen, wordt meteen een ClassCastException gegenereerd. De bestaansreden
van deze methoden is dat het Generics-mechanisme voorziet in statische typeveiligheid, maar dat het nog altijd mogelijk is dit mechanisme at runtime te
omzeilen. Dynamisch typeveilige views op collecties elimineren dit probleem.
5.4
Uitgebreide mogelijkheden voor reflectie
Een aantal van de nieuwe taaluitbreidingen hebben dus duidelijke veranderingen teweeg gebracht in bestaande packages. Een ander API die in sterke mate
beı̈nvloed werd, is deze voor reflectie. Een aantal nieuwe interfaces en excepties
werden toegevoegd in java.lang.reflect alsook in java.lang. Bestaande
klassen zoals Class, Method, Constructor en anderen werden aangepast om
de taaluitbreidingen te incorporeren.
De volgende nieuwe klassen en interfaces laten ons toe om informatie te
bekomen over generieke types en polymorfe methoden.
• Type – Deze interface werd toegevoegd om al de verschillende types die
in Java gebruikt worden te kunnen voorstellen, inclusief generieke types en typevariabelen. De klasse java.lang.Class implementeert deze
interface, die overigens geen enkele methode declareert.
74
• GenericDeclaration – Deze interface, die geı̈mplementeerd wordt door
de klassen Class, Method en Constructor, geeft aan dat zij typevariabelen kunnen declareren. De enige aanwezige methode getTypeParameters
geeft een array terug met alle instanties van TypeVariable die voorkomen in de generieke declaratie corresponderend met de instantie van
GenericDeclaration, in volgorde van voorkomen.
• TypeVariable – Deze interface representeert alle soorten typevariabelen,
bijvoorbeeld de T in Class<T>. Er zijn methoden om de naam van de
variabele op te vragen, om een array van Type-instanties te bekomen die
de bovengrenzen van de typevariabele bevat, en om toegang te krijgen
tot de GenericDeclaration waarin de typevariabele voorkomt.
• ParameterizedType – Instanties van klassen die deze interface implementeren, stellen invocaties van generieke types voor, zoals bijvoorbeeld
Collection<String>. Er is onder andere een methode aanwezig om het
overeenkomstige ruwe type te bekomen (Collection in dit geval).
• GenericArrayType – Deze interface stelt arrays voor waarvan het componenttype een type met parameters is of een typevariabele (herinner dat
we dergelijke arrays wel kunnen declareren maar niet altijd rechtstreeks
aanmaken met een new-expressie). Er is een methode voorzien om het
componenttype van de array op te vragen.
• WildcardType – Deze interface stelt een type voor waarin het wildcardsymbool voorkomt, zoals ? of ? extends T. De opgenomen methoden
stellen ons in staat de onder- en bovengrenzen op te vragen.
• MalformedParameterizedTypeException – Instanties van deze exceptieklasse, die overerft van java.lang.RuntimeException, worden geworpen wanneer geen instantie van een generiek type kan worden aangemaakt at runtime.
• TypeNotPresentException – Deze exceptie kan optreden wanneer code
toegang probeert te krijgen tot een type met een zekere naam, maar dit
type niet kan gevonden worden.
75
• GenericSignatureFormatError – Instanties van deze klasse geven een
fout in de bytecode aan. In de praktijk zou een dergelijke situatie zeldzaam moeten zijn.
We kunnen dus van een generieke declaratie de formele typeparameters
en hun bounds opvragen. We zijn echter niet in staat at runtime de actuele
binding tussen typevariabelen en hun argumenttypes op te vragen, dit omdat
door erasure deze type-informatie verloren gegaan is. Ook de “work-around”
in listing 5.1 zal niet werken: informatie over de binding van E is niet beschikbaar at runtime, en in deze klasse ook niet at compile time. Omdat een
referentie naar .class statisch opgelost moet worden, zal de compiler dus een
foutmelding geven.
Listing 5.1: Reflectieve informatie over typebinding
public class GenericClass<E> {
public Class<E> getElementType() {
return E.class;
}
}
Ook de bestaande API’s hebben wijzigingen ondergaan. Een overzicht:
• Member – Deze interface, die door de klassen Class, Field en Method
wordt geı̈mplementeerd, bevat een nieuwe methode isSynthetic die
aangeeft of een member al dan niet in het erasure-vertalingsproces door
de compiler toegevoegd werd. Denken we hierbij bijvoorbeeld aan de
brugmethoden.
• Class – Zelfs de declaratie van de klasse java.lang.Class ziet er niet
meer hetzelfde uit. De toevoeging van een typeparameter T laat een aantal interessante constructies toe, zoals bijvoorbeeld de volgende methode
T cast(Object) die een nieuwe instantie van T aflevert:
Listing 5.2: Het gebruik van Class<T>
Class<Serializable> cs = Serializable.class;
Object o = ...;
Serializable s = cs.cast(o);
76
De methode getSuperclass heeft eveneens een wijziging ondergaan, ze
geeft nu een resultaat van type Class<? super T> terug in plaats van
het ruwe type. Ook dit is een voorbeeld van betere typeveiligheid door
het gebruik van Generics. Evenzo bij de methode newInstance, die nu
T als resultaattype heeft. Dit elimineert de noodzaak voor een castoperatie:
Listing 5.3: De methode newInstance
Class<E> clazz;
E e = clazz.newInstance();
• Field – De methode getGenericType, die nuttig kan zijn als het attribuuttype een typevariabele is of een invocatie van een generiek type, is
de belangrijkste toevoeging
• Constructor en Method – De wijzigingen in Constructor zijn een subset
van deze in Method, dus voor de bondigheid bespreken we beide klassen
samen. In tandem zorgen de methoden getGenericExceptionTypes en
getGenericParameterTypes, die een array van Type-instanties teruggeven, ervoor dat we toegang hebben tot types met parameters die deel
uitmaken van de signatuur van de methode of constructor. Wijzigingen
die alleen betrekking hebben op Method, zijn de toevoeging van een methode getGenericReturnType voor resultaattypes met Generics-syntax,
en isBridge die aangeeft of een particuliere methode als brug werd ingevoegd door de compiler.
De API’s hebben op sommige plaatsen dus significante wijzigingen ondergaan om Java krachtiger en expressiever te maken. Ook andere taaluitbreidingen die we niet behandeld hebben, zoals metadata en typeveilige enumconstructies, hebben hieraan hun steentje bijgedragen. Helaas is het onvermijdelijke gevolg een toenemende complexiteit van de API’s, voornamelijk deze
voor reflectie omdat hierin ondersteuning dient gegeven te worden voor de
nieuwe extensies.
77
Hoofdstuk 6
Java Generics en templates in
C++: een korte vergelijking
De syntax die we in hoofdstuk 3 geı̈ntroduceerd hebben, zou misschien aanvankelijk de indruk hebben kunnen wekken dat Java Generics sterk gelijken
op templates in C++. Niets is echter minder waar: in feite hebben beide
mechanismen nauwelijks iets gemeen. Java Generics mogen we opvatten als
“syntactic sugar”, een extra vertalingslaag in de compiler die toelaat op een
betere manier om te gaan met code die ook vroeger al generiek was. In dit
opzicht is de benaming “autocast” die Bruce Eckel, co-auteur van [3], voorstelt in zijn weblog, inderdaad een vlag die de lading beter dekt. Templates
daarentegen zijn veel essentiëler, zelfs onontbeerlijk om enigszins generiek te
kunnen programmeren in C++. Ook op het gebied van implementatie wordt in
C++ een andere benadering genomen, waardoor het mechanisme met minder
restricties is behept en we, zoals een aantal auteurs vermelden, at compile time
eigenlijk over een volledig Turing-compleet rekensysteem kunnen beschikken.
Het is echter niet onze bedoeling om in deze thesisverhandeling in detail
de werking van het template-mechanisme uit de doeken te doen. De basisbeginselen ervan kunnen onder andere teruggevonden worden in [13] en [3], en
voor een uitgebreide beschrijving van geavanceerde technieken zoals template
metaprogramming, verwijzen we naar [10]. We zullen ons hier voornamelijk
beperken tot het vermelden van de belangrijke aspecten waarin templates zich
onderscheiden van Java Generics.
78
Een eerste duidelijk punt van verschil is de implementatietstijl: homogene
vertaling bij Java Generics tegenover heterogene conversie in C++. Onder homogene omzetting verstaan we dat een type met parameters wordt afgebeeld
op één enkel type dat alle instantiëringen van het generieke type kan representeren. Dit is in essentie wat de erasure-techniek uit hoofdstuk 4 realiseert.
Aan het andere uiteinde van het spectrum vinden we de heterogene werkwijze
terug, die ondermeer gebruikt wordt in het “template expansion” mechanisme
in C++. Deze genereert in principe specifieke code voor elke invocatie van
het generieke type met een particuliere set argumenten voor de typeparameters. Dit laatste feit impliceert tevens dat heterogene transformatie eigenlijk
meer type-informatie uit de broncode behoudt dan de homogene vertaling, wat
doorgaans meer vrijheid laat aan de ontwerpers van de taal en aan de gebruikers. Constructies die de beschikbaarheid van deze informatie vereisen, zoals
gecontroleerde typecasts naar types met parameters, de creatie van generieke
arrays en het gebruik van typevariabelen in new-expressies en static members,
stellen dan immers geen problemen meer.
Omdat voor elke onderscheiden invocatie gespecialiseerde code aangemaakt
wordt, is er bovendien typisch meer ruimte voor optimalisering. Zo worden
bijvoorbeeld van klassetemplates alleen die onderdelen geı̈nstantieerd die ook
effectief gebruikt worden. Ondanks het feit dat door de aanwezigheid van
mogelijk veel verschillende specialisaties het gevaar van “code bloat” bestaat,
beperkt dit toch enigszins de omvang van de linkeroutput. Ook op broncodeniveau zijn in C++ mogelijkheden voorzien om de prestaties te verbeteren: we
kunnen in dezelfde namespace voor dezelfde template volledige of gedeeltelijke specialisaties met identieke naam maar een andere implementatie opgeven,
waartussen de compiler bij invocatie dan kiest op basis van de specifieke vorm
van de templateparameters en de opgegeven argumenten. Specialisatie is dus
een manier om verschillende implementaties vast te leggen voor verschillende
gebruiksdoeleinden van dezelfde interface. Directe ondersteuning om dit schema toe te kunnen passen, ontbreekt in Java. Een probleem dat zich wel stelt
bij de heterogene vertaling, is het opvolgen van de verschillende invocaties die
van een generiek type aangemaakt worden bij het compileren (of linken).
De sterke punten van de homogene vertaling zijn eenvoud en de compacte
afmetingen van de gegenereerde code. Klassen en interfaces met Generics-code
79
kunnen in Java rechtstreeks gecompileerd worden naar één enkel class-bestand,
zodat de broncode niet hoeft te worden blootgesteld aan derden. Generieke
klassen, interfaces en methoden worden bovendien volledig op correctheid gecontroleerd vóórdat er effectief invocaties kunnen worden aangemaakt, daar
waar in C++ eerst de parameters worden ingevuld en dan pas nagegaan wordt
of de resulterende code (type)correct is en de gebruikte types alle operaties
ondersteunen die nodig zijn. Het achter de schermen toevoegen van brugmethoden en casts in de homogene transformatie kan echter at runtime voor
beperkte extra overhead zorgen.
Java Generics zijn dus geen templates. Verschillen op syntactisch niveau
zijn ondermeer dat we in Java geen primitieve types als argument voor typeparameters kunnen gebruiken, en dat we typevariabelen ook geen defaultargumenten kunnen meegeven. Daarnaast is er niet de mogelijkheid om via
“non-type parameters” instanties van primitieve integrale types zoals int door
te geven. Dit type templateparameter kan in C++ bijvoorbeeld gebruikt worden om at compile time de initiële opslagcapaciteit van datastructuren vast te
leggen. De afwezigheid van deze optie in Java is niet meteen een echt verlies.
Een direct gevolg hiervan is echter wel dat een aantal speciale technieken die
in C++ mogelijk worden gemaakt door volledige of partiële specialisatie, door
de mogelijkheid om via non-type parameters waarden door te geven, door het
gebruik van defaultargumenten, of door een combinatie van al deze technieken,
niet kunnen worden toegepast in Java. Bovendien wordt hierbij vaak gesteund
op het feit dat bij het instantiëren van templates at compile time al zekere
berekeningen uitgevoerd kunnen worden, wat bij Java Generics helemaal niet
het geval is.
Wanneer we het Java Collection Framework tenslotte eens naast de Standard
Template Library uit C++ leggen, zien we eveneens een aantal verschillen. Iteratoren (en koppels van iteratoren) vervullen een centrale rol in STL, terwijl
ze veel minder fundamenteel zijn in Java. Aan de andere kant is het Java
Collection Framework in sterke mate opgebouwd rond een aantal collectieinterfaces, terwijl dat niet zo is in STL. Tenslotte kunnen de STL-collecties
ook rechtstreeks waarden van primitieve types opnemen, zodat geen conversies
met wrappers nodig zijn.
80
Hoofdstuk 7
Java Generics: evolutie of
revolutie?
7.1
Een evaluatie
In deze thesisverhandeling hebben we een nieuwe manier toegelicht om in de
programmeertaal Java op een betere en meer gecontroleerde wijze met genericiteit om te gaan. Java Generics bieden ons verbeterde statische typecontrole
en de mogelijkheid om overbodige cast-operaties weg te halen uit broncode.
We hebben geı̈llustreerd hoe dit principe, wanneer het op een consistente en
consequente manier gebruikt wordt in combinatie met een aantal andere taaluitbreidingen, ons toelaat op een meer productieve wijze robuustere applicaties te schrijven. Voornamelijk vanwege het gebruikte implementatieschema
heeft deze nieuwe technologie niet de volledige kracht en flexibiliteit van het
template-mechanisme uit C++ overgeërfd. De vraag of dit een voordeel dan
wel een nadeel is, geeft wellicht aanleiding tot een eindeloze discussie. Het
was alleszins geen primaire designdoelstelling van de taalontwerpers, die hebben gewerkt vanuit het idee dat Java Generics het leven van de programmeur
gemakkelijker moeten maken.
Zoals in de verschillende voorgaande hoofdstukken echter ook duidelijk geworden is, kunnen we niet van de voordelen van het nieuwe ondersteunende
mechanisme voor generiek programmeren genieten zonder ook met de nadelen
81
ervan geconfronteerd te worden. We zetten de belangrijkste beperkingen even
op een rijtje.
• De precondities en het uiteindelijke implementatieschema hebben de slagkracht van Java Generics beperkt. Vele (nuttige) optimaliseringstechnieken die realiseerbaar zijn met templates in C++ zijn niet rechtstreeks
toepasbaar op Java Generics. Het vrije gebruik van typevariabelen wordt
bovendien door (te) veel restricties beknot. Tenslotte is ook het mechanisme van reflectie niet in staat ons at runtime alle gewenste generieke
type-informatie te bezorgen.
• Java Generics hebben een beperkte “waterdichtheid”. Alle correctheidsgaranties gaan verloren bij interactie met legacy code. Het is nog steeds
mogelijk code te produceren die foutloos compileert, maar niettemin faalt
met een ClassCastException bij uitvoering.
• De transparantie is niet optimaal. Gedegen kennis van het interne raderwerk, in het bijzonder de werking van de erasure-procedure en alle
consequenties die eraan verbonden zijn, is niet geheel onbelangrijk als
men Java Generics op een goede manier wil benutten.
• Conversie van oude API’s en standaardbibliotheken is niet altijd even
eenvoudig. Wanneer we ervoor willen zorgen dat de toegevoegde typeveiligheid niet tot gevolg heeft dat de toepasbaarheidsvoorwaarden van
methoden té restrictief worden, wordt de eenvoudige signatuur van de
oude methode al vrij snel gecompliceerder in de Generics-syntax (denken we bijvoorbeeld aan de methode Collections.max die we veelvuldig
als voorbeeld hebben gebruikt in deze tekst).
Ook het aantal correcties in de opeenvolgende edities van de Genericsspecificatie en de bugs in de eerste versies van de uitgebreide compiler lijken
erop te wijzen dat het nieuwe concept toch nog een zekere tijd nodig zal hebben
om aan maturiteit te winnen en ingeburgerd te raken. Dit wordt volgens ons
echter in voldoende mate gecompenseerd door het potentieel aan positieve
neveneffecten dat vervat zit in Java Generics. De belangrijkste verbeteringen
van de taal die ons in staat stellen op een vlotte manier foutloze code te
ontwikkelen, zijn ondermeer:
82
• de verhoogde uitdrukkingskracht van Java op broncodeniveau: kennis
en veronderstellingen die anders impliciet zouden blijven of afzonderlijk gedocumenteerd zouden moeten worden, kunnen expliciet verwoord
worden in de programmacode
• de verhoogde leesbaarheid en onderhoudbaarheid van generieke code, op
voorwaarde dat de gebruiker natuurlijk in staat is de gevraagde additionele type-informatie op een correcte manier aan te leveren en/of te
interpreteren
• de verhoogde typeveiligheid door het expliciet maken van typeparameters
en het impliciet maken van casts: dit is in de praktijk cruciaal voor
het flexibele en veilige gebruik van de containers uit het populaire Java
Collection Framework
• strengere statische typecontrole die de noodzaak tot sterk doorgedreven
runtime tests minder groot maakt: casts die door de compiler toegevoegd
worden, hebben de garantie niet te falen bij het uitvoeren van de code,
althans wanneer de volledige applicatie kan gecompileerd worden zonder
dat “unchecked warnings” gegenereerd worden
• het achterliggende implementatieschema verzekert een zekere compatibiliteit met reeds bestaande Java-code, zowel op het niveau van broncode
als op het niveau van bytecode, zonder dat daarvoor de werking van de
virtuele machine substantieel diende aangepast te worden
• migratiecompatibiliteit: de migratie van programmeren in termen van
het “generic idiom” naar het programmeren met de Generics-syntax hoeft
niet van vandaag op morgen te gebeuren, iedereen kan bovendien op eigen
tempo de overstap maken
Hoewel templates voor de uitdrukkingskracht van de programmeertaal C++
ongetwijfeld een grotere stap voorwaarts hebben betekend dan het geval is voor
Generics in Java, kunnen we besluiten dat het toevoegen van dit mechanisme
aan de programmeertaal Java ondanks de beperkingen zijn nut heeft en ons
de gelegenheid geeft door een andere bril naar het concept van generiek programmeren in Java te kijken.
83
7.2
De huidige status van J2SE 1.5.0 “Tiger”
Versie 1.5.0 van het J2SE platform is het eindprodukt van een grondige revisie
van de programmeertaal Java en de bijhorende platformbibliotheken. De resultaten uit niet minder dan 15 door het Java Community Process uitgewerkte
Java Specification Requests zijn erin opgenomen, naast nog tientallen andere
significante updates. Een overzicht van alle veranderingen kan teruggevonden
worden op http://java.sun.com/j2se/1.5.0/docs/relnotes/features.html. Deze
zijn bijna zonder uitzondering gericht op vier sleutelthema’s:
1. het vereenvoudigen van software-ontwikkeling: metadata, Generics, autoboxing, for-lus voor collecties en arrays, enumeratietypes, . . .
2. schaalbaarheid en prestatieverbetering
3. management en monitoring
4. verbeteringen voor desktop clients
Het spreekt vanzelf dat al deze updates niet van de ene dag op de andere
konden doorgevoerd worden. Daardoor heeft de release van de eerste betaimplementatie van het platform, die voor november 2003 aangekondigd was,
enige maanden vertraging opgelopen. Op het ogenblik dat we de laatste hand
legden aan deze tekst, werd beta 2 uitgebracht. Voor een definitieve uitgave is
het op dit ogenblik ongetwijfeld nog een aantal maanden wachten.
7.3
Alternatieven: een blik op de toekomst?
Wanneer we op zoek gaan naar valabele alternatieve implementatiemogelijkheden voor het verwerken van generieke type-informatie in Java-broncode, dan
vinden we een tweetal algemene strategieën terug.
De eerste mogelijkheid is het gebruik van een heterogeen vertalingsschema dat specifieke code genereert voor elke particuliere instantiëring van een
generiek type, en dit bij compilatie of bij het inladen van bytecode. Paper
84
[9] vermeldt echter dat deze aanpak problemen kan opleveren met het onderbrengen van de verschillende invocaties in een packagestructuur, en dat er ook
implicaties zijn voor het beveiligingsmodel van de virtuele machine. De beperkingen op het gebied van het vrij gebruik van typevariabelen kunnen dan wel
gerelaxeerd worden.
Een andere richting die verkend kan worden, is het behouden van typeinformatie at runtime. Het NextGen-project, dat onder andere in [2] beschreven wordt, is een eerste stap in die richting. Voordelen van deze aanpak zijn
dat de mogelijkheid bestaat nog meer expressiviteit aan de taal toe te voegen,
en dat de restricties die het gevolg zijn van de erasure-implementatie opgeheven kunnen worden. In het bijzonder is het gebruik van typevariabelen in
new-expressies geen probleem meer, en de generieke type-informatie kan ook
gebruikt worden in casts en instanceof-tests. Deze strategie zou daarom
eigenlijk nauwer aansluiten bij de filosofie van Java omdat in de bestaande
standaard reeds runtime informatie wordt bijgehouden over de klassen waartoe objecten behoren en over het elementtype van arrays. Aan de andere kant
is deze strategie minder eenvoudig in implementatie en gebruik dan Java Generics. Omdat at runtime geen type-informatie gebruikt wordt, zijn deze laatste
waarschijnlijk ook efficiënter. Bovendien kunnen Generics beter compatibiliteit behouden met legacy code dan met NextGen mogelijk is. Dit maakt het
overgangsproces van de oude standaard naar de uitgebreide versie eenvoudiger,
wat dan ook één van de grote verdiensten van Java Generics is.
85
Bibliografie
[1] Java Specification Request 14: adding generic types to the Java programming language. http://www.jcp.org/en/jsr/detail?id=14.
[2] E. Allen, R. Cartwright, and B. Stoler. Efficient implementation of runtime generic types for Java. In Proceedings of the IFIP TC2/WG2.1
Working Conference on Generic Programming, pages 207–236. Kluwer,
2002.
[3] C. Allison and B. Eckel. Thinking in C++, vol. 2: Practical Programming
– 2nd edition. Prentice Hall, Upper Saddle River, 2003.
[4] G. Bracha, N. Cohen, C. Kemper, M. Odersky, D. Stoutamire, K. Thorup,
and P. Wadler. Adding Generics to the Java Programming Language –
Public Draft Specification version 2.0. Technical report, 2003.
[5] G. Bracha, E. Ernst, N. Gafter, C. Plesner Hansen, M. Torgersen, and
P. von der Ahé. Adding wildcards to the Java programming language. In
Proceedings of the 19th annual ACM Symposium on Applied Computing,
pages 1289–1296, Nicosia, Cyprus, 2004. ACM Press.
[6] G. Bracha, J. Gosling, B. Joy, and G. Steele. The Java Language Specification – 2nd edition. Addison-Wesley, Boston, 2000.
[7] G. Bracha, M. Odersky, D. Stoutamire, and P. Wadler. GJ: extending
the Java programming language with type parameters. Technical report,
1998.
[8] G. Bracha, M. Odersky, D. Stoutamire, and P. Wadler. GJ specification.
Technical report, 1998.
86
[9] G. Bracha, M. Odersky, D. Stoutamire, and P. Wadler. Making the
future safe for the past: adding genericity to the Java programming
language. In Proceedings of the 13th ACM SIGPLAN Conference on
Object-Oriented Programming, Systems, Languages, and Applications, pages 183–200, Vancouver, British Columbia, Canada, 1998. ACM Press.
[10] N.M. Josuttis and D. Vandevoorde. C++ Templates – The Complete
Guide. Addison-Wesley, Boston, 2002.
[11] T. Lindholm and F. Yellin. The Java Virtual Machine Specification – 2nd
edition. Addison-Wesley, Boston, 1999.
[12] A.C. Myers, J.A. Bank, and B. Liskov. Parameterized types for Java.
In Proceedings of the ACM Symposium on Principles of Programming
Languages, Paris, France, 1997. ACM Press.
[13] B. Stroustrup. The C++ Programming Language – 3rd edition. AddisonWesley, Boston, 1997.
87
Download