Niejawnie ujęte domknięcia – jak to brzmi…

0
2521

Słowo wyjaśnienia

Pisanie tego artykułu zajęło mi ponad dwa tygodnie. Głównie z powodu nawału innego rodzaju zajęć. Początkowo był on pomyślany jako krótki wpis zajmujący się wprowadzonym w C# 5 zachowaniu pętli foreach, jednak ponieważ siedziałem nad nim po kilkanaście minut dziennie, całość rozwodniła się i za każdym razem dodawałem do niego jeszcze troszeczkę. Przez to całość wydaje mi się przegadana, sztuczna i bez określonego celu. Wpis ten jednak publikuję, bo mam nadzieję że stanie się on bazą do, być może, serii artykułów jeszcze głębiej analizujących problem lambd, funkcji anonimowych, delegatów i domknięć. Dlatego serii, bo pisząc tekst poniżej uświadomiłem sobie jak obszerny jest to temat i ile jeszcze rzeczy z nim związanych nie znam lub nie znam dokładnie. Sam z chęcią poczytam sobie więcej np. o expression trees. A teraz zapraszam do lektury i pamiętaj, ostrzegałem 🙂

Jeden błąd, a tyle radości

W poprzednim poście zamieściłem następujący obrazek:

Obraz 1: Ekran główny i jedyny aplikacji Gym Booster WinRT
Szczególnie interesujące jest w nim to, że każda seria ma numer 0 mimo, że na zdrowy rozum licznik powinien się zwiększać. I rzeczywiście tak miało być, lecz kod odpowiedzialny za wyświetlanie zawierał błąd, który objawił się właśnie w ten sposób. Błąd był bardzo łatwy do naprawienia, ale kiedyś spotkałem się z czymś podobnym, więc podsunął mi on pomysł związany właśnie z tytułowym problemem „niejawnie ujętych domknięć”. Mam nadzieję, że uda mi się wyjaśnić co to właściwie jest i dlaczego trzeba na to uważać.
Po pierwsze zdefiniuję jednak pewne pojęcia. Niekoniecznie są to dobre tłumaczenia, ale chciałbym móc się później do czego odwoływać, stąd muszę coś zaproponować.
  • closure – domknięcie
  • implicitly captured closure – niejawnie ujęte domknięcie
  • variable capturing – przechwycenie zmiennej

Niejawnie ujęte domknięcia

Foreach

Użytkownicy Resharpera z pewnością spotkali się z ostrzeżeniem „Access to modified closure
Obraz 2: Ostrzeżenie Resharpera

 

Opis na stronie JetBrainsa jest dość krótki, ale wyczerpuje temat i będę się posiłkował przykładem stamtąd (dzięki Łukasz, za podrzucenie serwisu Pastebin).


Listing 1: Foreach bez zmiennej pomocniczej

Jak widać, tworzona jest lista stringów, a w pętli chcemy utworzyć przyciski, które po naciśnięciu (wywoływanym automatycznie po wyjściu z pętli) wyświetlą okienko z każdym z elementów z listy. Takie są założenia, ale opis na stronie JetBrains mówi, że jest to kod błędny, ponieważ tak naprawdę każdy przycisk będzie wyświetlał napis „three„, czyli ostatni z listy, ponieważ foreach jest przez kompilator zmieniany na pętlę while, a zmienna po której się iteruje jest zdefiniowana poza pętlą, przez co po wyjściu z niej mamy ostatnią wartość zmiennej. Wyjściem miałoby być dodanie zmiennej wewnątrz pętli, w ten sposób:


Listing 2: Foreach ze zmienną pomocniczą

W linii 5 dodaliśmy zmienną i powinno hulać. Ale sprawdźmy najpierw, czy rzeczywiście problem istnieje. Po wklejeniu niepoprawionego kodu i odpaleniu programu, otrzymujemy wyniki:

Obraz 3: Poprawnie wyświetlone okna dialogowe

 

WAT? A przecież mówili, ostrzegali, że będzie źle i nieładnie… Czy kłamali? Nie do końca. Kod został uruchomiony w domyślnym projekcie utworzonym w Visual Studio 2015. Źródło zagadki zaś leży w wersjach C#, które nie są tożsame z wersjami platformy .Net. Od wersji 2012 Visual Studio jest już wyposażone w wersję C# 5.0, a jak widzimy na stronie MS dotyczącej przełomowych nowinek w VS2012, na pierwszym miejscu jest własnie zmienione zachowanie pętli foreach. Zbyt dużo płaczu i narzekań musiał wysłuchiwać Microsoft, aż w końcu uległ 🙂 Swoją drogą – to musi być super praca, projektować czy implementować .Net…

Ostatecznego potwierdzenia dokonamy oglądając kod z listingu nr 1 w narzędziu IlSpy, po ustawieniu C# jako języka wynikowego. Dzięki temu IlSpy weźmie kod IL i skonwertuje go do C#:


Listing 3: Kod z listingu pierwszego po rozłożeniu przez IlSpy

