paint-brush
ИЕнумерабле није оно што мислите да јесте – и крши ваш кодод стране@dmitriislabko
Нова историја

ИЕнумерабле није оно што мислите да јесте – и крши ваш код

од стране Dmitrii Slabko14m2025/02/18
Read on Terminal Reader

Предуго; Читати

Хајде да детаљно размотримо најчешћу грешку у вези са ИЕнумерабле – поновљено набрајање – али овог пута ћемо отићи мало дубље и размотрити зашто је поновљено набрајање грешка и које потенцијалне проблеме може да изазове, укључујући грешке које је тешко ухватити и репродуковати.
featured image - ИЕнумерабле није оно што мислите да јесте – и крши ваш код
Dmitrii Slabko HackerNoon profile picture
0-item

ТЛДР: Хајде да детаљно размотримо најчешћу грешку у вези са ИЕнумерабле – поновљено набрајање – али овог пута ћемо отићи мало дубље и размотрити зашто је поновљено набрајање грешка и које потенцијалне проблеме може да изазове, укључујући грешке које је тешко ухватити и репродуковати.

Шта је ИЕнумерабле

Прво прво – хајде да прегледамо (још опет, пошто има доста чланака о томе) шта је ИЕнумерабле, и генерички и негенерички. Многи програмери, као што показују многи интервјуи и прегледи кода, несвесно посматрају инстанце ИЕнумерабле као колекције, и ту ћемо почети.


Када погледамо дефиницију интерфејса ИЕнумерабле, ево шта видимо:

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


Нећемо улазити у детаље пописивача и тако даље; довољно је навести једну веома важну ствар: ИЕнумерабле није колекција. Већина типова колекција имплементира ИЕнумерабле, али то не претвара све имплементације ИЕнумерабле у колекције. Изненађујуће, то је оно што многи програмери пропуштају када имплементирају код који троши или производи ИЕнумерабле, а то је оно што има велики потенцијал за проблеме.


Дакле, шта је ИЕнумерабле? Постоји много различитих имплементација за ИЕнумерабле, али због једноставности можемо их сажети у једну (прилично нејасну) дефиницију: то је део кода који производи елементе на итерацији. За колекције у меморији овај код би једноставно прочитао тренутни елемент из основне колекције и померио његов унутрашњи показивач на следећи елемент, ако постоји. За софистицираније случајеве логика може бити веома разнолика и може имати било коју врсту нежељених ефеката који такође могу укључивати модификацију заједничког стања или зависити од заједничког стања.


Сада имамо мало бољу слику о томе шта је ИЕнумерабле, и то нам наговештава да имплементирамо потрошачки код на начин који не би требало да прави никакве претпоставке о овим тачкама:

  • трошкови потребни за производњу артикала – то јест, ако је ставка преузета из неке врсте складишта (поновно употребљена) или је створена;
  • исти предмет може бити произведен икада поново на следећим итерацијама;
  • све потенцијалне нежељене ефекте који би могли утицати (или не) на наредне итерације.


Као што видимо, ово је скоро супротно од општих конвенција када се понавља преко колекција у меморији, на пример:

  • колекција се не може мењати током итерације - ако је колекција измењена, то ће изазвати изузетак при преласку на следећи елемент у колекцији;
  • понављање преко исте колекције (која садржи исте елементе) ће увек произвести исте резултате и увек ће имати исте трошкове.


Безбедан начин да се ИЕнумерабле посматра је да се перципира као „произвођач података на захтев“. Једина гаранција коју овај произвођач података даје је да ће или набавити другу ставку или сигнализирати да нема више доступних ставки када буде позван. Све остало су детаљи имплементације одређеног произвођача података. Иначе, овде смо описали уговор интерфејса ИЕнумератор који омогућава итерацију преко ИЕнумерабле инстанце.


Још један важан део произвођача података на захтев је да он производи једну ставку по итерацији, а код потрошача може одлучити да ли жели да исцрпи све што је произвођач способан да произведе или да раније заустави потрошњу. Пошто произвођач података на захтев није ни покушао да ради на било којој потенцијалној 'будућој' ставкама, ово омогућава уштеду ресурса када се потрошња прерано заврши.


Дакле, када имплементирамо ИЕнумерабле произвођача, никада не би требало да правимо било какве претпоставке о обрасцима потрошње. Потрошачи могу покренути и зауставити потрошњу у било ком тренутку.

Потенцијални ефекти поновљених итерација.

