Liigu peamise sisu juurde

Geneerilised klassid

Sissejuhatus

Geneeriline klass on klass, mis deklareerib oma päises ühe või mitu tüübiparameetrit. Neid parameetreid saab klassi sees kasutada nagu tavalisi tüüpe väljade tüüpide, konstruktori parameetrite, meetodite tagastustüüpide ja lokaalsete muutujate tüüpide määramisel.

Geneeriliste klasside deklareerimine

Geneerilisi klasse deklareeritakse nagu tavalisi klasse. Ainus erinevus seisneb selles, et klassi nime järele lisatakse nurksulud, mille sees defineeritakse ära kasutatavad tüübiparameetrid.

public class Box<T> {
private T value;

public Box(T value) {
this.value = value;
}

public T getValue() {
return value;
}
}

Antud korras T oleks tüübiparameeter. Klassi sees tähistab T seda konkreetset tüüpi, mille kasutaja hiljem ette annab. Igas kohas, kus esineb T, asendab kompilaator selle kompileerimise käigus tegeliku tüübiga.

Sellisel viisil deklareeritud klasse kasutatakse järgnevalt:

Box<String>  stringBox  = new Box<>("hello");
Box<Integer> integerBox = new Box<>(42);

String s = stringBox.getValue(); // returns String
Integer i = integerBox.getValue(); // returns Integer

Iga instants on iseseisev ja tüübikindel versioon klassist Box. Box<String> ja Box<Integer> on kompilaatori vaatest erinevad tüübid.

Tüübiparameetrite nimereeglid

Kokkuleppeliselt kasutatakse tüübiparameetrite nimedena ühetähelisi suuri tähti:

TähtTähendus
TTüüp (üldotstarbeline)
EElement (kasutatakse kogumites)
KVõti (kasutatakse kujutistes)
VVäärtus (kasutatakse kujutistes)
NArv
RTagastustüüp

Need on konventsioonid ehk kokkulepitud head tavad. Need ei ole kindlad reeglid, tegelikuses võib kasutada mistahes sobivat identifikaatorit. Ühetähelisi nimesid eelistatakse, kuna siis on kohe selge, et tegemist on tüübiparameetriga, mitte tavalise klassiga.

Mitu tüübiparameetrit

Nagu varem mainitud, siis ühel geneerilisel klassil võib olla kas üks või mitu tüübiparameetrit. Kõik need parameetrid lähevad nurksulgude sisse ning on eraldatud komadega. Selle kohta levinud näide on klass Pair, mille eesmärk on hoida kahte potentsiaalselt erinevat tüüpi väärtust endas:

public class Pair<A, B> {
private final A first;
private final B second;

public Pair(A first, B second) {
this.first = first;
this.second = second;
}

public A getFirst() { return first; }
public B getSecond() { return second; }

@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}

Kasutamine:

Pair<String, Integer> entry = new Pair<>("Alice", 30);
System.out.println(entry.getFirst()); // Alice
System.out.println(entry.getSecond()); // 30
System.out.println(entry); // (Alice, 30)

Tüübid A ja B on teineteisest sõltumatud ehk need võivad olla samad või erinevad:

Pair<String, String> coords = new Pair<>("latitude", "longitude");
Pair<Integer, Integer> point = new Pair<>(10, 20);

Record klass

Lisaks klassidele saavad ka Record klassid olla geneerilised. Sarnaselt klassile on tüübiparameetrid deklareeritud record'i nime järel nurksulgudes.

record Pair<A, B>(A first, B second) { }

See on samaväärne ülaltoodud klassiga, kuid kogu korduv kood (boilerplate) genereeritakse automaatselt.

Pair<String, Integer> entry = new Pair<>("Alice", 30);
System.out.println(entry.first()); // Alice
System.out.println(entry.second()); // 30

var ja geneerilised klassid

Geneeriliste klasside kasutamisel võivad tüübid muutuda pikaks, eriti siis, kui kasutatakse pesastatud tüübiparameetreid. Sellisel juhul aitab var märksõna vähendada kordusi ja parandada loetavust.

Oluline on aga mõista, et tüüp peab siiski esinema deklaratsiooni ühel poolel — var ei kaota tüüpi, vaid laseb seda tuletada teise poole kaudu.

// Without var — type written twice
Map<String, List<Integer>> index = new HashMap<String, List<Integer>>();

// With diamond — type inferred on the right
Map<String, List<Integer>> index = new HashMap<>();

// With var — type inferred on the left
var index = new HashMap<String, List<Integer>>();

Kõik kolm deklaratsiooni loovad sama, täielikult määratud tüübiga muutuja. var on kõige kasulikum siis, kui parempoolne avaldis teeb tüübi üheselt mõistetavaks.

hoiatus

var-i kasutamisel tuleb paremal poolel tüübiparameetrid selgesõnaliselt määrata. Kui kasutada var-iga koos ainult teemantoperaatorit (<>), tuletatakse tüübiks ArrayList<Object>, mistõttu kaotavad geneerilised klassid oma mõtte:

var list = new ArrayList<>();          // ArrayList<Object> — not useful
var list = new ArrayList<String>(); // ArrayList<String> — correct

Põhjalikumalt käsitletakse var-i kasutusvõimalusi ja piiranguid vastavas artiklis.

Tüübiparameetrid eemaldatakse käitusajal

Tähtis on ka ära märkida, et tüübiparameetrid eksisteerivad ainult kompileerimise ajal. Kui Java kompileerib geneerilist klassi, siis asendatakse tüübiparameetrid kas Object või muu ette määratud tüübiga (kui ette antud). Seda protsessi nimetatakse tüübi kustutamiseks ehk type erasure.

See tähendab, et käitusajal on nii Box<String> kui ka Box<Integer> lihtsalt Box. Informatsiooni tüübi kohta kasutab kompilaator tüübikontrolliks ja vajadusel teisenduste lisamiseks, kuid käitusajal seda infot enam olemas ei ole.

Praktiline tagajärg on see, et tüübiparameetritega ei saa teha mõningaid toiminguid:

public class Example<T> {
// NOT allowed:
// T instance = new T(); // cannot instantiate a type parameter
// T[] array = new T[10]; // cannot create a generic array directly
// if (value instanceof T) { } // cannot use instanceof with a type parameter
}

Juhtudel, kus on vaja luua instants või uurida tüüpi käitusajal, tuleb parameetrina kaasa anda Class<T> objekt, kuid see on juba teema edasijõudnutele.

nõuanne

Kui tekib vajadus kirjutada new T() või kasutada instanceof T kontrolli, on see sageli märk sellest, et klassi disain tuleks üle vaadata.

  • Klass sõltub liiga palju mingist konkreetsest tüübist
  • Vastutus objekti loomise eest kuulub kuskile mujale

Levinud lahendused on tehasemeetodid (factory function) või Class<T> objekti edastamine.