Foreach został przełożony na pobranie obiektu enumeratora i użycie go w pętli while. Rzeczywiście też, zmienna str jest tworzona za każdym razem w pętli, przez co każda delegata ma jej wartość (do gry włącza się DisplayClass, o której za chwilę).

Podsumowując – Foreach w języku C# od wersji 5.0 jest „bezpieczny” – tj. działa tak, jak instynktownie czujemy, że powinien działać (niebezpieczne stwierdzenie, wiem).

For

Zmieńmy odrobinę kod z listingu pierwszego – pętlę foreach zmienimy na for. Używając automatycznej funkcji zmiany „foreachfor”, generuje się poprawny kod ze zmienną str zadeklarowaną wewnątrz pętli. Ciekawszy przypadek występuje jednak po przesunięciu deklaracji na zewnątrz:

Listing 4: Znów kod z L1, tym razem For a nie Foreach

 

Tutaj ponownie odzywa się R# wskazując, że prawdopodobnie próbujemy zrobić coś, czego nie do końca pragniemy. Uruchamiamy kod i…

Obraz 4: Niepoprawnie wyświetlone okna dialogowe

 

Tak, mamy wreszcie odtworzony problem, o którym myślałem na początku. Mimo, że na pierwszy rzut oka okna powinny wyświetlać trzy różne napisy, wyświetlany jest tylko ostatni z nich. Przechodzimy zatem na poziom niżej i sprawdzamy, dlaczego tak się dzieje.

Wnętrzności  – użycie zmiennej „zwyczajnej”

Oszczędzę analizy kodu IL, tym bardziej że sam nie czuję się w nim bardzo komfortowo. Szczęśliwie IlSpy wyświetla kontekstowe podpowiedzi co dany mnemonik robi, więc nie jest tak źle, ale analiza takiego kodu:
Obraz 5: Porównanie listingów ze zmienną deklarowaną w i poza pętlą (program Beyond Compare)
będzie co najmniej dłuższa, a na pewno bardziej uciążliwa. Postaram się zatem przerobić go na C# i tak go omówić.

Najpierw jednak parę słów o DisplayClass. Można ją zobaczyć w IlSpy wszędzie tam, gdzie korzystamy z funkcji ujęcia domknięć 🙂. Jeżeli wykorzystujemy same wyrażenia lambda, delegaty itp., ale bez tej funkcji, klasa nie zostanie wygenerowana. Dla najprostszego kodu:


Listing 5: Prosty Select z odwołaniem do zmiennej z zewnątrz

obejrzyjmy moduł w dekompilatorze:

Obraz 6: Klasa TestLibrary, widok w języku IL

 

W dekompilatorze widać tylko konstruktor, metodę Test oraz tajemniczą klasę <>c. Metoda ta została wygenerowana automatycznie przez kompilator i odpowiada lambdzie z listingu 5. Oto ona:

Obraz 7: Klasa prywatna <>c, widok w języku C#

 

