Zgodnie z obietnicą dziś napiszę trochę więcej o rzutowaniu, wydajności oraz dobrych praktykach. Muszę przyznać, że w jednej kwestii miałem nieprawdziwe informacje (znalezione gdzieś na forum) których byłem pewien ponieważ napisałem prosty program sprawdzający wydajność – jak na końcu pokażę popełniłem błąd podczas mierzenia wydajności spowodowany kompilacją JIT.
Na początku trochę przypomnienia:
1. Rzutowanie prefiksowe – najpopularniejszy typ znany np. z CPP.
int value = (int)boxedValue;
Można wykorzystać zarówno do rzutowania typów VALUE jak i referencyjnych. Bierze również pod uwagę własne operatory rzutowania explicit. Gdy typ się nie zgadza zostaje wyrzucony wyjątek. Zwykle rozwiązanie wolniejsze niż operator as.
2. Operator rzutowania as. Często uważany za szybsze rozwiązanie co jednak nie zawsze jest prawdą. Gdy nie można dokonać rzutowania wtedy zwracana jest wartość NULL. Niestety nie można wykorzystać dla typów prostych (value) oraz własnych operatorów rzutowania (explicit).
FileInfo = obj as FileInfo;
3. Operator is. Sprawdza czy obiekt jest danego typu.
if(obj is FileInfo) { //... }
Przejdźmy do pomiaru wydajności. Dokonamy 20 pomiarów, w którym pojedynczy pomiar to kilkadziesiąt operacji rzutowania. Zacznijmy od rozwiązania prefiksowego. Kod:
object genericObject = new A(); const int iterationsNumber = 10000; const int samplesNumber = 20; Stopwatch stopwatch = new Stopwatch(); long[,] samples = new long[samplesNumber,2]; for (int i = 0; i < samplesNumber; i++) { stopwatch.Restart(); int castsNumber = iterationsNumber*i+1; for (int j = 0; j < castsNumber; j++) { A castedObject = genericObject as A; if (castedObject != null) { } } stopwatch.Stop(); samples[i, 0] = castsNumber; samples[i, 1] = stopwatch.ElapsedTicks; }
Wartości:
Liczba rzutowań | Ticks |
1 |
360 |
10001 |
131 |
20001 |
258 |
30001 |
385 |
40001 |
514 |
50001 |
641 |
60001 |
779 |
70001 |
895 |
80001 |
1022 |
90001 |
1140 |
100001 |
1305 |
110001 |
1391 |
120001 |
1598 |
130001 |
1650 |
140001 |
1777 |
150001 |
1937 |
160001 |
2041 |
170001 |
2170 |
180001 |
2297 |
190001 |
2424 |
Wykres:
Na początku wspomniałem, że porównując wydajność as z prefiksowym popełniłem błąd. Spoglądając na tabelę widać, że pierwszy pomiar pokazuje, że rzutowanie 1 wartości było wolniejsze niż następne 10 000. Wcześniej po prostu najpierw zmierzyłem wydajność as, potem prefiksowego i dlatego te drugie wyszło mi bardzo szybkie. Jest to jednak spowodowane kompilacją JIT oraz wywołaniem metod StopWatcher’a – gdy pierwszy raz wywołujemy jakąkolwiek metodę, zwykle zabiera to więcej czasu niż następne (kompilacja).
Widzimy, ze koszt jest liniowy – to dobrze. Sprawdźmy teraz np. za pomocą .NET Reflector jak wygląda kod IL. Kod jest długi więc wklejam tylko najważniejszą linie wykonującą as:
L_0050: isinst WindowsFormsApplication2.A
Instrukcja isinst dokonuję rzutowania. Warto zaznaczyć, że isinst jest uważana za rozwiązanie wydajne, z tym, że nie sprawdza rzutowań zdefiniowanych przez użytkownika (explicit) – może dlatego jest szybsze…Jeśli mamy jakieś własne operatory rzutowania to należy skorzystać z rozwiązania prefiksowego.
Przejdźmy do pomiaru rzutowania klasycznego i operatora is. Kod:
object genericObject = new A(); const int iterationsNumber = 10000; const int samplesNumber = 20; Stopwatch stopwatch = new Stopwatch(); long[,] samples = new long[samplesNumber,2]; for (int i = 0; i < samplesNumber; i++) { stopwatch.Restart(); int castsNumber = iterationsNumber*i+1; for (int j = 0; j < castsNumber; j++) { if (genericObject is A) { A castedObject = (A) genericObject; } } stopwatch.Stop(); samples[i, 0] = castsNumber; samples[i, 1] = stopwatch.ElapsedTicks; }
Wynik:
Liczba rzutowań | Ticks |
1 |
357 |
10001 |
160 |
20001 |
316 |
30001 |
474 |
40001 |
628 |
50001 |
784 |
60001 |
942 |
70001 |
1097 |
80001 |
1254 |
90001 |
1410 |
100001 |
1571 |
110001 |
1722 |
120001 |
1880 |
130001 |
2373 |
140001 |
2196 |
150001 |
2349 |
160001 |
2535 |
170001 |
3016 |
180001 |
2838 |
190001 |
2980 |
Wykres:
Program został zrestartowany i można zauważyć, że rzutowanie prefiksowe jest nieco wolniejsze. Pierwszy wynik został również przekłamany z powodu JIT. Zajrzyjmy teraz jednak do .NET Reflector. Najpierw jak wygląda c# po wstępnej kompilacji:
Label_004E: if ((((genericObject as A) > null) == 0) != null) { goto Label_006B; } castedObject = (A) genericObject;
Zaraz, zaraz… Co tu robi operator as? W końcu użyliśmy is+prefix. Niestety is to tak na prawdę najpierw zrzutowanie za pomocą as a potem sprawdzenie czy jest on różny od NULL. Z tego względu taka konstrukcja jest wolna. Przejdźmy teraz do ID:
L_0050: isinst WindowsFormsApplication2.A L_0055: ldnull L_0056: cgt.un L_0058: ldc.i4.0 L_0059: ceq L_005b: stloc.s CS$4$0000 L_005d: ldloc.s CS$4$0000 L_005f: brtrue.s L_006b L_0061: nop L_0062: ldloc.0 L_0063: castclass WindowsFormsApplication2.A
Widzimy instrukcję isinst a następnie castclass. isinst powinna być już nam znana – jest to operator as. Z kolei castclass to rzutowanie bezpośrednie (prefiksowe).
Porównanie wydajności:
Jak widać, pomimo, że w IL jest niepotrzebne rzutowanie, wyniki są zbliżone jednak z lekką przewagą na as zamiast na is+prefix.
A co w przypadku gdy mamy tylko as albo rzutowanie prefiksowe – bez zbędnego operatora is? Na zdrowy rozsądek is powinien być szybszy ponieważ skoro as+prefix jest nieco wolniejszy tylko od samego as tzn., że rzutowanie prefiksowe nie narzuca zbyt wiele. Sprawdziłem i zależy to generalnie od hierarchii klas. Ponadto nawet pojedynczy IF potrafi zając sporą cześć czasu w porównaniu do rzutowania. Sprawa jest więc skomplikowana ponieważ wydajność zależy od JIT-compile, hierarchii klas itp. Przeglądałem inne próby oszacowania wydajności i padają różne wyniki: czasami prefix szybszy a czasami as. Spotkałem się również z opinią, że prefiksowe rzutowanie było dużo wolniejsze w .NET 1.1 jednak od następnych wersji różnica między as a prefix zanikła a nawet prefiksowe może być nieco szybsze. Widać, że wydajność jest trudna do oszacowania. Spróbujmy jednak wyciągnąć kilka wniosków z tego postu:
1. Użycie operatora is z rzutowaniem prefiksowym jest trochę wolniejsze bo musimy wykonać podwójne rzutowanie – lepiej wykorzystać po prostu as.
2. Wydajność rzutowania prefiksowego a as jest trudna do oszacowania ponieważ zależy od wielu czynników. Różnica jest również niewielka wiec uważam, że nie powinniśmy się tym martwić.
3. Używaj as tylko gdy istnieje możliwość błędnego rzutowania i przewidujesz to w aplikacji wprowadzając dodatkowy warunek. Jeśli spodziewasz się, że po zrzutowaniu powinieneś mieć zawszę poprawną wartość wtedy użyj rzutowania prefiksowego, które w razie niepowodzenia wyrzuci wyjątek. Operator as zwraca tylko NULL więc ewentualne problemy będą ciężkie w diagnozie – dużo więcej informacji mówi wyjątek InvalidCast (z opisem dokładnych obiektów) niż po prostu NULL Reference.
Innymi słowy, używaj rzutowania prefiksowego wyłącznie gdy nie przewidujesz sytuacji, że rzutowanie nie zakończy się sukcesem. Łapanie wyjątku jest dużo wolniejsze niż sprawdzanie wartości NULL.
Bardzo dobry artykuł.
jednym slowem roznice sa znikome I jak to zwykle bywa – najpierw czystosc kodu pozniej optymalizacja 🙂
Tak – wydajnosc nie ma znaczenia.
Ale konstrukcja, ktora podawalem w poprzednim poscie nie jest dobra i tak bo zawsze bedzie wolniejsza troche.
Po za tym jedna wazna sprawa. Jesli mamy petle od 1 do 1kk i w niej musimy castowac to rozsadna optymalizacja polegalaby na usunieciu potrzeby rzutowania a nie zastanawianiu sie czy to as czy prefix.