Czy ten kod z „Języka programowania C ++” 4. wydanie, sekcja 36.3.6, ma dobrze zdefiniowane zachowanie?

94

W sekcji C ++ Programming Language Bjarne Stroustrupa, 4. wydanie, sekcja 36.3.6 Operacje podobne do STL, następujący kod jest używany jako przykład łączenia :

void f2()
{
    std::string s = "but I have heard it works even if you don't believe in it" ;
    s.replace(0, 4, "" ).replace( s.find( "even" ), 4, "only" )
        .replace( s.find( " don't" ), 6, "" );

    assert( s == "I have heard it works only if you believe in it" ) ;
}

Assert zawodzi gcc( zobacz na żywo ) i Visual Studio( zobacz na żywo ), ale nie zawodzi, gdy użyjesz Clang ( zobacz na żywo ).

Dlaczego otrzymuję różne wyniki? Czy któryś z tych kompilatorów nieprawidłowo ocenia wyrażenie łańcuchowe, czy też ten kod wykazuje jakąś formę nieokreślonego lub niezdefiniowanego zachowania ?

Shafik Yaghmour
źródło
Lepiej:s.replace( s.replace( s.replace(0, 4, "" ).find( "even" ), 4, "only" ).find( " don't" ), 6, "" );
Ben Voigt
20
pomijając błąd, czy tylko ja uważam, że tak brzydki kod nie powinien być w książce?
Karoly Horvath
5
@KarolyHorvath Zauważ, że cout << a << b << coperator<<(operator<<(operator<<(cout, a), b), c)jest tylko nieznacznie mniej brzydki.
Oktalist
1
@Oktalist: :) przynajmniej mam taki zamiar. uczy wyszukiwania nazw zależnych od argumentów i składni operatorów w tym samym czasie w zwięzłym formacie ... i nie sprawia wrażenia, że ​​powinieneś pisać taki kod.
Karoly Horvath

Odpowiedzi:

104

Kod wykazuje nieokreślone zachowanie ze względu na nieokreśloną kolejność oceny wyrażeń podrzędnych, chociaż nie wywołuje niezdefiniowanego zachowania, ponieważ wszystkie efekty uboczne są wykonywane w ramach funkcji, które wprowadzają zależność sekwencjonowania w tym przypadku między efektami ubocznymi.

Ten przykład jest wymieniony w propozycji N4228: Refining Expression Evaluation Order for Idiomatic C ++, która mówi co następuje o kodzie w pytaniu:

[...] Ten kod został sprawdzony przez C ++ ekspertów na całym świecie, i opublikowane (C ++ Programming Language, 4 th Edition.) Jednak, jego podatność na nieokreślonym celu oceny została odkryta dopiero niedawno przez narzędzie [.. .]

Detale

Dla wielu osób może być oczywiste, że argumenty funkcji mają nieokreśloną kolejność oceny, ale prawdopodobnie nie jest tak oczywiste, jak to zachowanie współdziała z wywołaniami funkcji połączonych łańcuchami. Nie było to dla mnie oczywiste, kiedy po raz pierwszy analizowałem ten przypadek i najwyraźniej nie dla wszystkich ekspertów-recenzentów .

Na pierwszy rzut oka może się wydawać, że od każdego replace musi być oceniana od lewej do prawej, to odpowiednie grupy argumentów funkcji muszą być również oceniane jako grupy od lewej do prawej.

Jest to niepoprawne, argumenty funkcji mają nieokreśloną kolejność obliczania, chociaż wywołania funkcji w łańcuchu wprowadzają kolejność obliczania od lewej do prawej dla każdego wywołania funkcji, argumenty każdego wywołania funkcji są sekwencjonowane tylko przed wywołaniem funkcji składowej, których są częścią z. W szczególności dotyczy to następujących wezwań:

s.find( "even" )

i:

s.find( " don't" )

które są w nieokreślonej kolejności w odniesieniu do:

s.replace(0, 4, "" )

te dwa findwezwania mogą być ocenione przed lub po replace, co ma znaczenie, ponieważ ma wpływ uboczny sw sposób, który zmieniłby wynik find, zmienia długość s. A więc w zależności od tego, kiedy replacejest to oceniane względem tych dwóchfind wywołań, wynik będzie się różnić.

Jeśli spojrzymy na wyrażenie łańcuchowe i zbadamy kolejność oceny niektórych wyrażeń podrzędnych:

s.replace(0, 4, "" ).replace( s.find( "even" ), 4, "only" )
^ ^       ^  ^  ^    ^        ^                 ^  ^
A B       |  |  |    C        |                 |  |
          1  2  3             4                 5  6

i:

.replace( s.find( " don't" ), 6, "" );
 ^        ^                   ^  ^
 D        |                   |  |
          7                   8  9

