Meandry C#: dziwne enumy

1
386

Jakiś czas temu kolega pracujący po sąsiedzku podszedł do mnie z informacją, że natknął się na ciekawe zachowanie C#. Chodziło o niechciane wywoływanie przeładowania metody. Co prawda bieżący problem udało nam się rozwiązać, ale sam mechanizm pozostał dla mnie zagadką. Próbowałem szukać rozwiązania w książkach, które wcześniej czytałem, ale dopiero odpowiedź na maila, którego wysłałem do samego Erica Lipperta pozwoliła definitywnie stwierdzić co w trawie piszczy. Dziwne enumy – ot co 🙂

Geneza problemu

Problem był związany z wywołaniem metod związanych z dostępem do Oracle. Aby nie komplikować tematu, postanowiłem utworzyć przykładowy kod niezależny od żadnych zewnętrznych pakietów i korzystający tylko z mscorlib.dll. Pierwsza wersja kodu wygląda następująco:

Mamy tu wszystko, czego potrzeba. Prosty enum z jedną wartością odpowiadającą liczbie 0. Metodę Main, w której będziemy się bawić, oraz dwa przeładowania metody DoWork – jedno przyjmujące enuma, drugie obiekt. Dokładnie taką sytuację rozpatrywaliśmy z kolegą. Uruchamiając kod, na konsoli ukaże się następujący wynik:

Wynik dla jednej wartości enuma

Jak to interpretować? Przecież w obu przypadkach do metody przesyłam tę samą wartość enum zrzutowaną na typ integer. Z tym, że w pierwszym przypadku robię to  w jednej linicje, a w drugim najpierw deklaruję enuma, a rzutuję dopiero przy wywołaniu metody. Skąd zatem inne wyniki? Badanie zbudowanej biblioteki w ILSpy potwierdza te spostrzeżenia – wywoływane są dwa oddzielne przeładowania i nie jest to kwestia jakiejś optymalizacji podczas kompilacji. Kodu IL nie załączam dla przejrzystości.

Jeszcze większa zagadka

Po uzyskaniu powyższych wyników postanowiłem sprawdzić, czy podobne zachowanie zaobserwuję także dla innych wartości. Rozszerzyłem zatem enuma i dopisałem kilka linijek kodu:

Dodałem linijki: 4 oraz 16-19. Ponownie uruchomiłem program, uzyskując następujący wynik:

Wynik dla dwóch wartości enuma

Bystry obserwator zauważy, że tym razem wyniki są: po pierwsze jednakowe dla obydwu wywołań, a po drugie zgodne z przewidywaniami – enum rzutowany na integer powoduje wywołanie przeładowania przyjmującego obiekt. Podobnie jest dla innych wartości – 2, 3 etc. Co zatem różni enum odpowiadający wartości zero od pozostałych?

W poszukiwaniu odpowiedzi

Nie zajmuję się programowaniem po godzinach. Zazwyczaj mam mnóstwo ważniejszych rzeczy i tak było tym razem. W wolnych chwilach między implementacją jednej i drugiej funkcjonalności próbowałem jakoś wytłumaczyć to dziwne zachowanie. Szukałem też odpowiedzi w książkach, które prześwietlają C# tak dokładnie jak to tylko możliwe – „C# in depth” Jona Skeeta oraz „CLR via C#” Jeffrey’a Richtera. Niestety, nie znalazłem tam odpowiedzi a przytoczyłem te tytuły tylko po to, by pokazać że je znam 😛 

Normalnie być może bym się poddał. W końcu zauważona aberracja jest niszowa i szansa, że będzie jakoś uprzykrzała życie, jest naprawdę nikła. Jednak sprawa zafascynowała mnie do tego stopnia, że postanowiłem napisać do człowieka, którego uważam za największego eksperta od języka C# – Erica Lipperta. 

Eric Lippert to the rescue

