Grunderna i Java Generics

1. Introduktion

Java Generics introducerades i JDK 5.0 i syfte att minska buggar och lägga till ett extra lager av abstraktion över typer.

Den här artikeln är en snabb introduktion till Generics i Java, målet bakom dem och hur de kan användas för att förbättra kvaliteten på vår kod.

2. Behovet av generika

Låt oss föreställa oss ett scenario där vi vill skapa en lista i Java för att lagra heltal ; vi kan frestas att skriva:

List list = new LinkedList(); list.add(new Integer(1)); Integer i = list.iterator().next(); 

Överraskande kommer kompilatorn att klaga på den sista raden. Den vet inte vilken datatyp som returneras. Kompilatorn kommer att kräva en uttrycklig gjutning:

Integer i = (Integer) list.iterator.next();

Det finns inget avtal som kan garantera att listans returtyp är ett heltal. Den definierade listan kan innehålla vilket objekt som helst. Vi vet bara att vi hämtar en lista genom att inspektera sammanhanget. När man tittar på typer kan det bara garantera att det är ett objekt , vilket kräver en uttrycklig roll för att säkerställa att typen är säker.

Denna roll kan vara irriterande, vi vet att datatypen i den här listan är ett heltal . Medverkanden är också rörig vår kod. Det kan orsaka typrelaterade runtime-fel om en programmerare gör ett misstag med den uttryckliga castingen.

Det skulle vara mycket lättare om programmerare kunde uttrycka sin avsikt att använda specifika typer och kompilatorn kan säkerställa riktigheten hos en sådan typ. Detta är kärnidén bakom generiska läkemedel.

Låt oss ändra den första raden i föregående kodavsnitt till:

List list = new LinkedList();

Genom att lägga till diamantoperatören som innehåller typen, begränsar vi specialiseringen av denna lista endast till heltalstyp, dvs. vi anger vilken typ som kommer att hållas inne i listan. Kompilatorn kan genomdriva typen vid kompileringen.

I små program kan detta verka som ett trivialt tillskott, men i större program kan detta lägga till betydande robusthet och göra programmet lättare att läsa.

3. Generiska metoder

Generiska metoder är de metoder som skrivs med en enda metoddeklaration och kan kallas med argument av olika slag. Kompilatorn säkerställer riktigheten av vilken typ som används. Dessa är några egenskaper hos generiska metoder:

  • Generiska metoder har en typparameter (diamantoperatören som omsluter typen) före returtypen för metoddeklarationen
  • Typparametrar kan begränsas (gränserna förklaras senare i artikeln)
  • Generiska metoder kan ha olika typparametrar åtskilda av kommatecken i metodens signatur
  • Metodkropp för en generisk metod är precis som en vanlig metod

Ett exempel på att definiera en generisk metod för att konvertera en matris till en lista:

public  List fromArrayToList(T[] a) { return Arrays.stream(a).collect(Collectors.toList()); }

I föregående exempel, i metoden signatur innebär att metoden kommer att göra med generisk typ T . Detta behövs även om metoden återgår ogiltig.

Som nämnts ovan kan metoden hantera mer än en generisk typ, där så är fallet måste alla generiska typer läggas till metodens signatur, till exempel om vi vill ändra ovanstående metod för att hantera typ T och typ G , det ska skrivas så här:

public static  List fromArrayToList(T[] a, Function mapperFunction) { return Arrays.stream(a) .map(mapperFunction) .collect(Collectors.toList()); }

Vi skickar en funktion som omvandlar en matris med elementen av typ T till en lista med element av typen G. Ett exempel skulle vara att konvertera heltal till dess strängrepresentation :

@Test public void givenArrayOfIntegers_thanListOfStringReturnedOK() { Integer[] intArray = {1, 2, 3, 4, 5}; List stringList = Generics.fromArrayToList(intArray, Object::toString); assertThat(stringList, hasItems("1", "2", "3", "4", "5")); }

Det är värt att notera att Oracles rekommendation är att använda en stor bokstav för att representera en generisk typ och att välja en mer beskrivande bokstav för att representera formella typer, till exempel i Java används T för typ, K för nyckel, V för värde.

3.1. Avgränsade generika

Som nämnts tidigare kan typparametrar begränsas. Avgränsad betyder " begränsad ", vi kan begränsa typer som kan accepteras med en metod.

Till exempel kan vi specificera att en metod accepterar en typ och alla dess underklasser (övre gräns) eller en typ alla dess superklasser (nedre gräns).

