Liigu peamise sisu juurde

Prototype

Sissejuhatus

Enamasti tähendab objekti loomine konstruktorikutset koos argumentidega. Kuid mõnikord ei ole vaja alustada nullist - sul on juba olemas objekt, mis on peaaegu sobiv, ning soovid teha sellest koopia ja seda veidi kohandada.

Prototype muster tegelebki sellega: uute objektide loomine olemasolevaid kopeerides, mitte neid algusest peale parameetrite abil konstrueerides.

Probleemist lähemalt

Selle näite jaoks vaatleme veebipoodi, mis müüb eelkonfigureeritud arvuteid. Meil on mitu standardset konfiguratsiooni - kontoriarvuti, mänguriarvuti, arendaja tööjaam - ning kliendid ostavad neid korraga kümnete kaupa kontoritesse ja laboritesse. Iga tellimus vajab värsket Computer instantsi, sest klient võib seda hiljem kohandada (lisada rohkem RAM-i, vahetada GPU välja), kuid lähtepunkt on alati üks meie standardsetest ehitustest.

Iga arvuti nullist kokku panemine oleks igal korral raiskav ja korduv:

for (int i = 0; i < 12; i++) {
Computer pc = new Desktop(
"Office PC",
new CPU("Intel i5", 4),
new GPU("Integrated", 0),
16, // ramGb
false // hasRgbLighting
);
// ... add to order
}

Iga kord korratakse sama seadistust. Kui otsustame hiljem näiteks, et kontoriarvutil peaks olema 32 GB RAM-i, peame seda muutma igas kohas, kus seda luuakse.

Ideaalselt seadistaksime hoopis ühe kontoriarvuti korrektselt valmis ja ütleksime: "anna mulle kaksteist samasugust veel." Just seda prototype muster pakub.

Põhimõte

Prototype on objekt, mis oskab ennast ise kopeerida. Muster lisab vastavatele klassidele clone() meetodi, mille väljakutsumine tagastab uue, iseseisva objekti, mis on algselt identne originaaliga.

Computer template = new Desktop("Office PC", cpu, gpu, 16, false);

for (int i = 0; i < 12; i++) {
Computer pc = template.clone();
// ... add to order
}

Samas siit tekib üks huvitav aga vägagi oluline küsimus: mida täpselt tähendab "kopeerimine"?

Shallow copy ja deep copy

Shallow copy kopeerib objekti enda, kuid mitte objekte, millele see viitab. Kui meie Desktop sisaldab CPU välja, siis shallow copy puhul luuakse uus Desktop objekt, mille cpu väli viitab endiselt samale CPU objektile, millele viitab ka originaalne objekt.

Visuaalselt:

original  ──>  CPU ("Intel i5")
^
copy ───────┘

