Curioso comportamento di conversione implicita personalizzato dell'operatore a coalescenza nulla


542

Nota: questo sembra essere stato risolto a Roslyn

Questa domanda è nata quando ho scritto la mia risposta a questa , che parla dell'associatività dell'operatore a coalescenza nulla .

Proprio come promemoria, l'idea dell'operatore a coalescenza nulla è che un'espressione della forma

x ?? y

prima valuta x, quindi:

  • Se il valore di xè null, yviene valutato e questo è il risultato finale dell'espressione
  • Se il valore di xnon è null, nony viene valutato e il valore di è il risultato finale dell'espressione, dopo una conversione al tipo di tempo di compilazione di se necessarioxy

Ora di solito non c'è bisogno di una conversione, o è solo da un tipo nullable a uno non nullable - di solito i tipi sono gli stessi, o semplicemente da (diciamo) int?a int. Tuttavia, puoi creare i tuoi operatori di conversione impliciti e questi vengono utilizzati dove necessario.

Per il semplice caso di x ?? y, non ho visto alcun comportamento strano. Tuttavia, (x ?? y) ?? zvedo alcuni comportamenti confusi.

Ecco un programma di test breve ma completo: i risultati sono nei commenti:

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;
    }
}

Quindi abbiamo tre tipi di valore personalizzati, A, Be C, con le conversioni da A a B, da A a C, e B a C.

Riesco a capire sia il secondo che il terzo caso ... ma perché c'è una conversione da A a B in più nel primo caso? In particolare, mi sarei aspettato davvero che il primo caso e il secondo caso fossero la stessa cosa - dopo tutto, è solo estrarre un'espressione in una variabile locale.

Qualche appassionato di quello che sta succedendo? Sono estremamente riluttante a piangere "bug" quando si tratta del compilatore C #, ma sono sconcertato da quello che sta succedendo ...

EDIT: Okay, ecco un esempio più cattivo di quello che sta succedendo, grazie alla risposta del configuratore, che mi dà ulteriori motivi per pensare che sia un bug. EDIT: il campione non ha nemmeno bisogno di due operatori a coalescenza nulla ora ...

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;
    }
}

L'output di questo è:

Foo() called
Foo() called
A to int

Il fatto che Foo()venga chiamato due volte qui è estremamente sorprendente per me: non vedo alcun motivo per cui l'espressione venga valutata due volte.


32
Scommetto che hanno pensato "nessuno lo userà mai in questo modo" :)
cibernito il

57
Vuoi vedere qualcosa di peggio? Provare a utilizzare questa linea con tutte le conversioni implicite: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Otterrai:Internal Compiler Error: likely culprit is 'CODEGEN'
configuratore

5
Si noti inoltre che ciò non accade quando si utilizzano le espressioni Linq per compilare lo stesso codice.
configuratore

8
@Peter pattern improbabile, ma plausibile per(("working value" ?? "user default") ?? "system default")
Factor Mystic

23
@ yes123: quando si trattava solo della conversione, non ero del tutto convinto. Vederlo eseguire due volte un metodo ha reso abbastanza ovvio che si trattava di un bug. Saresti sorpreso da un comportamento che sembra errato ma in realtà è completamente corretto. Il team di C # è più intelligente di me - tendo a pensare di essere stupido fino a quando non ho dimostrato che qualcosa è colpa loro.
Jon Skeet,

Risposte:


418

Grazie a tutti coloro che hanno contribuito all'analisi di questo problema. È chiaramente un bug del compilatore. Sembra accadere solo quando c'è una conversione sollevata che coinvolge due tipi nullable sul lato sinistro dell'operatore coalescente.

Non ho ancora identificato dove esattamente le cose vanno male, ma ad un certo punto durante la fase di "abbassamento nulla" della compilazione - dopo l'analisi iniziale ma prima della generazione del codice - riduciamo l'espressione

result = Foo() ?? y;

dall'esempio sopra all'equivalente morale di:

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

Chiaramente ciò non è corretto; l'abbassamento corretto è

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

La mia ipotesi migliore sulla base della mia analisi finora è che l'ottimizzatore nullable sta andando fuori dai binari qui. Abbiamo un ottimizzatore nullable che cerca situazioni in cui sappiamo che una determinata espressione di tipo nullable non può essere nulla. Considera la seguente analisi ingenua: potremmo prima dirlo

result = Foo() ?? y;

equivale a

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

e quindi potremmo dirlo

