Saturday, June 12, 2021 19:51

Cuprins >> LINQ > Instrucțiunea yield return

Instrucțiunea yield return

În lecția anterioară, am vorbit despre IEnumerable și IEnumerator și despre modul în care acestea ne ajută atunci când trebuie să iterăm pe colecții de date. Să luăm un exemplu care tratează aceste concepte:


Avem o funcție, GenereazaNumereAleatorii(), în interiorul căreia declarăm o listă de int-uri, generăm un număr de int-uri aleatorii, egal cu parametrul _numarElemente, adăugăm int-urile aleatorii în listă, apoi returnăm lista. În metoda Main(), pur și simplu iterăm prin elementele returnate de respectiva metodă și afișăm rezultatele la consolă.

Rezultatul este acesta:

Problema cu această abordare este aceea că trebuie mai întâi să alocăm memorie pentru o listă, apoi trebuie să adăugăm elemente în aceasta, apoi să returnăm întreaga listă populată și trebuie să așteptăm ca întreaga listă să fie populată înainte de a putea accesa rezultatele, etc. Pare destul de ciudat, nu-i așa? În acest scop, Microsoft a adăugat în C# 2.0 ceea ce programatorii numesc blocuri iteratoare, care sunt funcții create pentru a returna fie IEnumerable, fie IEnumerator, în forma lor generică sau non-generică, folosind una sau mai multe instrucțiuni yield.

Pentru a începe să explic acest concept, să convertim același cod de mai sus, astfel încât să folosim o instrucțiune yield:

Vizualizează schimbări cod


Legend:

  • liniile verzi cu un semn plus lângă numerele liniilor sunt linii noi adăugate
  • liniile rosii cu un semn minus lângă numerele liniilor sunt linii vechi șterse



Puteți vedea că nu mai folosesc o listă, ceea ce este deja o îmbunătățire. Dacă începem să depanăm programul, vom ajunge la acest punct:

Dar, dacă apăsați Step Into sau apăsați tasta F11 în acest moment, astfel încât depanatorul să continue până la punctul următor al execuției, veți observa că, spre deosebire de așteptările voastre, nici măcar nu va intra în funcția GenereazaNumereAleatorii(), ca ar trebui. Adică, dacă execuția este întreruptă în punctul în care invocăm acea funcție, următorul pas logic ar fi să o vedem sărind în interiorul acelei funcții, pentru a îndeplini apelul:

Nu pare să o facă, nu-i așa? Deci, de ce refuză compilatorul să execute acea funcție și ce înseamnă faptul că a mers la cuvântul cheie in? Doar atunci când apăsăm din nou Step Into, vom ajunge acolo:

Nu numai asta, dar știind că operatorul return ar trebui să returneze imediat o valoare din funcția noastră și să returneze execuția la codul care a invocat funcția, veți fi și mai surprinși să observați că de fapt execuția continuă să se deplaseze înainte și înapoi între funcția apelantă și și cea apelată de mai multe ori, mai precis, de 10 ori. În plus, veți observa că execuția va reveni la acest punct:

Ceea ce înseamnă nu numai faptul că execuția sare înapoi la funcția GenereazaNumereAeatorii(), ci și că sare înapoi direct la incrementarea variabilei de control a buclei for, continuând iterația de unde a rămas în ciclul anterior! Este ca și cum compilatorul salvează starea funcției la momentul respectiv, într-un fel.

Deci, rezumatul acestei lecții este faptul că puteți utiliza yield return atunci când doriți să returnați mai mult de o valoare dintr-o funcție, pe durata mai multor apeluri. Pentru aceia dintre voi care doresc să înțeleagă cum realizează compilatorul acest lucru, și cum funcționează, să continuăm prin examinarea assembly-ului generat, cu un instrument numit ILSpy, pe care l-am folosit deja în unele lecții anterioare. Acest instrument ne permite să vedem codul pe care compilatorul îl generează din codurile noastre, fie în C#, fie în MSIL (Microsoft Intermediate Language).

Metoda Main() arată exact la fel:

Dar dacă navigăm la funcția GenereazaNumereAeatorii(), obținem acest lucru:

