paint-brush
Nespočetné množstvo nie je to, čo si myslíte – a porušuje váš kódpodľa@dmitriislabko
Nová história

Nespočetné množstvo nie je to, čo si myslíte – a porušuje váš kód

podľa Dmitrii Slabko14m2025/02/18
Read on Terminal Reader

Príliš dlho; Čítať

Pozrime sa podrobne na najčastejšiu chybu v súvislosti s IEnumerable - opakované vyčíslenie - ale tentoraz pôjdeme trochu hlbšie a zopakujeme si, prečo je opakované vyčíslenie chybou a aké potenciálne problémy môže spôsobiť, vrátane ťažko odchytiteľných a reprodukovateľných chýb.
featured image - Nespočetné množstvo nie je to, čo si myslíte – a porušuje váš kód
Dmitrii Slabko HackerNoon profile picture
0-item

TLDR: Pozrime sa podrobne na najbežnejšiu chybu v súvislosti s IEnumerable – opakované vyčíslenie – ale tentoraz pôjdeme trochu hlbšie a zopakujeme si, prečo je opakované vyčíslenie chybou a aké potenciálne problémy môže spôsobiť, vrátane ťažko odchytiteľných a reprodukovateľných chýb.

Čo je IEnumerable

Po prvé – zopakujme si (ešte raz, keďže je o tom dosť veľa článkov), čo je IEnumerable, generické aj negenerické. Mnohí vývojári, ako ukazujú mnohé rozhovory a recenzie kódu, nevedomky vnímajú inštancie IEnumerable ako zbierky, a tu začneme.


Keď sa pozrieme na definíciu rozhrania IEnumerable, vidíme tu:

 public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }


Nebudeme zachádzať do podrobností o enumerátoroch a tak ďalej; stačí uviesť jednu veľmi dôležitú vec: IEnumerable nie je zbierka. Väčšina typov kolekcií implementuje IEnumerable, ale to nezmení všetky implementácie IEnumerable na kolekcie. Prekvapivo to je to, čo mnohým vývojárom chýba, keď implementujú kód spotrebúvajúci alebo produkujúci IEnumerable, a to je to, čo má veľký potenciál problémov.


Takže, čo je IEnumerable? Existuje mnoho rôznych implementácií pre IEnumerable, ale kvôli jednoduchosti ich môžeme zhrnúť do jednej (dosť vágnej) definície: je to kus kódu, ktorý vytvára prvky pri iterácii. V prípade kolekcií v pamäti tento kód jednoducho načíta aktuálny prvok zo základnej kolekcie a presunie svoj interný ukazovateľ na ďalší prvok, ak existuje. V sofistikovanejších prípadoch môže byť logika veľmi rôznorodá a môže mať akékoľvek vedľajšie účinky, ktoré môžu zahŕňať aj úpravu zdieľaného stavu alebo závisieť od zdieľaného stavu.


Teraz máme o niečo lepší obraz o tom, čo je IEnumerable, a to nám naznačuje implementáciu náročného kódu spôsobom, ktorý by nemal robiť žiadne predpoklady v týchto bodoch:

  • náklady potrebné na výrobu položiek - to znamená, ak bola položka získaná z nejakého druhu skladu (opätovne použitá) alebo bola vytvorená;
  • tá istá položka môže byť vyrobená kedykoľvek znova v nasledujúcich iteráciách;
  • akékoľvek potenciálne vedľajšie účinky, ktoré by mohli ovplyvniť (alebo neovplyvniť) následné iterácie.


Ako vidíme, je to takmer opak všeobecných konvencií pri iterácii cez kolekcie v pamäti, napríklad:

  • kolekciu nie je možné upraviť počas iterácie – ak sa kolekcia upraví, spôsobí to výnimku pri prechode na ďalší prvok v kolekcii;
  • iterácia cez rovnakú kolekciu (obsahujúcu rovnaké prvky) vždy prinesie rovnaké výsledky a bude mať vždy rovnaké náklady.


Bezpečným spôsobom, ako sa na IEnumerable pozerať, je vnímať ho ako „producenta údajov na požiadanie“. Jedinou zárukou, ktorú tento producent údajov poskytuje, je, že si buď zaobstará ďalšiu položku, alebo signalizuje, že po zavolaní už nie sú k dispozícii žiadne ďalšie položky. Všetko ostatné sú detaily implementácie konkrétneho producenta údajov. Mimochodom, tu sme opísali zmluvu rozhrania IEnumerator, ktorá umožňuje iteráciu cez inštanciu IEnumerable.


