Curieux comportement de conversion implicite personnalisé de l'opérateur de coalescence nulle

542

Remarque: cela semble avoir été corrigé à Roslyn

Cette question s'est posée lors de l'écriture de ma réponse à celle-ci , qui parle de l'associativité de l' opérateur de coalescence nulle .

Pour rappel, l'idée de l'opérateur de coalescence nulle est qu'une expression de la forme

x ?? y

évalue d'abord x, puis:

  • Si la valeur de xest nulle, yest évaluée et c'est le résultat final de l'expression
  • Si la valeur de xn'est pas nulle, yn'est pas évaluée et la valeur de xest le résultat final de l'expression, après une conversion au type de compilation ysi nécessaire

Maintenant, généralement, il n'y a pas besoin de conversion, ou c'est juste d'un type nullable à un type non nullable - généralement les types sont les mêmes, ou simplement de (disons) int?à int. Cependant, vous pouvez créer vos propres opérateurs de conversion implicites et ceux-ci sont utilisés si nécessaire.

Pour le cas simple de x ?? y, je n'ai vu aucun comportement étrange. Cependant, avec (x ?? y) ?? zje vois un comportement déroutant.

Voici un programme de test court mais complet - les résultats sont dans les commentaires:

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

Nous avons donc trois types de valeurs personnalisés A, Bet C, avec les conversions de A en B, A en C et B en C.

Je peux comprendre à la fois le deuxième cas et le troisième cas ... mais pourquoi y a- t -il une conversion A vers B supplémentaire dans le premier cas? En particulier, je m'attendais vraiment à ce que le premier et le deuxième cas soient la même chose - il s'agit juste d'extraire une expression dans une variable locale, après tout.

Des preneurs sur ce qui se passe? Je suis extrêmement réticent à crier "bug" en ce qui concerne le compilateur C #, mais je suis perplexe quant à ce qui se passe ...

EDIT: D'accord, voici un exemple plus méchant de ce qui se passe, grâce à la réponse du configurateur, ce qui me donne une raison supplémentaire de penser que c'est un bug. EDIT: L'échantillon n'a même plus besoin de deux opérateurs de coalescence nulle maintenant ...

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

La sortie de ceci est:

Foo() called
Foo() called
A to int

Le fait d' Foo()être appelé deux fois ici me surprend énormément - je ne vois aucune raison pour que l'expression soit évaluée deux fois.

Jon Skeet
la source
32
Je parie qu'ils pensaient que "personne ne l'utilisera jamais de cette façon" :)
cyberzed le
57
Vous voulez voir quelque chose de pire? Essayez d' utiliser cette ligne avec toutes les conversions implicites: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Vous obtiendrez:Internal Compiler Error: likely culprit is 'CODEGEN'
configurateur
5
Notez également que cela ne se produit pas lorsque vous utilisez des expressions Linq pour compiler le même code.
configurateur
8
@Peter motif improbable, mais plausible pour(("working value" ?? "user default") ?? "system default")
Factor Mystic
23
@ yes123: Quand il s'agissait de la conversion, je n'étais pas entièrement convaincu. Le voir exécuter une méthode deux fois, il était assez évident que c'était un bug. Vous seriez étonné d'un comportement qui semble incorrect mais qui est en fait complètement correct. L'équipe C # est plus intelligente que moi - j'ai tendance à supposer que je suis stupide jusqu'à ce que je prouve que quelque chose est de leur faute.
Jon Skeet

Réponses:

418

Merci à tous ceux qui ont contribué à l'analyse de ce problème. Il s'agit clairement d'un bug du compilateur. Cela semble se produire uniquement lorsqu'il y a une conversion levée impliquant deux types annulables sur le côté gauche de l'opérateur de coalescence.

Je n'ai pas encore identifié où précisément les choses tournent mal, mais à un moment donné lors de la phase de "réduction nulle" de la compilation - après l'analyse initiale mais avant la génération du code - nous réduisons l'expression

result = Foo() ?? y;

de l'exemple ci-dessus à l'équivalent moral de:

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

C'est manifestement incorrect; l'abaissement correct est

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

Ma meilleure supposition, basée sur mon analyse jusqu'à présent, est que l'optimiseur nullable va dérailler ici. Nous avons un optimiseur nullable qui recherche les situations où nous savons qu'une expression particulière de type nullable ne peut pas être null. Considérons l'analyse naïve suivante: nous pourrions d'abord dire que

result = Foo() ?? y;

est le même que

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

puis on pourrait dire que

conversionResult = (int?) temp 

est le même que

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