și putem observa că arată destul de diferit de codurile pe care le aveam în programul nostru. Puteți vedea utilizarea operatorului new, care indică instanțierea unei clase numite <GenereazaNumereAleatorii>d__1, care poate părea cam ciudat pentru un nume de clasă, deoarece noi, ca utilizatori, nu avem voie să folosim caractere speciale, cum ar fi paranteze unghiulare, în nume de clase, dar acest lucru este perfect valabil pentru compilator. Navigați la această clasă și veți vedea ceva familiar:

Cât de convenabil! Această clasă implementează interfețele IEnumerable și IEnumerator! Iar funcția MoveNext() arată astfel:

Este destul de evident faptul că, deși folosim doar o simplă instrucțiune yield return, există o doză uriașă de „zahăr sintactic” implicat, în spatele cortinei. Să încercăm să reluăm aceleași procese pe care le face compilatorul, pentru a crea propria noastră versiune de bloc iterator. Mai întâi, scăpăm de instrucțiunea yield return și returnăm o instanță de clasă, așa cum s-a văzut deja:

Vizualizează schimbări cod


Legend:

  • liniile verzi cu un semn plus lângă numerele liniilor sunt linii noi adăugate
  • liniile rosii cu un semn minus lângă numerele liniilor sunt linii vechi șterse


La momentul acesta, doar am creat o clasă imbricată numită ClasaGenereazaNumereAleatorii (compilatorului îi place să mențină un domeniu de definiție cât mai restrâns posibil, de aceea generează o clasă imbricată) și am înlocuit instrucțiunea yield return cu returnarea unei instanțe a acestei noi clase. Desigur, în acest moment, codul nu va putea fi compilat din mai multe motive, dar vom corecta și acest lucru.

Pentru clasa mea personalizată, am implementat interfețele generice IEnumerable și IEnumerator, deoarece asta face și compilatorul.

Următorul pas ar fi să începem adăugarea codurilor pentru membrii interfețelor. Din lecția mea despre IEnumerator știți deja că metoda Reset() este complet inutilă, așa că o vom lăsa așa cum este.

Puteți observa că metoda IEnumerable.GetEnumerator() returnează un obiect IEnumerator și, dacă sunteți ca mine, puteți vedea că nu este necesar să scriem cod atât în ​​metoda IEnumerable.GetEnumerator(), cât și în GetEnumerator(). Amândouă returnează un obiect IEnumerator, deci, pot să returnez unul din celălalt:

Ați putea să credeți că GetEnumerator() pe care îl returnez va fi rezolvat în IEnumerable.GetEnumerator(), obținând astfel un apel recursiv nesfârșit, dar apelul se rezolvă de fapt la versiunea implementată neexplicit a GetEnumerator().

De asemenea, ați putea să vă întrebați de ce returnez this din versiunea implementată neexplicit a GetEnumerator(), iar răspunsul este simplu: clasa mea ClasaGenereazaNumereAleatorii implementează atât IEnumerable, cât și IEnumerator, ceea ce înseamnă nu numai că ESTE un IEnumerable, dar și un IEnumerator, care este exact ceea ce GetEnumerator() trebuie să returneze.

Aplic trucul de mai sus și proprietăților Current și IEnumerator.Current și voi returna versiunea implementată neexplicit din cea explicită:

Vizualizează schimbări cod


Legend:

  • liniile verzi cu un semn plus lângă numerele liniilor sunt linii noi adăugate
  • liniile rosii cu un semn minus lângă numerele liniilor sunt linii vechi șterse

În această formă, următorul pas logic este că bucla noastră for trebuie cumva să ajungă în interiorul funcției MoveNext(). Dacă vă gândiți bine, de fiecare dată când apelez MoveNext(), nu spun decât „mută data viitoare la următorul număr aleatoriu”. Din această cauză, toate variabilele pe care le-am folosit pentru a-mi construi bucla for trebuie să ajungă ca membri de date în ClasaGenereazaNumereAleatorii. Deci, ce variabile există în bucla mea? Există _numarElemente și i, iar aici le mut în clasa mea:

Ați putea să dezbateți faptul că ar trebui să transfer și aleator în clasa mea, deoarece foloseam aleator.Next() în bucla mea, dar, de fapt, nu prea am nevoie, deoarece aleator este definit ca membru de câmp, iar ClasaGenereazaNumereAleatorii este o clasă imbricată. Amintiți-vă că pentru clasele imbricate, clasa interioară poate accesa orice membru al clasei exterioare. Acesta este și motivul pentru care compilatorul creează o clasă imbricată.

În cele din urmă, am adăugat o variabilă int numită curent, deoarece, amintiți-vă, curent stochează numărul aleatoriu curent și avem nevoie de el pentru blocul enumerator.

Apoi, trebuie să inițializăm valoarea acestei variabile curent și o voi face specificând-o direct pe instanța ClasaGenereazaNumereAleatorii pe care o returnez în funcția GenereazaNumereAleatorii():

În punctul acesta, sunt gata și să mut corpul buclei mele, acum defunctă, în funcția MoveNext(). Pentru ca acest lucru să se întâmple, trebuie să adaug o altă variabilă int numită stare, pe care compilatorul o folosește de fapt pentru a ține evidența a ceea ce se întâmplă și când (de exemplu, într-o buclă for, variabila de control este setată la 0 o singură dată, este doar incrementată după aceea, etc):
Când compilatorul apelează MoveNext(), o transformă practic într-o instrucțiune switch, astfel încât să acomodeze această variabilă stare:

Vizualizează schimbări cod


Legend:

  • liniile verzi cu un semn plus lângă numerele liniilor sunt linii noi adăugate
  • liniile rosii cu un semn minus lângă numerele liniilor sunt linii vechi șterse


