Kolejność oceny wskaźników tablicowych (względem wyrażenia) w C

47

Patrząc na ten kod:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

Który wpis tablicy zostanie zaktualizowany? 0 czy 2?

Czy w specyfikacji C jest część wskazująca na pierwszeństwo działania w tym konkretnym przypadku?

Jiminion
źródło
21
Pachnie niezdefiniowanym zachowaniem. Z pewnością jest to coś, co nigdy nie powinno być celowo kodowane.
Fiddling Bits
1
Zgadzam się, że to przykład złego kodowania.
Jiminion
4
Niektóre anegdotyczne wyniki: godbolt.org/z/hM2Jo2
Bob__
15
Nie ma to nic wspólnego z indeksami tablic ani kolejnością operacji. Ma to związek z tym, co specyfika C nazywa „punktami sekwencyjnymi”, aw szczególności z faktem, że wyrażenia przypisania NIE tworzą punktu sekwencyjnego między wyrażeniem po lewej i po prawej stronie, więc kompilator może dowolnie to robić wybiera.
Lee Daniel Crocker
4
Powinieneś zgłosić żądanie funkcji, aby clangten fragment kodu wywołał ostrzeżenie IMHO.
Poniedziałek

Odpowiedzi:

51

Kolejność lewych i prawych argumentów

Aby wykonać przypisanie w arr[global_var] = update_three(2), implementacja C musi ocenić operandy i, jako efekt uboczny, zaktualizować zapamiętaną wartość lewego operandu. C 2018 6.5.16 (który dotyczy przydziałów) paragraf 3 mówi nam, że nie ma sekwencjonowania w lewym i prawym operandzie:

Oceny operandów nie są konsekwentne.

Oznacza to, że implementacja języka C może swobodnie obliczać wartość arr[global_var] najpierw pierwszą (obliczając wartość, rozumiemy, do czego odnosi się to wyrażenie), a następnie ocenić update_three(2), a na koniec przypisać wartość drugiej wartości temu pierwszemu; lub update_three(2)najpierw ocenić , a następnie obliczyć wartość, a następnie przypisać pierwszą z nich do drugiej; lub w celu oceny wartości i update_three(2)w pewien sposób zmieszany, a następnie przypisz odpowiednią wartość do lewej wartości.

We wszystkich przypadkach przypisanie wartości do wartości musi być ostatnie, ponieważ 6.5.16 3 mówi również:

… Efektem ubocznym aktualizacji zapamiętanej wartości lewego operandu jest sekwencja po obliczeniach wartości lewego i prawego operandu…

Naruszenie zasad sekwencjonowania

Niektórzy mogą zastanawiać się nad nieokreślonym zachowaniem z powodu używania go global_vari osobnej aktualizacji z naruszeniem 6.5 2, co mówi:

Jeśli efekt uboczny na obiekcie skalarnym nie ma wpływu na inny efekt uboczny na ten sam obiekt skalarny lub obliczenia wartości z wykorzystaniem wartości tego samego obiektu skalarnego, zachowanie jest niezdefiniowane…

Dla wielu praktyków języka C jest dość znane, że zachowanie wyrażeń, które x + x++nie jest zdefiniowane w standardzie C, ponieważ oba używają wartości xi oddzielnie ją modyfikują w tym samym wyrażeniu bez sekwencjonowania. Jednak w tym przypadku mamy wywołanie funkcji, które zapewnia pewne sekwencjonowanie. global_varjest używany arr[global_var]i jest aktualizowany w wywołaniu funkcjiupdate_three(2) .

6.5.2.2 10 mówi nam, że przed wywołaniem funkcji jest punkt sekwencji:

Istnieje punkt sekwencyjny po ocenach desygnatora funkcji i rzeczywistych argumentach, ale przed faktycznym wywołaniem…

Wewnątrz funkcji global_var = val;znajduje się pełne wyrażenie , podobnie jak 3in return 3;, na 6,8 4:

Pełne wyrażenie jest wyrażeniem, które nie jest częścią innego wyrazu, ani częścią declarator lub abstrakcyjnego declarator ...

Następnie między tymi dwoma wyrażeniami występuje punkt sekwencyjny, ponownie na 6,8 4:

… Między oceną pełnego wyrażenia a oceną następnego pełnego wyrażenia, które ma zostać ocenione, istnieje punkt sekwencyjny.

Zatem implementacja C może arr[global_var]najpierw ocenić, a następnie wykonać wywołanie funkcji, w którym to przypadku między nimi znajduje się punkt sekwencji, ponieważ jest jeden przed wywołaniem funkcji, lub może ocenić global_var = val;w wywołaniu funkcji, a następnie arr[global_var], w którym to przypadku występuje punkt sekwencji między nimi, ponieważ jest jeden po pełnym wyrażeniu. Zachowanie jest więc nieokreślone - jedną z tych dwóch rzeczy może być najpierw ocenione - ale nie jest niezdefiniowane.

Eric Postpischil
źródło
24

Wynik tutaj jest nieokreślony .

Chociaż kolejność operacji w wyrażeniu, które określają sposób grupowania podwyrażeń, jest dobrze zdefiniowana, kolejność oceny nie jest określona. W takim przypadku oznacza to, że albo global_varmożna go przeczytać jako pierwszy, albo wezwanie doupdate_three , ale nie ma sposobu, aby wiedzieć, który.

Jest nie niezdefiniowane zachowanie się tutaj, ponieważ wywołanie funkcji wprowadza punkt sekwencji, tak jak każde oświadczenie w funkcji w tym jeden, który modyfikujeglobal_var .

Aby wyjaśnić, norma C definiuje niezdefiniowane zachowanie w sekcji 3.4.3 jako:

niezdefiniowane zachowanie

zachowanie, po zastosowaniu niezbywalnej lub błędnej konstrukcji programu lub błędnych danych, dla których niniejszy standard międzynarodowy nie nakłada żadnych wymagań

i definiuje nieokreślone zachowanie w sekcji 3.4.4 jako:

nieokreślone zachowanie

użycie nieokreślonej wartości lub inne zachowanie, w przypadku gdy niniejsza Norma Międzynarodowa zapewnia dwie lub więcej możliwości i nie nakłada żadnych dalszych wymagań, na które w żadnym wypadku została wybrana

Norma stwierdza, że ​​kolejność oceny argumentów funkcji jest nieokreślona, ​​co w tym przypadku oznacza, że ​​albo arr[0]zostanie ustawiona na 3, albo arr[2]na 3.

dbush
źródło
„Wywołanie funkcji wprowadza punkt sekwencyjny” jest niewystarczające. Jeśli lewy operand jest oceniany jako pierwszy, to wystarczy, ponieważ wtedy punkt sekwencji oddziela lewy operand od ocen w funkcji. Ale jeśli lewy operand jest oceniany po wywołaniu funkcji, punkt sekwencyjny spowodowany wywołaniem funkcji nie znajduje się między ocenami w funkcji a oceną lewego operandu. Potrzebujesz także punktu sekwencji oddzielającego pełne wyrażenia.
Eric Postpischil
2
@EricPostpischil W terminologii sprzed C11 istnieje punkt sekwencji na wejściu i wyjściu z funkcji. W terminologii C11 całe ciało funkcji jest sekwencjonowane w nieokreślony sposób w odniesieniu do kontekstu wywołującego. Oba określają to samo, używając tylko innych terminów
MM
To absolutnie źle. Kolejność oceny argumentów zadania jest nieokreślona. Jeśli chodzi o wynik tego konkretnego przypisania, jest to utworzenie tablicy o niewiarygodnej treści, zarówno nieprzenoszalnej, jak i wewnętrznie niepoprawnej (niezgodnej z semantyką lub jednym z zamierzonych wyników). Idealny przypadek nieokreślonego zachowania.
kuroi neko
1
@kuroineko To, że dane wyjściowe mogą się różnić, nie powoduje automatycznie niezdefiniowanego zachowania. Norma ma różne definicje zachowań niezdefiniowanych i nieokreślonych, w tej sytuacji jest to drugie.
dbush
@EricPostpischil Masz tutaj punkty sekwencji (z C11 informacyjnego załącznika C): „Między ocenami oznaczenia funkcji a rzeczywistymi argumentami w wywołaniu funkcji i rzeczywistym wywołaniem. (6.5.2.2)”, „Między oceną pełnego wyrażenia i następne pełne wyrażenie do oceny ... / - / ... (opcjonalne) wyrażenie w instrukcji return (6.8.6.4) ". No cóż, na każdym średniku, ponieważ jest to pełne wyrażenie.
Lundin
1