Mais l'optimiseur peut intervenir et dire "whoa, attendez une minute, nous avons déjà vérifié que temp n'est pas nul; il n'est pas nécessaire de le vérifier une seconde fois simplement parce que nous appelons un opérateur de conversion levé". Nous les ferions optimiser juste pour

new int?(op_Implicit(temp2.Value)) 

Je suppose que nous mettons quelque part en cache le fait que la forme optimisée de (int?)Foo()est new int?(op_implicit(Foo().Value))mais ce n'est pas réellement la forme optimisée que nous voulons; nous voulons la forme optimisée de Foo () - remplacé-par-temporaire-puis-converti.

De nombreux bogues dans le compilateur C # sont le résultat de mauvaises décisions de mise en cache. Un mot pour le sage: chaque fois que vous mettez en cache un fait pour une utilisation ultérieure, vous créez potentiellement une incohérence si quelque chose de pertinent devait changer . Dans ce cas, la chose pertinente qui a changé après l'analyse initiale est que l'appel à Foo () doit toujours être réalisé comme une extraction d'un temporaire.

Nous avons fait beaucoup de réorganisation de la passe de réécriture nullable en C # 3.0. Le bogue se reproduit en C # 3.0 et 4.0 mais pas en C # 2.0, ce qui signifie que le bogue était probablement ma faute. Désolé!

J'obtiendrai un bogue entré dans la base de données et nous verrons si nous pouvons le corriger pour une future version de la langue. Merci encore à tous pour votre analyse; ce fut très utile!

MISE À JOUR: J'ai réécrit l'optimiseur nullable à partir de zéro pour Roslyn; il fait maintenant un meilleur travail et évite ce genre d'erreurs étranges. Pour quelques réflexions sur le fonctionnement de l'optimiseur de Roslyn, consultez ma série d'articles qui commence ici: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
la source
1
@Eric Je me demande si cela expliquerait aussi: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Maintenant que j'ai l'aperçu de l'utilisateur final de Roslyn, je peux confirmer qu'il y est corrigé. (Cependant, il est toujours présent dans le compilateur natif C # 5.)
Jon Skeet
84

C'est très certainement 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();
    }
}

Ce code affichera:

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

Cela m'a fait penser que la première partie de chaque ??expression de fusion est évaluée deux fois. Ce code l'a prouvé:

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

les sorties:

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

Cela semble se produire uniquement lorsque l'expression nécessite une conversion entre deux types nullables; J'ai essayé différentes permutations avec l'un des côtés étant une chaîne, et aucun d'entre eux n'a provoqué ce comportement.

configurateur
la source
11
Wow - évaluer l'expression deux fois semble vraiment mal. Bien repéré.
Jon Skeet
C'est un peu plus simple de voir si vous n'avez qu'un seul appel de méthode dans la source - mais cela le démontre toujours très clairement.
Jon Skeet
2
J'ai ajouté un exemple un peu plus simple de cette "double évaluation" à ma question.
Jon Skeet
8
Toutes vos méthodes sont-elles censées produire "X ()"? Cela rend quelque peu difficile de dire quelle méthode est réellement sortie sur la console.
jeffora
2
Il semblerait qu'il se X() ?? Y()développe en interne X() != null ? X() : Y(), d'où la raison pour laquelle il serait évalué deux fois.
Cole Johnson
54

Si vous regardez le code généré pour le cas groupé à gauche, il fait en fait quelque chose comme ceci ( 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);
}

Une autre découverte, si vous l' utilisez first , générera un raccourci si les deux aet bsont nuls et retournent c. Pourtant, si aou bn'est pas nul, il réévalue adans le cadre de la conversion implicite en Bavant de renvoyer lequel aou qui bn'est pas nul.

D'après la spécification C # 4.0, §6.1.4:

  • Si la conversion nullable est de S?à T?:
    • Si la valeur source est null(la HasValuepropriété est false), le résultat est la nullvaleur de type T?.
    • Sinon, la conversion est évaluée comme un déballage de S?à S, suivi de la conversion sous-jacente de Sà T, suivi d'un habillage (§4.1.10) de Tà T?.

Cela semble expliquer la deuxième combinaison déballage-emballage.


Le compilateur C # 2008 et 2010 produit un code très similaire, mais cela ressemble à une régression à partir du compilateur C # 2005 (8.00.50727.4927) qui génère le code suivant pour ce qui précède:

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

Je me demande si cela n'est pas dû à la magie supplémentaire donnée au système d'inférence de type?

