Czy nietrywialne instrukcje warunkowe należy przenieść do sekcji inicjalizacji pętli?

21

Ten pomysł pochodzi od tego pytania na stackoverflow.com

Powszechny jest następujący wzór:

final x = 10;//whatever constant value
for(int i = 0; i < Math.floor(Math.sqrt(x)) + 1; i++) {
  //...do something
}

Chcę, aby stwierdzenie warunkowe było czymś skomplikowanym i nie zmienia się.

Czy lepiej jest zadeklarować go w sekcji inicjalizacji pętli jako takiej?

final x = 10;//whatever constant value
for(int i = 0, j = Math.floor(Math.sqrt(x)) + 1; i < j; i++) {
  //...do something
}

Czy to jest bardziej jasne?

Co jeśli wyrażenie warunkowe jest proste, takie jak

final x = 10;//whatever constant value
for(int i = 0, j = n*n; i > j; j++) {
  //...do something
}
Celeritas
źródło
47
Może po prostu przenieś go do linii przed pętlą, możesz też nadać mu rozsądną nazwę.
jonrsharpe
2
@Mehrdad: Nie są równoważne. Jeśli xjest duży, Math.floor(Math.sqrt(x))+1jest równy Math.floor(Math.sqrt(x)). :-)
R ..
5
@jonrsharpe Ponieważ poszerzyłoby to zakres zmiennej. Niekoniecznie mówię, że to dobry powód, aby tego nie robić, ale niektórzy ludzie tego nie robią.
Kevin Krumwiede
3
@KevinKrumwiede Jeśli zasięg jest problemem, ogranicz go, umieszczając kod we własnym bloku, np. { x=whatever; for (...) {...} }Lub, jeszcze lepiej, zastanów się, czy wystarczająco dużo dzieje się w tym, że musi to być osobna funkcja.
Blrfl
2
@jonrsharpe Możesz nadać temu sensowną nazwę, deklarując go również w sekcji init. Nie mówię, że położyłbym to tam; nadal jest łatwiejszy do odczytania, jeśli jest osobny.
JollyJoker

Odpowiedzi:

62

Zrobiłbym coś takiego:

void doSomeThings() {
    final x = 10;//whatever constant value
    final limit = Math.floor(Math.sqrt(x)) + 1;
    for(int i = 0; i < limit; i++) {
         //...do something
    }
}

Szczerze mówiąc, jedynym dobrym powodem, aby wcisnąć j(teraz limit) wciskanie w nagłówek pętli, jest utrzymanie odpowiedniego zakresu. Wszystko, czego potrzeba, aby sprawić, że nie będzie problemu, to ładny, ciasno zamknięty zakres.

Potrafię docenić chęć bycia szybkim, ale nie poświęcam czytelności bez prawdziwego powodu.

Jasne, kompilator może zoptymalizować, inicjowanie wielu zmiennych może być legalne, ale pętle są wystarczająco trudne do debugowania. Proszę bądź miły dla ludzi. Jeśli to naprawdę nas spowalnia, dobrze jest to zrozumieć na tyle, aby to naprawić.

candied_orange
źródło
Słuszna uwaga. Zakładałbym, że nie zawracam sobie głowy inną zmienną, jeśli wyrażenie jest czymś prostym, na przykład for(int i = 0; i < n*n; i++){...}nie przypisałbyś n*ndo zmiennej, prawda?
Celeritas
1
Chciałbym i mam. Ale nie dla szybkości. Dla czytelności. Czytelny kod sam w sobie jest szybki.
candied_orange
1
Nawet problem określania zakresu zniknie, jeśli oznaczysz jako stałą ( final). Kogo to obchodzi, jeśli stała, która ma wymuszanie kompilatora uniemożliwiające jej zmianę, jest dostępna później w funkcji?
jpmc26,
Myślę, że dużą rzeczą jest to, czego oczekujesz. Wiem, że przykład korzysta z sqrt, ale co, jeśli byłaby to inna funkcja? Czy funkcja jest czysta? Czy zawsze oczekujesz takich samych wartości? Czy są jakieś skutki uboczne? Czy efekty uboczne są czymś, co zamierzasz zdarzyć podczas każdej iteracji?
Pieter B
38

Dobry kompilator wygeneruje ten sam kod w obu kierunkach, więc jeśli dążysz do wydajności, dokonaj zmiany tylko wtedy, gdy znajduje się ona w krytycznej pętli i rzeczywiście ją profilowałeś i okazało się, że to robi różnicę. Nawet jeśli kompilator nie może go zoptymalizować, jak ludzie zauważyli w komentarzach dotyczących sprawy z wywołaniami funkcji, w zdecydowanej większości przypadków różnica w wydajności będzie zbyt mała, aby była warta rozważenia przez programistę.

Jednak...

Nie możemy zapominać, że kod jest przede wszystkim środkiem komunikacji między ludźmi i obie opcje nie komunikują się zbyt dobrze z innymi ludźmi. Pierwszy sprawia wrażenie, że wyrażenie musi być obliczane przy każdej iteracji, a drugi znajdujący się w sekcji inicjalizacji oznacza, że ​​zostanie zaktualizowany gdzieś w pętli, gdzie jest naprawdę stały.

