Ciekawe zachowanie użytkownika polegające na koalescencji zerowej

542

Uwaga: wydaje się, że zostało to naprawione w Roslyn

To pytanie powstało, pisząc moją odpowiedź na to , które mówi o asocjatywności operatora zerowego koalescencji .

Przypominamy, że operatorem zerowego koalescencji jest wyrażenie formy

x ?? y

najpierw ocenia x, a następnie:

  • Jeśli wartość xjest równa null, yjest obliczana i jest to końcowy wynik wyrażenia
  • Jeśli wartość xjest różna od null, niey jest obliczana, a wartość jest końcowym wynikiem wyrażenia po konwersji na typ czasu kompilacji w razie potrzebyxy

Teraz zwykle nie ma potrzeby konwersji, lub jest to po prostu z typu zerowalnego na nieuznawalny - zwykle typy są takie same lub po prostu (powiedzmy) int?do int. Jednakże, można tworzyć własne operatory konwersji niejawnych, a te są stosowane tam, gdzie jest to konieczne.

W prostym przypadku x ?? ynie widziałem żadnego dziwnego zachowania. Jednak (x ?? y) ?? zwidzę pewne mylące zachowanie.

Oto krótki, ale kompletny program testowy - wyniki znajdują się w komentarzach:

using System;

public struct A
{
    public static implicit operator B(A input)
    {
        Console.WriteLine("A to B");
        return new B();
    }

    public static implicit operator C(A input)
    {
        Console.WriteLine("A to C");
        return new C();
    }
}

public struct B
{
    public static implicit operator C(B input)
    {
        Console.WriteLine("B to C");
        return new C();
    }
}

public struct C {}

class Test
{
    static void Main()
    {
        A? x = new A();
        B? y = new B();
        C? z = new C();
        C zNotNull = new C();

        Console.WriteLine("First case");
        // This prints
        // A to B
        // A to B
        // B to C
        C? first = (x ?? y) ?? z;

        Console.WriteLine("Second case");
        // This prints
        // A to B
        // B to C
        var tmp = x ?? y;
        C? second = tmp ?? z;

        Console.WriteLine("Third case");
        // This prints
        // A to B
        // B to C
        C? third = (x ?? y) ?? zNotNull;
    }
}

Więc mamy trzy typy wartości zwyczaj, A, Bi C, z konwersji z punktu A do B, A do C, a B do C.

Rozumiem zarówno drugi przypadek, jak i trzeci przypadek ... ale dlaczego w pierwszym przypadku występuje dodatkowa konwersja A do B. W szczególności, bym naprawdę nie spodziewał się, że pierwszy przypadek i drugi przypadek będzie to samo - to tylko wydobywania wyrażenia do zmiennej lokalnej, po wszystkim.

Masz ochotę na to, co się dzieje? Jestem wyjątkowo niezdecydowany, by wykrzykiwać „błąd”, jeśli chodzi o kompilator C #, ale jestem zaskoczony tym, co się dzieje ...

EDYCJA: OK, oto nieprzyjemny przykład tego, co się dzieje, dzięki odpowiedzi konfiguratora, która daje mi kolejny powód, by myśleć, że to błąd. EDYCJA: Próbka nie potrzebuje teraz nawet dwóch zerowych operatorów koalescencyjnych ...

using System;

public struct A
{
    public static implicit operator int(A input)
    {
        Console.WriteLine("A to int");
        return 10;
    }
}

class Test
{
    static A? Foo()
    {
        Console.WriteLine("Foo() called");
        return new A();
    }

    static void Main()
    {
        int? y = 10;

        int? result = Foo() ?? y;
    }
}

Wynikiem tego jest:

Foo() called
Foo() called
A to int

Fakt, że Foo()tutaj wywoływane są dwa razy, jest dla mnie ogromnie zaskakujący - nie widzę żadnego powodu, aby wyrażenie było oceniane dwukrotnie.

Jon Skeet
źródło
32
Założę się, że myśleli, że „nikt tego nigdy nie użyje w ten sposób” :)
cyberzed
57
Chcesz zobaczyć coś gorszego? Spróbuj użyć tej linii ze wszystkich ukrytych konwersji: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Otrzymasz:Internal Compiler Error: likely culprit is 'CODEGEN'
konfigurator
5
Zauważ też, że tak się nie dzieje, gdy używasz wyrażeń Linq do kompilacji tego samego kodu.
konfigurator
8
@Peter jest mało prawdopodobny, ale prawdopodobny dla(("working value" ?? "user default") ?? "system default")
Factor Mystic
23
@ yes123: Kiedy chodziło tylko o konwersję, nie byłem do końca przekonany. Gdy zobaczyłem, że wykonuje metodę dwukrotnie, stało się oczywiste, że to błąd. Byłbyś zaskoczony niektórymi zachowaniami, które wyglądają niepoprawnie, ale w rzeczywistości są całkowicie poprawne. Zespół C # jest mądrzejszy ode mnie - zakładam, że jestem głupi, dopóki nie udowodnię, że coś jest ich winą.
Jon Skeet,