Ďalšou dôležitou súčasťou producenta dát na požiadanie je, že produkuje jednu položku na iteráciu a spotrebiteľský kód sa môže rozhodnúť, či chce vyčerpať všetko, čo je producent schopný vyrobiť, alebo spotrebu zastaví skôr. Keďže sa producent údajov na požiadanie ani nepokúsil pracovať na žiadnych potenciálnych „budúcich“ položkách, umožňuje to šetriť zdroje, keď sa spotreba skončí predčasne.


Takže pri zavádzaní množstva výrobcov by sme nikdy nemali robiť žiadne predpoklady o vzorcoch spotreby. Spotrebitelia môžu začať a zastaviť spotrebu v ktoromkoľvek bode.

Možné účinky opakovaných iterácií.

Teraz, keď sme definovali správny spôsob konzumácie IEnumerable, pozrime si niekoľko príkladov opakovaných iterácií a ich potenciálny vplyv.


Predtým, než prejdeme k negatívnym príkladom, stojí za zmienku, že keď IEnumerable zosobňuje kolekciu v pamäti – pole, zoznam, hashset atď. – nie je na škodu opakované opakovanie ako také. Kód, ktorý spotrebuje IEnumerable cez kolekcie v pamäti, by vo väčšine prípadov bežal (takmer) rovnako efektívne ako kód spotrebúvajúci zodpovedajúce typy kolekcií. Samozrejme, v určitých prípadoch môžu existovať rozdiely, aj keď nie nevyhnutne negatívne, pretože Linq zaznamenal veľa významných zvýšení výkonu, ktoré by umožnili napríklad použiť vektorizované inštrukcie CPU pre kolekcie v pamäti alebo zhutniť volania viacerých metód rozhrania do jedného pre zložité výrazy Linq. Ďalšie podrobnosti nájdete v týchto článkoch: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq a https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq


Z hľadiska kvality kódu sa však viacnásobné opakovanie cez IEnumerable považuje za zlý postup, pretože si nikdy nemôžeme byť istí, aká konkrétna implementácia sa dostane pod kapotu.

Vedľajšia poznámka: keďže IEnumerable je rozhranie, jeho použitie namiesto konkrétnych typov núti kompilátor vysielať volania virtuálnej metódy (inštrukcia IL „callvirt“), aj keď konkrétna základná trieda implementuje túto metódu ako nevirtuálnu, takže volanie nevirtuálnej metódy by stačilo. Volania virtuálnych metód sú drahšie, pretože vždy potrebujú prejsť cez tabuľku metód inštancie, aby sa vyriešila adresa metódy; tiež zabraňujú potenciálnemu inliningu metódy. Aj keď to možno považovať za mikrooptimalizáciu, existuje pomerne veľa ciest kódu, ktoré by vykazovali rôzne metriky výkonu, ak by sa namiesto rozhraní použili konkrétne typy.

Keď opakovaná iterácia je naozaj zlá voľba.

Malé vylúčenie zodpovednosti: tento príklad je založený na skutočnom kúsku kódu, ktorý bol anonymizovaný a obsahuje všetky detaily skutočnej implementácie.

Tento kus kódu získaval údaje zo vzdialeného koncového bodu pre zoznam prichádzajúcich parametrov.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }

Čo sa tu môže pokaziť? Pozrime sa na najjednoduchší príklad:

 var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();


Mnoho vývojárov by považovalo tento kód za bezpečný - pretože zabraňuje opakovaným iteráciám tým, že zhmotní výstup metódy do kolekcie v pamäti. Ale je to tak?


Predtým, ako prejdeme k detailom, urobme test. Na testovanie môžeme použiť veľmi jednoduchú implementáciu „externalService“:

 record Data(int Value); class Service { private static int counter = 0; public async Task<IData> QueryForDataAsync(int id, CancellationToken ct) { var timestamp = Stopwatch.GetTimestamp(); await Task.Delay(TimeSpan.FromMilliseconds(30), ct); int cv = Interlocked.Increment(ref counter); Console.WriteLine($"QueryForData - id={id} - {cv}; took {Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds:F0} ms"); return new Data(id); } }


Potom môžeme spustiť test:

 var externalService = new Service(); var results = (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList(); Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } Console.WriteLine($"Getting the count again: {results.Count()}");


A získajte výstup:

 QueryForData - id=3 - 1; took 41 ms QueryForData - id=1 - 3; took 43 ms QueryForData - id=2 - 2; took 42 ms QueryForData - id=1 - 4; took 33 ms QueryForData - id=2 - 5; took 30 ms QueryForData - id=3 - 6; took 31 ms Querying completed Retrieved 3 elements Getting the count again: 3