Mojego maila z opisem problemu wysłałem jako ostatnią deskę ratunku. Nie spodziewałem się, że ktoś tak zajęty jak Eric będzie miał czas i ochotę odpisywać na pojedyncze pytanie, nie zadane na forum publicznym. W najlepszym wypadku oczekiwałem skierowania do opisanego już wyjaśnienia. Jakie było moje zdziwienie, gdy na następny dzień otrzymałem dłuuugiego maila, w którym Eric nie tylko opisuje wyczerpująco przyczynę zachowania, ale jeszcze… przeprasza za to, że to jego wina 🙂 Obszerny, przetłumaczony wyciąg z jego maila załączam poniżej. Wydaje mi się, że w jednoznaczny sposób wyjaśnia on tajemnicze zachowanie opisane powyżej.

Rozwiązanie problemu

Wszystko rozbija się o to, że zero jest traktowane w języku C# w specjalny sposób. 

Garść faktów o enumach

  • Enum to lekka osłona (wrapper) na typ System.Int32 (integer – liczba całkowita ze znakiem)
  • Każda wartość z zakresu integer zgodna z typem, który osłania enum jest prawidłową wartością enuma – nawet, jeżeli nie została jawnie zadeklarowana. Jeżeli zatem mamy deklarację: „enum E { X = 2}”, dozwolone jest: „var e = (E)1234;”
  • „default(E)” to wartość enuma osłaniająca wartość integer = 0
  • Nieprzypisane pola i elementy tablic (Array) są inicjalizowane wartościami domyślnymi. Jeżeli mamy zatem pole „E e;”, jego wartością początkową będzie zero – nawet, jeżeli enum nie ma tej wartości zadeklarowanej (nasz przykładowy ma tylko jedną – X = 2)
  • Co za tym idzie, ZAWSZE należy deklarować enumy z wartością odpowiadającą zeru – dzięki temu pamiętamy, że istnieje możliwość, że enum ją przyjmie. 

Kilka słów o genezie języka C#

  • Język C# został zaprojektowany tak, aby jak najbardziej trafiać w potrzeby programistów C++ – szczególnie tych pracujących z technologią COM
  • W technologii COM bardzo popularne są „flagi enumeracyjne”, na przykład: „enum Permissions {None = 0, Read = 1, Write = 2, Delete = 4, Append = 8, …}”
  • W COM bardzo popularne jest inicjalizowanie „flag enumeracyjnych” zerem, a nie wartością „None”

Biorąc powyższe pod uwagę, projektanci języka C# zdecydowali, że konstrukt: „E e = 0;” będzie prawidłowy dla każdego enuma. Ta zasada miała być sformułowana jako: „a literal integer zero is convertible to any enum type” (literał z zakresu integer może być przekonwertowany do dowolnego typu enum). 

Smaczki językowe

Smaczkiem jest tutaj wyrażenie „a literal integer zero”. Oznacza ono „tekst w programie”, który musi być czymś w rodzaju: „0” lub „0x0” lub czymś podobnym.

Zasada z poprzedniego paragrafu nie została niestety zaimplementowana. Kompilator C# zawierał błąd, przez który każde wyrażenie zawierające „literał zerowy” mogło być przekonwertowane do każdego typu enum.

Na przykład coś takiego: „int x = 123; E e = 0 * x;” było dozwolone nawet, jeżeli „0*x” nie jest wyrażeniem „literal integer zero”, a jedynie jest równe zeru.

Kilka słów o kompilatorze

Kompilator zawiera zasady interpretowania, kiedy coś jest „literałem”, kiedy „stałą” a kiedy „wartością”.

Literały to tekst i nic poza tym.

Stałe to wyrażenia zawierające wyłącznie stałe, bez zmiennych.

A zatem kompilator powinien zauważyć, że wyrażenie „0*x” to wartość zero, a nie stała zero, ponieważ zawiera zmienną x (zmienna nie jest stała 🙂 ). Z kolei wyrażenie „0*1” nie jest literałem, ale jest stałą.

