Neugieriges benutzerdefiniertes implizites Konvertierungsverhalten des Null-Koaleszenz-Operators

542

Hinweis: Dies scheint behoben worden zu sein Roslyn

Diese Frage stellte sich, als ich meine Antwort auf diese Frage schrieb , in der es um die Assoziativität der Null-Koaleszenz-Operators geht .

Nur zur Erinnerung, die Idee des Null-Koaleszenz-Operators ist, dass ein Ausdruck des Formulars

x ?? y

zuerst bewertet x , dann:

  • Wenn der Wert von x null ist, ywird ausgewertet und das ist das Endergebnis des Ausdrucks
  • Wenn der Wert von xnicht null ist, ywird er nicht ausgewertet, und der Wert von xist das Endergebnis des Ausdrucks nach einer Konvertierung in den Kompilierungszeittyp von, yfalls erforderlich

Normalerweise ist jetzt keine Konvertierung mehr erforderlich, oder es wird nur von einem nullbaren Typ in einen nicht nullbaren Typ konvertiert - normalerweise sind die Typen gleich oder nur von (sagen wir) int?nachint . Sie können jedoch Ihre eigenen impliziten Konvertierungsoperatoren erstellen, die bei Bedarf verwendet werden.

Für den einfachen Fall von x ?? y ich kein merkwürdiges Verhalten gesehen. Doch mit (x ?? y) ?? zmir etwas verwirrend Verhalten sehen.

Hier ist ein kurzes, aber vollständiges Testprogramm - die Ergebnisse finden Sie in den Kommentaren:

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

Wir haben also drei benutzerdefinierte Werttypen A.B und Cmit Umwandlungen von A nach B, A bis C und B bis C.

Ich kann sowohl den zweiten als auch den dritten Fall verstehen ... aber warum gibt es im ersten Fall eine zusätzliche Umwandlung von A nach B. Insbesondere würde ich wirklich erwartet, dass der erste und der zweite Fall dasselbe sind - es wird schließlich nur ein Ausdruck in eine lokale Variable extrahiert.

Irgendwelche Abnehmer, was los ist? Ich zögere sehr, "Bug" zu schreien, wenn es um den C # -Compiler geht, aber ich bin ratlos, was los ist ...

EDIT: Okay, hier ist ein schlimmeres Beispiel dafür, was los ist, dank der Antwort des Konfigurators, die mir weiteren Grund gibt zu glauben, dass es ein Fehler ist. BEARBEITEN: Das Beispiel benötigt jetzt nicht einmal zwei Null-Koaleszenz-Operatoren ...

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

Die Ausgabe davon ist:

Foo() called
Foo() called
A to int

Die Tatsache, dass Foo()hier zweimal aufgerufen wird, ist für mich sehr überraschend - ich sehe keinen Grund, den Ausdruck zweimal zu bewerten .

Jon Skeet
quelle
32
Ich wette, sie dachten "niemand wird es jemals so benutzen" :)
Cyberzed
57
Willst du etwas Schlimmeres sehen? Versuchen Sie, diese Zeile mit allen impliziten Konvertierungen zu verwenden : C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Sie erhalten:Internal Compiler Error: likely culprit is 'CODEGEN'
Konfigurator
5
Beachten Sie auch, dass dies nicht der Fall ist, wenn Linq-Ausdrücke zum Kompilieren desselben Codes verwendet werden.
Konfigurator
8
@ Peter unwahrscheinliches Muster, aber plausibel für(("working value" ?? "user default") ?? "system default")
Factor Mystic
23
@ yes123: Als es nur um die Konvertierung ging, war ich nicht ganz überzeugt. Die zweimalige Ausführung einer Methode machte es ziemlich offensichtlich, dass dies ein Fehler war. Sie werden erstaunt sein über ein Verhalten, das falsch aussieht, aber tatsächlich völlig korrekt ist. Das C # -Team ist schlauer als ich - ich gehe eher davon aus, dass ich dumm bin, bis ich bewiesen habe, dass etwas ihre Schuld ist.
Jon Skeet

