Wydaje mi się, że ludzie bardzo nie lubią takich goto
stwierdzeń, więc poczułem potrzebę nieco tego wyjaśnienia.
Wierzę, że „emocje”, które ludzie w goto
końcu sprowadzają się do zrozumienia kodu i (nieporozumień) na temat możliwych implikacji wydajnościowych. Zanim odpowiem na pytanie, najpierw przejdę do kilku szczegółów na temat jego kompilacji.
Jak wszyscy wiemy, C # jest kompilowany do IL, który jest następnie kompilowany do asemblera za pomocą kompilatora SSA. Dam trochę wglądu, jak to wszystko działa, a następnie spróbuję odpowiedzieć na pytanie.
Od C # do IL
Najpierw potrzebujemy fragmentu kodu C #. Zacznijmy od prostych:
foreach (var item in array)
{
// ...
break;
// ...
}
Zrobię to krok po kroku, aby dać ci dobry obraz tego, co dzieje się pod maską.
Pierwsze tłumaczenie: od foreach
do równoważnej for
pętli (Uwaga: używam tutaj tablicy, ponieważ nie chcę wchodzić w szczegóły IDisposable - w takim przypadku musiałbym również użyć IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Drugie tłumaczenie: for
i break
jest tłumaczone na łatwiejszy odpowiednik:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
Tłumaczenie i trzeci (jest to odpowiednik kodu IL): możemy zmienić break
i while
do gałęzi:
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
Kompilator wykonuje te czynności w jednym kroku, ale daje wgląd w proces. Kod IL, który ewoluuje z programu C #, jest dosłownym tłumaczeniem ostatniego kodu C #. Możesz zobaczyć tutaj: https://dotnetfiddle.net/QaiLRz (kliknij „zobacz IL”)
Jedną z rzeczy, które zauważyłeś tutaj, jest to, że podczas procesu kod staje się bardziej złożony. Najłatwiejszym sposobem na zaobserwowanie tego jest fakt, że potrzebowaliśmy coraz więcej kodu, aby stwierdzić to samo. Możesz także o to argumentowaćforeach
, for
, while
i break
są rzeczywiście krótkie Ręce za goto
, co jest częściowo prawdą.
Od IL do asemblera
Kompilator .NET JIT to kompilator SSA. Nie będę tutaj wchodził w wszystkie szczegóły formularza SSA i jak stworzyć optymalizujący kompilator, to po prostu za dużo, ale może dać podstawowe zrozumienie tego, co się stanie. Dla głębszego zrozumienia najlepiej zacząć czytać o optymalizacji kompilatorów (lubię tę książkę za krótkie wprowadzenie: http://ssabook.gforge.inria.fr/latest/book.pdf ) i LLVM (llvm.org) .
Każdy kompilator optymalizacyjny opiera się na fakcie, że kod jest łatwy i ma przewidywalne wzorce . W przypadku pętli FOR korzystamy z teorii grafów do analizy gałęzi, a następnie optymalizujemy takie rzeczy, jak cykle w naszych gałęziach (np. Gałęzie do tyłu).
Jednak teraz mamy gałęzie przekazujące do implementacji naszych pętli. Jak można się domyślić, jest to rzeczywiście jeden z pierwszych kroków, które JIT zamierza naprawić, w następujący sposób:
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
Jak widać, mamy teraz odgałęzienie, które jest naszą małą pętlą. Jedyną rzeczą, która wciąż jest tutaj nieprzyjemna, jest gałąź, z którą skończymy z powodu naszejbreak
oświadczenie. W niektórych przypadkach możemy to przenieść w ten sam sposób, ale w innych pozostanie.
Dlaczego więc kompilator to robi? Cóż, jeśli uda nam się rozwinąć pętlę, możemy ją wektoryzować. Możemy nawet być w stanie udowodnić, że dodawane są tylko stałe, co oznacza, że cała nasza pętla może zniknąć w powietrzu. Podsumowując: czyniąc wzorce przewidywalnymi (powodując, że gałęzie są przewidywalne), możemy udowodnić, że pewne warunki utrzymują się w naszej pętli, co oznacza, że możemy wykonywać magię podczas optymalizacji JIT.
Jednak gałęzie mają tendencję do przełamywania tych miłych, przewidywalnych wzorców, co jest czymś optymalizującym, a więc niechęcią. Przerwij, kontynuuj, goto - wszyscy zamierzają przełamać te przewidywalne wzorce - i dlatego nie są tak naprawdę „mili”.
Powinieneś również zdać sobie sprawę z tego, że proste foreach
jest bardziej przewidywalne niż kilka goto
stwierdzeń, które są wszędzie. Pod względem (1) czytelności i (2) z perspektywy optymalizatora jest to zarówno lepsze rozwiązanie.
Inną rzeczą wartą wspomnienia jest to, że jest to bardzo istotne dla optymalizacji kompilatorów do przypisywania rejestrów do zmiennych (proces zwany alokacją rejestrów ). Jak zapewne wiesz, w twoim CPU jest tylko skończona liczba rejestrów i są one zdecydowanie najszybszymi elementami pamięci w twoim sprzęcie. Zmienne używane w kodzie, który znajduje się w najbardziej wewnętrznej pętli, częściej przypisują rejestr, podczas gdy zmienne poza twoją pętlą są mniej ważne (ponieważ ten kod prawdopodobnie jest mniej trafiony).
Pomoc, zbyt duża złożoność ... co powinienem zrobić?
Najważniejsze jest to, że zawsze powinieneś używać konstrukcji językowych, które masz do dyspozycji, które zwykle (domyślnie) budują przewidywalne wzorce dla twojego kompilatora. Staraj się unikać dziwnych oddziałów, jeśli to możliwe (konkretnie: break
, continue
, goto
lub return
w środku niczego).
Dobrą wiadomością jest to, że te przewidywalne wzorce są łatwe do odczytania (dla ludzi) i łatwe do wykrycia (dla kompilatorów).
Jeden z tych wzorów nosi nazwę SESE, co oznacza Single Entry Single Exit.
A teraz dochodzimy do prawdziwego pytania.
Wyobraź sobie, że masz coś takiego:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
Najłatwiejszym sposobem uczynienia z tego przewidywalnego wzoru jest po prostu if
całkowite wyeliminowanie :
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
W innych przypadkach można również podzielić metodę na 2 metody:
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
Zmienne tymczasowe? Dobry, zły czy brzydki?
Możesz nawet zdecydować o zwróceniu wartości logicznej z pętli (ale ja osobiście wolę formularz SESE, ponieważ tak właśnie kompilator to zobaczy i myślę, że jest łatwiejszy do odczytania).
Niektórzy uważają, że łatwiej jest użyć zmiennej tymczasowej i proponują takie rozwiązanie:
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
Ja osobiście jestem przeciwny takiemu podejściu. Spójrz jeszcze raz, jak skompilowany jest kod. Teraz zastanów się, co to zrobi z tymi ładnymi, przewidywalnymi wzorami. Zrób zdjęcie?
Racja, pozwól mi to przeliterować. Stanie się tak, że:
- Kompilator wypisze wszystko jako gałęzie.
- W ramach etapu optymalizacji kompilator przeprowadzi analizę przepływu danych, próbując usunąć dziwną
more
zmienną, która jest używana tylko w przepływie kontrolnym.
- Jeśli się powiedzie, zmienna
more
zostanie usunięta z programu i pozostaną tylko gałęzie. Te gałęzie zostaną zoptymalizowane, więc z wewnętrznej pętli wydostaniesz się tylko jedna gałąź.
- Jeśli się nie powiedzie, zmienna
more
jest zdecydowanie używana w wewnętrznej pętli, więc jeśli kompilator jej nie zoptymalizuje, ma dużą szansę na przypisanie do rejestru (który zużywa cenną pamięć rejestru).
Podsumowując: optymalizator w twoim kompilatorze sprawi wiele kłopotów, aby dowiedzieć się, że more
jest używany tylko do przepływu sterowania, aw najlepszym przypadku scenariusz przetłumaczy go na jedną gałąź poza zewnętrzną dla pętla.
Innymi słowy, najlepszym scenariuszem jest to, że zakończy się odpowiednikiem tego:
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
Moja osobista opinia na ten temat jest dość prosta: jeśli tak właśnie zamierzaliśmy, uczyńmy świat łatwiejszym zarówno dla kompilatora, jak i czytelności, i napiszmy to od razu.
tl; dr:
Dolna linia:
- Jeśli to możliwe, użyj prostego warunku w pętli for. Trzymaj się konstrukcji języka wysokiego poziomu, które masz do dyspozycji w jak największym stopniu.
- Jeśli wszystko zawiedzie, a ty zostaniesz z jednym
goto
lub bool more
, wybierz ten pierwszy.