Czy istnieje wyjaśnienie operatorów wbudowanych w „k + = c + = k + = c;”?

89

Jakie jest wyjaśnienie wyniku następującej operacji?

k += c += k += c;

Próbowałem zrozumieć wynik wyjściowy z następującego kodu:

int k = 10;
int c = 30;
k += c += k += c;
//k=80 instead of 110
//c=70

i obecnie nie rozumiem, dlaczego wynik „k” to 80. Dlaczego przypisanie k = 40 nie działa (w rzeczywistości program Visual Studio informuje mnie, że ta wartość nie jest używana gdzie indziej)?

Dlaczego k 80 a nie 110?

Jeśli podzielę operację na:

k+=c;
c+=k;
k+=c;

wynik to k = 110.

Próbowałem przejrzeć CIL , ale nie jestem tak dogłębny w interpretacji wygenerowanego CIL i nie mogę uzyskać kilku szczegółów:

 // [11 13 - 11 24]
IL_0001: ldc.i4.s     10
IL_0003: stloc.0      // k

// [12 13 - 12 24]
IL_0004: ldc.i4.s     30
IL_0006: stloc.1      // c

// [13 13 - 13 30]
IL_0007: ldloc.0      // k expect to be 10
IL_0008: ldloc.1      // c
IL_0009: ldloc.0      // k why do we need the second load?
IL_000a: ldloc.1      // c
IL_000b: add          // I expect it to be 40
IL_000c: dup          // What for?
IL_000d: stloc.0      // k - expected to be 40
IL_000e: add
IL_000f: dup          // I presume the "magic" happens here
IL_0010: stloc.1      // c = 70
IL_0011: add
IL_0012: stloc.0      // k = 80??????
Andrii Kotliarov
źródło
3
Otrzymałeś inny wynik, ponieważ podzieliłeś funkcję, k + = c + = k + = c = 80, ponieważ wartości k i c pozostają takie same we wszystkich sumach, więc k + = c + = k + = c jest równe do 10 + 30 + 10 + 30
João Paulo Amorim
78
Ciekawe ćwiczenie, ale w praktyce nigdy nie pisz kodu w ten sposób, chyba że chcesz, aby współpracownicy Cię nienawidzili. :)
UnhandledExcepSean
3
@AndriiKotliarov, ponieważ k + = c + = k + = c wynosi 10 + 30 + 10 + 30, więc K otrzymuje wszystkie wartości, a C otrzymuje tylko 3 ostatnie argumenty 30 + 10 + 30 = 70
João Paulo Amorim
6
Warto również czytanie - Eric Lippert jest odpowiedzią na czym polega różnica między i ++ i ++ I?
Wai Ha Lee
34
"Doktorze, doktorze, boli, kiedy to robię!" "Więc nie rób tego."
David Conrad,

Odpowiedzi:

104

Operacja taka jak a op= b;jest równoważna a = a op b;. Przypisanie może być użyte jako instrukcja lub jako wyrażenie, podczas gdy jako wyrażenie daje przypisaną wartość. Twoje oświadczenie ...

k += c += k += c;

... może, ponieważ operator przypisania jest prawostronny, może być również zapisany jako

k += (c += (k += c));

lub (rozwinięte)

k =  k +  (c = c +  (k = k  + c));
     10301030   // operand evaluation order is from left to right
      |         |        ↓    ↓
      |         ↓   4010 + 30   // operator evaluation7030 + 40
8010 + 70

Gdzie podczas całej oceny używane są stare wartości zaangażowanych zmiennych. Jest to szczególnie prawdziwe w przypadku wartości k(zobacz moją recenzję IL poniżej i link podany przez Wai Ha Lee). Dlatego nie otrzymujesz 70 + 40 (nowa wartość k) = 110, ale 70 + 10 (stara wartość k) = 80.

