Lekce 5 - Immutable objects (neměnné objekty)
V minulé lekci, Object pool (fond objektů), jsme si ukázali návrhový vzor Object pool, který zamezí opakovanému vytváření objektů, jejichž konstrukce je příliš složitá nebo trvá dlouhou dobu.
V dnešním tutoriálu si představíme neměnné objekty (immutable objects), u kterých nemůžeme změnit jejich vnitřní stav. Podíváme se na způsob jak správně takové objekty implementovat a vysvětlíme si k čemu nám jsou dobré.
Neměnné objekty
V programech často pracujeme s jednoduchými třídami jako je
Rect
(obdélník), Point
(bod), Line
(přímka), Interval
a jim podobné. Společnou charakteristikou
těchto tříd je, že mají relativně jednoduché rozhraní. Zpravidla pouze
několik atributů a využíváme je pouze lokálně. Málokdy potřebujeme, aby
se změnila souřadnice obdélníku někde jinde v programu, a proto je vhodné
změnu zakázat pro další zjednodušení kódu. V některých
případech nemáme ani na vybranou, jelikož neměnnost vyžaduje hardware,
operační systém počítače nebo daná technologie. Například při
použití vláken je mnohem jednodušší používat immutable objekty, jelikož
jsou thread-safe.
Thread-safe znamená, že můžeme kód spouštět na více vláknech. Nemusíme se starat o to, že nám více vláken bude upravovat stejnou proměnnou.
Proč je textový řetězec neměnný?
Problematiku si vysvětleme na textovém řetězci.
Textový řetězec je v počítači reprezentován jako souvislý blok v paměti. Z programátorského hlediska bychom mohli říci, že se jedná o pole znaků. Pole mají určité omezující vlastnosti. Například se automaticky nezvětšují. Když tedy vytvoříme pole, jedná se o prostý blok paměti. Můžeme založit pole s velikostí, kterou dynamicky zadáme až za běhu programu, ale velikost již existujícího pole modifikovat nemůžeme.
Je tedy patrné, že u řetězců, je nutné zakázat jejich modifikaci, jinak by po změně mohl být text různě dlouhý. Je tedy nutné vytvořit nové pole a překopírovat do nového pole hodnoty původního pole. Ve výsledku máme v paměti dva řetězce, kdy se poté jeden uvolní.
Něco jiného je třída StringBuilder
, která
využívá právě polí nebo spojových seznamů, aby mohla řetězec volně
modifikovat. Co je ale důležité, řetězec dostaneme až ve chvíli, kdy
zavoláme metodu ToString()
. Ta převede seznam na pole znaků,
který vrátí.
Hodnotové datové typy
Proč si zde pod neměnnými objekty zmiňujeme hodnotové datové
typy, tedy základní datové typy jako je int
,
double
a další? Protože když se zamyslíme, zjistíme, že
všechny hodnotové datové typy jsou neměnné. Uložíme-li
celé číslo, víme, že pokud jsme mu nepřiřadili jinou hodnotu, bude jeho
hodnota stále stejná. A to bez ohledu na to, kolika funkcemi prošel. To u
referenčních typů jako objekt nevíme, protože každá z
metod může změnit vnitřní stav objektu.
Výhody neměnných objektů
Neměnné objekty nám poskytují několik výhod:
- Hodnoty se nám nemění pod rukama. Máme jistotu, že to co jsme do proměnné uložili tam bude, akorát na jiném místě v programu.
- S předchozím bodem souvisí i bezpečnost z hlediska více vláken, protože v žádném z vláken nemůžeme proměnnou změnit, pouze vytvořit její kopii. Máme zajištěno, že nemůže dojít ke kolizi při zapisování několika vlákny. Ve velkých aplikacích si s těmito typy objektů ušetříme spoustu zamykání, zbytečných chyb a vývojového času.
Nevýhody neměnných objektů
Toto paradigma přináší i své nevýhody:
- Pokaždé, když chceme upravit objekt, musíme vytvořit nový. Čím větší objekt je, tím více výkonu spotřebováváme na vytvoření nové verze objektu.
- Při častějším vytváření objektů nám narůstá využití paměti.
Implementace vzoru
Implementace je velmi klíčová. Pokud někde necháme skulinku, může nám program změnit proměnnou. Z hlediska návrhu si budeme muset dát pozor na několik věcí.
Konstantní atributy
Všechny atributy, na kterých je třída závislá definujeme jako
konstantní, read-only
nebo jiným způsobem dle konkrétního
programovacího jazyka.
Hluboká kopie objektu
Dáme si pozor na všechny atributy, které jsou referenčními typy (přiřazujeme jim objekty). Vždy musíme udělat hlubokou kopii objektu, aby k ní žádná jiná část programu nemohla přistupovat. Kdybychom to neprovedli, nezměníme sice přímo hodnotu neměnného typu, ale změníme nepřímo jeho vnitřní reprezentaci.
Více informací o hluboké kopii objektu se dozvíme ve zdejších kurzech objektově orientovaného programování u konkrétního jazyka.
Přepsání zděděných metod
Většinou musíme přepsat výchozí metody objektů, jako
GetHashCode()
, Equals()
a další. Uveďme si
například metodu GetHashCode()
. Její výchozí implementace se
rozhoduje na základě umístění v paměti. Jestliže tedy
porovnáváme dva objekty za účelem získání jejich hashe, vrátí se nám
stejná hodnota pro objekty, které jsou na stejném místě v paměti.
Prakticky to znamená, že se jedná o identické objekty,
protože porovnáváme objekt sám se sebou. Implementace vzoru se musí
rozhodovat na základě hodnot, které neměnný objekt má.
Nikoliv na základě umístění v paměti, protože každý objekt se bude
nacházet na jiném místě v paměti.
Modifikace stavu objektu
Všechny metody, které nějakým způsobem operují s vnitřním stavem
objektu, nesmí měnit hodnoty přímo na objektu. Musí vracet novou instanci
se změněnými hodnotami, abychom zajistili neměnitelnost původního
objektu. Například pro celé číslo by operace
+
,-
,\
,*
,/
a
%
vracely vždy novou instanci. Původní instance by zůstala
nezměněna. Všimněme si, že je to přesně chování, které od
celočíselných typů očekáváme.
Časté chyby implementace vzoru
Zaměříme se na konstruktor objektu a
operátor =
.
Konstruktor objektu
Zvláštní pozor si musíme dát na konstruktor a metodu
Clone()
, neboli jakoukoliv metodu vytvářející
kopii. Kopie musí být vždy hluboká. Nejprve se
musí vytvořit hluboká kopie původního objektu a teprve poté se mohou
přiřadit hodnoty k vlastním atributům.
Operátor =
Operátor =
je poněkud kontroverzní. V některých jazycích
si jej můžeme sami nadefinovat, v některých to možné není. Cítíme, že
operátor =
by měl změnit stav objektu, můžeme jej tedy
použít s neměnnými objekty? Odpověď je ano, protože operátor
=
nemění hodnoty původního objektu, ale jen přepíše
referenci, aby se odkazoval na jiný objekt.
Příklad použití vzoru
Nyní se již můžeme podívat na implementaci takového neměnného objektu. Naším úkolem bude definovat třídu, která bude předávána referencí tak, aby nebylo možné změnit její vnitřní hodnotu. Bude se tedy chovat jako hodnotový datový typ.
Budeme předpokládat, že neexistuje typ pro komplexní čísla. Komplexní číslo se skládá ze dvou částí, reálného a imaginárního čísla.
UML diagram
UML diagram našeho příkladu vypadá následovně:

