S imutabilními kolekcemi se v C# setkáváme poměrně často, ale často si neuvědomujeme všechny možnosti, které máme, v čem přesně se liší, a jak vybrat tu správnou v daný moment.
Imutabilní – neboli neměnné – kolekce a kolekce pouze pro čtení působí na první pohled jako jednoduchý koncept. Stačí si představit např. seznam nebo slovník, jehož prvky nelze přidávat ani odebírat, pouze číst.
K čemu je to ale dobré?
Při zodpovídání této otázky zjistíme, že C# nabízí několik typů imutabilních či read-only kolekcí a odpověď záleží na širším kontextu. Pokud chceme psát kvalitní kód, je dobré se v dostupných možnostech vyznat a umět zvolit tu správnou. Pojďme se na ně podívat.
Rozhraní IReadOnly: pouze pro čtení na oko
S touto skupinou se vývojáři většinou setkají jako první. Jde o rozhraní jako IReadOnlyList<T>, IReadOnlyCollection<T> nebo IReadOnlyDictionary<TKey, TValue>.
Tato rozhraní obsahují podmnožinu metod klasických kolekcí - chybí právě ty metody, kterými lze kolekce měnit.
List<string> names = ["Ada", "Grace"];
IReadOnlyList<string> roNames = names;
Kód pracující s proměnnou roNames nemůže volat metody jako Add, Remove a Clear. To je pro vývojáře užitečný signál a zároveň vynucená kontrola.
Garance imutability je však pouze kosmetická. Kód pracující s původní referencí names může změny provádět libovolně. Obě reference ukazují na tentýž objekt, změna se tedy projeví i v roNames.
Referenci roNames je dokonce možné přetypovat zpět na List<string> a provést změnu. Tímto explicitním porušením kontraktu si však vývojář již v podstatě říká o problémy.
Přestože jsou rozhraní ze skupiny IReadOnly pouze pro čtení spíše na oko, jde o velmi užitečný mechanismus. Jeho využívání téměř nic nestojí a hlavními benefity jsou zpřehlednění kódu a zamezení mutaci kolekce omylem. Ve většině základních případů je to přesně to, co potřebujeme.
Třídy ReadOnly: opět jen na oko
Pro úplnost uveďme, že stejný princip platí i pro podobnou skupinu tříd zahrnující např. ReadOnlyCollection<T>, ReadOnlyDictionary<TKey,TValue> a ReadOnlySet<T>.
ReadOnlyCollection<string> rocNames = names.AsReadOnly();
Podobně jako rozhraní skupiny IReadOnly jsou i třídy skupiny ReadOnly jen jakýmsi obalem nad původní referencí. Skrz tento obal nelze kolekci upravovat, protože chybí mutační metody. Ale pod obalem se skrývá klasický mutovatelný objekt.
Immutable: opravdová imutabilita
Co kdybychom místo pouhé reference pouze pro čtení chtěli skutečně imutabilní kolekci?
Intuitivně bychom mohli sáhnout po třídách jako ImmutableList<T>, ImmutableDictionary<TKey, TValue>, ImmutableHashSet<T> a dalších. Instance těchto tříd jsou skutečně imutabilní.
IImmutableList<string> fruits = ImmutableList.Create("kiwi", "mango");
Takto vzniklý objekt bude vždy obsahovat právě řetězce kiwi a mango, nehledě na to, kolik referencí na něj vytvoříme a co s nimi budeme dělat.
Jaké benefity tato garance přináší? Tady je potřeba dát pozor na to, k čemu třídy skupiny Immutable ve skutečnosti slouží.
Při práci s imutabilními kolekcemi nejde o to, že se data vůbec nemění. Nesmí se měnit v rámci konkrétního objektu. Pokud chceme kolekci, která například obsahuje jeden prvek navíc, je nutné vytvořit kolekci novou jako kopii původní s naší změnou:
IImmutableList<string> fruitsAndVegetables = fruits.Add("carrot");
Podstata Immutable tříd spočívá v tom, že v tomto okamžiku nedochází ke kopírování celé původní kolekce. Vtip je v tom, že na pozadí se využívá mechanismus sdílené paměti, který dovoluje novému objektu efektivně využít neměnná data původní kolekce.
To přináší klíčovou úsporu paměti v situaci, kdy je původní kolekce velká. Změny lze navíc transparentně řetězit.
IImmutableList<string> fruitsAndVegetablesAndMeat = fruitsAndVegetables.Add("beef");
Kvůli tomuto mechanismu může být čtení z těchto imutabilních kolekcí pomalejší. Tento kompromis má své opodstatnění. Efektivní vytvoření kopie se změnou při zachování imutability původního objektu je klíčové pro některé styly programování – zejména funkcionální programování – a může účinně zmenšit komplexitu paralelních programů.
Frozen: vytvořit jednou, číst mnohokrát
V případě, že chceme imutabilní kolekci skutečně optimalizovanou pro čtení, můžeme od verze .NET 8 sáhnout po FrozenDictionary<TKey,TValue> a FrozenSet<T>.
FrozenSet<string> colors = new HashSet<string> { "yellow", "green" }.ToFrozenSet();
Takto vzniklá "zamrzlá" kolekce nabízí rychlé operace pro čtení. To se hodí v případě velké kolekce a častého přístupu k datům v ní. Cenou za tuto optimalizaci je pomalejší vytvoření takové kolekce. Při vytváření jsou totiž data kolekce analyzována a je pro ně vypočítána struktura umožňující právě ono rychlé čtení.
Háček na závěr
Imutabilní kolekce mají ještě jeden háček: jejich garance se vztahuje pouze na strukturu kolekce. Pokud kolekce obsahuje mutovatelné objekty, jejich data se mohou nadále měnit. Řešení je zřejmé - pokud na tom záleží, je nutné použít imutabilní objekty.
Kterou vybrat?
Na závěr si zopakujme možnosti, které nám C# nabízí:
- Rozhraní
IReadOnly využijeme běžně v kódu, když chceme vyjádřit, že se daná kolekce nemá měnit. - Třídy
Immutable jsou užitečné, pokud potřebujeme vytvářet změny bez modifikace původní instance kolekce. - Třídy
Frozen se hodí, pokud chceme optimalizovat čtení z kolekce za cenu jejího pomalejšího vytvoření a ztráty možnosti ji upravovat.
Já osobně v kódu často používám rozhraní IReadOnly. Naproti tomu třídy Immutable a Frozen se hodí spíše ve specifických situacích. Je však užitečné znát jejich vlastnosti nejen pro případy, kdy je jejich použití na místě, ale i proto, abychom se vyhnuli jejich nesprávnému použití.