Сада, пошто смо дефинисали прави начин да се користи ИЕнумерабле, хајде да прегледамо неколико примера поновљених итерација и њихов потенцијални утицај.


Пре него што пређемо на негативне примере, вреди напоменути да када ИЕнумерабле опонаша колекцију у меморији – низ, листу, хешсет, итд. – нема штете од поновљених итерација самих по себи. Код који користи ИЕнумерабле преко колекција у меморији у већини случајева би радио (скоро) једнако ефикасно као код који користи одговарајуће типове колекција. Наравно, могу постојати разлике у одређеним случајевима, иако не нужно негативне, пошто је Линк видео многа значајна повећања перформанси која би омогућила, на пример, коришћење векторизованих ЦПУ инструкција за колекције у меморији или збијање вишеструких позива метода интерфејса у један за сложене Линк изразе. Прочитајте ове чланке за више детаља: хттпс ://девблогс.мицрософт.цом/дотнет/перформанце-импровементс-ин-нет-8/#линк и хттпс://девблогс.мицрософт.цом/дотнет/перформанце-импровементс-ин-нет-9/#линк


Међутим, са становишта квалитета кода, постојање вишеструких итерација преко ИЕнумерабле се сматра лошом праксом, јер никада не можемо бити сигурни која би конкретна имплементација стигла испод хаубе.

Додатна напомена: пошто је ИЕнумерабле интерфејс, његово коришћење уместо конкретних типова приморава компајлер да емитује виртуелне позиве метода („цаллвирт“ ИЛ инструкција), чак и када конкретна основна класа имплементира ову методу као невиртуелну, тако да би позив невиртуелне методе био довољан. Позиви виртуелних метода су скупљи, јер увек морају да прођу кроз табелу метода инстанце да би разрешили адресу методе; такође, оне спречавају потенцијално уметање метода. Иако се ово може сматрати микрооптимизацијом, постоји доста путања кода који би показали различите метрике перформанси ако би се уместо интерфејса користили конкретни типови.

Када је поновљена итерација заиста лош избор.

Мало одрицање одговорности: овај пример је заснован на делу кода из стварног живота који је анонимизован и има све стварне детаље имплементације апстраховане.

Овај део кода је преузимао податке са удаљене крајње тачке за долазну листу параметара.

 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); }

Шта овде може поћи по злу? Хајде да погледамо најједноставнији пример:

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


Многи програмери би овај код сматрали безбедним – јер спречава поновљене итерације материјализујући излаз методе у колекцију у меморији. Али да ли је?


Пре него што уђемо у детаље, урадимо пробни рад. Можемо узети врло једноставну имплементацију 'ектерналСервице' за тестирање:

 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); } }


Онда можемо да покренемо тест:

 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()}");


И добијте излаз:

 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


Нешто овде није у реду, зар не? Очекивали бисмо да добијемо 'КуериФорДата' излаз само 3 пута, пошто имамо само 3 ИД-а у улазном аргументу. Међутим, излаз јасно показује да се број извршења удвостручио чак и пре него што је позив ТоЛист() завршен.


Да бисмо разумели зашто, погледајмо методу РетриевеАндПроцессДатаАсинц:

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


И погледајмо овај позив:

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


Када се позове метод РетриевеАндПроцессДатаАсинц, дешавају се следеће ствари.


На линији 1 добијамо IEnumerable<Task<Data>> инстанцу - у нашем случају, то би била 3 задатка, пошто шаљемо улазни низ са 3 елемента. Сваки задатак се ставља у ред чекања од стране скупа нити за извршење, и чим је нит доступна, он почиње. Тачна тачка завршетка ових задатака је неодређена због специфичности планирања скупа нити и конкретног хардвера на којем би овај код радио.


У реду 2 позив Task.WhenAll осигурава да су сви задаци из IEnumerable<Task<Data>> инстанце стигли до завршетка; у суштини, у овом тренутку добијамо прва 3 излаза из КуериФорДатаАсинц методе. Када се ред 2 заврши, можемо бити сигурни да су и сва 3 задатка завршена.


Међутим, ред 3 је место где су сви ђаволи поставили заседу. Хајде да их откријемо.


Променљива 'ретриевалТаскс' (на линији 1) је IEnumerable<Task<Data>> инстанца. Сада, хајде да направимо корак уназад и запамтимо да ИЕнумерабле није ништа друго до произвођач – део кода који производи (креира или поново користи) инстанце датог типа. У овом случају променљива 'ретриевалТаскс' је део кода који би:


  • идите преко колекције 'идс';
  • за сваки елемент ове колекције позиваће метод ектерналСервице.КуериФорДатаАсинц;
  • врати инстанцу задатка произведену претходним позивом.