Ciekawostki, które rzucają się w oczy to nazewnictwo (niedozwolone w C# znaki są na porządku dziennym w IL), a także sam fakt, że wyrażenie lambda zamieniane jest wewnętrznie metodę klasy prywatnej.

Nazewnictwo wraz z innymi różnicami między językami platformy .Net to temat na osobny post i nie będziemy się teraz tym zajmować, ale popatrzmy jeszcze chwilę na klasę <>c. Jeżeli do naszej klasy dodamy inne metody zawierające lambdy, to wewnętrznie nadal będą one umieszczane w jednej klasie <>c, ale dla każdej lambdy będzie wygenerowana jedna metoda (nawet, jeżeli wyrażenia lambda są takie same:

Obraz 8: Dla każdej lambdy jedna metoda w klasie <>c nawet, jeżeli lambdy są takie same

 

To samo tyczy się metod anonimowych, delegatów, funkcji, które w rzeczy samej są do siebie bardzo podobne z wyglądu – a jak to jest wewnętrznie, zobaczymy w jednym z przyszłych odcinków. Zagadnienie jest dla mnie o tyle ciekawe, że potrafię powiedzieć z grubsza jak to wszystko działa, ale gdy dochodzę do szczegółów, wszystko staje się mgliste i z chęcią sam dowiem się więcej na ten temat.

Tak czy inaczej – widzimy, że dla metod anonimowych/wyrażeń lambda tworzone są oddzielne metody w prywatnej klasie. Jeżeli używamy w tych wyrażeniach zmiennych z zakresu zewnętrznego, wszystko jest w porządku – zmienna jest zaszyta w metodzie odpowiadającej lambdzie i nic nie może pójść źle 🙂

Teraz sprawdzimy, co będzie gdy zderzymy się z tematem ujęcia domknięć.

Wnętrzności – użycie zmiennej „z domknięciem”

Zmieniamy kod z listingu 5:


Listing 6: Pierwsze, proste przechwycenie zmiennej

Znów przechodzimy do przechwytywania zmiennych. Do metody ProcessList przekazujemy lambdę wykorzystującą zmienną numberToBeAdded. Jednak dla kompilatora jest to już sytuacja, z którą musi sobie radzić w sposób inny niż poprzednio. Oto jak prezentuje się teraz wnętrze modułu:

Obraz 9: Wygenerowana automatycznie klasa DisplayClass 

 

Klasa <>c__DisplayClass jest widoczna tylko w trybie IL, dlatego teraz pokażę jak wygląda ona w IlAsm, a następnie będę ją już tłumaczył na C#, dla większej przejrzystości. Będę się także odnosił do niej jako do DisplayClass, pomijając niewygodne znaki.

Obraz 10: Wnętrze klasy DisplayClass

 

Kod klasy z obrazu 10 oraz listingu 6 można przetłumaczyć i połączyć następująco:


Listing 7: Tłumaczenie DisplayClass dla prostego przypadku

Jak widać – nic skomplikowanego, jedna akcja i jedno pole publiczne o nazwie zmiennej. Tworzymy instancję klasy, przypisujemy wartość do jej pola i wywołujemy metodę, która z tej wartości korzysta. A jak by to wyglądało w przypadku kodu z listingu 4? Oto proponowane tłumaczenie:


Listing 8: Tłumaczenie DisplayClass dla przypadku „z haczykiem”

Klasa DisplayClass nie zmieniła się zbytnio. Jednak jej użycie pokazuje, jaki błąd popełniamy – przypisujemy do jej pola za każdym razem nową wartość str, nadpisując starą, a po wyjściu z pętli metoda DisplayClass.action będzie korzystać właśnie z wartości str tej klasy – ostatniej, nadpisanej na samym końcu przed wyjściem z pętli. Stąd za każdym razem widzimy dialog wyświetlający napis „three”. Co byłoby, gdyby zmienna str była zadeklarowana wewnątrz pętli? Deklaracja obiektu klasy DisplayClass byłaby także wewnątrz pętli. Niby mała różnica, a jednak diametralnie zmienia zachowanie programu.

To by było na tyle z podstawowym przypadkiem. Na tym mógłbym skończyć, ale w procesie pozyskiwania wiedzy dowiedziałem się o ciekawostkach, które także mogą być przydatne. Pierwsza z nich to problem wielu zmiennych. Zmieńmy listing 6, dodając drugą lambdę:


Listing 9: Test z dwiema zmiennymi

Teraz DisplayClass będzie wyglądać następująco:


Listing 10: Przetłumaczony test z dwiema zmiennymi

Jako, że nie zostały wygenerowane dwie klasy z jedną lambdą każda, a jedna klasa ma te dwie, są one ze sobą związane. Może to powodować problemy przy sprzątaniu obiektów przez Garbage Collector. Nie mam co prawda konkretnych przykładów, ale czytałem, że taki problem może zaistnieć, jeżeli obie lambdy są wykorzystywane do obsługi długo żyjących obiektów. Być może znajdę kiedyś taki przykład i wtedy nie omieszkam go przedstawić – niniejszym zapisuję to w swoim notatniku.

Druga ciekawostka to miejsce, w którym inicjowany jest obiekt klasy DisplayClass. Weźmy taki przykład:


Listing 11: Kod ze zmniejszoną wydajnością

Mimo, że wyrażenie lambda znajduje się wewnątrz wyrażenia if, które będzie prawdą bardzo, bardzo rzadko, to obiekt DisplayClass będzie tworzony zaraz po wejściu do pętli for. Będzie to miało znaczenie wydajnościowe dla dużych list. Aby się przed tym uchronić, wystarczy zmienić kod w ten sposób:


Listing 12: Kod z poprawioną wydajnością, ale gorszym wyglądem

Teraz obiekt DisplayClass będzie tworzony wewnątrz wyrażenia if, czyli dużo rzadziej niż w pierwszym przypadku. Prawdopodobnie jednak cały problem jest bardzo rzadko spotykany – jak wiadomo, nie ma co optymalizować na siłę i od razu, więc lepiej pisać kod czytelny i przejrzysty, a nad wydajnością zastanowić się, gdy będzie się miało z nią problemy.

Trzecia i ostatnia ciekawostka, znaleziona w serwisie StackOverflow – dlaczego DisplayClass nazywa się akurat tak? Otóż nazwa ta pochodzi z żargonu programistów zajmujących się wnętrznościami .Net i oznacza ona klasę zachowującą się w specjalny sposób przy przeglądaniu jej w debuggerze. Bardziej pasującą nazwą byłaby np. ClosureClass, ale wyszło jak wyszło 🙂

Kończę na teraz – jak napisałem na początku, ten artykuł to niezmierzony wór z zagadnieniami, które tylko poruszyłem, a nie dałem żadnej dobrej odpowiedzi lub wyjaśnienia. Postaram się w przyszłych wpisać wracać tu i rozpracowywać poszczególne zagadnienia. Jeszcze tylko garść linków, na które się natknąłem i z których korzystałem przy pisaniu tego posta:

0 0 votes
Article Rating
Subscribe
Powiadom o
guest
0 komentarzy
najstarszy
najnowszy oceniany
Inline Feedbacks
View all comments