conversionResult = (int?) temp 

equivale a

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

Ma l'ottimizzatore può intervenire e dire "whoa, aspetta un minuto, abbiamo già verificato che temp non è null; non è necessario controllarlo per null una seconda volta solo perché stiamo chiamando un operatore di conversione revocato". Li avremmo ottimizzati per solo

new int?(op_Implicit(temp2.Value)) 

La mia ipotesi è che stiamo nascondendo da qualche parte il fatto che la forma ottimizzata (int?)Foo()è new int?(op_implicit(Foo().Value))ma che in realtà non è la forma ottimizzata che vogliamo; vogliamo la forma ottimizzata di Foo () - sostituita-con-temporanea-e-quindi-convertita.

Molti bug nel compilatore C # sono il risultato di cattive decisioni di memorizzazione nella cache. Una parola al saggio: ogni volta che si memorizza nella cache un fatto da utilizzare in un secondo momento, si sta potenzialmente creando un'incoerenza qualora qualcosa di rilevante cambi . In questo caso la cosa rilevante che è cambiata dopo l'analisi iniziale è che la chiamata a Foo () dovrebbe sempre essere realizzata come un recupero di un temporaneo.

Abbiamo fatto molta riorganizzazione del passaggio di riscrittura nullable in C # 3.0. Il bug si riproduce in C # 3.0 e 4.0 ma non in C # 2.0, il che significa che probabilmente il bug era mio male. Scusate!

Prenderò un bug inserito nel database e vedremo se possiamo sistemarlo per una versione futura della lingua. Grazie ancora a tutti per l'analisi; è stato molto utile!

AGGIORNAMENTO: ho riscritto da zero l'ottimizzatore nullable per Roslyn; ora fa un lavoro migliore ed evita questo tipo di strani errori. Per alcune riflessioni su come funziona l'ottimizzatore di Roslyn, vedi la mia serie di articoli che inizia qui: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/


1
@Eric Mi chiedo se questo spiegherebbe anche: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug

12
Ora che ho l'anteprima dell'utente finale di Roslyn, posso confermare che è stato risolto lì. (È ancora presente nel compilatore C # 5 nativo.)
Jon Skeet,

84

Questo è sicuramente un bug.

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();
    }
}

Questo codice genererà:

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

Ciò mi ha fatto pensare che la prima parte di ogni ??espressione di coalescenza sia valutata due volte. Questo codice lo ha dimostrato:

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

uscite:

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

Questo sembra accadere solo quando l'espressione richiede una conversione tra due tipi nullable; Ho provato varie permutazioni con uno dei lati di una stringa, e nessuno di questi ha causato questo comportamento.


11
Wow - valutare l'espressione due volte sembra davvero molto sbagliato. Ben individuato.
Jon Skeet,

È leggermente più semplice vedere se nella sorgente è presente solo una chiamata al metodo, ma ciò lo dimostra ancora chiaramente.
Jon Skeet,

2
Ho aggiunto un esempio leggermente più semplice di questa "doppia valutazione" alla mia domanda.
Jon Skeet,

8
Tutti i tuoi metodi dovrebbero produrre "X ()"? Rende in qualche modo difficile dire quale metodo stia effettivamente trasmettendo alla console.
jeffora,

2
Sembrerebbe X() ?? Y()espandersi internamente a X() != null ? X() : Y(), quindi perché sarebbe valutato due volte.
Cole Johnson,

54

Se dai un'occhiata al codice generato per il caso raggruppato a sinistra, in realtà fa qualcosa del genere ( 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);
}

Un'altra scoperta, se lo usi first genererà un collegamento se entrambi ae bsono nulli e restituiti c. Tuttavia, se ao bè diverso da null, rivaluta acome parte della conversione implicita Bprima di restituire quale ao bè diverso da null.

Dalla specifica C # 4.0, §6.1.4:

  • Se la conversione nullable è da S?a T?:
    • Se il valore di origine è null( HasValueproprietà is false), il risultato è il nullvalore di tipo T?.
    • Altrimenti, la conversione viene valutata come un wrapping da S?a S, seguita dalla conversione sottostante da Sa T, seguita da un wrapping (§4.1.10) da Ta T?.

Questo sembra spiegare la seconda combinazione da scartare.


Il compilatore C # 2008 e 2010 produce un codice molto simile, tuttavia sembra una regressione dal compilatore C # 2005 (8.00.50727.4927) che genera il codice seguente per quanto sopra:

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