Chodzi o to, że (według C # specyfikacji ) „Argumenty operacji w wyrażeniu ocenia się od lewej do prawej” (argumenty są zmienne ci kw naszym przypadku). Jest to niezależne od pierwszeństwa i asocjatywności operatorów, które w tym przypadku narzucają kolejność wykonania od prawej do lewej. (Patrz komentarze Eric Lippert jest odpowiedzią na tej stronie).


Teraz spójrzmy na IL. IL zakłada maszynę wirtualną opartą na stosie, tj. Nie używa rejestrów.

IL_0007: ldloc.0      // k (is 10)
IL_0008: ldloc.1      // c (is 30)
IL_0009: ldloc.0      // k (is 10)
IL_000a: ldloc.1      // c (is 30)

Stos wygląda teraz następująco (od lewej do prawej; góra stosu jest po prawej)

10 30 10 30

IL_000b: add          // pops the 2 top (right) positions, adds them and pushes the sum back

10 30 40

IL_000c: dup

10 30 40 40

IL_000d: stloc.0      // k <-- 40

10 30 40

IL_000e: add

10 70

IL_000f: dup

10 70 70,

IL_0010: stloc.1      // c <-- 70

10 70

IL_0011: add

80

IL_0012: stloc.0      // k <-- 80

Zwróć na to uwagę IL_000c: dup , IL_000d: stloc.0tj. Pierwsze przypisanie dok mógł zostać zoptymalizowane z dala. Prawdopodobnie jest to robione dla zmiennych przez jitter podczas konwersji IL na kod maszynowy.

Należy również zauważyć, że wszystkie wartości wymagane do obliczeń są umieszczane na stosie przed dokonaniem jakiegokolwiek przypisania lub są obliczane na podstawie tych wartości. Wartości przypisane (przez stloc) nigdy nie są ponownie używane podczas tej oceny.stloczdejmuje szczyt stosu.


Wynik poniższego testu konsoli to (Release tryb z włączonymi optymalizacjami)

oceniając k (10)
oceniając c (30)
oceniając k (10)
oceniając c (30)
40 przypisane do k
70 przypisane do c
80 przypisane do k

private static int _k = 10;
public static int k
{
    get { Console.WriteLine($"evaluating k ({_k})"); return _k; }
    set { Console.WriteLine($"{value} assigned to k"); _k = value; }
}

private static int _c = 30;
public static int c
{
    get { Console.WriteLine($"evaluating c ({_c})"); return _c; }
    set { Console.WriteLine($"{value} assigned to c"); _c = value; }
}

public static void Test()
{
    k += c += k += c;
}
Olivier Jacot-Descombes
źródło
Możesz dodać wynik końcowy z liczbami we wzorze, aby uzyskać jeszcze pełniejsze: ostateczne to, k = 10 + (30 + (10 + 30)) = 80a ta ckońcowa wartość jest umieszczona w pierwszym nawiasie, czyli c = 30 + (10 + 30) = 70.
Franck,
2
Rzeczywiście, jeśli kjest lokalny, martwy magazyn jest prawie na pewno usuwany, jeśli optymalizacje są włączone, i zachowywany, jeśli nie są. Interesującym pytaniem jest to, czy jitter może usunąć martwy magazyn, jeśli kjest to pole, właściwość, miejsce w tablicy itd.; w praktyce uważam, że nie.
Eric Lippert
Test konsoli w trybie wydania rzeczywiście pokazuje, że kjest to przypisane dwukrotnie, jeśli jest to właściwość.
Olivier Jacot-Descombes
26

Po pierwsze, odpowiedzi Henka i Oliviera są poprawne; Chcę to wyjaśnić w nieco inny sposób. W szczególności chciałbym odnieść się do tego, co przedstawiłeś. Masz ten zestaw instrukcji:

int k = 10;
int c = 30;
k += c += k += c;

I wtedy błędnie dochodzisz do wniosku, że powinno to dać taki sam wynik jak ten zestaw instrukcji:

int k = 10;
int c = 30;
k += c;
c += k;
k += c;

Warto zobaczyć, jak się pomyliłeś i jak to zrobić dobrze. Właściwy sposób na rozbicie tego jest taki.

Najpierw przepisz najbardziej zewnętrzny + =

k = k + (c += k += c);

Po drugie, przepisz najbardziej zewnętrzny znak +. Mam nadzieję, że zgodzisz się, że x = y + z musi zawsze być tym samym, co „oszacuj y na tymczasowe, oszacuj z na tymczasowe, zsumuj tymczasowe, przypisz sumę do x” . Zróbmy to bardzo wyraźnie:

int t1 = k;
int t2 = (c += k += c);
k = t1 + t2;

Upewnij się, że jest to jasne, ponieważ jest to krok, który popełniłeś źle . Rozbijając złożone operacje na prostsze, musisz upewnić się, że robisz to powoli i ostrożnie oraz nie pomijaj poszczególnych kroków . Pomijanie kroków jest tym, gdzie popełniamy błędy.

OK, teraz ponownie, powoli i ostrożnie, podziel przypisanie na t2.

int t1 = k;
int t2 = (c = c + (k += c));
k = t1 + t2;

Przypisanie przypisze tę samą wartość do t2, jaka jest przypisana do c, więc powiedzmy, że:

int t1 = k;
int t2 = c + (k += c);
c = t2;
k = t1 + t2;

Świetny. Teraz podziel drugą linię:

int t1 = k;
int t3 = c;
int t4 = (k += c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

Świetnie, robimy postępy. Podziel przypisanie do t4:

int t1 = k;
int t3 = c;
int t4 = (k = k + c);
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

Teraz podziel trzecią linię:

int t1 = k;
int t3 = c;
int t4 = k + c;
k = t4;
int t2 = t3 + t4;
c = t2;
k = t1 + t2;

A teraz możemy spojrzeć na całość:

int k = 10;  // 10
int c = 30;  // 30
int t1 = k;  // 10
int t3 = c;  // 30
int t4 = k + c; // 40
k = t4;         // 40
int t2 = t3 + t4; // 70
c = t2;           // 70
k = t1 + t2;      // 80

Więc kiedy skończymy, k wynosi 80, a c wynosi 70.

Teraz spójrzmy, jak to jest zaimplementowane w IL:

int t1 = k;
int t3 = c;  
  is implemented as
ldloc.0      // stack slot 1 is t1
ldloc.1      // stack slot 2 is t3

Teraz jest to trochę trudne:

int t4 = k + c; 
k = t4;         
  is implemented as
ldloc.0      // load k
ldloc.1      // load c
add          // sum them to stack slot 3
dup          // t4 is stack slot 3, and is now equal to the sum
stloc.0      // k is now also equal to the sum

Mogliśmy zaimplementować powyższe jako

ldloc.0      // load k
ldloc.1      // load c
add          // sum them
stloc.0      // k is now equal to the sum
ldloc.0      // t4 is now equal to k

ale używamy sztuczki „dup”, ponieważ sprawia, że ​​kod jest krótszy i łatwiejszy w przypadku jittera, i otrzymujemy ten sam wynik. Ogólnie rzecz biorąc, generator kodu C # stara się zachować jak najwięcej tymczasowych „efemerycznych” na stosie. Jeśli okaże się, że łatwiej śledzić IL mniej ephemerals skręcić optymalizacji off , a generator kodu będzie mniej agresywny.

Teraz musimy zrobić tę samą sztuczkę, aby uzyskać c:

int t2 = t3 + t4; // 70
c = t2;           // 70
  is implemented as:
add          // t3 and t4 are the top of the stack.
dup          
stloc.1      // again, we do the dup trick to get the sum in 
             // both c and t2, which is stack slot 2.

i w końcu:

k = t1 + t2;
  is implemented as
add          // stack slots 1 and 2 are t1 and t2.
stloc.0      // Store the sum to k.

Ponieważ nie potrzebujemy kwoty do niczego innego, nie oszukujemy jej. Stos jest teraz pusty, a my jesteśmy na końcu instrukcji.

Morał tej historii jest taki: kiedy próbujesz zrozumieć skomplikowany program, zawsze wykonuj operacje pojedynczo . Nie idź na skróty; sprowadzą cię na manowce.

Eric Lippert
źródło
3
@ OlivierJacot-Descombes: Odpowiednia linia spec jest w sekcji „operatorów” i mówi: „Argumenty w wyrażeniu są obliczane od lewej do prawej, na przykład w. F(i) + G(i++) * H(i), Metoda F nazywa się stosując starą wartość I, a następnie metodą G jest wywoływana ze starą wartością i, a na koniec metoda H jest wywoływana z nową wartością i . Jest to oddzielne i niezwiązane z pierwszeństwem operatorów. " (Podkreślenie dodane.) Więc chyba się myliłem, mówiąc, że nigdzie nie ma miejsca, w którym „stara wartość jest używana”! Występuje w przykładzie. Ale bit normatywny to „od lewej do prawej”.
Eric Lippert
1
To było brakujące ogniwo. Kwintesencja polega na tym , że musimy rozróżnić między kolejnością oceny operandów a priorytetem operatorów . Ocena operandu przebiega od lewej do prawej, aw przypadku PO operand wykonuje od prawej do lewej.
Olivier Jacot-Descombes
4
@ OlivierJacot-Descombes: Dokładnie tak. Pierwszeństwo i łączność nie mają nic wspólnego z kolejnością, w jakiej są oceniane podwyrażenia, poza faktem, że pierwszeństwo i łączność określają granice podwyrażeń . Podwyrażenia są oceniane od lewej do prawej.
Eric Lippert
1
Ups wygląda na to, że nie możesz przeciążać operatorów przypisania: /
johnny 5
1
@ johnny5: Zgadza się. Ale możesz przeciążyć +, a wtedy otrzymasz +=za darmo, ponieważ x += yjest zdefiniowany jako x = x + ywyjątek xjest oceniany tylko raz. Dzieje się tak niezależnie od tego, czy +jest wbudowany, czy zdefiniowany przez użytkownika. A więc: spróbuj przeładować +typ referencyjny i zobacz, co się stanie.
Eric Lippert
14

Sprowadza się to do: czy pierwsza jest +=stosowana do oryginału, kczy do wartości, która została obliczona bardziej w prawo?

Odpowiedź jest taka, że ​​chociaż przypisania są powiązane od prawej do lewej, operacje nadal są wykonywane od lewej do prawej.

Więc +=wykonuje najbardziej lewy 10 += 70.

Henk Holterman
źródło
1
To ładnie umieszcza go w łupinie orzecha.
Aganju
W rzeczywistości są to operandy, które są oceniane od lewej do prawej.
Olivier Jacot-Descombes
0

Wypróbowałem przykład z gcc i pgcc i otrzymałem 110. Sprawdziłem wygenerowany IR, a kompilator rozszerzył wyrażenie do:

k = 10;
c = 30;
k = c+k;
c = c+k;
k = c+k;

co wydaje mi się rozsądne.

Brian Yang
źródło
-1

w przypadku tego rodzaju przypisań łańcuchowych należy przypisać wartości zaczynając od najbardziej prawej strony. Musisz przypisać i obliczyć i przypisać go do lewej strony, i przejść przez to aż do ostatniego (przypisanie z lewej strony), Jasne, że jest obliczane jako k = 80.

Hasan Zeki Alp
źródło
Prosimy nie publikować odpowiedzi, które po prostu powtarzają to, co wiele innych odpowiedzi już stwierdza.
Eric Lippert
-1

Prosta odpowiedź: Zastąp zmienne wartościami i masz to:

int k = 10;
int c = 30;
k += c += k += c;
10 += 30 += 10 += 30
= 10 + 30 + 10 + 30
= 80 !!!
Thomas Michael
źródło
Ta odpowiedź jest błędna. Chociaż ta technika działa w tym konkretnym przypadku, ten algorytm ogólnie nie działa. Na przykład k = 10; m = (k += k) + k;nie oznacza m = (10 + 10) + 10. Języki z mutującymi wyrażeniami nie mogą być analizowane tak, jakby miały chętnie zastępowane wartości . Podstawianie wartości odbywa się w określonej kolejności w odniesieniu do mutacji i należy to wziąć pod uwagę.
Eric Lippert
-1

Możesz rozwiązać ten problem, licząc.

a = k += c += k += c

Istnieją dwa cs, a dwa kjest tak

a = 2c + 2k

A w konsekwencji operatorów języka krówna się również2c + 2k

To zadziała dla dowolnej kombinacji zmiennych w tym stylu łańcucha:

a = r += r += r += m += n += m

Więc

a = 2m + n + 3r

I rbędzie równy temu samemu.

Możesz obliczyć wartości innych liczb, obliczając tylko ich przypisanie po lewej stronie. Więc mjest równy 2m + ni nrówny n + m.

To pokazuje, że k += c += k += c;jest inaczej k += c; c += k; k += c;i dlatego otrzymujesz różne odpowiedzi.

Niektórzy ludzie w komentarzach wydają się być zaniepokojeni, że możesz próbować nadmiernie uogólniać ten skrót na wszystkie możliwe typy dodatków. Dlatego wyjaśnię, że ten skrót ma zastosowanie tylko do tej sytuacji, tj. Łączenia w łańcuch dodawania przypisań dla wbudowanych typów liczb. Nie działa (koniecznie), jeśli dodasz inne operatory, np. ()Lub +, lub jeśli wywołasz funkcje lub jeśli nadpisałeś +=, lub jeśli używasz czegoś innego niż podstawowe typy liczb. Ma to tylko pomóc w konkretnej sytuacji w pytaniu .

Matt Ellen
źródło
To nie odpowiada na pytanie
johnny 5
@ johnny5 wyjaśnia, dlaczego otrzymujesz wynik, np. ponieważ tak działa matematyka.
Matt Ellen,
2
Matematyka i kolejność operacji, które kompilator ocenia w instrukcji, to dwie różne rzeczy. Zgodnie z twoją logiką k + = c; c + = k; k + = c powinno dawać ten sam wynik.
johnny 5
Nie, johnny 5, nie o to chodzi. Matematycznie to różne rzeczy. Trzy oddzielne operacje dają wynik 3c + 2k.
Matt Ellen,
2
Niestety, twoje "algebraiczne" rozwiązanie jest tylko przypadkowo poprawne. Twoja technika ogólnie nie działa . Rozważ x = 1;i y = (x += x) + x;czy twierdzisz, że „są trzy x, więc y jest równe 3 * x”? Ponieważ yjest równy 4w tym przypadku. A co z y = x + (x += x);twoim twierdzeniem, że prawo algebraiczne „a + b = b + a” jest spełnione i to też jest 4? Ponieważ jest to 3. Niestety, C # nie przestrzega zasad algebry w liceum, jeśli w wyrażeniach występują efekty uboczne . C # przestrzega zasad algebry efektów ubocznych.
Eric Lippert