Sellest piisab, kui objektid millele viidatakse on muutumatud. Vastupidises olukorras, kui üks koopia näiteks muudaks CPU-d (nt overclock'ib seda), jõuaks selline muudatus kõikide objektideni, mis jagavad seda viidet.

Deep copy kopeerib lisaks objektile ka kõik objektid, millele see viitab, rekursiivselt. Iga koopia saab enda iseseisva CPU:

original  ──>  CPU ("Intel i5")
copy ──> CPU ("Intel i5") (separate instance)

Meie näite puhul oleks vaja kasutada deep copy't. Igal tellimuses oleval arvutil peaksid olema oma komponendid. Ühe kliendi muudatused ei tohiks mõjutada teisi kliente.

Object.clone() ja selle puudujäägid

Javas on olemas sisseehitatud mehhanism objektide kloonimiseks: Cloneable liides ja Object.clone() meetod. Praktikas samas välditakse nende kasutamist mitmel põhjusel:

  • Object.clone() tagastab Object tüüpi objekti. Iga kasutaja peab tegelema tüübi teisendusega, kuigi on täpselt teada, mis tüüpi objekti kloonitakse.
  • Cloneable ei deklareeri tegelikult clone() meetodit. See on markerliides - selle teostamine ei lisa ühtegi meetodit. Tegelik clone() meetod asub Object klassis ja tuleb ise üle kirjutada.
  • CloneNotSupportedException on kontrollitud erind. Iga clone() väljakutse nõuab try/catch plokki, isegi kui on teada, et see ei saa ebaõnnestuda.
  • Vaikimisi clone() teeb shallow copy. Iga viidatud väli tuleb käsitsi kloonida, vastasel juhul tekivad raskesti leitavad vead.
  • Konstruktorite eiramine. Kloonitud objekt ei läbi konstruktorit, mis teeb invariantide (kehtivustingimuste) tagamise keerulisemaks.

Raamatus Effective Java soovitab Joshua Bloch Cloneable-it täielikult vältida ning kasutada selle asemel koopiakonstruktorit (copy constructor).

Prototype mustri rakendamine

Rakendame eelneva näite peal prototype mustrit. Esmalt loome selleks koopiakonstruktori. Selle konstruktori eesmärk on sisendina võtta sisse teine objekt ning kopeerida selle väärtused ümber:

public class CPU {
private final String model;
private final int cores;

public CPU(String model, int cores) {
this.model = model;
this.cores = cores;
}

// Copy constructor
public CPU(CPU other) {
this.model = other.model;
this.cores = other.cores;
}
}

Prototype mustri enda jaoks loome uue liidese Prototype<T>.

public interface Prototype<T> {
T clone();
}

<T> tüübi parameeter on siin võtmetähtsusega. Erinevalt Object.clone() meetodist tagastab see clone() täpselt selle tüübi, mida ootame, ilma et peaks tüüpe teisendama hakkama.

Nüüd jääb alles ainult loodud liidese implementeerimine:

public abstract class Computer implements Prototype<Computer> {
protected String name;
protected CPU cpu;
protected GPU gpu;
protected int ramGb;

public Computer(String name, CPU cpu, GPU gpu, int ramGb) {
this.name = name;
this.cpu = cpu;
this.gpu = gpu;
this.ramGb = ramGb;
}

// Copy constructor — performs a deep copy of mutable references.
protected Computer(Computer other) {
this.name = other.name;
this.cpu = new CPU(other.cpu); // deep copy
this.gpu = new GPU(other.gpu); // deep copy
this.ramGb = other.ramGb;
}

@Override
public abstract Computer clone();
}

Pane tähele, et koopiakonstruktor loob teadlikult uued CPU ja GPU objektid, selle asemel et lihtsalt viiteid kopeerida. Just sellest tulebki „deepdeep copy mõistes.

Kovariantsed tagastustüübid

Kovariantsed tagastustüübid on Java omadus, mis teeb selle mustri puhtaks ja mugavaks. Sisuliselt, kui alamklass kirjutab meetodi üle, lubab Java kitsendada tagastustüüpi konkreetsemaks alamklassiks.

Eelnevas näites implementeerisime Prototype liidest abstraktse klassi tasemel, millest tulenes abstraktne clone() meetod:

public abstract Computer clone();

Kuid iga alamklass võib selle üle kirjutada spetsiifilisema tagastustüübiga:

public class Desktop extends Computer {
private boolean hasRgbLighting;

public Desktop(String name, CPU cpu, GPU gpu, int ramGb,
boolean hasRgbLighting) {
super(name, cpu, gpu, ramGb);
this.hasRgbLighting = hasRgbLighting;
}

private Desktop(Desktop other) {
super(other);
this.hasRgbLighting = other.hasRgbLighting;
}

@Override
public Desktop clone() { // returns Desktop, not Computer
return new Desktop(this);
}
}

Kutsudes desktop.clone(), saad tulemuseks Desktop objekti, mitte Computer - ilma igasuguse tüübi teisenduseta, kuigi abstraktne meetod on deklareeritud baasklassis. See teebki kohandatud Prototype<T> liidese kasutamise märksa mugavamaks kui Object.clone() kasutamise.

Prototüüpide register

Prototüüpe on võimalik hoida erineval viisil, üheks nendest oleks luua eraldi register nende jaoks:

public class ComputerRegistry {
private final Map<String, Computer> prototypes = new HashMap<>();

public void registerPrototype(String key, Computer prototype) {
prototypes.put(key, prototype);
}

public Computer createComputer(String key) {
Computer prototype = prototypes.get(key);
if (prototype == null) {
throw new IllegalArgumentException("No prototype: " + key);
}
return prototype.clone();
}

public boolean hasPrototype(String key) {
return prototypes.containsKey(key);
}
}

Register seadistatakse ühe korra täielikult konfigureeritud prototüüpidega ning seejärel küsib ülejäänud kood arvuteid nime järgi:

registry.registerPrototype("office",
new Desktop("Office PC", i5, integrated, 16, false));
registry.registerPrototype("gaming",
new Desktop("Gaming Rig", i9, rtx4080, 32, true));

Computer pc1 = registry.createComputer("office");
Computer pc2 = registry.createComputer("office");
Computer rig = registry.createComputer("gaming");

Kutsuv kood ei pea enam teadma, kuidas konkreetseid arvutiklasse konstrueerida. Piisab ainult prototüübi nimest ja tagastatakse vastav objekt.

Kasutusjuhud

Prototype muster sobib, kui

  • Objekti nullist loomine on kulukas või keeruline.
  • On vaja palju objekte, mis on suuresti sarnased mõne olemasolevaga.
  • Täpset loodavat klassi ei teata kompileerimise ajal, kuid olemas on näidisobjekt, mida saab kopeerida.

Lihtsate ja odavate objektide puhul on tavaline konstruktor selgem lahendus. Ära kasuta prototype’i lihtsalt sellepärast, et kloonimine tundub elegantne - kasuta seda siis, kui kopeerimine päriselt säästab tööd või lihtsustab koodi.