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