The compiler accepted more programs than it should and that’s a bug. 

Eric Lippert

Zdecydowano zatem, że zbyt ryzykowne jest ograniczenie działania kompilatora wyłącznie do tego, co mówi specyfikacja i umożliwienie konwersji do enumów wyłącznie z literałów. Podczas pisania programów zbyt często ludzie chcą też konwertować stałe. W związku z tym, zamiast pierwotnie opisanej zasady, w kompilatorze zaimplementowano jej zmodyfikowaną wersję „constant zero integer is convertible to any enum”. Zmieniono tylko troszeczkę, prawda? 🙂

Ale tutaj doszedł do głosu jeszcze jeden wypadek. Eric, odpowiedzialny za implementację tej zmienionej zasady poszedł troszkę zbyt daleko i zaimplementował jej odrobinę zmienioną wersję 🙂 W ten sposób to, co umozliwia kompilator to „any constant zero of any type is convertible to any enum” (stała zerowa dowolnego typu może być  przekonwertowana do dowolnego enuma).  W owym czasie nikt nie odkrył tej omyłkowej zamiany. Dziesięć lat później kompilator nadal ją zawiera mimo, że jest ona błędna.

Zachowanie wyjaśnione

Mamy więc wszystkie elementy potrzebne do wyjaśnienia owego dziwnego zachowania. 

  • DoWork(0); – wywołanie metody z literałem zerowym konwertowanym do dowolnego enuma. Zostanie wybrane przeładowanie przyjmujące enum.
  • DoWork((int)val0); – wywołanie metody z „wartością zerową nie będącą stałą”, niemożliwym do konwersji do enuma. Jak pamiętamy, tylko stałe mogą być konwertowane. Zostanie wybrana wersja przyjmująca obiekt.
  • DoWork((int)TestEnum.Val0); – wartości enumów to stałe. Po konwersji do typu integer, to nadal stałe. Jest to zatem stała zerowa i zostanie wybrane przeładowanie ackeptujące enum.
  • DoWork(0.0); – na koniec prawdziwa bomba. Tego nie wykryliśmy, ale Eric sam podpowiedział, że takie coś także jest możliwe. Wartość nie będąca typu integer w tym przypadku także zostanie zaakceptowana przez przeładowanie akceptujące enum! Dzieje się tak ponieważ „0.0” to stała zerowa, także konwertowalne do enuma 🙂

Probably worst mistake…

To już koniec. Napisałem do Erica jeszcze dwa czy trzy maile, na każdy odpowiedział cierpliwie i wyczerpująco. Od dawna uważałem go za guru d/s C#, ale swoją chęcią do pomocy zaskarbił sobie także opinię nie tylko świetnego fachowca, ale i dobrego człowieka – a wydaje mi się, że to drugie jest o wiele ważniejsze. 

Eric, opisując zabawnie i ze swadą tak hermetyczny problem jak ten opisany powyżej, wplótł w wypowiedź także elementy samobiczowania. Wziął odpowiedzialność za to dziwne zachowanie kompilatora i podsumował swój pierwszy mail słowami:

That was probably the worst mistake I made on the C# team, but now you understand the crazy behaviour. It’s totally my fault. Sorry about that! 

Jako programiści doskonale wiemy jakie jest życie i niech pierwszy rzuci kamieniem ten, który podobnej lub gorszej omyłki w życiu nie popełnił. Eric, przyznając się do błędu, dał naprawdę fajne świadectwo tego, że prawdziwy fachowiec nie boi się przyznać do pomyłki. Wie, że są one nieuniknione i jesteśmy na nie skazani. Musimy pracować tak, by było ich najmniej, gdy się zdarzą, próbować naprawić, a jeżeli wszystko zawiedzie – nie poddawać się, lecz iść dalej i robić swoje. Tego nam wszystkim życzę.