Antworten:

418

Vielen Dank an alle, die zur Analyse dieses Problems beigetragen haben. Es ist eindeutig ein Compiler-Fehler. Dies scheint nur dann der Fall zu sein, wenn auf der linken Seite des Koaleszenzoperators eine angehobene Konvertierung mit zwei nullbaren Typen erfolgt.

Ich habe noch nicht festgestellt, wo genau etwas schief geht, aber irgendwann während der Kompilierungsphase "Nullable Lowering" - nach der ersten Analyse, aber vor der Codegenerierung - reduzieren wir den Ausdruck

result = Foo() ?? y;

vom obigen Beispiel bis zum moralischen Äquivalent von:

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

Das ist eindeutig falsch; Das richtige Absenken ist

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

Aufgrund meiner bisherigen Analyse gehe ich davon aus, dass der nullfähige Optimierer hier aus dem Ruder läuft. Wir haben einen nullbaren Optimierer, der nach Situationen sucht, in denen wir wissen, dass ein bestimmter Ausdruck vom nullbaren Typ möglicherweise nicht null sein kann. Betrachten Sie die folgende naive Analyse: Wir könnten das zuerst sagen

result = Foo() ?? y;

ist das gleiche wie

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

und dann könnten wir das sagen

conversionResult = (int?) temp 

ist das gleiche wie

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

Der Optimierer kann jedoch eingreifen und sagen: "whoa, warte eine Minute, wir haben bereits überprüft, dass temp nicht null ist. Es ist nicht erforderlich, es ein zweites Mal auf null zu überprüfen, nur weil wir einen aufgehobenen Konvertierungsoperator aufrufen." Wir würden sie es einfach optimieren

new int?(op_Implicit(temp2.Value)) 

Ich vermute, dass wir irgendwo die Tatsache zwischenspeichern, dass die optimierte Form von (int?)Foo()ist, new int?(op_implicit(Foo().Value))aber das ist nicht wirklich die optimierte Form, die wir wollen; Wir wollen die optimierte Form von Foo () - ersetzt durch temporär und dann konvertiert.

Viele Fehler im C # -Compiler sind auf schlechte Caching-Entscheidungen zurückzuführen. Ein Wort zu den Weisen: Jedes Mal, wenn Sie eine Tatsache zur späteren Verwendung zwischenspeichern, verursachen Sie möglicherweise eine Inkonsistenz, falls sich etwas Relevantes ändert . In diesem Fall hat sich nach der ersten Analyse geändert, dass der Aufruf von Foo () immer als Abruf eines temporären Objekts realisiert werden sollte.

Wir haben den nullbaren Umschreibungsdurchlauf in C # 3.0 viel reorganisiert. Der Fehler wird in C # 3.0 und 4.0 reproduziert, aber nicht in C # 2.0, was bedeutet, dass der Fehler wahrscheinlich mein Fehler war. Es tut uns leid!

Ich werde einen Fehler in die Datenbank eingeben lassen und sehen, ob wir diesen Fehler für eine zukünftige Version der Sprache beheben können. Nochmals vielen Dank an alle für Ihre Analyse; es war sehr hilfreich!

