Curioso comportamento di conversione implicito personalizzato dell’operatore a coalescenza nulla

Nota: sembra che sia stato risolto in Roslyn

Questa domanda è sorta quando ho scritto la mia risposta a questo , che parla dell’associatività dell’operatore a coalescenza nulla .

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

x ?? y 

prima valuta x , quindi:

  • Se il valore di x è nullo, y viene valutato e questo è il risultato finale dell’espressione
  • Se il valore di x è non nullo, y non viene valutato e il valore di x è il risultato finale dell’espressione, dopo una conversione al tipo di tempo di compilazione di y se necessario

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 solo da (dire) int? a int . Tuttavia, puoi creare i tuoi operatori di conversione impliciti e quelli sono utilizzati dove necessario.

Per il semplice caso di x ?? y x ?? y , non ho visto nessun comportamento strano. Tuttavia, con (x ?? y) ?? z (x ?? y) ?? z Vedo un comportamento confuso.

Ecco un breve ma completo programma di test: 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 valori personalizzati, A , B e C , con conversioni da A a B, da A a C e da B a C.

Posso capire sia il secondo caso che il terzo caso … ma perché nel primo caso c’è una conversione da A a B extra? In particolare, mi sarei davvero aspettato che il primo caso e il secondo caso fossero la stessa cosa: è solo estrarre un’espressione in una variabile locale, dopotutto.

Qualche cliente su cosa sta succedendo? Sono estremamente esitante nel gridare “bug” quando si tratta del compilatore C #, ma sono perplesso su cosa sta succedendo …

EDIT: Ok, ecco un esempio più brutto di quello che sta succedendo, grazie alla risposta del configuratore, che mi dà ulteriori motivi per pensare che sia un bug. EDIT: l’esempio 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.

Grazie a tutti coloro che hanno contribuito ad analizzare questo problema. È chiaramente un bug del compilatore. Sembra che accada solo quando c’è una conversione sollevata che coinvolge due tipi annullabili sul lato sinistro dell’operatore a coalescenza.

Non ho ancora identificato dove esattamente le cose vadano storte, ma ad un certo punto durante la fase di “annullabile 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 basata sulla mia analisi finora è che l’ottimizzatore nullable sta uscendo dai binari qui. Abbiamo un ottimizzatore nullable che cerca situazioni in cui sappiamo che un’espressione particolare di tipo nullable non può essere null. 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à controllato che temp non è nullo, non c’è bisogno di controllarlo per null una seconda volta solo perché stiamo chiamando un operatore di conversione sollevato”. Li faremmo ottimizzarli solo per

 new int?(op_Implicit(temp2.Value)) 

La mia ipotesi è che stiamo nascondendo il fatto che la forma ottimizzata di (int?)Foo() è new int?(op_implicit(Foo().Value)) ma non è in realtà la forma ottimizzata che vogliamo; vogliamo la forma ottimizzata di Foo () – sostituito-con-temporaneo e poi convertito.

Molti errori nel compilatore C # sono il risultato di cattive decisioni di memorizzazione nella cache. Una parola per il saggio: ogni volta che metti in cache un fatto per utilizzarlo in un secondo momento, stai potenzialmente creando un’incoerenza nel caso in cui qualcosa di rilevante cambi . In questo caso la cosa rilevante che ha cambiato l’analisi iniziale dopo è che la chiamata a Foo () dovrebbe sempre essere realizzata come un recupero di un temporaneo.

Abbiamo fatto un sacco di riorganizzazione del nullable rewriting pass 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 il bug era probabilmente il mio cattivo. Scusate!

Verrà inserito un bug nel database e vedremo se riusciremo a risolverlo per una versione futura della lingua. Grazie ancora a tutti per la tua analisi; è stato molto utile!

AGGIORNAMENTO: ho riscritto da zero l’ottimizzatore nullable per Roslyn; ora fa un lavoro migliore ed evita questi tipi 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/

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 produrrà:

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

Questo mi ha fatto pensare che la prima parte di ciascuno ?? l’espressione di coalescenza viene 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 annullabili; Ho provato varie permutazioni con uno dei lati di una stringa, e nessuno di loro ha causato questo comportamento.

Se date un’occhiata al codice generato per il caso del gruppo di 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 altro ritrovamento, se lo usi per first , genererà un collegamento se entrambi a e b sono nulli e ritornano c . Tuttavia se a o b è non nullo, rivaluta come parte della conversione implicita in B prima di restituire quale di b è non nullo.

Dalla specifica C # 4.0, §6.1.4:

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

Questo sembra spiegare la seconda combinazione di wrapping wrapping.


Il compilatore C # 2008 e 2010 produce codice molto simile, tuttavia sembra una regressione dal compilatore C # 2005 (8.00.50727.4927) che genera il seguente codice 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 questo non è dovuto alla magia aggiuntiva data al sistema di inferenza di tipo?

In realtà, chiamerò questo un bug ora, con l’esempio più chiaro. Ciò vale ancora, ma la doppia valutazione non è certamente buona.

Sembra come se A ?? B A ?? B è implementato come A.HasValue ? A : B A.HasValue ? A : B In questo caso, c’è anche molto cast (seguendo il cast regolare per il ternario ?: Operatore). Ma se ignori tutto ciò, allora ha senso in base a come è implementato:

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

Qui puoi vedere che x.HasValue è controllato due volte e se x ?? y x ?? y richiede il lancio, x sarà lanciato due volte.

L’avrei messo semplicemente come un artefatto di come ?? è 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 ?? è implementato. Take-away: non annidare espressioni coalescenti con effetti collaterali.

Non sono affatto un esperto di C # come potete vedere dalla mia storia di domande, ma, ho provato questo e penso che sia un bug …. ma come novizio, devo dire che non capisco tutto quello che sta succedendo qui, quindi cancellerò la mia risposta se sono lontano.

Sono giunto a questa conclusione di bug creando una versione diversa del tuo programma che si occupa dello stesso scenario, ma molto meno complicata.

Sto usando tre proprietà integer null con i backing store. Ho impostato ciascuno a 4 e poi eseguito int? something2 = (A ?? B) ?? C; int? something2 = (A ?? B) ?? C;

( Codice completo qui )

Questo legge solo la A e nient’altro.

Questa affermazione mi sembra che dovrebbe:

  1. Inizia tra parentesi, guarda A, restituisci A e finisci se A non è nullo.
  2. Se A era nullo, valutare B, finire se B non è nullo
  3. Se A e B erano nulli, valutare C.

Quindi, dato che A non è nulla, guarda solo A e finisce.

Nel tuo esempio, mettendo un breakpoint nel primo caso mostra che x, y e z non sono tutti nulli e quindi, mi aspetterei che fossero trattati allo stesso modo del mio esempio meno complesso …. ma temo di essere troppo di un novizio di C # e ha completamente perso il punto di questa domanda!