Odpowiedzi:

418

Dziękujemy wszystkim, którzy przyczynili się do analizy tego problemu. Jest to oczywiście błąd kompilatora. Wydaje się, że dzieje się to tylko wtedy, gdy następuje podniesienie konwersji obejmujące dwa typy zerowalne po lewej stronie operatora koalescencyjnego.

Nie określiłem jeszcze, gdzie dokładnie coś pójdzie nie tak, ale w pewnym momencie podczas fazy kompilacji „obniżania wartości zerowych” - po wstępnej analizie, ale przed wygenerowaniem kodu - zmniejszamy wyrażenie

result = Foo() ?? y;

z powyższego przykładu do moralnego odpowiednika:

A? temp = Foo();
result = temp.HasValue ? 
    new int?(A.op_implicit(Foo().Value)) : 
    y;

Oczywiście jest to niepoprawne; prawidłowe obniżenie to

result = temp.HasValue ? 
    new int?(A.op_implicit(temp.Value)) : 
    y;

Moim najlepszym przypuszczeniem na podstawie moich dotychczasowych analiz jest to, że optymalizator dopuszczający wartości zerowe zjeżdża tutaj z torów. Mamy optymalizator null, który wyszukuje sytuacje, w których wiemy, że określone wyrażenie typu nullable nie może być zerowe. Rozważ następującą naiwną analizę: moglibyśmy to najpierw powiedzieć

result = Foo() ?? y;

jest taki sam jak

A? temp = Foo();
result = temp.HasValue ? 
    (int?) temp : 
    y;

i moglibyśmy to powiedzieć

conversionResult = (int?) temp 

jest taki sam jak

A? temp2 = temp;
conversionResult = temp2.HasValue ? 
    new int?(op_Implicit(temp2.Value)) : 
    (int?) null

Ale optymalizator może wkroczyć i powiedzieć: „zaraz, poczekaj chwilę, już sprawdziliśmy, że temp nie jest zerowy; nie ma potrzeby sprawdzania go ponownie po raz drugi tylko dlatego, że nazywamy podniesionym operatorem konwersji”. Zoptymalizowalibyśmy to do sprawiedliwego

new int?(op_Implicit(temp2.Value)) 

Domyślam się, że gdzieś zapamiętujemy fakt, że zoptymalizowana forma (int?)Foo()jest, new int?(op_implicit(Foo().Value))ale tak naprawdę nie jest to zoptymalizowana forma, jakiej chcemy; chcemy zoptymalizowanej formy Foo () - zamienionej na-tymczasową-a-następnie-przekonwertowaną.

Wiele błędów w kompilatorze C # jest wynikiem złych decyzji dotyczących buforowania. Słowo dla mądrych: za każdym razem, gdy buforujesz fakt do późniejszego wykorzystania, potencjalnie tworzysz niespójność, jeśli coś istotnego się zmieni . W tym przypadku istotną rzeczą, która zmieniła się po wstępnej analizie, jest to, że wywołanie Foo () powinno zawsze być realizowane jako pobranie tymczasowe.

W C # 3.0 przeprowadziliśmy wiele reorganizacji zerowej przepustki przepisywania. Błąd jest odtwarzany w C # 3.0 i 4.0, ale nie w C # 2.0, co oznacza, że ​​błąd był prawdopodobnie moim złym. Przepraszam!

Dostanę błąd do bazy danych i zobaczymy, czy uda nam się to naprawić w przyszłej wersji języka. Jeszcze raz dziękuję wszystkim za analizę; to było bardzo pomocne!