Niečo tu nesedí, však? Očakávali by sme, že výstup 'QueryForData' dostaneme iba 3-krát, pretože vo vstupnom argumente máme iba 3 identifikátory. Výstup však jasne ukazuje, že počet vykonaní sa zdvojnásobil ešte pred dokončením volania ToList().


Aby sme pochopili prečo, pozrime sa na metódu RetrieveAndProcessDataAsync:

 1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);


A pozrime sa na tento hovor:

 (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();


Keď sa zavolá metóda RetrieveAndProcessDataAsync, stanú sa nasledujúce veci.


Na riadku 1 dostaneme inštanciu IEnumerable<Task<Data>> - v našom prípade by to boli 3 úlohy, keďže predkladáme vstupné pole s 3 prvkami. Každá úloha je zaradená do frontu v oblasti vlákien na vykonanie a akonáhle je vlákno k dispozícii, spustí sa. Presný bod dokončenia týchto úloh nie je určený kvôli špecifikám plánovania oblasti vlákien a konkrétnemu hardvéru, na ktorom bude tento kód bežať.


Na riadku 2 volanie Task.WhenAll zabezpečuje, že všetky úlohy z inštancie IEnumerable<Task<Data>> boli dokončené; v podstate v tomto bode získame prvé 3 výstupy z metódy QueryForDataAsync. Keď sa dokončí riadok 2, môžeme si byť istí, že sa dokončili aj všetky 3 úlohy.


Avšak, riadok 3 je miesto, kde všetci diabli položili zálohu. Poďme ich odhaliť.


Premenná 'retrievalTasks' (v riadku 1) je inštancia IEnumerable<Task<Data>> . Teraz urobme krok späť a pamätajme si, že IEnumerable nie je nič iné ako producent – kus kódu, ktorý produkuje (vytvára alebo opätovne používa) inštancie daného typu. V tomto prípade je premenná „retrievalTasks“ kus kódu, ktorý by:


  • prejdite cez kolekciu 'id';
  • pre každý prvok tejto kolekcie by sa volala metóda externalService.QueryForDataAsync;
  • vrátiť inštanciu úlohy vytvorenú predchádzajúcim volaním.


Celú túto logiku za našou inštanciou IEnumerable<Task<Data>> môžeme vyjadriť trochu inak. Upozorňujeme, že hoci tento kus kódu vyzerá celkom odlišne od pôvodného výrazu ids.Select(id => externalService.QueryForDataAsync(id, ct)) , robí presne to isté.


 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }


Takže môžeme považovať premennú 'retrievalTasks' za volanie funkcie s konštantnou preddefinovanou množinou vstupov. Táto funkcia by sa volala zakaždým, keď vyriešime hodnotu premennej. Metódu RetrieveAndProcessDataAsync môžeme prepísať spôsobom, ktorý by plne odrážal túto myšlienku a ktorý by fungoval úplne rovnako ako počiatočná implementácia:


 async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); return retrievalFunc().Select(t => t.Result); }


Teraz veľmi jasne vidíme, prečo bol výstup nášho testovacieho kódu zdvojnásobený: funkcia 'retrievalFunc' sa volá dvakrát... Ak náš spotrebovávaný kód pokračuje v tej istej inštancii IEnumerable, rovnalo by sa to opakovaným volaniam metódy 'DataProducer', ktorá by svoju logiku spúšťala znova a znova pri každom opakovaní.


Dúfam, že teraz je logika opakovaných iterácií IEnumerable jasná.

Ďalšie potenciálne dôsledky opakovaných iterácií.

O tejto ukážke kódu je však stále potrebné spomenúť jednu vec.


Pozrime sa ešte raz na prepísanú implementáciu:

 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } } async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); // First producer call. return retrievalFunc().Select(t => t.Result); // Second producer call. }


Producent v tomto prípade zakaždým vytvára nové inštancie úloh a voláme to dvakrát. To vedie k dosť zvláštnemu a nie tak zrejmému faktu, že keď voláme Task.WhenAll a .Select(t => t.Result) inštancie úloh, s ktorými tieto dve časti kódu pracujú, sú odlišné. Úlohy, na ktoré sa čakalo (a teda boli dokončené) nie sú tie isté úlohy, z ktorých metóda vracia výsledky.


