Question Comportement de conversion implicite personnalisé de l'opérateur coalescent nul


Note: cela semble avoir été corrigé dans Roslyn

Cette question a surgi lors de l'écriture de ma réponse à celui-là, qui parle de l'associativité de la opérateur null-coalescent.

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 x est nul, y est évalué et c'est le résultat final de l'expression
  • Si la valeur de x est non nul, y est ne pas évalué, et la valeur de x est le résultat final de l'expression, après une conversion au type de compilation y si nécessaire

À présent d'habitude il n'y a pas besoin de conversion, ou simplement d'un type nullable à un type non nullable - généralement les types sont les mêmes, ou juste de (disons) int? à int. Cependant, vous pouvez créez 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 pas vu de comportement étrange. Cependant, avec (x ?? y) ?? z Je 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ées, A, B et C, avec des conversions de A à B, de A à C et de B à C.

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

Tout preneur sur ce qui se passe? Je suis extrêmement hésitant à crier "bug" quand il s'agit du compilateur C #, mais je suis perplexe quant à ce qui se passe ...

EDIT: Ok, 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 bogue. EDIT: L'échantillon n'a même pas 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 que Foo() est appelé deux fois ici est extrêmement surprenant pour moi - je ne peux pas voir aucune raison pour que l'expression soit évalué deux fois.


504
2018-06-06 19:11


origine


Réponses:


Merci à tous ceux qui ont contribué à l'analyse de ce problème. C'est clairement un bug du compilateur. Il semble que cela ne se produise que lorsqu'il y a une conversion levée impliquant deux types nullables sur le côté gauche de l'opérateur de coalescence.

Je n'ai pas encore identifié où exactement les choses vont mal, mais à un moment donné pendant la phase de compilation "abaissable" - 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;

Clairement, c'est incorrect. l'abaissement correct est

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

Ma meilleure estimation, basée sur mon analyse jusqu’à présent, est que l’optimiseur nullable est en train de dérailler. Nous avons un optimiseur nullable qui recherche des situations où nous savons qu'une expression particulière de type nullable ne peut pas être nulle. 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;

et 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 nulle, il n'est pas nécessaire de le vérifier une seconde fois juste parce que nous appelons un opérateur de conversion levé". Nous les optimisons pour juste

new int?(op_Implicit(temp2.Value)) 

Je suppose que nous sommes 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 vraiment la forme optimisée que nous voulons; nous voulons la forme optimisée de Foo () - remplacé-avec-temporaire-et-puis-converti.

De nombreux bogues dans le compilateur C # sont le résultat de mauvaises décisions de mise en cache. Un mot pour les sages: 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 change. Dans ce cas, la chose 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 réorganisé 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 mon mauvais. Pardon!

J'obtiendrai un bogue 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. c'était 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 ces sortes d'erreurs bizarres. Pour plus d'informations sur le fonctionnement de l'optimiseur dans Roslyn, consultez ma série d'articles qui commence ici: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/


400
2018-06-07 21:01



C'est 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 va sortir:

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 ?? l'expression de coalescence 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 NULL; J'ai essayé diverses permutations avec l'un des côtés étant une chaîne, et aucun d'eux n'a causé ce comportement.


79
2018-06-06 20:17



Si vous jetez un oeil au code généré pour le cas groupé à gauche, il fait quelque chose comme ça (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 trouvaille, si vous utilisation  first il va générer un raccourci si les deux a et b sont null et retour c. Pourtant si a ou b est non nul, il réévalue a dans le cadre de la conversion implicite à B avant de retourner lequel des a ou b est non nul.

De la spécification C # 4.0, §6.1.4:

  • Si la conversion nullable provient de S? à T?:   
    • Si la valeur source est null (HasValue la propriété est false), le résultat est le null valeur du type T?.
    • Sinon, la conversion est évaluée comme un déroulage de S? à S, suivi de la conversion sous-jacente de S à T, suivi d'un emballage (§4.1.10) de T à T?.

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


Le compilateur C # 2008 et 2010 produit un code très similaire, mais cela ressemble à une régression 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 ce n'est pas en raison de la la magie donné au système d'inférence de type?


51
2018-06-06 19:15



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

Il semble que A ?? B est mis en œuvre comme A.HasValue ? A : B. Dans ce cas, il y a aussi beaucoup de casting (suite au casting régulier pour le ternaire ?: opérateur). Mais si vous ignorez tout cela, cela a du sens en fonction de la façon dont il est implémenté:

  1. A ?? B  se développe pour A.HasValue ? A : B
  2. A est notre x ?? y. Agrandir 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 x.HasValue est vérifié deux fois, et si x ?? y nécessite un casting, x sera lancé deux fois.

Je le poserais simplement comme un artefact de la façon dont ?? est implémenté, plutôt qu'un bogue de compilateur.    Take-Away: Ne créez pas d'opérateurs de casting implicites avec des effets secondaires.

Il semble être un bug compilateur tournant autour de la façon dont ?? est implémenté. À emporter: ne pas imbriquer des expressions coalescentes avec des effets secondaires.


14
2018-06-06 19:40



Je ne suis pas du tout un expert en C # comme vous pouvez le constater dans l’historique de mes 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 venu à cette bug conclusion en faisant une version différente de votre programme qui traite du même scénario, mais beaucoup moins compliqué.

J'utilise trois propriétés entières nulles avec des banques de support. Je définis chacun à 4 et ensuite courir int? something2 = (A ?? B) ?? C;

(Code complet ici)

Cela lit juste le A et rien d'autre.

Cette déclaration me ressemble à ce qu'il devrait:

  1. Commencer dans les parenthèses, regarder A, retourner A et finir si A n'est pas nul.
  2. Si A était nul, évaluer B, terminer 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 finit.

Dans votre exemple, placer un point d'arrêt au premier cas montre que x, y et z ne sont pas nuls et que, par conséquent, je m'attendrais à ce qu'ils soient traités de la même façon que mon exemple moins complexe. d'un débutant C # et ont manqué le point de cette question complètement!


9
2018-06-06 20:34