web-dev-qa-db-fra.com

Curieux comportement de conversion implicite personnalisé de l'opérateur null-coalescing

Remarque: cela semble avoir été corrigé dans 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 opérateur null-coalescing .

Pour rappel, l’opérateur à coalescence nulle est qu’une expression de la forme

x ?? y

évalue d’abord x, puis:

  • Si la valeur de x est nulle, y est évalué et correspond au résultat final de l'expression.
  • Si la valeur de x est non nulle, y est pas évaluée et la valeur de x est le résultat final de la expression, après conversion au type de compilation y si nécessaire

Maintenant généralement il n'y a pas besoin de conversion, ou c'est simplement d'un type nullable à un 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, qui 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 en B, A en C et B en C.

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

Des preneurs sur ce qui se passe? Je suis extrêmement hésitant à crier "bug" quand il s'agit du compilateur C #, mais je suis perplexe sur 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 à 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() soit appelé deux fois ici me surprend énormément - je ne vois aucune raison pour que l'expression soit évaluée deux fois.

526
Jon Skeet

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

Je n'ai pas encore identifié où les choses tournent mal, mais à un moment donné pendant la phase de compilation "Nullable Bolding" - après l'analyse initiale mais avant la génération de 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 inexact. l'abaissement correct est

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

D'après les analyses que j'ai effectuées jusqu'à présent, la meilleure hypothèse est que l'optimiseur nullable se désactive Rails ici. Nous disposons d'un optimiseur nullable qui recherche les situations dans lesquelles nous savons qu'une expression particulière de type nullable ne peut pas 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 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 avions optimiser loin pour

new int?(op_Implicit(temp2.Value)) 

Je suppose que nous sommes quelque part en train de mettre 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 souhaitons; nous voulons la forme optimisée de Foo () - remplacé-par-temporaire-puis-converti.

De nombreux bogues dans le compilateur C # résultent de mauvaises décisions de mise en cache. Un mot au sage: chaque fois que vous cachez un fait pour l'utiliser plus tard, vous créez potentiellement une incohérence si quelque chose de pertinent change . 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 beaucoup réorganisé la passe de réécriture annulable 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!

Je vais faire entrer un bogue dans la base de données et nous verrons si nous pouvons le réparer pour une future version du langage. Merci encore à tous pour votre analyse. c'était très utile!

UPDATE: 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 dans Roslyn, voir ma série d'articles qui commence ici: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/ =

411
Eric Lippert

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 produira:

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 coalesce 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é diverses permutations avec l'un des côtés étant une chaîne, et aucune d'entre elles n'a provoqué ce comportement.

84
configurator

Si vous regardez le code généré pour le cas groupé à gauche, il 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 tilisezfirst, un raccourci sera généré si a et b sont nuls et renvoient c. Pourtant, si a ou b est non nul, il réévalue a dans le cadre de la conversion implicite en B avant de renvoyer lequel de a ou b n'est pas null.

À partir de la spécification C # 4.0, § 6.1.4:

  • Si la conversion Nullable est de S? à T?:
    • Si la valeur source est null (la propriété HasValue est false), le résultat est la valeur null de type T?.
    • Sinon, la conversion est évaluée en tant que décompression de S? à S, suivi de la conversion sous-jacente de S à T, suivi d’un retour à la ligne (§ 4.1.10) de T à T?.

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


Les compilateurs C # 2008 et 2010 produisent 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 cela n’est pas dû au supplément magique attribué au système d’inférence de type?

54
user7116

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 ?? B est implémenté comme A.HasValue ? A : B. Dans ce cas, il y a beaucoup de casting aussi (après le casting régulier pour le ternaire ?: opérateur). Mais si vous ignorez tout cela, alors cela a du sens en fonction de la façon dont il est implémenté:

  1. A ?? B se développe en A.HasValue ? A : B
  2. A est notre x ?? y. Étendre à x.HasValue : x ? y
  3. remplace toutes les occurrences de A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Ici vous pouvez voir que x.HasValue est coché deux fois, et si x ?? y nécessite un casting, x sera jeté deux fois.

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

Cela semble être un bug du compilateur qui tourne autour de la façon dont ?? est implémenté. À emporter: ne nichez pas d'expressions coalescentes avec des effets secondaires.

16
Philip Rieck

Je ne suis pas du tout un expert en C #, comme le montre l'historique de mes questions, mais j'ai essayé cela et je pense que c'est un bug ... mais en tant que débutant, je dois dire que je ne comprends pas tout ce qui se passe sur ici donc je vais supprimer ma réponse si je suis loin.

Je suis parvenu à cette conclusion bug 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'entiers nuls avec des magasins de sauvegarde. Je mets chacun à 4, puis je lance int? something2 = (A ?? B) ?? C;

( Code complet ici )

Cela ne fait que lire le A et rien d’autre.

Cette déclaration me semble devoir:

  1. Commencez entre crochets, regardez A, retournez A et terminez 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 se termine.

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 manière que mon exemple moins complexe ... mais je crains que je ne suis trop d'un débutant en C # et ont manqué le point de cette question complètement!

10
Wil