Próbowałem i zaktualizowałem wpis 0.

Jednak zgodnie z tym pytaniem: czy wyrażenie po prawej stronie zawsze będzie oceniane jako pierwsze

Kolejność oceny jest nieokreślona i nie ma konsekwencji. Dlatego uważam, że takiego kodu należy unikać.

Mickael B.
źródło
Otrzymałem również aktualizację na pozycji 0.
Jiminion
1
Zachowanie nie jest niezdefiniowane, ale nie jest określone. Naturalnie w zależności od któregokolwiek z nich należy unikać.
Antti Haapala
@AnttiHaapala Edytowałem
Mickael B.
1
Hmm ah i nie jest to niesekwencjonowane, ale nieokreślone sekwencjonowanie ... 2 osoby stojące losowo w kolejce są sekwencjonowane w nieokreślony sposób. Neo wewnątrz Agenta Smitha nie ma konsekwencji i nastąpi niezdefiniowane zachowanie.
Antti Haapala
0

Ponieważ nie ma sensu wysyłać kodu dla przypisania, zanim będzie można przypisać wartość, większość kompilatorów C najpierw wyemituje kod wywołujący funkcję i zapisze gdzieś wynik (rejestr, stos itp.), A następnie wyemituje kod, który zapisuje tę wartość do ostatecznego miejsca docelowego i dlatego odczytują zmienną globalną po jej zmianie. Nazwijmy to „porządkiem naturalnym”, nieokreślonym żadnym standardem, lecz czystą logiką.

Jednak w trakcie optymalizacji kompilatory spróbują wyeliminować pośredni krok tymczasowego przechowywania wartości i spróbują zapisać wynik funkcji tak bezpośrednio, jak to możliwe, do ostatecznego miejsca docelowego, w takim przypadku często będą musieli najpierw przeczytać indeks , np. do rejestru, aby móc bezpośrednio przenieść wynik funkcji do tablicy. Może to spowodować odczyt globalnej zmiennej przed jej zmianą.

Jest to więc zasadniczo niezdefiniowane zachowanie z bardzo złą właściwością, że jest całkiem prawdopodobne, że wynik będzie inny, w zależności od tego, czy przeprowadzana jest optymalizacja i jak agresywna jest ta optymalizacja. Twoim zadaniem jako programisty jest rozwiązanie tego problemu poprzez zakodowanie:

int idx = global_var;
arr[idx] = update_three(2);

lub kodowanie:

int temp = update_three(2);
arr[global_var] = temp;

Ogólna zasada: jeśli zmienne globalne nie są const(lub nie są, ale wiesz, że żaden kod nigdy ich nie zmieni jako efekt uboczny), nigdy nie powinieneś ich używać bezpośrednio w kodzie, jak w środowisku wielowątkowym, nawet tego nie można zdefiniować:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

Ponieważ kompilator może go odczytać dwa razy, a inny wątek może zmienić wartość pomiędzy dwoma odczytami. Jednak optymalizacja z pewnością spowodowałaby, że kod przeczytałby go tylko raz, więc możesz ponownie uzyskać różne wyniki, które teraz zależą również od czasu innego wątku. W ten sposób będziesz mieć o wiele mniej bólu głowy, jeśli przed użyciem przechowujesz zmienne globalne w tymczasowej zmiennej stosu. Należy pamiętać, że jeśli kompilator uważa, że ​​jest to bezpieczne, najprawdopodobniej zoptymalizuje nawet to, a zamiast tego bezpośrednio użyje zmiennej globalnej, więc w końcu może nie mieć różnicy w wydajności lub wykorzystaniu pamięci.