user7116
la source
+1, mais je ne pense pas que cela explique vraiment pourquoi la conversion est effectuée deux fois. Il ne faut évaluer l'expression qu'une seule fois, OMI.
Jon Skeet
@ Jon: J'ai joué et trouvé (comme l'a fait @configurator) que lorsque cela est fait dans un arbre d'expression, cela fonctionne comme prévu. Travailler sur le nettoyage des expressions pour l'ajouter à mon message. Je devrais alors affirmer qu'il s'agit d'un "bug".
user7116
@Jon: ok lorsque vous utilisez des arbres d'expression, il se transforme (x ?? y) ?? zen lambdas imbriqués, ce qui garantit une évaluation dans l'ordre sans double évaluation. Ce n'est évidemment pas l'approche adoptée par le compilateur C # 4.0. D'après ce que je peux dire, la section 6.1.4 est abordée de manière très stricte dans ce chemin de code particulier et les temporaires ne sont pas élidés, ce qui entraîne la double évaluation.
user7116
16

En fait, je vais appeler cela un bug maintenant, avec l'exemple plus clair. Cela tient toujours, mais la double évaluation n'est certainement pas bonne.

Il semble que A ?? Bsoit mis en œuvre en tant que A.HasValue ? A : B. Dans ce cas, il y a aussi beaucoup de coulée (après la coulée régulière pour l' ?:opérateur ternaire ). Mais si vous ignorez tout cela, cela a du sens en fonction de la façon dont il est mis en œuvre:

  1. A ?? B s'étend à A.HasValue ? A : B
  2. Aest notre x ?? y. Développer jusqu'àx.HasValue : x ? y
  3. remplacer toutes les occurrences de A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Ici, vous pouvez voir que cette x.HasValueoption est vérifiée deux fois et si elle x ?? ynécessite un lancer, xelle sera lancée deux fois.

Je le mettrais simplement comme un artefact de la façon dont ??est implémenté, plutôt que comme un bug du compilateur. À emporter: ne créez pas d'opérateurs de casting implicites avec des effets secondaires.

Il semble que ce soit un bogue du compilateur qui tourne autour de la façon dont il ??est implémenté. À emporter: ne pas imbriquer les expressions coalescentes avec des effets secondaires.

Philip Rieck
la source
Oh, je ne voudrais certainement pas utiliser du code comme celui-ci normalement, mais je pense qu'il pourrait toujours être classé comme un bogue du compilateur dans la mesure où votre première extension devrait inclure "mais seulement évaluer A et B une fois". (Imaginez s'il s'agissait d'appels de méthode.)
Jon Skeet
@Jon Je suis d'accord que cela pourrait l'être aussi - mais je ne dirais pas que c'est clair. Eh bien, en fait, je peux voir que A() ? A() : B()cela évaluera probablement A()deux fois, mais A() ?? B()pas tellement. Et comme ça n'arrive qu'au casting ... Hmm ... Je viens de me convaincre que ça ne se comporte pas correctement.
Philip Rieck
10

Je ne suis pas du tout un expert en C # comme vous pouvez le voir dans mon historique de questions, mais j'ai essayé et je pense que c'est un bug ... mais en tant que débutant, je dois dire que je ne comprends pas tout ici, donc je vais supprimer ma réponse si je suis loin.

Je suis arrivé à cette bugconclusion en créant une version différente de votre programme qui traite du même scénario, mais beaucoup moins compliquée.

J'utilise trois propriétés d'entier nul avec des magasins de sauvegarde. Je mets chacun à 4 puis je lanceint? something2 = (A ?? B) ?? C;

( Code complet ici )

Cela lit juste le A et rien d'autre.

Cette déclaration me semble qu'elle devrait:

  1. Commencez entre parenthèses, regardez A, retournez A et terminez si A n'est pas nul.
  2. Si A était nul, évaluez B, terminez si B n'est pas nul
  3. Si A et B étaient nuls, évaluez C.

Donc, comme A n'est pas nul, il ne regarde que A et termine.

Dans votre exemple, mettre un point d'arrêt au premier cas montre que x, y et z ne sont pas tous nuls et donc, je m'attendrais à ce qu'ils soient traités de la même manière que mon exemple moins complexe .... mais je crains d'être trop d'un débutant C # et ont complètement raté le point de cette question!

Wil
la source
5
L'exemple de Jon est en quelque sorte un cas d'angle obscur en ce qu'il utilise une structure nullable (un type de valeur qui est "similaire" aux types intégrés comme un int). Il pousse l'affaire plus loin dans un coin obscur en fournissant plusieurs conversions de types implicites. Cela nécessite que le compilateur modifie le type des données lors de la vérification null. C'est à cause de ces conversions de types implicites que son exemple est différent du vôtre.
user7116