AKTUALIZACJA: Przepisałem optymalizator null od zera dla Roslyn; teraz wykonuje lepszą pracę i pozwala uniknąć tego rodzaju dziwnych błędów. Aby dowiedzieć się, jak działa optymalizator w Roslyn, zobacz moją serię artykułów, która zaczyna się tutaj: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
źródło
1
@Eric Zastanawiam się, czy to by również wyjaśniało: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Teraz, gdy mam podgląd Roslyn dla użytkownika końcowego, mogę potwierdzić, że został naprawiony. (Nadal jednak jest obecny w natywnym kompilatorze C # 5).
Jon Skeet
84

To zdecydowanie błąd.

public class Program {
    static A? X() {
        Console.WriteLine("X()");
        return new A();
    }
    static B? Y() {
        Console.WriteLine("Y()");
        return new B();
    }
    static C? Z() {
        Console.WriteLine("Z()");
        return new C();
    }

    public static void Main() {
        C? test = (X() ?? Y()) ?? Z();
    }
}

Ten kod wyświetli:

X()
X()
A to B (0)
X()
X()
A to B (0)
B to C (0)

To sprawiło, że pomyślałem, że pierwsza część każdego ??wyrażenia koalescencji jest oceniana dwukrotnie. Ten kod to udowodnił:

B? test= (X() ?? Y());

wyjścia:

X()
X()
A to B (0)

Wydaje się, że dzieje się tak tylko wtedy, gdy wyrażenie wymaga konwersji między dwoma typami zerowalnymi; Próbowałem różnych kombinacji z jedną ze stron będących sznurkiem i żadna z nich nie spowodowała takiego zachowania.

konfigurator
źródło
11
Wow - dwukrotna ocena wyrażenia wydaje się bardzo błędna. Dobrze zauważony.
Jon Skeet,
Nieco łatwiej jest sprawdzić, czy masz tylko jedno wywołanie metody w źródle - ale to wciąż pokazuje to bardzo wyraźnie.
Jon Skeet,
2
Dodałem nieco prostszy przykład tej „podwójnej oceny” do mojego pytania.
Jon Skeet
8
Czy wszystkie twoje metody powinny wypisywać „X ()”? Utrudnia to określenie, która metoda jest faktycznie wysyłana do konsoli.
jeffora
2
Wydaje się, że X() ?? Y()rozszerza się wewnętrznie X() != null ? X() : Y(), dlatego zostałby oceniony dwukrotnie.
Cole Johnson
54

Jeśli spojrzysz na wygenerowany kod dla sprawy pogrupowanej w lewo, faktycznie robi coś takiego ( csc /optimize-):

C? first;
A? atemp = a;
B? btemp = (atemp.HasValue ? new B?(a.Value) : b);
if (btemp.HasValue)
{
    first = new C?((atemp.HasValue ? new B?(a.Value) : b).Value);
}

Innym znalezisko, jeśli używać first będzie generować skrót jeśli oba ai bsą nieważne i powrotu c. Jeśli jednak aczy bjest niezerowe ponownie je ocenia ajako część niejawna konwersja do Bprzed powrotem, który z alub bjest niezerowe.

Ze specyfikacji C # 4.0, pkt 6.1.4:

  • Jeśli pustych konwersja wynosi od S?do T?:
    • Jeśli wartością źródłową jest null( HasValuewłaściwość to false), wynikiem jest nullwartość typu T?.
    • W przeciwnym razie konwersja jest oceniana jako odwijanie z S?do S, po czym następuje konwersja z Sdo do T, a następnie owijanie (§4.1.10) z Tdo T?.

To wydaje się wyjaśniać drugą kombinację rozpakowywania i owijania.


Kompilator C # 2008 i 2010 produkuje bardzo podobny kod, jednak wygląda to na regresję z kompilatora C # 2005 (8.00.50727.4927), który generuje następujący kod dla powyższego:

A? a = x;
B? b = a.HasValue ? new B?(a.GetValueOrDefault()) : y;
C? first = b.HasValue ? new C?(b.GetValueOrDefault()) : z;

Zastanawiam się, czy nie wynika to z dodatkowej magii nadanej systemowi wnioskowania typu?

użytkownik7116
źródło
+1, ale nie sądzę, że tak naprawdę wyjaśnia, dlaczego konwersja jest wykonywana dwukrotnie. IMO powinno oceniać wyrażenie tylko raz.
Jon Skeet,
@Jon: Bawiłem się i odkryłem (tak jak @ konfigurator), że po wykonaniu w drzewie wyrażeń działa zgodnie z oczekiwaniami. Pracuję nad usunięciem wyrażeń, aby dodać je do mojego postu. Musiałbym wówczas założyć, że jest to „błąd”.
user7116
@Jon: ok, gdy używasz drzew wyrażeń, zamienia się (x ?? y) ?? zw zagnieżdżone lambdy, co zapewnia ocenę w kolejności bez podwójnej oceny. Oczywiście nie jest to podejście kompilatora C # 4.0. Z tego, co mogę powiedzieć, do sekcji 6.1.4 podchodzimy bardzo ściśle w tej konkretnej ścieżce kodu, a tymczasowe elementy nie są pomijane, co powoduje podwójną ocenę.
user7116
16

Właściwie to nazwę to teraz błędem, z wyraźniejszym przykładem. Nadal tak jest, ale podwójna ocena z pewnością nie jest dobra.

Wygląda na A ?? Bto, że jest zaimplementowany jako A.HasValue ? A : B. W tym przypadku jest też dużo rzutowania (po zwykłym rzutowaniu dla ?:operatora trójskładnikowego ). Ale jeśli zignorujesz to wszystko, ma to sens w zależności od tego, jak jest to zaimplementowane:

  1. A ?? B rozwija się do A.HasValue ? A : B
  2. Ajest nasz x ?? y. Rozwiń dox.HasValue : x ? y
  3. zastąp wszystkie wystąpienia A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Tutaj możesz zobaczyć, że x.HasValuejest sprawdzany dwukrotnie, a jeśli x ?? ywymaga rzucenia, xzostanie rzucony dwukrotnie.

Odłożyłbym to po prostu jako artefakt, w jaki sposób ??jest implementowany, a nie błąd kompilatora. Na wynos: Nie twórz niejawnych operatorów rzutujących z efektami ubocznymi.

Wygląda na to, że jest to błąd kompilatora, który dotyczy sposobu ??implementacji. Na wynos: nie zagnieżdżaj wyrażeń koalescencyjnych z efektami ubocznymi.

Philip Rieck
źródło
Och, na pewno nie będzie chciał użyć kodu jak to normalnie, ale myślę, że mógłby jeszcze zostać zaklasyfikowane jako błąd kompilatora w to Twoja pierwsza ekspansja powinna zawierać „ale tylko oceny A i B Once”. (Wyobraź sobie, że były to wywołania metod).
Jon Skeet
@Jon Zgadzam się, że to może być również - ale nie nazwałbym tego jednoznacznym. Cóż, właściwie to widzę, że A() ? A() : B()prawdopodobnie to oceni A()dwa razy, ale A() ?? B()nie tak bardzo. A ponieważ zdarza się to tylko podczas castingu ... Hmm .. Właśnie powiedziałem sobie, że z pewnością nie zachowuje się poprawnie.
Philip Rieck,
10

W ogóle nie jestem ekspertem od C #, jak widać z mojej historii pytań, ale wypróbowałem to i myślę, że to błąd .... ale jako nowicjusz muszę powiedzieć, że nie rozumiem, co się dzieje tutaj, więc usunę odpowiedź, jeśli jestem daleko.

Doszedłem do tego bugwniosku, tworząc inną wersję twojego programu, która dotyczy tego samego scenariusza, ale o wiele mniej skomplikowaną.

Korzystam z trzech właściwości zerowych liczb całkowitych ze sklepami zapasowymi. Ustawiam każdy na 4, a następnie biegnęint? something2 = (A ?? B) ?? C;

( Pełny kod tutaj )

To po prostu czyta A i nic więcej.

To oświadczenie dla mnie wygląda tak, jak powinno:

  1. Zacznij od nawiasów, spójrz na A, zwróć A i zakończ, jeśli A nie jest zerowe.
  2. Jeśli A było puste, oceń B, zakończ, jeśli B nie jest puste
  3. Jeśli A i B były zerowe, oceń C.

Ponieważ A nie jest zerowe, patrzy tylko na A i kończy.

W twoim przykładzie umieszczenie punktu przerwania w Pierwszym Przypadku pokazuje, że x, y i z nie są równe zeru i dlatego spodziewałbym się, że będą traktowane tak samo jak mój mniej złożony przykład .... ale obawiam się, że jestem za bardzo nowicjusza z C # i całkowicie przegapiłem sedno tego pytania!

Wil
źródło
5
Przykład Jona jest niejasnym przypadkiem narożnym, ponieważ używa nullable struct (typ wartości, który jest „podobny” do typów wbudowanych, takich jak an int). Popycha skrzynkę dalej w niejasny kąt, zapewniając wiele niejawnych konwersji typu. Wymaga to od kompilatora zmiany typu danych podczas sprawdzania null. To z powodu tych domyślnych konwersji jego przykład różni się od twojego.
user7116