(Na wypadek, gdyby ktoś zapytał, dlaczego miałby to zrobić x + 2 * xzamiast 3 * x- na niektórych procesorach dodawanie jest ultraszybkie, podobnie jak mnożenie przez potęgę drugą, ponieważ kompilator zamieni je na przesunięcia bitów ( 2 * x == x << 1), jednak mnożenie z dowolnymi liczbami może być bardzo wolne , dlatego zamiast mnożenia przez 3, uzyskujesz znacznie szybszy kod poprzez przesunięcie bitów x o 1 i dodanie x do wyniku - a nawet ten trik jest wykonywany przez nowoczesne kompilatory, jeśli pomnożysz przez 3 i włączysz agresywną optymalizację, chyba że jest to nowoczesny cel Procesor, w którym mnożenie jest równie szybkie jak dodawanie, ponieważ sztuczka spowolniłaby obliczenia).

Mecki
źródło
2
Nie jest to zachowanie nieokreślone - standard wymienia możliwości i jedno z nich jest wybierane w każdym przypadku
Antti Haapala
Kompilator nie zmieni się 3 * xw dwa odczyty x. Może odczytać x raz, a następnie wykonać metodę x + 2 * x na rejestrze, w którym wczytał x do
MM
6
@Mecki „Jeśli nie możesz powiedzieć, jaki jest wynik, patrząc tylko na kod, wynik jest niezdefiniowany” - niezdefiniowane zachowanie ma bardzo specyficzne znaczenie w C / C ++, i to nie wszystko. Inni respondenci wyjaśnili, dlaczego ten konkretny przypadek jest nieokreślony , ale nie jest nieokreślony .
marcelm
3
Doceniam zamiar rzucić nieco światła na wnętrze komputera, nawet jeśli wykracza to poza pierwotne pytanie. Jednak UB jest bardzo precyzyjnym żargonem C / C ++ i należy go używać ostrożnie, zwłaszcza gdy chodzi o techniczną znajomość języka. Zamiast tego możesz rozważyć użycie właściwego terminu „nieokreślone zachowanie”, co znacznie poprawiłoby odpowiedź.
kuroi neko
2
@Mecki „ Niezdefiniowane ma bardzo szczególne znaczenie w języku angielskim ” ... ale w pytaniu oznaczonym language-lawyer, gdy dany język ma swoje własne „bardzo szczególne znaczenie” dla nieokreślonych , wprowadzisz zamieszanie, nie używając definicja języka.
TripeHound
-1

Globalna edycja: przepraszam chłopaki, wyrzuciłem wszystkich z pracy i napisałem wiele bzdur. Tylko stary geezer narzekający.

Chciałem wierzyć, że C zostało oszczędzone, ale niestety od C11 zostało wyrównane z C ++. Najwyraźniej wiedza o tym, co kompilator zrobi z efektami ubocznymi wyrażeń, wymaga teraz rozwiązania małej zagadki matematycznej polegającej na częściowym uporządkowaniu sekwencji kodu na podstawie „znajduje się przed punktem synchronizacji”.

Zdarzyło mi się zaprojektować i wdrożyć kilka krytycznych systemów osadzonych w czasie rzeczywistym w czasach K&R (w tym kontroler samochodu elektrycznego, który mógłby wysłać ludzi uderzających w najbliższą ścianę, gdyby silnik nie był kontrolowany, 10 ton przemysłowych robot, który mógłby zmiażdżyć ludzi na miazgę, jeśli nie byłby właściwie dowodzony, oraz warstwa systemowa, która, choć nieszkodliwa, miałaby kilkadziesiąt procesorów wysysających szynę danych z mniej niż 1% obciążenia systemowego).

Mogę być zbyt ostrożny lub głupi, aby dostrzec różnicę między niezdefiniowanym a nieokreślonym, ale myślę, że nadal mam całkiem niezłe pojęcie o tym, co oznacza równoczesne wykonywanie i dostęp do danych. W mojej, prawdopodobnie uzasadnionej opinii, ta obsesja na punkcie C ++, a teraz C facetów z ich językami domowymi przejmującymi problemy z synchronizacją, jest kosztownym marzeniem. Albo wiesz, co to jest równoczesne wykonywanie, i nie potrzebujesz żadnego z tych gadżetów, albo nie, i zrobiłbyś światu przysługę, nie próbując go zepsuć.

