Pracuję w sklepie .Net, C # i mam współpracownika, który nalega, abyśmy używali gigantycznych instrukcji Switch w naszym kodzie z dużą ilością „przypadków”, a nie zorientowanych obiektowo. Jego argument konsekwentnie powraca do faktu, że instrukcja Switch kompiluje się do „tabeli skoków procesora” i dlatego jest najszybszą opcją (chociaż w innych sprawach nasz zespół mówi, że nie zależy nam na szybkości).
Szczerze mówiąc, nie mam argumentów przeciwko temu ... ponieważ nie wiem o czym, do cholery, on mówi.
Czy on ma rację?
Czy on tylko mówi swój tyłek?
Próbuję się tutaj nauczyć.
c#
.net
switch-statement
James P. Wright
źródło
źródło
Odpowiedzi:
Prawdopodobnie jest starym hakerem C i tak, mówi ze swojego tyłka. .Net to nie C ++; kompilator .Net jest coraz lepszy, a najmądrzejsze hacki przynoszą efekt przeciwny do zamierzonego, jeśli nie dzisiaj, to w następnej wersji. Preferowane są małe funkcje, ponieważ .NET JIT-y każda funkcja raz przed jej użyciem. Tak więc, jeśli niektóre przypadki nigdy nie zostaną trafione podczas cyklu życia programu, więc nie powstają żadne koszty przy ich kompilacji. W każdym razie, jeśli prędkość nie jest problemem, nie powinno być optymalizacji. Najpierw napisz dla programisty, a następnie dla kompilatora. Twój współpracownik nie będzie łatwo przekonać, więc udowodnię empirycznie, że lepiej zorganizowany kod jest w rzeczywistości szybszy. Wybrałbym jeden z jego najgorszych przykładów, przepisałem je w lepszy sposób, a następnie upewniłem się, że twój kod jest szybszy. Wybierz, jeśli musisz. Następnie uruchom go kilka milionów razy, profiluj i pokaż mu.
EDYTOWAĆ
Bill Wagner napisał:
Punkt 11: Zrozumienie przyciągania małych funkcji (skuteczne wydanie C # Second Edition) Pamiętaj, że tłumaczenie kodu C # na kod wykonywalny maszynowo jest procesem dwuetapowym. Kompilator C # generuje IL, która jest dostarczana w zestawach. Kompilator JIT generuje kod maszynowy dla każdej metody (lub grupy metod, gdy jest włączone wstawianie), w razie potrzeby. Małe funkcje znacznie ułatwiają kompilatorowi JIT amortyzację tego kosztu. Małe funkcje są również bardziej skłonne do kandydowania do wbudowania. To nie tylko drobiazg: Prostszy przepływ kontroli ma takie samo znaczenie. Mniej gałęzi kontrolnych w funkcjach ułatwia kompilatorowi JIT rejestrowanie zmiennych. Pisanie wyraźniejszego kodu jest nie tylko dobrą praktyką; w ten sposób tworzysz bardziej wydajny kod w czasie wykonywania.
EDYCJA 2:
Więc ... najwyraźniej instrukcja switch jest szybsza i lepsza niż kilka instrukcji if / else, ponieważ jedno porównanie jest logarytmiczne, a drugie liniowe. http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html
Cóż, moim ulubionym podejściem do zamiany ogromnej instrukcji switch jest słownik (a czasem nawet tablica, jeśli włączam wyliczenia lub małe liczby całkowite), który odwzorowuje wartości na funkcje wywoływane w odpowiedzi na nie. Takie postępowanie zmusza do usunięcia wielu nieprzyjemnych wspólnych stanów spaghetti, ale to dobra rzecz. Oświadczenie o dużej zmianie zwykle jest koszmarem konserwacyjnym. Więc ... w przypadku tablic i słowników wyszukiwanie zajmie cały czas i nie będzie marnowania dodatkowej pamięci.
Nadal nie jestem przekonany, że instrukcja zamiany jest lepsza.
źródło
O ile twój kolega nie jest w stanie udowodnić, że ta zmiana zapewnia rzeczywistą wymierną korzyść w skali całej aplikacji, jest gorsza od twojego podejścia (tj. Polimorfizmu), który faktycznie zapewnia taką korzyść: łatwość utrzymania.
Mikrooptymalizacji należy dokonać dopiero po zlikwidowaniu wąskich gardeł. Przedwczesna optymalizacja jest źródłem wszelkiego zła .
Prędkość jest kwantyfikowalna. Niewiele przydatnych informacji w „podejściu A jest szybsze niż podejście B”. Pytanie brzmi: „O ile szybciej? ”.
źródło
Kogo to obchodzi, jeśli jest szybciej?
O ile nie piszesz oprogramowania działającego w czasie rzeczywistym, jest mało prawdopodobne, aby niewielka ilość przyspieszenia, jaką możesz uzyskać, robiąc coś w całkowicie szalony sposób, znacznie zmieni Twój klient. Nie chciałbym nawet walczyć z tym na froncie prędkości, ten facet najwyraźniej nie będzie słuchał żadnych argumentów na ten temat.
Utrzymanie jest jednak celem gry, a instrukcja zmiany giganta nie jest nawet w niewielkim stopniu możliwa do utrzymania, w jaki sposób wytłumaczysz różne ścieżki w kodzie nowym osobom? Dokumentacja musi być tak długa jak sam kod!
Ponadto masz całkowitą niezdolność do skutecznego testowania jednostkowego (zbyt wiele możliwych ścieżek, nie wspominając o prawdopodobnym braku interfejsów itp.), Co sprawia, że Twój kod jest jeszcze trudniejszy w utrzymaniu.
[Po stronie zainteresowań: JITter działa lepiej na mniejszych metodach, więc instrukcje gigantycznych przełączników (i ich z natury duże metody) zaszkodzą twojej prędkości w dużych złożeniach, IIRC.]
źródło
Odsuń się od instrukcji zamiany ...
Tego rodzaju instrukcje przełączania należy unikać jak zarazy, ponieważ naruszają one zasadę otwartej zamkniętej . Zmusza zespół do wprowadzania zmian w istniejącym kodzie, gdy trzeba dodać nową funkcjonalność, a nie tylko do dodawania nowego kodu.
źródło
Przeżyłem koszmar znany jako masywna skończona maszyna stanów manipulowana przez masowe instrukcje przełączników. Co gorsza, w moim przypadku FSM obejmował trzy biblioteki DLL C ++ i było całkiem jasne, że kod został napisany przez kogoś zorientowanego w C.
Dane, o które musisz dbać, to:
Zadanie polegające na dodaniu nowej funkcji do tego zestawu bibliotek DLL było w stanie przekonać kierownictwo, że przepisanie 3 bibliotek DLL zajmie mi tyle samo czasu, co właściwie zorientowanej obiektowo biblioteki DLL, tak jak dla mnie łatanie małp i jury przypisze rozwiązanie do tego, co już tam było. Przepisanie odniosło ogromny sukces, ponieważ nie tylko wspierało nową funkcjonalność, ale było znacznie łatwiejsze do rozszerzenia. W rzeczywistości zadanie, które normalnie zająłoby tydzień, aby upewnić się, że niczego nie zepsułeś, skończyłoby się kilka godzin.
A co z czasami wykonania? Nie było zwiększenia ani zmniejszenia prędkości. Aby być uczciwym, nasza wydajność została dławiona przez sterowniki systemowe, więc jeśli rozwiązanie obiektowe faktycznie byłoby wolniejsze, nie znalibyśmy go.
Co jest złego w masywnych instrukcjach przełączania dla języka OO?
źródło
Nie kupuję argumentu dotyczącego wydajności; chodzi przede wszystkim o łatwość utrzymania kodu.
ALE: czasami gigantyczna instrukcja switch jest łatwiejsza do utrzymania (mniej kodu) niż kilka małych klas przesłaniających funkcje wirtualne abstrakcyjnej klasy bazowej. Na przykład, jeśli miałbyś zaimplementować emulator procesora, nie zaimplementowałbyś funkcjonalności każdej instrukcji w osobnej klasie - po prostu umieściłbyś ją w gigantycznym przełączniku na opcode, prawdopodobnie wywołując funkcje pomocnicze w celu uzyskania bardziej złożonych instrukcji.
Ogólna zasada: jeśli przełącznik jest w jakiś sposób wykonywany na TYPIE, prawdopodobnie powinieneś użyć funkcji dziedziczenia i funkcji wirtualnych. Jeśli przełączenie jest wykonywane dla WARTOŚCI o stałym typie (np. Kod operacji instrukcji, jak wyżej), można pozostawić go takim, jakim jest.
źródło
Nie możesz mnie przekonać, że:
Jest znacznie szybszy niż:
Dodatkowo wersja OO jest po prostu łatwiejsza w utrzymaniu.
źródło
Ma rację, że wynikowy kod maszynowy będzie prawdopodobnie bardziej wydajny. Kompilator essential przekształca instrukcję switch w zestaw testów i rozgałęzień, które będą względnie nielicznymi instrukcjami. Istnieje duża szansa, że kod wynikający z bardziej abstrakcyjnych podejść będzie wymagał więcej instrukcji.
JEDNAK : Prawie na pewno jest tak, że twoja aplikacja nie musi martwić się o tego rodzaju mikrooptymalizację, w przeciwnym razie nie będziesz używać .net. W przypadku bardzo ograniczonych aplikacji wbudowanych lub pracochłonnych procesorów należy zawsze pozwolić kompilatorowi na optymalizację. Skoncentruj się na pisaniu czystego, łatwego do utrzymania kodu. To prawie zawsze ma o wiele większą wartość niż kilka dziesiątych nanosekundowych czasów wykonania.
źródło
Jednym z głównych powodów używania klas zamiast instrukcji switch jest to, że instrukcje switch zwykle prowadzą do jednego ogromnego pliku, który ma dużo logiki. Jest to zarówno koszmar utrzymania, jak i problem z zarządzaniem źródłami, ponieważ musisz sprawdzić i edytować ten ogromny plik zamiast różnych mniejszych plików klasy
źródło
instrukcja switch w kodzie OOP jest silnym wskaźnikiem brakujących klas
wypróbuj obie strony i uruchom kilka prostych testów prędkości; są szanse, że różnica nie jest znacząca. Jeśli tak, a kod ma krytyczne znaczenie dla czasu, należy zachować instrukcję switch
źródło
Zwykle nienawidzę słowa „przedwczesna optymalizacja”, ale to cuchnie. Warto zauważyć, że Knuth użył tego słynnego cytatu w kontekście naciskania na użycie
goto
instrukcji w celu przyspieszenia kodu w krytycznych obszarach. To jest klucz: ścieżki krytyczne .Sugerował użycie go
goto
do przyspieszenia kodu, ale ostrzega przed programistami, którzy chcieliby robić tego rodzaju rzeczy w oparciu o przeczucia i przesądy dla kodu, który nawet nie jest krytyczny.Faworyzowanie
switch
instrukcji w jak największym stopniu jednolicie w całej bazie kodu (niezależnie od tego, czy obsługiwane jest duże obciążenie) jest klasycznym przykładem tego, co Knuth nazywa programistą „rozsądnym i głupim”, który spędza cały dzień walcząc o utrzymanie swojego „zoptymalizowanego” "kod, który zamienił się w koszmar debugowania w wyniku próby zaoszczędzenia groszy na kilogramach. Taki kod rzadko jest łatwy do utrzymania, a tym bardziej wydajny.Ma rację z bardzo podstawowej perspektywy wydajności. Według mojej wiedzy żaden kompilator nie jest w stanie zoptymalizować kodu polimorficznego obejmującego obiekty i dynamiczne wysyłanie lepiej niż instrukcja switch. Nigdy nie skończysz z LUT lub tabelą skoków do kodu wstawionego z kodu polimorficznego, ponieważ taki kod zwykle służy jako bariera optymalizatora dla kompilatora (nie będzie wiedział, którą funkcję wywołać do czasu, w którym dynamiczna wysyłka występuje).
Bardziej przydatne jest nie myśleć o tym koszcie w kategoriach tabel skoków, ale bardziej w kategoriach bariery optymalizacji. W przypadku polimorfizmu wywołanie
Base.method()
nie pozwala kompilatorowi wiedzieć, która funkcja zostanie ostatecznie wywołana, jeślimethod
jest wirtualna, nie jest zapieczętowana i może zostać zastąpiona. Ponieważ nie wie, która funkcja zostanie wywołana z wyprzedzeniem, nie może zoptymalizować wywołania funkcji i wykorzystać więcej informacji przy podejmowaniu decyzji optymalizacyjnych, ponieważ tak naprawdę nie wie, która funkcja zostanie wywołana czas kompilacji kodu.Optymalizatory są w najlepszym momencie, gdy mogą zajrzeć do wywołania funkcji i dokonać optymalizacji, które albo całkowicie spłaszczą rozmówcę i odbiorcę, albo przynajmniej zoptymalizują rozmówcę, aby najskuteczniej współpracować z odbiorcą. Nie mogą tego zrobić, jeśli nie wiedzą, która funkcja zostanie wcześniej wywołana.
Wykorzystanie tego kosztu, który często wynosi grosze, w celu uzasadnienia przekształcenia go w jednolity standard kodowania jest ogólnie bardzo głupie, szczególnie w miejscach, które wymagają rozszerzenia. To jest najważniejsza rzecz, na którą należy zwrócić uwagę w przypadku oryginalnych przedwczesnych optymalizatorów: chcą przekształcić niewielkie problemy z wydajnością w standardy kodowania stosowane jednolicie w całej bazie kodu, bez względu na łatwość konserwacji.
Obrażam trochę cytat „stary haker C” użyty w przyjętej odpowiedzi, ponieważ jestem jednym z nich. Nie każdy, kto programuje od dziesięcioleci, poczynając od bardzo ograniczonego sprzętu, zmienił się w przedwczesny optymalizator. Ale ja też z nimi spotkałem. Ale te typy nigdy nie mierzą rzeczy takich jak nieprzewidywalność gałęzi lub bufory pamięci podręcznej, myślą, że wiedzą lepiej, i opierają swoje pojęcia nieefektywności w złożonej bazie kodu produkcyjnego opartej na przesądach, które nie są prawdziwe dzisiaj, a czasem nigdy nie są prawdziwe. Ludzie, którzy naprawdę pracowali w obszarach krytycznych pod względem wydajności, często rozumieją, że skuteczna optymalizacja jest skutecznym ustalaniem priorytetów, a próba uogólnienia standardu kodowania obniżającego łatwość konserwacji, aby zaoszczędzić grosze, jest bardzo nieefektywna.
Grosze są ważne, gdy masz tanią funkcję, która nie wykonuje tyle pracy, co nazywa się miliard razy w bardzo ciasnej, krytycznej dla wydajności pętli. W takim przypadku ostatecznie oszczędzamy 10 milionów dolarów. Nie warto golić groszy, gdy masz funkcję wywoływaną dwa razy, dla której samo ciało kosztuje tysiące dolarów. Nie jest rozsądnie spędzać czas na targowaniu się o grosze podczas zakupu samochodu. Warto targować się o grosze, jeśli kupujesz milion puszek sody od producenta. Kluczem do skutecznej optymalizacji jest zrozumienie tych kosztów we właściwym kontekście. Ktoś, kto próbuje zaoszczędzić grosze przy każdym zakupie i sugeruje, że wszyscy próbują targować się o grosze bez względu na to, co kupują, nie jest wykwalifikowanym optymistą.
źródło
Wygląda na to, że twój współpracownik bardzo martwi się wydajnością. Może się zdarzyć, że w niektórych przypadkach duża struktura obudowy / przełącznika będzie działać szybciej, ale mam nadzieję, że przeprowadzilibyście eksperyment, wykonując testy czasowe dla wersji OO i wersji przełącznika / skrzynki. Zgaduję, że wersja OO ma mniej kodu i jest łatwiejsza do naśladowania, zrozumienia i utrzymania. Najpierw argumentowałbym za wersją OO (ponieważ konserwacja / czytelność powinna być początkowo ważniejsza) i rozważam wersję przełącznika / skrzynki tylko wtedy, gdy wersja OO ma poważne problemy z wydajnością i można wykazać, że przełącznik / skrzynka spowoduje znaczna poprawa.
źródło
Jedną z zalet konserwacji polimorfizmu, o której nikt nie wspomniał, jest to, że będziesz w stanie ładniej ustrukturyzować swój kod za pomocą dziedziczenia, jeśli zawsze włączasz tę samą listę spraw, ale czasami kilka spraw jest obsługiwanych w ten sam sposób, a czasami nie są
Na przykład. jeśli przełączasz się między
Dog
,Cat
aElephant
czasamiDog
iCat
masz ten sam przypadek, możesz sprawić, by oba dziedziczyły po klasie abstrakcyjnejDomesticAnimal
i umieściły te funkcje w klasie abstrakcyjnej.Byłem też zaskoczony, że kilka osób użyło parsera jako przykładu, w którym nie użyłbyś polimorfizmu. W przypadku parsera przypominającego drzewo jest to zdecydowanie niewłaściwe podejście, ale jeśli masz coś takiego jak asembler, gdzie każda linia jest nieco niezależna, i zaczynasz od kodu, który wskazuje, jak należy interpretować resztę linii, całkowicie użyłbym polimorfizmu i fabrykę. Każda klasa może implementować funkcje takie jak
ExtractConstants
lubExtractSymbols
. Zastosowałem to podejście w przypadku zabawkowego tłumacza BASIC.źródło
„Powinniśmy zapomnieć o małej wydajności, powiedzmy około 97% czasu: przedwczesna optymalizacja jest źródłem wszelkiego zła”
Donald Knuth
źródło
Nawet jeśli nie było to złe z punktu widzenia łatwości konserwacji, nie sądzę, że będzie to lepsze z punktu widzenia wydajności. Wirtualne wywołanie funkcji jest po prostu jedną dodatkową pośrednią (tak samo jak w najlepszym przypadku dla instrukcji switch), więc nawet w C ++ wydajność powinna być w przybliżeniu równa. W języku C #, gdzie wszystkie wywołania funkcji są wirtualne, instrukcja przełączania powinna być gorsza, ponieważ w obu wersjach występuje taki sam narzut wirtualny wywołania funkcji.
źródło
Twój kolega nie mówi z tyłu, jeśli chodzi o komentarz dotyczący skoków. Jednak użycie tego do usprawiedliwienia pisania złego kodu jest błędem.
Kompilator C # konwertuje instrukcje przełączające z zaledwie kilkoma przypadkami na serię instrukcji if / else, więc nie jest szybszy niż użycie instrukcji if / else. Kompilator konwertuje większe instrukcje przełączników na Słownik (tabelę skoków, o której mówi twój kolega). Więcej informacji można znaleźć w tej odpowiedzi na pytanie przepełnienia stosu na ten temat .
Instrukcja dużego przełącznika jest trudna do odczytania i utrzymania. Słownik „przypadków” i funkcji jest znacznie łatwiejszy do odczytania. Ponieważ właśnie w ten sposób zamienia się przełącznik, ty i twój kolega powinniście korzystać bezpośrednio ze słowników.
źródło
Niekoniecznie mówi ze swojego tyłka. Przynajmniej w
switch
instrukcjach C i C ++ można zoptymalizować, aby przeskakiwać tabele, podczas gdy nigdy nie widziałem, aby stało się to z dynamiczną wysyłką w funkcji, która ma dostęp tylko do wskaźnika podstawowego. Przynajmniej ten ostatni wymaga znacznie inteligentniejszego optymalizatora, który patrzy na znacznie więcej otaczającego kodu, aby dowiedzieć się dokładnie, jaki podtyp jest używany z wirtualnego wywołania funkcji za pośrednictwem podstawowego wskaźnika / odwołania.Ponadto dynamiczna wysyłka często służy jako „bariera optymalizacyjna”, co oznacza, że kompilator często nie będzie w stanie wstawić kodu i optymalnie przydzielić rejestrów, aby zminimalizować rozlewanie się stosu i wszystkie inne wymyślne rzeczy, ponieważ nie może ustalić, co funkcja wirtualna zostanie wywołana przez wskaźnik podstawowy, aby wstawić ją i wykonać całą magię optymalizacji. Nie jestem pewien, czy chcesz, aby optymalizator był tak inteligentny i próbował zoptymalizować pośrednie wywołania funkcji, ponieważ może to potencjalnie prowadzić do generowania wielu gałęzi kodu osobno w dół na stosie wywołań (funkcja, która
foo->f()
wywołałaby aby wygenerować zupełnie inny kod maszynowy niż ten, który wywołujebar->f()
przez wskaźnik bazowy, a funkcja wywołująca tę funkcję musiałaby następnie wygenerować dwie lub więcej wersji kodu, i tak dalej - ilość generowanego kodu maszynowego byłaby wybuchowa - być może nie tak źle ze śladowym JIT, który generuje kod „w locie” podczas śledzenia ścieżek wykonywania na gorąco).Jednakże, ponieważ wiele odpowiedzi powtórzyło się, to zły powód, aby faworyzować mnóstwo
switch
instrukcji, nawet jeśli są one przekazywane szybciej niż jakikolwiek margines. Poza tym, jeśli chodzi o mikro-wydajności, rzeczy takie jak rozgałęzianie i wstawianie mają zwykle dość niski priorytet w porównaniu do rzeczy takich jak wzorce dostępu do pamięci.To powiedziawszy, wskoczyłem tutaj z nietypową odpowiedzią. Chcę uzasadnić możliwość zachowania
switch
instrukcji nad rozwiązaniem polimorficznym wtedy i tylko wtedy, gdy wiadomo na pewno, że będzie tylko jedno miejsce, które musi wykonaćswitch
.Doskonałym przykładem jest centralny moduł obsługi zdarzeń. W takim przypadku na ogół nie ma wielu miejsc obsługujących zdarzenia, tylko jeden (dlaczego jest to „centralny”). W takich przypadkach nie korzysta się z rozszerzalności zapewnianej przez rozwiązanie polimorficzne. Rozwiązanie polimorficzne jest korzystne, gdy istnieje wiele miejsc, które wykonałyby analogiczne
switch
stwierdzenie. Jeśli wiesz na pewno, że będzie tylko jeden,switch
instrukcja z 15 przypadkami może być o wiele prostsza niż zaprojektowanie klasy bazowej odziedziczonej przez 15 podtypów z zastąpionymi funkcjami i fabryki do ich tworzenia, tylko wtedy, gdy zostaną użyte w jednej funkcji w całym systemie. W takich przypadkach dodanie nowego podtypu jest o wiele bardziej nużące niż dodaniecase
instrukcji do jednej funkcji. Jeśli cokolwiek, argumentowałbym za łatwością utrzymania, a nie za wydajnością,switch
oświadczenia w tym szczególnym przypadku, w którym nie korzysta się z rozszerzalności.źródło