UPDATE: Ich habe den nullbaren Optimierer für Roslyn von Grund auf neu geschrieben. es macht jetzt einen besseren Job und vermeidet diese Art von seltsamen Fehlern. Einige Gedanken zur Funktionsweise des Optimierers in Roslyn finden Sie in meiner Artikelserie, die hier beginnt: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
quelle
1
@ Eric Ich frage mich, ob dies auch erklären würde: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Nachdem ich die Endbenutzervorschau von Roslyn habe, kann ich bestätigen, dass sie dort behoben ist. (Es ist jedoch immer noch im nativen C # 5-Compiler vorhanden.)
Jon Skeet
84

Dies ist definitiv ein Fehler.

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

Dieser Code gibt Folgendes aus:

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

Das ließ mich denken, dass der erste Teil jedes ??Koaleszenzausdrucks zweimal ausgewertet wird. Dieser Code hat es bewiesen:

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

Ausgänge:

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

Dies scheint nur zu geschehen, wenn der Ausdruck eine Konvertierung zwischen zwei nullbaren Typen erfordert. Ich habe verschiedene Permutationen ausprobiert, wobei eine der Seiten eine Zeichenfolge ist, und keine davon hat dieses Verhalten verursacht.

Konfigurator
quelle
11
Wow - das zweimalige Auswerten des Ausdrucks scheint in der Tat sehr falsch zu sein. Gut erkannt.
Jon Skeet
Es ist etwas einfacher zu sehen, ob Sie nur einen Methodenaufruf in der Quelle haben - aber das zeigt es immer noch sehr deutlich.
Jon Skeet
2
Ich habe meiner Frage ein etwas einfacheres Beispiel für diese "doppelte Bewertung" hinzugefügt.
Jon Skeet
8
Sollen alle Ihre Methoden "X ()" ausgeben? Es macht es etwas schwierig zu sagen, welche Methode tatsächlich auf der Konsole ausgegeben wird.
Jeffora
2
Es scheint X() ?? Y()sich intern zu erweitern X() != null ? X() : Y(), weshalb es zweimal bewertet würde.
Cole Johnson
54

Wenn Sie sich den generierten Code für den Fall mit der linken Gruppe ansehen, funktioniert er tatsächlich wie folgt ( 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);
}

Ein weiterer Fund, wenn Sie verwenden first es wird eine Verknüpfung , wenn beide erzeugt aund bist null und Rückkehr c. Wenn jedoch aoder bnicht Null ist, wird es aim Rahmen der impliziten Konvertierung in neu bewertet , Bbevor zurückgegeben wird, welches von aoder bnicht Null ist.

Aus der C # 4.0-Spezifikation, §6.1.4:

  • Wenn die nullfähige Konvertierung von S?nach ist T?:
    • Wenn der Quellwert null( HasValueEigenschaft ist false) ist, ist das Ergebnis der nullWert vom Typ T?.
    • Andernfalls wird die Konvertierung als Entpackung von S?bis ausgewertet S, gefolgt von der zugrunde liegenden Konvertierung von Sbis T, gefolgt von einer Umhüllung (§4.1.10) von Tbis T?.

Dies scheint die zweite Kombination aus Auspacken und Umwickeln zu erklären.


Der C # 2008- und 2010-Compiler erzeugt einen sehr ähnlichen Code. Dies scheint jedoch eine Regression des C # 2005-Compilers (8.00.50727.4927) zu sein, der den folgenden Code für die oben genannten generiert:

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

Ich frage mich, ob dies nicht auf die zusätzliche Magie zurückzuführen ist , die dem Typinferenzsystem verliehen wird.

user7116
quelle
+1, aber ich denke nicht, dass es wirklich erklärt, warum die Konvertierung zweimal durchgeführt wird. Der Ausdruck sollte nur einmal ausgewertet werden, IMO.
Jon Skeet
@Jon: Ich habe herumgespielt und festgestellt (wie @configurator), dass es in einem Ausdrucksbaum wie erwartet funktioniert. Ich arbeite daran, die Ausdrücke zu bereinigen, um sie meinem Beitrag hinzuzufügen. Ich müsste dann davon ausgehen, dass dies ein "Bug" ist.
user7116
@Jon: OK, wenn Expression Trees verwendet werden, (x ?? y) ?? zwerden verschachtelte Lambdas daraus, was eine ordnungsgemäße Auswertung ohne doppelte Auswertung gewährleistet. Dies ist offensichtlich nicht der Ansatz des C # 4.0-Compilers. Soweit ich das beurteilen kann, wird Abschnitt 6.1.4 in diesem speziellen Codepfad sehr streng angegangen, und die Provisorien werden nicht entfernt, was zu einer doppelten Bewertung führt.
user7116
16

Eigentlich werde ich das jetzt einen Fehler nennen, mit dem klareren Beispiel. Dies gilt immer noch, aber die Doppelbewertung ist sicherlich nicht gut.