Takže tu výrobca vytvára dva rôzne súbory úloh. Na prvú skupinu úloh sa čaká asynchrónne – volanie Task.WhenAll – ale na druhú skupinu úloh sa nečaká. Namiesto toho kód volá priamo na získavanie vlastnosti Result , čo je v skutočnosti neslávne známy anti-vzor synchronizácie cez asynchronizáciu. Nechcel by som zachádzať do detailov tohto anti-vzorca, keďže ide o rozsiahlu tému. Tento článok od Stephena Touba to vrhá dosť svetla: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/


Avšak len pre úplnosť uvádzame niektoré potenciálne problémy, ktoré môže tento kód spôsobiť:

  • uviaznutia pri použití na pracovnej ploche (WinForms, WPF, MAUI) alebo v aplikáciách .Net Fx ASP.NET;
  • niť bazén hladovanie pri vyššom zaťažení.


Ak abstrahujeme od aktuálnej vzorky kódu, ktorá produkovala tieto jednoduché úlohy, čelíme skutočnosti, že opakované iterácie môžu ľahko spôsobiť viacnásobné vykonanie akejkoľvek operácie a nemusí to byť idempotentné (to znamená, že následné volania s rovnakými vstupmi budú musieť priniesť odlišné výsledky alebo dokonca jednoducho zlyhať). Napríklad zmeny zostatku na účte.


Aj keby boli tieto operácie idempotentné, môžu mať vysoké výpočtové náklady a ich opakované vykonávanie by jednoducho zbytočne spálilo naše zdroje. A ak hovoríme o kóde bežiacom v cloude, tieto zdroje môžu mať náklady, ktoré by sme museli zaplatiť.


Opäť, pretože opakované iterácie cez IEnumerable inštancie sa dajú celkom ľahko vynechať, môže byť veľmi ťažké zistiť, prečo aplikácia padá, míňa veľa zdrojov (vrátane peňazí) alebo robí veci, ktoré by nemala robiť.

Okoreniť veci len trochu.

Zoberme si pôvodný testovací kód a mierne ho zmeňte:

 var externalService = new Service(); var cts = new CancellationTokenSource(); // New line. var results = (await RetrieveAndProcessDataAsync([1, 2, 3], cts.Token)); // Using cts.Token instead of a default token, and not materializing the IEnumerable. Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } cts.Cancel(); // New line. Console.WriteLine($"Getting the count again: {results.Count()}");


Nechám na čitateľa, aby sa pokúsil spustiť tento kód. Bude to dobrá demonštrácia potenciálnych vedľajších účinkov, s ktorými sa môžu neočakávane stretnúť opakované iterácie.

Ako opraviť tento kód?

Poďme sa pozrieť:

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); // Adding .ToArray() call. await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


Pridaním jediného volania .ToArray() do počiatočného IEnumerable<Task<Data>> by sme 'materializovali' inštanciu IEnumerable do kolekcie v pamäti a akékoľvek následné opakované iterácie v kolekcii v pamäti robia presne to, čo by sme predpokladali – jednoducho načítali dáta z pamäte bez akýchkoľvek neočakávaných vedľajších účinkov spôsobených opakovaným spúšťaním kódu.


V podstate, keď vývojári píšu takýto kód (ako v počiatočnej vzorke kódu), zvyčajne predpokladajú, že tieto údaje sú „vytesané do kameňa“ a pri prístupe k nim by sa nikdy nestalo nič neočakávané. Aj keď, ako sme práve videli, je to dosť ďaleko od pravdy.


Metódu by sme mohli ešte vylepšiť, ale to si necháme na ďalšiu kapitolu.

Na výrobu IEnumerable.

Práve sme sa pozreli na problémy, ktoré môžu vyplynúť z používania IEnumerable, keď je založené na mylných predstavách – keď neberie do úvahy, že pri používaní IEnumerable by sa nemal robiť ani jeden z týchto predpokladov:


  • náklady potrebné na výrobu položiek - to znamená, ak bola položka získaná z nejakého druhu skladu (opätovne použitá) alebo bola vytvorená;
  • ak je možné tú istú položku vyrobiť ešte niekedy v nasledujúcich iteráciách;
  • akékoľvek potenciálne vedľajšie účinky, ktoré by mohli ovplyvniť (alebo neovplyvniť) následné iterácie.


