Lekce 4 - 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 a zbytečnému vytváření objektů, jejichž konstrukce je příliš složitá nebo trvá dlouhou dobu.
U neměnných objektů nemůžeme změnit jejich vnitřní stav. V C#
.NET/Javě je to například třída String, která po každé operaci vrací
nový objekt typu String
. Podíváme se na způsob jak správně
takové objekty implementovat a vysvětlíme si k čemu nám jsou dobré.
Motivace
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 přímo 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ř. při použití vláken je mnohem
jednodušší používat immutable objekty, jelikož jsou
thread-safe).
Proč je String
neměnný?
Problematiku si vysvětleme na třídě String
. Pro ty, co
programují v C++ je odpověď zřejmá. Pro ostatní zde přináším menší
objasnění.
Textový řetězec je v počítači reprezentován jako souvislý blok paměti. Z programátorského hlediska bychom mohli říci, že se jedná o pole znaků. Na rozdíl od polí ve vyšších programovacích jazycích (např. PHP) mají pole v C/C++ určité omezující vlastnosti, například se automaticky nezvětšují. Když tedy v C vytvoříme pole, jedná se o prostý blok paměti. Pokud chceme pole zvětšit, musíme požádat o další (větší) místo někde jinde v paměti, původní hodnoty do něj zkopírovat a poté původní (menší) pole uvolnit. Pole nelze jednoduše zvětšit z toho důvodu, že za ním v paměti zkrátka nemusí být volno a nedalo by se prodloužit.
Ačkoliv můžeme v programu (i v C) použít seznamy, které se automaticky
rozšiřují (tzv. spojové seznamy), operační systém si s nimi neporadí.
Proto např. před vypsáním textu do konzole operačního systému (které
chceme pravděpodobně provádět poměrně často) musíme vytvořit klasické
pole a zkopírovat do něj náš text. Aby byl jazyk efektivní, měla by
třída String
ukládat řetězce tak, jak je potřebuje operační
systém.
Je tedy patrné, že u řetězců (které String
reprezentuje)
je nutné zakázat jejich modifikaci, protože by po změně mohl být text
jinak dlouhý a to implementace neumožňuje. Je tedy nutné vytvoření nového
pole a překopírováním pole původního, ve výsledku máme v paměti
dva řetězce, kdy se jeden poté uvolní.
Pozn.: 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 nedělá nic jiného, než že
převede seznam na pole znaků, který vrátí.
Hodnotové datové typy
Proč zmiňuji hodnotové datové typy, tedy základní datový typy jako je
int
, double
a další? Protože když se na chvilku
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ů (objektů) nevíme, protože každá
z metod může změnit vnitřní stav objektu.
Ve vyšších jazycích (PHP nebo Java) můžeme však obvykle vytvářet jen objektové typy. To je další z věcí, kde se liší C/C++ od vyšších programovacích jazyků. V nižších programovacích jazycích si můžeme říci, jakým způsobem chceme proměnou (nebo objekt) předat. Jestli funkce dostane referenci nebo kopii samotného objektu si specifikuje sám autor metody. Ve vyšších programovacích jazycích jsou objekty vždy předávány jako reference.
Naším úkolem nyní bude definovat třídu, která bude předávána referencí tak, aby nebylo možné její vnitřní hodnotu změnit a svým způsobem se chovala jako hodnotový datový typ.
Implementace
Implementace je velmi klíčová, pokud někde necháme skulinku, může nám program proměnnou změnit. Z hlediska návrhu si budeme muset dát pozor na několik věcí:
- Všechny atributy, na kterých je třída závislá, definujeme jako
konstantní, popřípadě
read-only
nebo jiným způsobem (záleží na jazyku), aby nám kompilátor kontroloval, jestli se hodnota mění. - 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 naleznete ve zdejších kurzech základů OOP u konkrétního jazyka, pod pojmem "klonování".
- Většinou musíme přepsat další, výchozí metody objektů. Např. v C#
.NET jsou to metody
GetHashCode()
aEquals()
a v JavěhashCode()
aequals()
. Výchozí implementace metodyGetHashCode()
/hashCode()
se rozhoduje na základě umístění v paměti. Tedy jestliže porovnáváme dva objekty (nebo chceme získat jejich hash) vrátí se stejná hodnota pro objekty, které jsou ve stejné paměti (prakticky to znamená, že se jedná o identické objekty – porovnáváme objekt sám se sebou). Naše implementace se musí rozhodovat na základě hodnot, které neměnný objekt má, a nikoliv na základě umístění v paměti. - Všechny funkce, které nějakým způsobem operují s vnitřním stavem
objektu, nesmí měnit hodnoty objektu přímo, ale musí vracet novou instanci
se změněnými hodnotami. Například pro celé číslo by operace
+,-,\,*,/,%
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
- Zvláštní pozor si musíme dát na kopírovací konstruktor a metodu
Clone()
(či jakoukoliv metodu vytvářející kopii). Kopie musí být vždy hluboká. Na obdobném principu funguje kopírovací konstruktor, které si musí vytvořit kopii původního objektu a teprve poté může hodnoty přiřadit k vlastním atributům. - Operátor
=
je poněkud kontroverzní. V některých jazycích si jej můžeme sami nadefinovat, v některých to není možné (vyšší jazyky). Asi 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.
Nyní se již můžeme podívat na implementaci takového neměnného objektu v C# .NET. Budeme předpokládat, že neexistuje typ pro komplexní čísla. My si takový typ vytvoříme:
class KomplexniCislo : ICloneable { private readonly int RealnaCast; private readonly int ImaginarniCast; public KomplexniCislo(int RealnaCast, int ImaginarniCast) { this.RealnaCast = RealnaCast; this.ImaginarniCast = ImaginarniCast; } public KomplexniCislo() { this.RealnaCast = 0; this.ImaginarniCast = 0; } public KomplexniCislo(KomplexniCislo Copy) { this.RealnaCast = Copy.RealnaCast; this.ImaginarniCast = Copy.ImaginarniCast; } public object 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() == this.GetHashCode(); } public override int GetHashCode() { return RealnaCast.GetHashCode() * 73 + ImaginarniCast.GetHashCode(); } public static bool operator ==(KomplexniCislo prvni, KomplexniCislo druhe) { return prvni.Equals(druhe); } public static bool operator !=(KomplexniCislo prvni, KomplexniCislo druhe) { return !prvni.Equals(druhe); } public static KomplexniCislo operator +(KomplexniCislo prvni, KomplexniCislo druhe) { return new KomplexniCislo(prvni.RealnaCast+druhe.RealnaCast,prvni.ImaginarniCast+druhe.ImaginarniCast); } }
Pozn.: Šipka
nahoru má být přerušovaná a namísto Extends
zase
implements
.
Výhody
Neměnné objekty nám poskytují několik výhod, kterých můžeme využít.
- Hodnoty se nám nemění pod rukama. Máme jistotu, že co jsme do proměnné uložili tam bude na libovolném 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é z vláken nemůže 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.
Změna vnitřního stavu
Č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říklad může být právě třída
String
a StringBuilder
. Ačkoliv je v globálním
měřítku práce se StringBuilderem pomalejší než práce se
samotným Stringem, za časté změny neplatíme žádnou režii. To
ale neznamená, že by mohl StringBuilder
nahradit
String
. StringBuilder
(jak již název napovídá)
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. A
jak již bylo řečeno na začátku, 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.