Observați că în funcția MoveNext() am adăugat un cod comentat reprezentând o buclă for, identică celei pe care am folosit-o anterior. Aceasta pentru a avea o referință la ceea ce ar trebui să facă funcția MoveNext(). Acum, să analizăm pas cu pas ceea ce am făcut în funcția MoveNext(): după crearea instrucțiunii switch pentru variabila stare, am adăugat primul caz, când stare are valoarea 0 . Aceasta se execută o singură dată, când iterația foreach începe. Prin urmare, aici inițializăm și variabila i la 0, pentru a avea o valoare implicită de resetare, apoi îi spunem compilatorului să meargă la cazul 1, folosind o instrucțiune goto. Acest lucru se datorează faptului că trebuie să returnăm și o primă valoare a iterației. Gândiți-vă la acest caz 0 ca fiind partea for (int = 0; ... a buclei for, unde declarăm și inițializăm variabila de control.

În partea case 1 a instrucțiunii switch trebuie să implementăm partea for (... i < _numarElemente; ... a buclei for. Astfel, primul lucru pe care îl fac este să verific dacă i este egal sau mai mare decât _numarElemente, caz în care returnez false, pentru a termina iterația.

În afară de asta, stabilesc și valoarea variabilei stare la 1, astfel încât următorul apel la funcția MoveNext() să reia execuția de la cazul 1 și să nu ruleze din nou cazul 0, care ar reseta din nou valoarea lui i.

În acest moment, nu vom incrementa pe i, deoarece, amintiți-vă, o buclă for inițializează mai întâi variabila de control, apoi face o verificare a condiției de încheiere, apoi rulează codul din corpul său, și numai DUPĂ aceea, incrementează variabila de control. Deci, după ce facem verificarea condiției de încheiere a bluclei, trebuie să implementăm corpul buclei for, și ce anume conține corpul respectiv? Instrucțiunea yield return.

Desigur, asta înseamnă de fapt returnarea valorii proprietății Current, dar, nu uitați, nu putem face asta, deoarece funcția MoveNext() returnează o valoare booleană, indicând dacă iterația continuă sau nu. Deoarece nu putem returna valoarea reală a iterației curente, trebuie să o atribuim câmpului de curent al proprietății Current. Desigur, ceea ce trebuie să îi atribuim este o nouă valoare aleatorie și obținem această valoare aleatorie din clasa părinte a clasei noastre imbricate ClasaGenereazaNumereAleatorii, folosind variabila aleator declarată în interiorul ei și apelând funcția Next() pe ea.

După aceea, trebuie să returnez true, pentru a indica faptul că iterația poate continua. Dar, înainte de asta, amintiți-vă, ce face o buclă for după ce rulează codul din corpul său? Incrementează variabila de control. Pentru a imita acest lucru, am adăugat un caz 2 la switch, în care incrementez pe i. Acest lucru este similar cu partea for (... i++) a buclei for.

Pentru a mă asigura că execuția ajunge în acest caz la următoarea iterație, înainte de a returna true, setez valoarea variabilei stare la 2.

În cele din urmă, după incrementarea variabilei de control, o buclă for verifică din nou condiția de încheiere și, dacă este adevărată, reexecută codul din corpul său. Ei bine, avem acea parte în cazul 1 al instrucțunii switch, deci, după ce incrementăm i în cazul 2, folosim un nou goto pentru a sări la cazul 1 și a executa din nou aceste coduri.

În partea de jos a funcției MoveNext() am adăugat și o instrucțiune return false; doar pentru a face compilatorul fericit, deși codul este scris în așa fel încât această instrucțiune ar trebui să nu fie executată niciodată. Proprietatea Current doar returnează valoarea variabilei câmp curent.

 

Și cam atât. Acum ar trebui să pot executa implementarea personalizată de yield return, așa că hai să o verificăm. Pornim programul în modul de depanare apăsând tasta F11 și, când avansăm la bucla foreach, vom vedea că va ajunge la funcția GenereazaNumereAleatorii():

În interiorul acestei funcții, instanțiem o nouă instanță ClasaGenereazaNumereAleatorii și îi setăm variabila numarElemente la 0. De asemenea, returnăm această instanță, deoarece GenereazaNumereAleatorii returnează un IEnumerable, iar ClasaGenereazaNumereAleatorii o implementează:

Deoarece clasa ClasaGenereazaNumereAleatorii implementează IEnumerable, este, de asemenea, un enumerator. Prin urmare, execuția va sări în interiorul funcției GetEnumerator(), pentru a o returna:

Apoi, când ajunge la cuvântul cheie in, enumeratorul va apela funcția MoveNext() pentru prima dată. Deoarece în interiorul funcției MoveNext() comutăm valoarea variabilei stare, și din moment ce valoarea sa este 0 în acest moment, cazul  0 a instrucțiunii switch va fi executată, unde inițializăm pe i la 0 și transferăm execuția la cazul 1:

În cazul 1, verificăm dacă nu cumva condiția de încheiere a buclei este îndeplinită, apoi setăm proprietatea Current la o nouă valoare aleatorie, setăm variabila stare la 2, astfel încât următorul apel să ajungă la partea de incrementare a variabilei de control, și returnăm true, astfel încât iterația să continue:

În acest moment, primim un număr aleatoriu afișat la consolă:

Iar execuția revine la funcția MoveNext(), pentru următoarea iterație. Dar, deoarece la ultima iterație am setat valoarea variabilei stare la 2, execuția merge la cazul 2 al instrucțiunii switch, unde incrementăm valoarea lui i, și abia apoi trecem la cazul 1, unde returnăm o nouă valoare aleatorie:

Există câteva lucruri de reținut atunci când utilizați cuvântul cheie yield în codurile dvs.:

  • nu utilizați yield într-un bloc unsafe.
  • nu utilizați cuvintele cheie ref sau out cu parametrii unei metode bloc iterator, operator sau accesor (getter/setter).
  • yield return poate fi plasat într-un bloc try doar dacă este urmat de un bloc finally.
  • yield break poate fi pus într-un bloc try...catch, dar nu în blocul finally.
  • nu folosiți yield în metode anonime.

În lecția următoare, voi scrie despre operatorul yield break, pe care îl putem folosi pentru a opri returnarea rezultatelor într-o iterație ce foloseste operatorul yield return.

Tags: , , , , ,

Leave a Reply



Follow the white rabbit