TLDR: Tekintsük át részletesen az IEnumerable-val kapcsolatos leggyakoribb hibát - ismételt felsorolást -, de ezúttal egy kicsit mélyebbre megyünk, és áttekintjük, hogy az ismételt felsorolás miért hiba, és milyen potenciális problémákat okozhat, beleértve a nehezen elkapható és reprodukálható hibákat.
Először is – nézzük át (még egyszer, mivel elég sok cikk van erről), hogy mi is az az IEnumerable, mind az általános, mind a nem általános. Sok fejlesztő, amint azt számos interjú és kódismertető mutatja, akaratlanul is gyűjteményként tekinti az IEnumerable példányait, és mi itt kezdjük.
Ha megnézzük az IEnumerable interfész definícióját, a következőket látjuk:
public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }
Nem fogunk belemenni az összeírók és egyebek részleteibe; elég egy nagyon fontos dolgot leszögezni: az IEnumerable nem gyűjtemény. A legtöbb gyűjteménytípus megvalósítja az IEnumerable-t, de ez nem teszi az összes IEnumerable megvalósítást gyűjteménybe. Meglepő módon ez az, amit sok fejlesztő hiányol, amikor IEnumerable-t használó vagy előállító kódot implementál, és ez az, ami nagy problémákat rejt magában.
Tehát mi az IEnumerable? Az IEnumerable-nak számos különféle implementációja létezik, de az egyszerűség kedvéért ezeket egy (meglehetősen homályos) definícióban foglalhatjuk össze: ez egy kódrészlet, amely iteráción keresztül állít elő elemeket. A memórián belüli gyűjtemények esetében ez a kód egyszerűen beolvassa az aktuális elemet az alapul szolgáló gyűjteményből, és áthelyezi a belső mutatóját a következő elemre, ha létezik. Kifinomultabb esetekben a logika nagyon változatos lehet, és bármilyen mellékhatása lehet, amelyek magukban foglalhatják a megosztott állapot módosítását, vagy függhetnek a megosztott állapottól.
Most egy kicsit jobb képünk van arról, hogy mi az IEnumerable, és ez arra enged következtetni, hogy a fogyasztó kódot oly módon valósítsuk meg, hogy ne feltételezzünk ezekről a pontokról:
Amint látjuk, ez majdnem az ellentéte az általános konvencióknak, amikor például a memórián belüli gyűjteményekben iterálunk:
Az IEnumerable biztonságos megközelítése, ha úgy tekintjük, mint egy „igény szerinti adatszolgáltatót”. Az egyetlen garancia, amit ez az adatszolgáltató ad, az az, hogy vagy beszerez egy másik terméket, vagy jelzi, hogy nincs több elérhető tétel, amikor hívják. Minden más egy adott adatelőállító megvalósítási részletei. Egyébként itt leírtuk az IEnumerator interfész szerződését, amely lehetővé teszi az iterációt egy IEnumerable példányon keresztül.
Az on-demand adatelőállító másik fontos eleme, hogy iterációnként egy tételt állít elő, és a fogyasztó kód eldöntheti, hogy ki akarja-e használni, amit a termelő képes előállítani, vagy korábban leállítja a fogyasztást. Mivel az on-demand adatkészítő még csak nem is próbálkozott potenciális „jövőbeli” tételeken dolgozni, ez erőforrás-megtakarítást tesz lehetővé, ha a fogyasztás idő előtt befejeződik.
Tehát az IEnumerable termelők megvalósítása során soha nem szabad feltételeznünk a fogyasztási szokásokat. A fogyasztó bármikor kezdeményezheti és leállíthatja a fogyasztást.
Most, mivel meghatároztuk az IEnumerable felhasználásának megfelelő módját, tekintsünk át néhány példát az ismételt iterációkra és azok lehetséges hatására.
Mielőtt a negatív példákra térnénk, érdemes megemlíteni, hogy ha az IEnumerable egy memórián belüli gyűjteményt - tömböt, listát, hashsetet stb. - személyesít meg, önmagában nem árt az ismétlődő iterációk. Az a kód, amely az IEnumerable-ot használja a memórián belüli gyűjteményekhez képest, a legtöbb esetben (majdnem) ugyanolyan hatékonyan fut, mint az egyező gyűjteménytípusokat fogyasztó kód. Természetesen bizonyos esetekben lehetnek eltérések, bár nem feltétlenül negatívak, mivel a Linq számos jelentős teljesítménynövekedést tapasztalt, amelyek lehetővé teszik például vektorizált CPU-utasítások használatát a memórián belüli gyűjteményekhez, vagy több interfész metódushívások tömörítését az összetett Linq kifejezésekhez. Kérjük, olvassa el ezeket a cikkeket további részletekért: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq és https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq
A kódminőség szempontjából azonban az IEnumerable többszörös iterációja rossz gyakorlatnak számít, mivel soha nem lehetünk biztosak abban, hogy milyen konkrét implementáció érkezik a motorháztető alá.
Egy mellékes megjegyzés: mivel az IEnumerable egy interfész, ennek használata a konkrét típusok helyett virtuális metódushívások kiadására kényszeríti a fordítót (a 'callvirt' IL utasítást), még akkor is, ha egy konkrét mögöttes osztály nem virtuálisként valósítja meg ezt a metódust, így egy nem virtuális metódushívás is elegendő. A virtuális metódushívások drágábbak, mivel mindig át kell menniük a példány metódustábláján a metóduscím feloldásához; továbbá megakadályozzák a lehetséges módszer beillesztését. Bár ez mikrooptimalizálásnak tekinthető, meglehetősen sok kódútvonal létezik, amelyek eltérő teljesítménymutatókat mutatnának, ha konkrét típusokat használnának az interfészek helyett.
Egy kis felelősségkizárás: ez a példa egy valós kódrészleten alapul, amelyet anonimizáltak, és az összes valós megvalósítási részletet elvontak.
Ez a kódrészlet adatokat kért le egy távoli végpontról a bejövő paraméterlista számára.
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); }
Mi lehet itt a baj? Tekintsük át a legegyszerűbb példát:
var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();
Sok fejlesztő biztonságosnak tartaná ezt a kódot, mert megakadályozza az ismétlődő iterációkat azáltal, hogy a metódus kimenetét egy memórián belüli gyűjteményben materializálja. De vajon az?
Mielőtt belemennénk a részletekbe, végezzünk egy tesztet. Tesztelésre használhatunk egy nagyon egyszerű „externalService” implementációt:
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); } }
Ezután lefuttathatjuk a tesztet:
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()}");
És kapja meg a kimenetet:
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
Itt valami elromlott, igaz? Azt vártuk volna, hogy csak háromszor kapjuk meg a 'QueryForData' kimenetet, mivel csak 3 azonosítónk van a bemeneti argumentumban. A kimenet azonban jól mutatja, hogy a végrehajtások száma megduplázódott még a ToList() hívás befejezése előtt.
Az ok megértéséhez nézzük meg a RetrieveAndProcessDataAsync metódust:
1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);
És nézzük ezt a felhívást:
(await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();
A RetrieveAndProcessDataAsync metódus meghívásakor a következő dolgok történnek.
Az 1. sorban egy IEnumerable<Task<Data>>
példányt kapunk - esetünkben ez 3 feladat lenne, mivel egy 3 elemű bemeneti tömböt adunk meg. Az egyes feladatokat a szálkészlet sorba állítja végrehajtásra, és amint rendelkezésre áll egy szál, elindul. Ezeknek a feladatoknak a pontos befejezési pontja nincs meghatározva a szálkészlet ütemezési sajátosságai és a konkrét hardver miatt, amelyen ez a kód fut.
A 2. sorban a Task.WhenAll
hívás biztosítja, hogy az IEnumerable<Task<Data>>
példányból származó összes feladat befejeződött; lényegében ezen a ponton kapjuk meg a QueryForDataAsync metódus első 3 kimenetét. Amikor a 2. sor befejeződik, biztosak lehetünk benne, hogy mind a 3 feladatot teljesítettük.
Azonban a 3. vonal az, ahol az összes ördög lesben állt. Kutassuk ki őket.
A 'retrievalTasks' változó (az 1. sorban) egy IEnumerable<Task<Data>>
példány. Most tegyünk egy lépést hátra, és ne feledjük, hogy az IEnumerable nem más, mint előállító – egy kódrészlet, amely adott típusú példányokat állít elő (létrehoz vagy újra felhasznál). Ebben az esetben a „retrievalTasks” változó egy olyan kódrészlet, amely:
Mindezt IEnumerable<Task<Data>>
példányunk mögötti logikát kissé másképp fejezhetjük ki. Kérjük, vegye figyelembe, hogy bár ez a kódrészlet meglehetősen különbözik az eredeti ids.Select(id => externalService.QueryForDataAsync(id, ct))
kifejezéstől, pontosan ugyanazt teszi.
IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }
Tehát a 'retrievalTasks' változót függvényhívásként kezelhetjük, állandó előre definiált bemenetekkel. Ezt a függvényt minden alkalommal meghívjuk, amikor feloldjuk a változó értékét. A RetrieveAndProcessDataAsync metódust átírhatjuk úgy, hogy az teljes mértékben tükrözze ezt az elképzelést, és teljesen egyformán működjön a kezdeti megvalósítással:
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); }
Most már nagyon világosan láthatjuk, hogy miért duplázták meg a tesztkód kimenetét: a 'retrievalFunc' függvény kétszer hívódik meg... Ha a fogyasztó kódunk folyamatosan ugyanazon az IEnumerable példányon megy keresztül, akkor egy 'DataProducer' metódus ismétlődő hívásait jelentené, amely minden ismétlésnél újra és újra futtatná a logikáját.
Remélem, most már világos az IEnumerable ismételt iterációi mögött meghúzódó logika.
Egy dolgot azonban még meg kell említeni ezzel a kódmintával kapcsolatban.
Nézzük még egyszer az átírt megvalósítást:
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. }
A producer ebben az esetben minden alkalommal új feladatpéldányokat hoz létre, mi pedig kétszer hívjuk meg. Ez egy meglehetősen sajátos és nem annyira nyilvánvaló tényhez vezet, hogy amikor Task.WhenAll
és .Select(t => t.Result)
függvényeket hívjuk, a feladatpéldányok, amelyeken ez a két kódrészlet működik, eltérőek. A várt (és így a befejezésig érkezett) feladatok nem ugyanazok a feladatok, amelyekből a metódus az eredményeket adja vissza.
Tehát itt a producer két különböző feladatsort hoz létre. Az első feladatkészletet aszinkron módon várják - a Task.WhenAll
hívást -, de a második feladatcsoportot nem. Ehelyett a kód közvetlenül a Result
property gettert hívja meg, amely gyakorlatilag a hírhedt szinkronizálás aszinkronizálás ellen mintája. Nem mennék bele ennek az antimintázatnak a részleteibe, mivel ez egy nagy téma. Stephen Toub ezen cikke eléggé megvilágítja a dolgot: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/
A teljesség kedvéért azonban itt van néhány lehetséges probléma, amelyet ez a kód okozhat:
Ha elvonatkoztatunk az ezeket az egyszerű feladatokat előállító jelenlegi kódmintától, akkor szembesülünk azzal a ténnyel, hogy az ismételt iterációk könnyen többszörös végrehajtást idézhetnek elő bármely műveletnél, és nem biztos, hogy idempotens (vagyis az azonos bemenetekkel történő későbbi hívások más eredményt produkálnak, vagy egyszerűen meghiúsulnak). Például a számlaegyenleg változása.
Még ha ezek a műveletek idempotensek is lennének, magas számítási költséggel járhatnak, és így ismételt végrehajtásuk egyszerűen hiába égetné el erőforrásainkat. És ha a felhőben futó kódról beszélünk, akkor ezeknek az erőforrásoknak költsége lehet, amelyet nekünk kell fizetnünk.
Ismételten, mivel az IEnumerable példányok ismétlődő iterációit meglehetősen könnyű kihagyni, nagyon nehéz lehet kideríteni, hogy egy alkalmazás miért omlik össze, miért költ el sok erőforrást (beleértve a pénzt), vagy miért tesz olyan dolgokat, amelyeket nem kellene.
Vegyük az eredeti tesztkódot, és változtassuk meg kissé:
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()}");
A kód futtatását az olvasóra bízom. Jól demonstrálja a lehetséges mellékhatásokat, amelyekkel az ismételt iterációk váratlanul szembesülhetnek.
Nézzük meg:
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); }
Ha egyetlen .ToArray()
hívást adunk a kezdeti IEnumerable<Task<Data>>
-hoz, az IEnumerable példányt egy memórián belüli gyűjteménybe 'materializáljuk', és a memórián belüli gyűjtemény további ismétlései pontosan azt teszik, amit feltételeznénk – egyszerűen csak beolvassa az adatokat a memóriából az ismételt kódvégrehajtások által okozott váratlan mellékhatások nélkül.
Lényegében, amikor a fejlesztők ilyen kódot írnak (mint a kezdeti kódmintában), általában azt feltételezik, hogy ezek az adatok „kőbe vannak vésve”, és soha nem történik semmi váratlan, amikor hozzáférnek. Bár, mint az imént láttuk, ez meglehetősen távol áll az igazságtól.
A módszert tovább fejleszthetnénk, de ezt a következő fejezetre hagyjuk.
Csak azt néztük meg, hogy milyen problémák merülhetnek fel az IEnumerable használatából, ha az tévhiteken alapul - amikor nem veszi figyelembe, hogy az IEnumerable fogyasztása során egyik feltételezést sem szabad meghozni:
Most pedig nézzük meg azt az ígéretet, amelyet számos gyártónak (ideális esetben) be kell tartania fogyasztói számára:
Ismét tekintsük át korábbi kódmintánkat ebből a szempontból.
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); }
Lényegében ez a kód nem teljesíti ezeket az ígéreteket, mivel minden kemény emelés az első két sorban megtörténik, mielőtt elkezdené az IEnumerable előállítását. Tehát, ha bármely fogyasztó úgy döntene, hogy korábban leállítja a fogyasztást, vagy egyáltalán nem kezdi el, akkor is a QueryForDataAsync metódus kerül meghívásra minden bemenetre.
Figyelembe véve az első két sor viselkedését, sokkal jobb lenne a metódust átírni egy memórián belüli gyűjtemény létrehozásához, például:
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(); }
Ez a megvalósítás nem ad semmiféle „on-demand” garanciát – éppen ellenkezőleg, nagyon egyértelmű, hogy az adott input feldolgozásához szükséges összes munka elkészülne, és a megfelelő eredményeket visszaadnák.
Ha azonban szükségünk van az „on-demand data producer” viselkedésre, akkor a módszert teljesen át kell írni, hogy biztosítsuk. Például:
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; } }
Míg a fejlesztők általában nem gondolnak az IEnumerable ilyen szerződéses sajátosságaira, más kódfelhasználók gyakran feltételezik, hogy megfelelnek ezeknek a sajátosságoknak. Tehát, ha az IEnumerable kódot előállító kód megfelel ezeknek a jellemzőknek, az egész alkalmazás jobban működne.
Remélem, ez a cikk segített az olvasónak megérteni a különbséget a behajtási szerződés és az IEnumerable szerződés sajátosságai között. A gyűjtemények általában bizonyos tárhelyet biztosítanak tárgyaik számára (általában a memóriában), és módot adnak a tárolt tételek áttekintésére; a nem olvasható gyűjtemények is meghosszabbítják ezt a szerződést azzal, hogy lehetővé teszik a tárolt tételek módosítását/hozzáadását/eltávolítását. Míg a gyűjtemények nagyon konzisztensek a tárolt tételekkel kapcsolatban, az IEnumerable alapvetően nagyon magas volatilitást deklarál ebben a tekintetben, mivel az elemek akkor jönnek létre, amikor egy IEnumerable példányt ismételnek.
Tehát mi lenne a bevált gyakorlat az IEnumerable használatához? Nézzük csak a pontlistát:
.Where
és .Select
), de minden olyan hívást, amely tényleges iterációt okozna, kerülni kell. Ha a feldolgozási logika többszörös áthaladást igényel egy IEnumerable-on, vagy materializálja azt egy memórián belüli gyűjteményben, vagy ellenőrizze, hogy a logika módosítható-e egyetlen lépésre tételenként.