Cała ta ogromna ilość abstrakcyjnych barier pamięci jest po prostu spowodowana tymczasowym zestawem ograniczeń wieloprocesorowych systemów pamięci podręcznej, z których wszystkie można bezpiecznie zamknąć we wspólnych obiektach synchronizacji systemu operacyjnego, takich jak na przykład muteksy i zmienne warunkowe C ++ oferuje.
Koszt takiej enkapsulacji to zaledwie drobny spadek wydajności w porównaniu z tym, co może osiągnąć zastosowanie drobnoziarnistych instrukcji dla konkretnego procesora w niektórych przypadkach.
Thevolatile kluczowe (lub a#pragma dont-mess-with-that-variabledla mnie, jako programisty systemu, opieka) byłaby wystarczająca, aby powiedzieć kompilatorowi, aby przestał zmieniać kolejność dostępu do pamięci. Optymalny kod może być łatwo wytworzony za pomocą bezpośrednich dyrektyw asm, aby posypać kod sterownika niskiego poziomu i kod systemu operacyjnego instrukcjami ad hoc CPU. Bez dogłębnej wiedzy o tym, jak działa podstawowy sprzęt (system pamięci podręcznej lub interfejs magistrali), i tak będziesz musiał pisać bezużyteczny, nieefektywny lub wadliwy kod.

Drobna korekta volatilesłowa kluczowego i Boba byłaby wszystkim, ale najbardziej wujek programistów niskiego poziomu. Zamiast tego zwykły gang maniaków matematyki w C ++ miał dzień w terenie, projektując jeszcze jedną niezrozumiałą abstrakcję, ulegając ich typowej tendencji do projektowania rozwiązań szukających nieistniejących problemów i mylących definicję języka programowania ze specyfikacjami kompilatora.

Tylko tym razem zmiana wymagała zniszczenia podstawowego aspektu języka C, ponieważ te „bariery” musiały zostać wygenerowane nawet w kodzie niskiego poziomu, aby działały poprawnie. To między innymi spowodowało spustoszenie w definicji wyrażeń, bez żadnego wyjaśnienia ani uzasadnienia.

Podsumowując, fakt, że kompilator może wytworzyć spójny kod maszynowy z tego absurdalnego fragmentu C, jest tylko daleką konsekwencją sposobu, w jaki ludzie C ++ radzili sobie z potencjalnymi niespójnościami systemów pamięci podręcznej pod koniec 2000 roku.
To spowodowało straszny bałagan jednego podstawowego aspektu C (definicja wyrażenia), tak że ogromna większość programistów C - którzy nie dbają o systemy pamięci podręcznej i słusznie - jest teraz zmuszona polegać na guru, aby wyjaśnić różnica między a = b() + c()i a = b + c.

Zgadywanie, co stanie się z tym niefortunnym zestawem, jest i tak stratą czasu i wysiłku. Niezależnie od tego, co zrobi z niego kompilator, ten kod jest patologicznie niepoprawny. Jedyną odpowiedzialną rzeczą, jaką można z tym zrobić, jest wysłanie go do kosza.
Koncepcyjnie, skutki uboczne można zawsze usunąć z wyrażeń, przy trywialnym wysiłku wyraźnego zezwolenia na modyfikację przed lub po ocenie, w osobnym oświadczeniu.
Ten gówniany kod mógł być usprawiedliwiony w latach 80-tych, kiedy nie można było oczekiwać, że kompilator coś zoptymalizuje. Ale teraz, gdy kompilatory od dawna stają się mądrzejsze niż większość programistów, pozostaje tylko kawałek gównianego kodu.

Nie rozumiem również znaczenia tej nieokreślonej / nieokreślonej debaty. Albo możesz polegać na kompilatorze do generowania kodu o spójnym działaniu, albo nie możesz. To, czy nazywasz to niezdefiniowanym, czy nieokreślonym, wydaje się kwestią sporną.

Według mojej prawdopodobnie poinformowanej opinii C jest już wystarczająco niebezpieczny w swoim stanie K&R. Przydatną ewolucją byłoby dodanie zdrowych środków bezpieczeństwa. Na przykład, korzystając z tego zaawansowanego narzędzia do analizy kodu, specyfikacje zmuszają kompilator do wdrożenia przynajmniej generowania ostrzeżeń o kodzie bonkers, zamiast cichego generowania kodu potencjalnie niewiarygodnego.
Zamiast tego faceci postanowili na przykład zdefiniować stały porządek oceny w C ++ 17. Teraz każde imbecylowe oprogramowanie jest aktywnie zachęcane do celowego umieszczania efektów ubocznych w swoim kodzie, pławiąc się w przekonaniu, że nowe kompilatory chętnie zajmą się zaciemnianiem w deterministyczny sposób.

K&R był jednym z prawdziwych cudów świata komputerowego. Za dwadzieścia dolców masz pełną specyfikację języka (widziałem, jak pojedyncze osoby piszą kompletne kompilatory za pomocą tej książki), doskonałą instrukcję obsługi (spis treści zwykle wskazywałby na kilka stron odpowiedzi na twoje pytanie) pytanie) oraz podręcznik, który nauczy Cię rozsądnego posługiwania się językiem. Uzupełnij go uzasadnieniem, przykładami i mądrymi słowami ostrzegającymi o licznych sposobach nadużywania języka do robienia bardzo, bardzo głupich rzeczy.