W rzeczywistości wolałbym, aby został wyciągnięty ponad pętlę i uczyniony, finalaby było to natychmiast i obficie jasne dla każdego, kto czyta kod. Nie jest to również idealne, ponieważ zwiększa zakres zmiennej, ale twoja funkcja zamykająca nie powinna zawierać znacznie więcej niż tej pętli.

Karl Bielefeldt
źródło
5
Po rozpoczęciu dołączania wywołań funkcji kompilator staje się znacznie trudniejszy do optymalizacji. Kompilator musiałby mieć specjalną wiedzę, że Math.sqrt nie ma skutków ubocznych.
Peter Green
@PeterGreen Jeśli jest to Java, JVM może to rozgryźć, ale może to chwilę potrwać.
Chrylis -on strike-
Pytanie jest oznaczone zarówno C ++, jak i Java. Nie wiem, jak zaawansowana jest JVM Javy i kiedy może i nie może tego rozgryźć, ale wiem, że generalnie kompilatory C ++ nie mogą tego rozgryźć, ale funkcja jest czysta, chyba że ma niestandardową adnotację informującą kompilator tak, lub ciało funkcji jest widoczne i wszystkie pośrednio wywoływane funkcje można wykryć jako czyste według tych samych kryteriów. Uwaga: brak efektów ubocznych nie wystarczy, aby usunąć go z pętli. Funkcje zależne od stanu globalnego nie mogą zostać przeniesione z pętli, jeśli ciało pętli może zmodyfikować stan.
hvd
Staje się interesujący, gdy Math.sqrt (x) zostanie zastąpiony przez Mymodule.SomeNonPureMethodWithSideEffects (x).
Pieter B
9

Jak powiedział @Karl Bielefeldt w swojej odpowiedzi, zwykle nie stanowi to problemu.

Jednak kiedyś był to powszechny problem w C i C ++ i pojawiła się sztuczka, która pomogła rozwiązać problem bez zmniejszania czytelności kodu - iteracja wstecz, aż do0 .

final x = 10;//whatever constant value
for(int i = Math.floor(Math.sqrt(x)); i >= 0; i--) {
  //...do something
}

Teraz warunkiem każdej iteracji jest dokładnie to, >= 0co każdy kompilator skompiluje w 1 lub 2 instrukcjach montażu. Każdy procesor wykonany w ciągu ostatnich kilku dekad powinien mieć podstawowe kontrole takie jak te; Robiąc szybkie sprawdzenie na mojej maszynie x64, widzę, że to przewidywalnie zmienia się w cmpl $0x0, -0x14(%rbp)(Long-int-porównaj wartość 0 vs. rejestr rbp offsetowany -14) i jl 0x100000f59(przeskocz do instrukcji po pętli, jeśli poprzednie porównanie było prawdziwe dla „2nd-arg <1st-arg ”) .

Zauważ, że usunąłem + 1z Math.floor(Math.sqrt(x)) + 1; aby matematyka się powiodła, wartość początkowa powinna wynosić int i = «iterationCount» - 1. Warto również zauważyć, że twój iterator musi być podpisany; unsigned intnie będzie działać i prawdopodobnie ostrzega kompilatora.

Po programowaniu w językach opartych na języku C przez ~ 20 lat piszę teraz tylko pętle iteracyjne odwrotnego indeksowania, chyba że istnieje konkretny powód, aby iterować indeksujący dalej. Oprócz prostszych kontroli warunków, iteracja odwrotna często prowadzi również kroki boczne, co w innym przypadku byłoby kłopotliwymi mutacjami macierzowymi podczas iteracji.