Implementace UML diagramu
Pojďme si tedy vytvořit třídu komplexního čísla:
-
class KomplexniCislo : ICloneable { private readonly int RealnaCast; private readonly int ImaginarniCast; public KomplexniCislo() { RealnaCast = 0; ImaginarniCast = 0; } public KomplexniCislo(int realnaCast, int imaginarniCast) { RealnaCast = realnaCast; ImaginarniCast = imaginarniCast; } public KomplexniCislo(KomplexniCislo komplexniCislo) { RealnaCast = komplexniCislo.GetRealnaCast(); ImaginarniCast = komplexniCislo.GetImaginarniCast(); } public KomplexniCislo Clone() { return new KomplexniCislo(RealnaCast, ImaginarniCast); } public override bool Equals(object druheCislo) { if (!(druheCislo is KomplexniCislo)) return false; KomplexniCislo druheKomplexniCislo = (KomplexniCislo)druheCislo; return druheKomplexniCislo.GetHashCode() == GetHashCode(); } public override int GetHashCode() { return RealnaCast.GetHashCode() * 73 + ImaginarniCast.GetHashCode(); } public int GetRealnaCast() { return RealnaCast; } public int GetImaginarniCast() { return ImaginarniCast; } public static bool operator ==(KomplexniCislo prvniCislo, KomplexniCislo druheCislo) { return prvniCislo.Equals(druheCislo); } public static bool operator !=(KomplexniCislo prvniCislo, KomplexniCislo druheCislo) { return !prvniCislo.Equals(druheCislo); } public static KomplexniCislo operator +(KomplexniCislo prvniCislo, KomplexniCislo druheCislo) { return new KomplexniCislo(prvniCislo.GetRealnaCast() + druheCislo.GetRealnaCast(), prvniCislo.GetImaginarniCast() + druheCislo.GetImaginarniCast()); } }
-
public class KomplexniCislo implements Cloneable { private final int realnaCast; private final int imaginarniCast; public KomplexniCislo() { this.realnaCast = 0; this.imaginarniCast = 0; } public KomplexniCislo(int realnaCast, int imaginarniCast) { this.realnaCast = realnaCast; this.imaginarniCast = imaginarniCast; } public KomplexniCislo(KomplexniCislo komplexniCislo) { this.realnaCast = komplexniCislo.getRealnaCast(); this.imaginarniCast = komplexniCislo.getImaginarniCast(); } @Override public KomplexniCislo clone() { return new KomplexniCislo(this.realnaCast, this.imaginarniCast); } @Override public boolean equals(object druheCislo) { if (this == druheCislo) { return true; } if (druheCislo == null || getClass() != druheCislo.getClass()) { return false; } KomplexniCislo that = (KomplexniCislo)komplexniCislo; return this.hashCode() == that.hashCode(); } @Override public int getHashCode() { return Integer.hashCode(realnaCast) * 73 + Integer.hashCode(imaginarniCast); } public int getRealnaCast() { return realnaCast; } public int getImaginarniCast() { return imaginarniCast; } public static KomplexniCislo add(KomplexniCislo prvniCislo, KomplexniCislo druheCislo) { return new KomplexniCislo(prvniCislo.getRealnaCast() + druheCislo.getRealnaCast(), prvniCislo.getImaginarniCast() + druheCislo.getImaginarniCast()); } }
-
class KomplexniCislo { private int $realnaCast; private int $imaginarniCast; public function __construct(int $realnaCast = 0, int $imaginarniCast = 0) { $this->realnaCast = $realnaCast; $this->imaginarniCast = $imaginarniCast; } public function __construct(KomplexniCislo $komplexniCislo) { $this->realnaCast = $komplexniCislo->getRealnaCast(); $this->imaginarniCast = $komplexniCislo->getImaginarniCast(); } public function clone(): KomplexniCislo { return new KomplexniCislo($this->realnaCast, $this->imaginarniCast); } public function equals(object $druheCislo): bool { if (!($druheCislo instanceof KomplexniCislo)) { return false; } return $this->getHashCode() === $druheCislo->getHashCode(); } public function getHashCode(): int { return $this->realnaCast * 73 + $this->imaginarniCast; } public function getRealnaCast(): int { return $this->realnaCast; } public function getImaginarniCast(): int { return $this->imaginarniCast; } public static function add(KomplexniCislo $prvniCislo, KomplexniCislo $druheCislo): KomplexniCislo { return new KomplexniCislo($prvniCislo->getRealnaCast() + $druheCislo->getRealnaCast(), $prvniCislo->getImaginarniCast() + $druheCislo->getImaginarniCast()); } }
-
class KomplexniCislo { constructor(realnaCast = 0, imaginarniCast = 0) { this.realnaCast = realnaCast; this.imaginarniCast = imaginarniCast; } constructor(komplexniCislo) { this.realnaCast = komplexniCislo.getRealnaCast(); this.imaginarniCast = komplexniCislo.getImaginarniCast(); } clone() { return new KomplexniCislo(this.realnaCast, this.imaginarniCast); } equals(druheCislo) { if (!(druheCislo instanceof KomplexniCislo)) { return false; } return this.getHashCode() === druheCislo.getHashCode(); } getHashCode() { return this.realnaCast * 73 + this.imaginarniCast; } getRealnaCast() { return this.realnaCast; } getImaginarniCast() { return this.imaginarniCast; } static add(prvniCislo, druheCislo) { return new KomplexniCislo(prvniCislo.getRealnaCast() + druheCislo.getRealnaCast(), prvniCislo.getImaginarniCast() + druheCislo.getImaginarniCast()); } }
-
class KomplexniCislo: def __init__(self, realna_cast=0, imaginarni_cast=0): self.realna_cast = realna_cast self.imaginarni_cast = imaginarni_cast def __init__(self, komplexniCislo): self.realna_cast = komplexniCislo.get_realna_cast() self.imaginarni_cast = komplexniCislo.get_imaginarni_cast() def __eq__(self, other): if not isinstance(other, KomplexniCislo): return False return self.get_hash_code() == other.get_hash_code() def clone(self): return KomplexniCislo(self.realna_cast, self.imaginarni_cast) def get_hash_code(self): return self.realna_cast * 73 + self.imaginarni_cast def get_realna_cast(self): return self.realna_cast def get_imaginarni_cast(self): return self.imaginarni_cast def __add__(self, prvniCislo, druheCislo): return KomplexniCislo(prvniCislo.get_realna_cast() + druheCislo.get_realna_cast(), prvniCislo.get_imaginarni_cast() + druheCislo.get_imaginarni_cast())
Můžeme si všimnout, že u implementace pro C# dochází k přetěžování jednotlivých operátorů, protože to C# umožňuje. Můžeme si tak nahradit chování jednotlivých operátorů vlastním chováním.
Násobení imaginárního čísla číslem 73 v metodě
GetHashCode()
se provádí pro snížení počtu kolizí hashe.
Číslo 73 je prvočíslo, a tím ještě snižuje šanci na kolizi.
Závěr
Jak bylo zmíněno výše časté vytváření a mazání objektů z paměti
může mít neblahý důsledek na výkon programu. Proto většina neměnných
typů přichází s jejich proměnnými variantami. Příkladem může být
právě třída String
a StringBuilder
. Ačkoliv je v
globálním měřítku práce se třídou StringBuilder
pomalejší
za časté změny neplatíme žádnou režii. To ale neznamená, že by mohl
StringBuilder
nahradit String
.
StringBuilder
slouží k vytváření a modifikaci řetězce.
Kdykoliv můžeme zavolat metodu ToString()
a získat řetězec, se
kterým budeme dále pracovat. Většina knihoven potřebuje řetězec ve
formátu jak jej ukládá String
. V takových situacích nemůžeme
do metody předat StringBuilder
.
V další lekci, Method chaining a method cascading, si vysvětlíme význam pomocných proměnných ve zdrojových kódech, návrhový vzor Method chaining a také si ukážeme, jak funguje Method cascading.