Zauważ, że ignorujemy fakt, że 4i 7można je dalej podzielić na więcej wyrażeń podrzędnych. Więc:

  • Ajest sekwencjonowany, przed Bktóry jest sekwencjonowany, przed Cktóry jest sekwencjonowany wcześniejD
  • 1to 9są nieokreślone w kolejności w odniesieniu do innych wyrażeń podrzędnych z niektórymi wyjątkami wymienionymi poniżej
    • 1 do 3są sekwencjonowane wcześniejB
    • 4do 6są sekwencjonowane wcześniejC
    • 7do 9są sekwencjonowane wcześniejD

Kluczem do tego problemu jest to, że:

  • 4aby 9zostały indeterminately zsekwencjonowany w odniesieniu doB

Potencjalna kolejność wyboru ewaluacji dla 4i 7w odniesieniu do Bwyjaśnia różnicę w wynikach pomiędzy oceną clangi gccpodczas oceny f2(). W moich testach clangocenia Bprzed oceną 4i 7podczas gccocenia ją po. Możemy skorzystać z następującego programu testowego, aby zademonstrować, co się dzieje w każdym przypadku:

#include <iostream>
#include <string>

std::string::size_type my_find( std::string s, const char *cs )
{
    std::string::size_type pos = s.find( cs ) ;
    std::cout << "position " << cs << " found in complete expression: "
        << pos << std::endl ;

    return pos ;
}

int main()
{
   std::string s = "but I have heard it works even if you don't believe in it" ;
   std::string copy_s = s ;

   std::cout << "position of even before s.replace(0, 4, \"\" ): " 
         << s.find( "even" ) << std::endl ;
   std::cout << "position of  don't before s.replace(0, 4, \"\" ): " 
         << s.find( " don't" ) << std::endl << std::endl;

   copy_s.replace(0, 4, "" ) ;

   std::cout << "position of even after s.replace(0, 4, \"\" ): " 
         << copy_s.find( "even" ) << std::endl ;
   std::cout << "position of  don't after s.replace(0, 4, \"\" ): "
         << copy_s.find( " don't" ) << std::endl << std::endl;

   s.replace(0, 4, "" ).replace( my_find( s, "even" ) , 4, "only" )
        .replace( my_find( s, " don't" ), 6, "" );

   std::cout << "Result: " << s << std::endl ;
}

Wynik dla gcc( zobacz na żywo )

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position  don't found in complete expression: 37
position even found in complete expression: 26

Result: I have heard it works evenonlyyou donieve in it

Wynik dla clang( zobacz na żywo ):

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position even found in complete expression: 22
position don't found in complete expression: 33

Result: I have heard it works only if you believe in it

Wynik dla Visual Studio( zobacz na żywo ):

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position  don't found in complete expression: 37
position even found in complete expression: 26
Result: I have heard it works evenonlyyou donieve in it

Szczegóły ze standardu

Wiemy, że o ile nie określono inaczej, oceny wyrażeń podrzędnych są bez kolejności, pochodzi to z projektu standardowej sekcji C ++ 11 1.9 Wykonanie programu, która mówi:

O ile nie zaznaczono inaczej, oceny operandów poszczególnych operatorów i podwyrażeń poszczególnych wyrażeń nie są sekwencjonowane. [...]

i wiemy, że wywołanie funkcji wprowadza sekwencję, zanim relacja funkcji wywoła wyrażenie postfix i argumenty w odniesieniu do ciała funkcji, z sekcji 1.9:

[...] Podczas wywoływania funkcji (niezależnie od tego, czy funkcja jest wbudowana, czy nie), każde obliczenie wartości i efekt uboczny związane z jakimkolwiek wyrażeniem argumentu lub z wyrażeniem z przyrostkiem oznaczającym wywoływaną funkcję jest sekwencjonowane przed wykonaniem każdego wyrażenia lub instrukcji w treści wywoływanej funkcji. [...]

Wiemy również, że dostęp do członków klasy, a tym samym tworzenie łańcuchów, będą oceniane od lewej do prawej, z sekcji 5.2.5 Dostęp do członków klasy, która mówi:

[...] Wyrażenie z przyrostkiem przed kropką lub strzałką jest obliczane; 64 wynik tej oceny, wraz z wyrażeniem id, określa wynik całego wyrażenia postfiksowego.

Należy zauważyć, że w przypadku, gdy wyrażenie-id staje się niestatyczną funkcją składową, nie określa kolejności oceny listy-wyrażeń w ramach, ()ponieważ jest to oddzielne wyrażenie podrzędne. Odpowiednia gramatyka z 5.2 wyrażeń Postfix :

postfix-expression:
    postfix-expression ( expression-listopt)       // function call
    postfix-expression . templateopt id-expression // Class member access, ends
                                                   // up as a postfix-expression

Zmiany w C ++ 17

Wniosek p0145r3: Refining Expression Evaluation Order for Idiomatic C ++ wprowadził kilka zmian. Obejmuje zmiany, które zapewniają kodowi dobrze określone zachowanie, wzmacniając kolejność reguł oceny dla wyrażeń postfiksowych i ich listy wyrażeń .

[wyr.call] p5 mówi:

Wyrażenie postfiksowe jest sekwencjonowane przed każdym wyrażeniem na liście-wyrażeń i dowolnym argumencie domyślnym . Inicjalizacja parametru, w tym każde skojarzone obliczenie wartości i efekt uboczny, jest nieokreślone w kolejności w odniesieniu do dowolnego innego parametru. [Uwaga: Wszystkie efekty uboczne oceny argumentów są sekwencjonowane przed wejściem do funkcji (patrz 4.6). —End note] [Przykład:

void f() {
std::string s = "but I have heard it works even if you don’t believe in it";
s.replace(0, 4, "").replace(s.find("even"), 4, "only").replace(s.find(" don’t"), 6, "");
assert(s == "I have heard it works only if you believe in it"); // OK
}

- koniec przykładu]

Shafik Yaghmour
źródło
7
Jestem trochę zaskoczony, widząc, że „wielu ekspertów” przeoczyło ten problem. Powszechnie wiadomo, że ocena wyrażenia postfiksowego wywołania funkcji nie jest sekwencjonowana - przed oceną argumentów (we wszystkich wersjach C i C ++).
MM
@ShafikYaghmour Wywołania funkcji są nieokreślone w kolejności względem siebie nawzajem i wszystkiego innego, z wyjątkiem zanotowanych relacji sekwencjonowania przed. Jednakże, ocena 1, 2, 3, 5, 6, 8, 9 "even", "don't"oraz kilka przypadków sjest unsequenced względem siebie.
TC
4
@TC nie, to nie jest (i w ten sposób pojawia się ten „błąd”). Np foo().func( bar() ). Może zadzwonić foo()przed lub po wywołaniu bar(). Wyrażenie postfiksowe to foo().func. Argumenty i wyrażenie postfiksowe są sekwencjonowane przed func()treścią, ale bez kolejności względem siebie.
MM
@MattMcNabb Ach, racja, źle odczytałem. Mówisz o samym wyrażeniu postfiksowym , a nie o wywołaniu. Tak, zgadza się, są bezkarne (chyba że ma zastosowanie inna zasada).
TC
6
Istnieje również czynnik, który zakłada, że ​​kod pojawiający się w książce B.Stroustrupa jest poprawny, w przeciwnym razie ktoś z pewnością już by to zauważył! (powiązane; użytkownicy SO wciąż znajdują nowe błędy w K&R)
MM
4

Ma to na celu dodanie informacji na ten temat w odniesieniu do C ++ 17. Propozycja ( Refining Expression Evaluation Order for Idiomatic C ++ Revision 2 ) dotycząca C++17rozwiązania problemu z przytoczeniem powyższego kodu była wzorem.

Zgodnie z sugestią, dodałem odpowiednie informacje z propozycji i zacytowałem (podkreśla moje):

Kolejność obliczania wyrażeń, jaka jest obecnie określona w standardzie, podważa porady, popularne idiomy programowania lub względne bezpieczeństwo standardowych obiektów bibliotecznych. Pułapki nie są przeznaczone tylko dla nowicjuszy lub nieostrożnego programisty. Dotykają nas wszystkich bezkrytycznie, nawet jeśli znamy zasady.

Rozważ następujący fragment programu:

void f()
{
  std::string s = "but I have heard it works even if you don't believe in it"
  s.replace(0, 4, "").replace(s.find("even"), 4, "only")
      .replace(s.find(" don't"), 6, "");
  assert(s == "I have heard it works only if you believe in it");
}

Asercja ma na celu potwierdzenie zamierzonego wyniku programisty. Wykorzystuje „łańcuch” wywołań funkcji składowych, co jest powszechną standardową praktyką. Ten kod został sprawdzony przez ekspertów C ++ na całym świecie i opublikowany (The C ++ Programming Language, 4. wydanie). Jednak jego podatność na nieokreśloną kolejność oceny została odkryta dopiero niedawno przez narzędzie.

W artykule zaproponowano zmianę C++17reguły wstępnej dotyczącej kolejności oceny ekspresji, na którą wpłynęło Ci istnieje od ponad trzech dekad. Zaproponowano, że język powinien gwarantować współczesne idiomy lub ryzykować „pułapki i źródła niejasnych, trudnych do znalezienia błędów” takich jak to, co stało się z powyższym przykładem kodu.

Propozycja C++17ma wymagać, aby każde wyrażenie miało dobrze zdefiniowaną kolejność oceny :

  • Wyrażenia postfiksowe są oceniane od lewej do prawej. Obejmuje to wywołania funkcji i wyrażenia wyboru elementu członkowskiego.
  • Wyrażenia przypisania są oceniane od prawej do lewej. Obejmuje to zadania złożone.
  • Operandy do operatorów przesunięcia są obliczane od lewej do prawej.
  • Kolejność oceny wyrażenia zawierającego przeciążony operator jest określana przez kolejność skojarzoną z odpowiednim operatorem wbudowanym, a nie przez reguły wywołań funkcji.

Powyższy kod kompiluje się pomyślnie przy użyciu GCC 7.1.1i Clang 4.0.0.

ricky m
źródło