Slipp D. Thompson
źródło
1
Wszystko, co piszesz, jest technicznie poprawne. Komentarz na temat instrukcji może być jednak mylący, ponieważ wszystkie współczesne procesory zostały tak zaprojektowane, aby dobrze współpracowały z pętlami iteracji do przodu, niezależnie od tego, czy mają dla nich specjalne instrukcje. W każdym razie większość czasu zwykle spędza się w pętli, nie wykonując iteracji.
Jørgen Fogh
4
Należy zauważyć, że współczesne optymalizacje kompilatora są zaprojektowane z myślą o „normalnym” kodzie. W większości przypadków kompilator wygeneruje kod tak samo szybki, niezależnie od tego, czy użyjesz „sztuczek” optymalizacyjnych, czy nie. Ale niektóre sztuczki mogą faktycznie utrudniać optymalizację w zależności od stopnia ich skomplikowania. Jeśli użycie pewnych sztuczek sprawia, że ​​kod jest bardziej czytelny lub pomaga w wykrywaniu typowych błędów, to świetnie, ale nie oszukuj się, myśląc: „jeśli napiszę kod w ten sposób, będzie to szybsze”. Napisz kod w normalny sposób, a następnie profiluj, aby znaleźć miejsca, w których musisz zoptymalizować.
0x5453,
Zauważ również, że unsignedliczniki będą działać tutaj, jeśli zmodyfikujesz czek (najłatwiejszym sposobem jest dodanie tej samej wartości po obu stronach); Na przykład, dla każdego ubytku Dec, kontrola (i + Dec) >= Decpowinna zawsze mieć taki sam wynik jak signedodprawy i >= 0, zarówno signedi unsignedliczników, o ile język ma dobrze zdefiniowane zasady wraparound dla unsignedzmiennych (konkretnie, -n + n == 0musi być prawdą zarówno dla signedi unsigned). Należy jednak pamiętać, że może to być mniej wydajne niż >=0czek podpisany , jeśli kompilator nie ma dla niego optymalizacji.
Justin Time - Przywróć Monikę
1
@JustinTime Tak, podpisany wymóg jest najtrudniejszy; dodawanie Decstałej zarówno do wartości początkowej, jak i końcowej działa, ale czyni ją mniej intuicyjną, a jeśli używasz ijako indeksu tablicowego, musisz również zrobić unsigned int arrayI = i - Dec;w ciele pętli. Po prostu używam iteracji do przodu, gdy utknąłem z niepodpisanym iteratorem; często z i <= count - 1warunkiem, aby logika była równoległa do pętli odwrotnej iteracji.
Slipp D. Thompson,
1
@ SlippD.Thompson Nie miałem na myśli dodawania Deckonkretnie wartości początkowej i końcowej, ale przesunięcie sprawdzania stanu Decpo obu stronach. for (unsigned i = N - 1; i + 1 >= 1; i--) /*...*/ Pozwala to inormalnie korzystać z pętli, jednocześnie gwarantując, że najniższa możliwa wartość po lewej stronie warunku to 0(aby zapobiec zakłócaniu zawijania). To na pewno dużo prostsze w użyciu iteracji do przodu podczas pracy z niepodpisanych liczników, choć.
Justin Time - Przywróć Monikę
3

Staje się interesujący, gdy Math.sqrt (x) zostanie zastąpiony przez Mymodule.SomeNonPureMethodWithSideEffects (x).

Zasadniczo moim sposobem działania jest: jeśli oczekuje się, że zawsze da tę samą wartość, to oceń to tylko raz. Na przykład List.Count, jeśli lista ma się nie zmieniać podczas działania pętli, przenieś licznik poza pętlę do innej zmiennej.

Niektóre z tych „liczeń” mogą być zaskakująco drogie, szczególnie w przypadku baz danych. Nawet jeśli pracujesz nad zestawem danych, który nie powinien ulec zmianie podczas iteracji listy.

Pieter B.
źródło
Kiedy liczenie jest kosztowne, nie powinieneś go wcale używać. Zamiast tego powinieneś robić ekwiwalentfor( auto it = begin(dataset); !at_end(it); ++it )
Ben Voigt
@BenVoigt Korzystanie z iteratora jest zdecydowanie najlepszym sposobem dla tych operacji. Po prostu wspomniałem o tym, aby zilustrować mój pogląd na temat stosowania nieczystych metod z efektami ubocznymi.
Pieter B
0

Moim zdaniem jest to bardzo specyficzne dla języka. Na przykład, jeśli użyję C ++ 11, podejrzewam, że gdyby funkcja sprawdzania warunków była constexprfunkcją, kompilator bardzo prawdopodobnie zoptymalizowałby wiele wykonań, ponieważ wie, że za każdym razem uzyska tę samą wartość.

Jeśli jednak wywołanie funkcji jest funkcją biblioteki, która nie jest constexprkompilatorem, prawie na pewno wykona ją przy każdej iteracji, ponieważ nie może tego wywnioskować (chyba że jest wbudowane i dlatego można ją wydedukować jako czyste).

Wiem mniej o Javie, ale biorąc pod uwagę, że jest to skompilowany JIT, zgaduję, że kompilator ma wystarczająco dużo informacji w czasie wykonywania, aby prawdopodobnie wbudować i zoptymalizować ten warunek. Ale opierałoby się to na dobrej konstrukcji kompilatora, a kompilator decydujący o tej pętli był priorytetem optymalizacji, którego możemy się tylko domyślać.

Osobiście uważam, że jest nieco bardziej elegancki postawić warunek wewnątrz pętli for, jeśli możesz, ale jeśli jest to kompleks będę pisać go w constexprlub inlinefunkcji, lub twoi języki equivalant do zrozumienia, że funkcja ta jest czysta i optimisable. To sprawia, że ​​intencja jest oczywista i utrzymuje styl idiomatycznej pętli bez tworzenia ogromnej, nieczytelnej linii. Daje również warunek sprawdzania nazwy, jeśli jest to jego własna funkcja, dzięki czemu czytelnicy mogą natychmiast zobaczyć logicznie, do czego służy sprawdzanie, bez odczytywania go, jeśli jest złożony.

Rzeczywistość
źródło