Mi chiedo se ciò non sia dovuto alla magia aggiuntiva data al sistema di inferenza del tipo?


+1, ma non credo che spieghi davvero perché la conversione viene eseguita due volte. Dovrebbe valutare l'espressione solo una volta, IMO.
Jon Skeet,

@Jon: sto giocando e ho scoperto (come ha fatto @configurator) che una volta fatto in un albero delle espressioni funziona come previsto. Sto lavorando per ripulire le espressioni per aggiungerlo al mio post. Dovrei quindi affermare che si tratta di un "bug".
user7116

@Jon: ok quando si usano gli alberi delle espressioni si trasforma (x ?? y) ?? zin lambda nidificati, che assicura una valutazione in ordine senza doppia valutazione. Questo ovviamente non è l'approccio adottato dal compilatore C # 4.0. Da quello che posso dire, la sezione 6.1.4 viene affrontata in modo molto rigoroso in questo particolare percorso di codice e i provvisori non vengono elusi con conseguente doppia valutazione.
user7116

16

In realtà, lo chiamerò un bug ora, con l'esempio più chiaro. Questo è ancora valido, ma la doppia valutazione non è certamente buona.

Sembra che A ?? Bsia implementato come A.HasValue ? A : B. In questo caso, c'è anche molto casting (a seguito del casting regolare per l' ?:operatore ternario ). Ma se ignori tutto ciò, allora ha senso in base a come viene implementato:

  1. A ?? B si espande a A.HasValue ? A : B
  2. Aè nostro x ?? y. Espandi ax.HasValue : x ? y
  3. sostituire tutte le occorrenze di A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Qui puoi vedere che x.HasValueè selezionato due volte, e se x ?? yrichiede il cast, xverrà lanciato due volte.

Lo metterei semplicemente come un artefatto di come ??viene implementato, piuttosto che un bug del compilatore. Take-Away: non creare operatori di casting impliciti con effetti collaterali.

Sembra essere un bug del compilatore che ruota attorno a come ??viene implementato. Take-away: non annidare espressioni coalescenti con effetti collaterali.


Oh, sicuramente non vorrei usare questo codice normalmente, ma penso che potrebbe ancora essere classificato come un bug del compilatore in quanto la tua prima espansione dovrebbe includere "ma valutare solo A e B una volta". (Immagina se fossero chiamate di metodo.)
Jon Skeet,

@Jon sono d'accordo che potrebbe anche essere - ma non lo definirei ben definito. Bene, in realtà, posso vedere che A() ? A() : B()probabilmente valuterà A()due volte, ma A() ?? B()non così tanto. E dato che succede solo al casting ... Hmm .. Mi sono appena convinto a pensare che non si sta comportando correttamente.
Philip Rieck,

10

Non sono affatto un esperto di C # come puoi vedere dalla mia cronologia delle domande, ma, l'ho provato e penso che sia un bug .... ma come principiante, devo dire che non capisco tutto qui, quindi eliminerò la mia risposta se sono lontano.

Sono giunto a questa bugconclusione realizzando una versione diversa del tuo programma che si occupa dello stesso scenario, ma molto meno complicato.

Sto usando tre proprietà integer null con i backup store. Ho impostato ciascuno su 4 e quindi ho eseguitoint? something2 = (A ?? B) ?? C;

( Codice completo qui )

Questo legge solo la A e nient'altro.

Questa affermazione per me mi sembra che dovrebbe:

  1. Inizia tra parentesi, guarda A, ritorna A e termina se A non è nullo.
  2. Se A era null, valuta B, termina se B non è null
  3. Se A e B erano nulli, valutare C.

Quindi, poiché A non è nullo, guarda solo A e termina.

Nel tuo esempio, mettere un breakpoint nel Primo Caso mostra che x, y e z non sono tutti nulli e quindi mi aspetterei che vengano trattati allo stesso modo del mio esempio meno complesso .... ma temo di essere troppo di un principiante C # e ho perso completamente il punto di questa domanda!


5
L'esempio di Jon è in qualche modo un oscuro caso angolare in quanto sta usando una struttura nullable (un tipo di valore che è "simile" ai tipi predefiniti come un int). Spinge ulteriormente il caso in un angolo oscuro fornendo più conversioni di tipo implicite. Ciò richiede che il compilatore cambi il tipo di dati durante il controllo null. È a causa di queste conversioni di tipo implicito che il suo esempio è diverso dal tuo.
user7116
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.