Zniszczenie tego dziedzictwa za tak mały zysk wydaje mi się okrutnym marnotrawstwem. Ale znowu mogę nie rozumieć tego całkowicie. Może jakaś miła dusza mogłaby wskazać mi przykład nowego kodu C, który w znacznym stopniu wykorzystuje te skutki uboczne?

kuroi neko
źródło
Zachowanie niezdefiniowane, jeśli występują skutki uboczne dla tego samego obiektu w tym samym wyrażeniu, C17 6,5 / 2. Nie mają one konsekwencji według C17 6.5.18 / 3. Ale tekst z 6.5 / 2 „Jeśli efekt uboczny na obiekcie skalarnym nie ma wpływu na inny efekt uboczny na ten sam obiekt skalarny lub obliczenia wartości z wykorzystaniem wartości tego samego obiektu skalarnego, zachowanie jest niezdefiniowane”. nie ma zastosowania, ponieważ obliczanie wartości wewnątrz funkcji jest sekwencjonowane przed lub po dostępie do indeksu tablicy, niezależnie od tego, czy operator przypisania ma w sobie niepodzielone argumenty.
Lundin
Wywołanie funkcji zachowuje się jak „mutex przeciwko nieuporządkowanemu dostępowi”. Podobne do niejasnych bzdur operatora przecinka jak 0,expr,0.
Lundin
Myślę, że uwierzyłeś autorom Standardu, gdy powiedzieli: „Niezdefiniowane zachowanie daje licencję implementatora, aby nie wychwytywać niektórych błędów programu, które są trudne do zdiagnozowania. Identyfikuje także obszary możliwego rozszerzenia języka zgodnego: implementator może ulepszyć język, udostępniając definicja oficjalnie niezdefiniowanego zachowania . ” i powiedział, że Standard nie powinien poniżać użytecznych programów, które nie były ściśle zgodne. Myślę, że większość autorów Standardu uznałaby za oczywiste, że ludzie starający się pisać wysokiej jakości kompilatory ...
supercat
... powinni starać się wykorzystywać UB jako okazję do uczynienia swoich kompilatorów jak najbardziej użytecznymi dla swoich klientów. Wątpię, by ktokolwiek mógł sobie wyobrazić, że autorzy kompilatora użyliby go jako pretekstu do odpowiedzi na skargi dotyczące „Twój kompilator przetwarza ten kod mniej użytecznie niż wszystkich innych” za pomocą „To dlatego, że Standard nie wymaga od nas przetwarzania go w sposób użyteczny, a implementacje które użytecznie przetwarzają programy, których zachowanie nie jest nakazane przez Standard, jedynie promują pisanie uszkodzonych programów ”.
supercat
Nie rozumiem sensu twojej uwagi. Poleganie na specyficznym dla kompilatora zachowaniu jest gwarancją braku możliwości przenoszenia. Wymaga to także wielkiej wiary w producenta kompilatora, który może w dowolnym momencie przerwać dowolną z tych „dodatkowych definicji”. Jedyne, co kompilator może zrobić, to generować ostrzeżenia, które mądry i kompetentny programista może zdecydować się obsługiwać jak błędy. Problem, jaki widzę w przypadku tego potwora ISO, polega na tym, że sprawia on, że tak okropny kod jest legalny w przykładzie OP (z bardzo niejasnych powodów w porównaniu z definicją wyrażenia K&R).
kuroi neko