Es scheint als wäre A ?? Bimplementiert als A.HasValue ? A : B. In diesem Fall gibt es auch viel Casting (nach dem regulären Casting für den ternären ?:Operator). Wenn Sie dies alles ignorieren, ist dies sinnvoll, je nachdem, wie es implementiert ist:

  1. A ?? B erweitert sich auf A.HasValue ? A : B
  2. Aist unser x ?? y. Erweitern Sie aufx.HasValue : x ? y
  3. Ersetzen Sie alle Vorkommen von A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Hier sehen Sie, dass dies x.HasValuezweimal überprüft wird und bei Bedarf zweimal x ?? ygewirkt xwird.

Ich würde es einfach als Artefakt dafür ablegen, wie ?? Implementierung und nicht als Compiler-Fehler . Take-Away: Erstellen Sie keine impliziten Casting-Operatoren mit Nebenwirkungen.

Es scheint ein Compiler-Fehler zu sein, der sich um die ??Implementierung dreht . Take-away: Verschachteln Sie verschmelzende Ausdrücke nicht mit Nebenwirkungen.

Philip Rieck
quelle
Oh, ich würde Code wie diesen definitiv nicht normal verwenden wollen, aber ich denke, er könnte immer noch als Compiler-Fehler eingestuft werden, da Ihre erste Erweiterung "aber nur einmal A und B auswerten" sollte. (Stellen Sie sich vor, es wären Methodenaufrufe.)
Jon Skeet
@ Jon Ich stimme zu, dass es auch so sein könnte - aber ich würde es nicht eindeutig nennen. Nun, eigentlich kann ich sehen, dass A() ? A() : B()das möglicherweise A()zweimal ausgewertet wird, aber A() ?? B()nicht so sehr. Und da es nur beim Casting passiert ... Hmm ... habe ich mich gerade überredet zu denken, dass es sich mit Sicherheit nicht richtig verhält.
Philip Rieck
10

Ich bin überhaupt kein C # -Experte, wie Sie aus meiner Fragenhistorie ersehen können, aber ich habe es ausprobiert und ich denke, es ist ein Fehler ... aber als Neuling muss ich sagen, dass ich nicht alles verstehe, was vor sich geht hier, damit ich meine Antwort lösche, wenn ich weit weg bin.

Ich bin zu diesem bugSchluss gekommen, indem ich eine andere Version Ihres Programms erstellt habe, die sich mit demselben Szenario befasst, aber viel weniger kompliziert ist.

Ich verwende drei Ganzzahl-Null-Eigenschaften mit Hintergrundspeichern. Ich setze jedes auf 4 und renne dannint? something2 = (A ?? B) ?? C;

( Vollständiger Code hier )

Dies liest nur das A und sonst nichts.

Diese Aussage sieht für mich so aus, als ob sie:

  1. Beginnen Sie in den Klammern, schauen Sie sich A an, geben Sie A zurück und beenden Sie, wenn A nicht null ist.
  2. Wenn A null war, bewerten Sie B und beenden Sie, wenn B nicht null ist
  3. Wenn A und B Null waren, bewerten Sie C.

Da A also nicht null ist, wird nur A betrachtet und beendet.

In Ihrem Beispiel zeigt das Setzen eines Haltepunkts im ersten Fall, dass x, y und z alle nicht null sind, und daher würde ich erwarten, dass sie genauso behandelt werden wie mein weniger komplexes Beispiel ... aber ich fürchte, ich bin zu viel eines C # Neulings und haben den Punkt dieser Frage völlig verfehlt!

Wil
quelle
5
Jons Beispiel ist insofern etwas dunkel, als er eine nullfähige Struktur verwendet (ein Werttyp, der den eingebauten Typen wie einem "ähnlich" ist int). Er schiebt den Fall weiter in eine dunkle Ecke, indem er mehrere implizite Typkonvertierungen bereitstellt. Dies erfordert den Compiler die sich ändern Art der Daten während der Überprüfung vor null. Aufgrund dieser impliziten Typkonvertierungen unterscheidet sich sein Beispiel von Ihrem.
user7116