Teraz sa pozrime na sľub, ktorý by mal (v ideálnom prípade) dodržať pre svojich spotrebiteľov množstvo výrobcov:

  • položky sa vyrábajú „na požiadanie“ – nepredpokladá sa, že by sa „vopred“ vynakladalo žiadne úsilie;
  • spotrebitelia môžu kedykoľvek zastaviť iteráciu, čo by malo ušetriť zdroje, ktoré by boli potrebné, ak by spotreba pokračovala;
  • ak sa iterácia (spotreba) nezačala, nemali by sa použiť žiadne zdroje.


Z tohto hľadiska si opäť preštudujme našu predchádzajúcu ukážku kódu.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


Tento kód v podstate nespĺňa tieto sľuby, pretože všetko ťažké sa robí na prvých dvoch riadkoch predtým, ako začne produkovať IEnumerable. Ak by sa teda ktorýkoľvek spotrebiteľ rozhodol ukončiť spotrebu skôr, alebo by ju dokonca nezačal vôbec, pre všetky vstupy by sa stále volala metóda QueryForDataAsync.


Vzhľadom na správanie prvých dvoch riadkov by bolo oveľa lepšie prepísať metódu na vytvorenie kolekcie v pamäti, ako napríklad:

 async Task<IList<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result).ToArray(); }


Táto implementácia neposkytuje žiadne záruky „on-demand“ – naopak, je úplne jasné, že všetka práca potrebná na spracovanie daného vstupu by bola dokončená a vrátili by sa zodpovedajúce výsledky.


Ak však potrebujeme správanie „producenta údajov na požiadanie“, metóda by sa musela úplne prepísať, aby ho poskytovala. Napríklad:

 async IAsyncEnumerable<Data> RetrieveAndProcessDataAsAsyncEnumerable(IList<int> ids, [EnumeratorCancellation] CancellationToken ct) { foreach (int id in ids) { var result = await externalService.QueryForData(id, ct); yield return result; } }


Zatiaľ čo vývojári zvyčajne neuvažujú o týchto špecifikách zmluvy IEnumerable, iný kód, ktorý ho spotrebúva, by často vytváral predpoklady zodpovedajúce týmto špecifikám. Takže, keď kód produkujúci IEnumerable zodpovedá týmto špecifikám, celá aplikácia by fungovala lepšie.

Záver.

Dúfam, že tento článok pomohol čitateľovi pochopiť rozdiel medzi inkasnou zmluvou a špecifikáciami zmluvy IEnumerable. Kolekcie vo všeobecnosti poskytujú určité úložisko pre svoje položky (zvyčajne v pamäti) a spôsoby, ako prechádzať uložené položky; zbierky, ktoré nie sú určené len na čítanie, tiež rozširujú túto zmluvu tým, že umožňujú upravovať/pridávať/odstraňovať uložené položky. Zatiaľ čo kolekcie sú veľmi konzistentné, pokiaľ ide o uložené položky, IEnumerable v podstate deklaruje veľmi vysokú nestálosť v tomto ohľade, pretože položky sa vyrábajú, keď sa inštancia IEnumerable iteruje.


Aké by boli teda najlepšie postupy pri príchode do IEnumerable? Uveďme len zoznam bodov:

  • Vždy sa vyhnite opakovaným opakovaniam – pokiaľ to nie je váš skutočný zámer a nechápete dôsledky. Je bezpečné priradiť viacero metód rozšírenia Linq k inštancii IEnumerable (ako napríklad .Where a .Select ), ale akémukoľvek inému volaniu, ktoré by spôsobilo skutočnú iteráciu, sa treba vyhnúť. Ak logika spracovania vyžaduje viacero prechodov cez IEnumerable, buď to zhmotnite do kolekcie v pamäti, alebo skontrolujte, či je možné logiku zmeniť na jeden prechod na základe jednotlivých položiek.
  • Keď vytváranie IEnumerable zahŕňa asynchrónny kód, zvážte jeho zmenu na IAsyncEnumerable alebo nahraďte IEnumerable „materializovanou“ reprezentáciou – napríklad, keď by ste radšej využili výhody paralelného spustenia a vrátili výsledky po dokončení všetkých úloh.
  • Kód produkujúci IEnumerable by mal byť zostavený spôsobom, ktorý by umožnil vyhnúť sa míňaniu zdrojov, ak by sa iterácia zastavila skôr alebo sa nezačala vôbec.
  • Nepoužívajte IEnumerable pre dátové typy, pokiaľ nepotrebujete jeho špecifiká. Ak váš kód potrebuje určitý stupeň „zovšeobecnenia“, uprednostnite iné rozhrania typu kolekcie, ktoré neimplikujú správanie „producenta údajov na požiadanie“, ako napríklad IList alebo IReadOnlyCollection.