För att deklarera en övre gräns typ använder vi nyckelordet sträcker sig efter typen följt av den övre gränsen som vi vill använda. Till exempel:

public  List fromArrayToList(T[] a) { ... } 

Nyckelordet extends används här för att betyda att typen T förlänger den övre gränsen i fall av en klass eller implementerar en övre gräns vid ett gränssnitt.

3.2. Flera gränser

En typ kan också ha flera övre gränser enligt följande:

Om en av de typer som förlängs med T är en klass (dvs. nummer ) måste den placeras först i listan över gränser. Annars orsakar det ett kompileringsfel.

4. Använda jokertecken med generika

Jokertecken representeras av frågetecknet i Java “ ? ”Och de används för att referera till en okänd typ. Jokertecken är särskilt användbara när man använder generika och kan användas som parametertyp men först är det en viktig anmärkning att tänka på.

Det är känt att Object är supertypen för alla Java-klasser, men en samling av Object är inte supertypen för någon samling.

Till exempel är en List inte supertyp av List och tilldelning av en variabel av typ List till en variabel av typ List kommer att orsaka ett kompileringsfel. Detta för att förhindra eventuella konflikter som kan hända om vi lägger till heterogena typer i samma samling.

Samma regel gäller för alla samlingar av en typ och dess undertyper. Tänk på detta exempel:

public static void paintAllBuildings(List buildings) { buildings.forEach(Building::paint); }

if we imagine a subtype of Building, for example, a House, we can't use this method with a list of House, even though House is a subtype of Building. If we need to use this method with type Building and all its subtypes, then the bounded wildcard can do the magic:

public static void paintAllBuildings(List buildings) { ... } 

Now, this method will work with type Building and all its subtypes. This is called an upper bounded wildcard where type Building is the upper bound.

Wildcards can also be specified with a lower bound, where the unknown type has to be a supertype of the specified type. Lower bounds can be specified using the super keyword followed by the specific type, for example, means unknown type that is a superclass of T (= T and all its parents).

5. Type Erasure

Generics were added to Java to ensure type safety and to ensure that generics wouldn't cause overhead at runtime, the compiler applies a process called type erasure on generics at compile time.

Type erasure removes all type parameters and replaces it with their bounds or with Object if the type parameter is unbounded. Thus the bytecode after compilation contains only normal classes, interfaces and methods thus ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

This is an example of type erasure:

public  List genericMethod(List list) { return list.stream().collect(Collectors.toList()); } 

With type erasure, the unbounded type T is replaced with Object as follows:

// for illustration public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } // which in practice results in public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } 

If the type is bounded, then the type will be replaced by the bound at compile time:

public  void genericMethod(T t) { ... } 

would change after compilation:

public void genericMethod(Building t) { ... }

6. Generics and Primitive Data Types

A restriction of generics in Java is that the type parameter cannot be a primitive type.

For example, the following doesn't compile:

List list = new ArrayList(); list.add(17);

To understand why primitive data types don't work, let's remember that generics are a compile-time feature, meaning the type parameter is erased and all generic types are implemented as type Object.

As an example, let's look at the add method of a list:

List list = new ArrayList(); list.add(17);

The signature of the add method is:

boolean add(E e);

And will be compiled to:

boolean add(Object e);

Therefore, type parameters must be convertible to Object. Since primitive types don't extend Object, we can't use them as type parameters.

However, Java provides boxed types for primitives, along with autoboxing and unboxing to unwrap them:

Integer a = 17; int b = a; 

So, if we want to create a list which can hold integers, we can use the wrapper:

List list = new ArrayList(); list.add(17); int first = list.get(0); 

The compiled code will be the equivalent of:

List list = new ArrayList(); list.add(Integer.valueOf(17)); int first = ((Integer) list.get(0)).intValue(); 

Future versions of Java might allow primitive data types for generics. Project Valhalla aims at improving the way generics are handled. The idea is to implement generics specialization as described in JEP 218.

7. Conclusion

Java Generics är ett kraftfullt tillskott till Java-språket eftersom det gör programmerarens jobb enklare och mindre felbenäget. Generics tillämpar typkorrekthet vid sammanställningstid och, viktigast av allt, möjliggör implementering av generiska algoritmer utan att orsaka några extra omkostnader för våra applikationer.

Källkoden som medföljer artikeln finns tillgänglig på GitHub.