Сву ову логику иза наше IEnumerable<Task<Data>> инстанце можемо изразити мало другачије. Имајте на уму да иако овај део кода изгледа сасвим другачије од оригиналног израза ids.Select(id => externalService.QueryForDataAsync(id, ct)) , он ради потпуно исто.


 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()); return retrievalFunc().Select(t => t.Result); }


Сада можемо врло јасно да видимо зашто је излаз нашег тестног кода удвостручен: функција 'ретриевалФунц' се позива двапут... Ако наш потрошни код настави да иде преко исте ИЕнумерабле инстанце, био би једнак поновљеним позивима методе 'ДатаПродуцер', која би своју логику изводила изнова и изнова за сваку поновну итерацију.


Надам се да је сада логика која стоји иза поновљених итерација ИЕнумерабле-а јасна.

Даље потенцијалне импликације поновљених итерација.

Ипак, треба напоменути једну ствар о овом узорку кода.


Погледајмо поново написану имплементацију:

 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. }


Произвођач у овом случају сваки пут креира нове инстанце задатка, а ми га позивамо два пута. Ово доводи до прилично необичне и не тако очигледне чињенице да када позовемо Task.WhenAll и .Select(t => t.Result) инстанце задатка на којима раде ова два дела кода су различите. Задаци на које се чекало (и самим тим стигли до завршетка) нису исти задаци из којих метода враћа резултате.


Дакле, овде произвођач креира два различита скупа задатака. Први скуп задатака се чека асинхроно - позив Task.WhenAll - али се други скуп задатака не чека. Уместо тога, код позива директно геттер својства Result који је у ствари злогласни анти-узорак синхронизације преко асинхронизације. Не бих улазио у детаље овог анти-обрасца, јер је ово велика тема. Овај чланак Степхена Тоуба баца доста светла на то: хттпс: //девблогс.мицрософт.цом/пфктеам/схоулд-и-екпосе-синцхроноус-врапперс-фор-асинцхроноус-метходс/


Међутим, само ради комплетности, ево неких потенцијалних проблема који овај код може изазвати:

  • застоји, када се користе у десктоп (ВинФормс, ВПФ, МАУИ) или .Нет Фк АСП.НЕТ апликацијама;
  • гладовање базена навоја када је под већим оптерећењима.


Ако апстрахујемо од тренутног узорка кода који је производио ове једноставне задатке, суочићемо се са чињеницом да поновљене итерације могу лако да изазову вишеструка извршења за било коју операцију, и да можда неће бити идемпотентна (то јест, наредни позиви са истим улазима ће донети различите резултате или чак једноставно неуспешно). На пример, стање на рачуну се мења.


Чак и да су те операције идемпотентне, могле би имати високе трошкове рачунања, па би њихово поновљено извршење једноставно узалуд сагорело наше ресурсе. А ако говоримо о коду који се покреће у облаку, ови ресурси могу имати цену коју бисмо морали да платимо.


Опет, пошто је поновљене итерације преко ИЕнумерабле инстанци прилично лако пропустити, може бити веома тешко открити зашто се апликација руши, троши много ресурса (укључујући новац) или ради ствари које не би требало да ради.

Зачините ствари само мало.

Узмимо оригинални тест код и мало га променимо:

 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()}");


Оставићу читаоцу да покуша да покрене овај код. Биће то добра демонстрација потенцијалних нежељених ефеката на које поновљене итерације могу неочекивано да наиђу.

Како поправити овај код?

Хајде да погледамо:

 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); }


Додавањем једног позива .ToArray() почетном IEnumerable<Task<Data>> ми бисмо 'материјализовали' ИЕнумерабле инстанцу у колекцију у меморији, а све накнадне поновне итерације преко колекције у меморији раде управо оно што бисмо претпоставили - једноставно прочитајте податке из меморије без икаквих неочекиваних нежељених ефеката кода изазваних поновљеним екецу-ом.


У суштини, када програмери напишу такав код (као у почетном узорку кода), они обично претпостављају да су ови подаци 'укочени' и да се ништа неочекивано никада неће догодити када им се приступи. Мада, као што смо управо видели, ово је прилично далеко од истине.


Могли бисмо додатно побољшати методу, али ово ћемо оставити за следеће поглавље.

О производњи ИЕнумерабле.

Управо смо погледали проблеме који могу настати због употребе ИЕнумерабле-а када је заснован на погрешним схватањима – када се не узима у обзир да ниједна од ових претпоставки не треба да се прави када се користи ИЕнумерабле:


  • трошкови потребни за производњу артикала – то јест, ако је ставка преузета из неке врсте складишта (поновно употребљена) или је створена;
  • ако се исти предмет може икада поново произвести у наредним итерацијама;
  • све потенцијалне нежељене ефекте који би могли утицати (или не) на наредне итерације.


Сада, хајде да погледамо обећање које би ИЕнумерабле произвођачи требало (идеално) да задрже за своје потрошаче:

  • артикли се производе 'на захтев' - не би требало да се ради 'унапред';
  • потрошачи су слободни да зауставе итерацију у било ком тренутку, а то би требало да уштеди ресурсе који би били потребни ако би се потрошња наставила;
  • ако итерација (потрошња) није започела, не треба користити ресурсе.


Опет, погледајмо наш претходни узорак кода са овог становишта.

 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); }


У суштини, овај код не испуњава ова обећања, јер се све тешкоће обавља у прва два реда, пре него што почне да производи ИЕнумерабле. Дакле, ако би било који потрошач одлучио да прекине потрошњу раније или је уопште не би започео, метода КуериФорДатаАсинц би и даље била позвана за све улазе.


Узимајући у обзир понашање прва два реда, било би много боље преписати метод да би се произвела колекција у меморији, као што је:

 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(); }


Ова имплементација не пружа никакве гаранције „на захтев“ – напротив, врло је јасно да би сав посао потребан за обраду датог уноса био завршен, а резултати подударања би били враћени.


Међутим, ако нам је потребно понашање „произвођача података на захтев“, метод би морао да буде потпуно преписан да би га обезбедио. на пример:

 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; } }


Док програмери обично не размишљају о овим специфичностима уговора за ИЕнумерабле, други код који га конзумирају често би правили претпоставке које одговарају овим специфичностима. Дакле, када се код који производи ИЕнумерабле поклапа са тим специфичностима, цела апликација би радила боље.

Закључак.

Надам се да је овај чланак помогао читаоцу да види разлику између уговора о наплати и специфичности ИЕнумерабле уговора. Колекције генерално обезбеђују извесно складиште за своје ставке (обично у меморији) и начине да се пређу преко ускладиштених ставки; Колекције које нису само за читање такође проширују овај уговор дозвољавајући модификовање/додавање/уклањање сачуваних ставки. Док су колекције веома конзистентне у вези са ускладиштеним ставкама, ИЕнумерабле у суштини објављује веома високу волатилност у овом погледу пошто се ставке производе када се инстанца ИЕнумерабле понавља.


Дакле, које би биле најбоље праксе када дођете у ИЕнумерабле? Хајде да дамо само листу поена:

  • Увек избегавајте поновљене итерације - осим ако је то оно што заиста намеравате и разумете последице. Безбедно је повезати више метода проширења Линк са ИЕнумерабле инстанцом (као што су .Where и .Select ), али било који други позив који би проузроковао стварну итерацију је ствар коју треба избегавати. Ако логика обраде захтева вишеструке пролазе преко ИЕнумерабле-а, или га материјализујте у колекцију у меморији или прегледајте да ли се логика може променити у један пролаз на основу ставке.
  • Када производња ИЕнумерабле укључује асинхронизовани код, размислите о томе да га промените у ИАсинцЕнумерабле или да замените ИЕнумерабле 'материјализованом' репрезентацијом - на пример, када бисте радије искористили предност паралелног извршавања и вратили резултате након што се сви задаци заврше.
  • Код који производи ИЕнумерабле треба да буде изграђен на начин који би омогућио избегавање трошења ресурса ако би се итерација раније зауставила или уопште не би почела.
  • Немојте користити ИЕнумерабле за типове података осим ако вам нису потребне његове специфичности. Ако је вашем коду потребан одређени степен 'генерализације', преферирајте друге интерфејсе типа колекције који не подразумевају понашање 'произвођача података на захтев', као што су ИЛист или ИРеадОнлиЦоллецтион.


L O A D I N G
. . . comments & more!

About Author

Dmitrii Slabko HackerNoon profile picture
Dmitrii Slabko@dmitriislabko
Accomplished software developer with 25+ years of experience. Focus on .NET technologies.

ХАНГ ТАГС

ОВАЈ ЧЛАНАК ЈЕ ПРЕДСТАВЉЕН У...