Comportamento curioso de conversão implícita personalizado do operador nulo-coalescente

542

Nota: isso parece ter sido corrigido em Roslyn

Essa pergunta surgiu ao escrever minha resposta a esta , que fala sobre a associatividade do operador de coalescência nula .

Apenas como lembrete, a ideia do operador de coalescência nula é que uma expressão da forma

x ?? y

primeiro avalia xe depois:

  • Se o valor de xfor nulo, yfor avaliado e esse for o resultado final da expressão
  • Se o valor de xfor não nulo, nãoy for avaliado e o valor de for o resultado final da expressão, após uma conversão para o tipo de tempo de compilação, se necessárioxy

Agora, normalmente, não há necessidade de conversão, ou é apenas de um tipo anulável para um não anulável - geralmente os tipos são os mesmos ou apenas de (digamos) int?para int. No entanto, você pode criar seus próprios operadores implícitos de conversão, e esses são usados ​​sempre que necessário.

Para o simples caso de x ?? y, eu não vi nenhum comportamento estranho. No entanto, com (x ?? y) ?? zeu vejo algum comportamento confuso.

Aqui está um programa de teste curto, mas completo - os resultados estão nos comentários:

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

Portanto, temos três tipos de valores personalizados A, Be C, com conversões de A para B, A para C e B para C.

Eu posso entender o segundo e o terceiro casos ... mas por que há uma conversão extra de A para B no primeiro caso? Em particular, eu realmente esperava que o primeiro e o segundo casos fossem a mesma coisa - afinal, é apenas extrair uma expressão em uma variável local.

Algum comprador sobre o que está acontecendo? Estou extremamente hesitante em chorar "bug" quando se trata do compilador C #, mas estou perplexo com o que está acontecendo ...

EDIT: Ok, aqui está um exemplo mais desagradável do que está acontecendo, graças à resposta do configurador, o que me dá mais motivos para pensar que é um bug. EDIT: A amostra nem precisa de dois operadores coalescentes nulos agora ...

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

A saída disso é:

Foo() called
Foo() called
A to int

O fato de Foo()ser chamado duas vezes aqui é extremamente surpreendente para mim - não vejo motivo para a expressão ser avaliada duas vezes.

Jon Skeet
fonte
32
Aposto que eles pensavam "ninguém nunca vai usá-lo dessa forma" :)
cyberzed
57
Quer ver algo pior? Tente usar essa linha com todas as conversões implícitas: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Você obterá:Internal Compiler Error: likely culprit is 'CODEGEN'
configurator
5
Observe também que isso não acontece ao usar expressões Linq para compilar o mesmo código.
configurator
8
@ Peter padrão improvável, mas plausível para(("working value" ?? "user default") ?? "system default")
Fator Místico
23
@ yes123: Quando se tratava apenas da conversão, não estava totalmente convencido. Ver ele executar um método duas vezes tornou bastante óbvio que isso era um bug. Você ficaria surpreso com algum comportamento que parece incorreto, mas é realmente completamente correto. A equipe de C # é mais esperta do que eu - eu suponho que estou sendo estúpido até provar que algo é culpa deles.
Jon Skeet

Respostas:

418

Obrigado a todos que contribuíram para analisar esse problema. É claramente um bug do compilador. Parece ocorrer apenas quando há uma conversão levantada envolvendo dois tipos anuláveis ​​no lado esquerdo do operador coalescente.

Ainda não identifiquei onde exatamente as coisas dão errado, mas em algum momento durante a fase de compilação "anulável" - após a análise inicial, mas antes da geração do código - reduzimos a expressão

result = Foo() ?? y;

do exemplo acima ao equivalente moral de:

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

Claramente isso está incorreto; a descida correta é

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

Meu melhor palpite, com base em minha análise até agora, é que o otimizador anulável está saindo dos trilhos aqui. Temos um otimizador anulável que procura situações em que sabemos que uma expressão específica do tipo anulável não pode ser nula. Considere a seguinte análise ingênua: podemos dizer primeiro que

result = Foo() ?? y;

é o mesmo que

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

e então podemos dizer que

conversionResult = (int?) temp 

é o mesmo que

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

Mas o otimizador pode intervir e dizer "uau, espere um minuto, já verificamos que temp não é nulo; não há necessidade de checar nulos uma segunda vez só porque estamos chamando um operador de conversão levantado". Nós os otimizamos para apenas

new int?(op_Implicit(temp2.Value)) 

Meu palpite é que estamos em algum lugar escondendo o fato de que a forma otimizada de (int?)Foo()é, new int?(op_implicit(Foo().Value))mas essa não é realmente a forma otimizada que queremos; queremos a forma otimizada de Foo () - substituída por temporária e depois convertida.

Muitos erros no compilador C # são resultado de decisões incorretas de armazenamento em cache. Uma palavra para o sábio: toda vez que você armazena em cache um fato para uso posterior, você potencialmente cria uma inconsistência, caso algo relevante mude . Nesse caso, o importante que mudou após a análise inicial é que a chamada para Foo () deve sempre ser realizada como uma busca temporária.

Reorganizamos bastante o passe de reescrita anulável no C # 3.0. O bug é reproduzido no C # 3.0 e 4.0, mas não no C # 2.0, o que significa que o bug provavelmente foi o meu mal. Desculpa!

Vou inserir um bug no banco de dados e veremos se podemos consertar isso para uma versão futura do idioma. Obrigado novamente a todos pela sua análise; foi muito útil!

UPDATE: reescrevi o otimizador anulável do zero para Roslyn; agora faz um trabalho melhor e evita esses tipos de erros estranhos. Para algumas reflexões sobre como o otimizador em Roslyn funciona, consulte minha série de artigos que começa aqui: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
fonte
1
@Eric Gostaria de saber se isso também explicaria: connect.microsoft.com/VisualStudio/feedback/details/642227 #
MarkPflug
12
Agora que tenho a Visualização do usuário final do Roslyn, posso confirmar que ele foi corrigido lá. (É ainda presente no C nativa # 5 compilador embora.)
Jon Skeet
84

Definitivamente, isso é um 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();
    }
}

Este código produzirá:

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

Isso me fez pensar que a primeira parte de cada ??expressão de coalescência é avaliada duas vezes. Este código provou:

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

saídas:

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

Isso parece acontecer apenas quando a expressão requer uma conversão entre dois tipos anuláveis; Eu tentei várias permutações com um dos lados sendo uma string e nenhum deles causou esse comportamento.

configurador
fonte
11
Uau - avaliar a expressão duas vezes parece muito errado. Bem manchado.
precisa
É um pouco mais simples verificar se você tem apenas uma chamada de método na fonte - mas isso ainda a demonstra com muita clareza.
precisa
2
Adicionei um exemplo um pouco mais simples dessa "avaliação dupla" à minha pergunta.
91311 Jon Skeet
8
Todos os seus métodos deveriam estar exibindo "X ()"? Isso torna um pouco difícil dizer qual método está realmente sendo exibido no console.
Jeffora
2
Parece X() ?? Y()expandir-se internamente para X() != null ? X() : Y(), portanto, por que seria avaliado duas vezes.
Cole Johnson
54

Se você der uma olhada no código gerado para o caso agrupado à esquerda, ele realmente fará algo como isto ( 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);
}

Outra descoberta, se você o usar first , irá gerar um atalho se ambos ae bforem nulos e retornarem c. No entanto, se aou bnão for nulo, ele reavalia acomo parte da conversão implícita para Bantes de retornar qual aoub não é nulo.

Na especificação do C # 4.0, §6.1.4:

  • Se a conversão anulável for de S?paraT? :
    • Se o valor de origem for null(a HasValuepropriedade é false), o resultado será o nullvalor do tipoT? .
    • Caso contrário, a conversão é avaliada como um desempacotamento de S?para S, seguido pela conversão subjacente de Spara T, seguida por um empacotamento (§4.1.10) de Tpara T?.

Isso parece explicar a segunda combinação de desembrulhar-embrulhar.


O compilador C # 2008 e 2010 produz um código muito semelhante, no entanto, isso parece uma regressão do compilador C # 2005 (8.00.50727.4927) que gera o seguinte código para o acima:

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

Gostaria de saber se isso não é devido à magia adicional dada ao sistema de inferência de tipos?

user7116
fonte
+1, mas acho que não explica realmente por que a conversão é realizada duas vezes. Ele deve avaliar a expressão apenas uma vez, IMO.
precisa
@ Jon: Eu andei brincando e descobri (como o @configurator) que, quando feito em uma Árvore de Expressões, funciona como esperado. Trabalhando na limpeza das expressões para adicioná-lo à minha postagem. Eu teria que postar que isso é um "bug".
user7116
@ Jon: ok ao usar Árvores de Expressão, ele se transforma (x ?? y) ?? zem lambdas aninhadas, o que garante uma avaliação em ordem sem avaliação dupla. Obviamente, essa não é a abordagem adotada pelo compilador C # 4.0. Pelo que sei, a seção 6.1.4 é abordada de maneira muito rigorosa nesse caminho de código específico e os temporários não são eliminados, resultando na dupla avaliação.
precisa saber é o seguinte
16

Na verdade, chamarei isso de bug agora, com o exemplo mais claro. Isso ainda é válido, mas a dupla avaliação certamente não é boa.

Parece que A ?? Bé implementado como A.HasValue ? A : B. Nesse caso, também existem muitas transmissões (após a transmissão regular para o ternário?: operador ). Mas se você ignorar tudo isso, isso faz sentido com base em como é implementado:

  1. A ?? B expande para A.HasValue ? A : B
  2. A é nosso x ?? y . Expandir parax.HasValue : x ? y
  3. substitua todas as ocorrências de A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Aqui você pode ver que x.HasValueé verificado duas vezes e, se x ?? yrequer transmissão,x será lançado duas vezes.

Eu colocaria isso simplesmente como um artefato de como ?? é implementado, em vez de um bug do compilador. Take-Away: não crie operadores de elenco implícitos com efeitos colaterais.

Parece ser um bug do compilador que gira em torno de como ??é implementado. Take-away: não aninhe expressões coalescentes com efeitos colaterais.

Philip Rieck
fonte
Definitivamente, eu não gostaria de usar código como esse normalmente, mas acho que ele ainda pode ser classificado como um bug do compilador, pois sua primeira expansão deve incluir "mas apenas avaliar A e B uma vez". (Imagine se eles eram chamadas de método.)
Jon Skeet
@ Jon Eu concordo que poderia ser assim - mas eu não diria isso claramente. Bem, na verdade, eu posso ver que A() ? A() : B()possivelmente será avaliado A()duas vezes, mas A() ?? B()não tanto. E já que isso só acontece no elenco ... Humm ... acabei de me convencer a pensar que certamente não está se comportando corretamente.
Philip Rieck 06/06
10

Eu não sou um especialista em C #, como você pode ver no meu histórico de perguntas, mas tentei isso e acho que é um bug ... mas, como novato, devo dizer que não entendo tudo o que está acontecendo aqui, então vou excluir minha resposta se estiver longe.

Cheguei a essa bugconclusão criando uma versão diferente do seu programa que lida com o mesmo cenário, mas muito menos complicado.

Estou usando três propriedades de número inteiro nulo com lojas de backup. Defino cada um como 4 e depois corroint? something2 = (A ?? B) ?? C;

( Código completo aqui )

Isso apenas lê o A e nada mais.

Esta declaração para mim parece-me que deveria:

  1. Comece entre parênteses, observe A, retorne A e termine se A não for nulo.
  2. Se A for nulo, avalie B, termine se B não for nulo
  3. Se A e B forem nulos, avalie C.

Portanto, como A não é nulo, ele apenas olha para A e termina.

No seu exemplo, colocar um ponto de interrupção no Primeiro Caso mostra que x, ye z não são nulos e, portanto, eu esperaria que eles fossem tratados da mesma forma que meu exemplo menos complexo .... mas eu tenho medo de ser demais. de um novato em C # e perdeu completamente o objetivo desta pergunta!

Wil
fonte
5
O exemplo de Jon é um caso de canto obscuro, pois ele está usando uma estrutura anulável (um tipo de valor que é "semelhante" aos tipos internos, como um int). Ele empurra o caso ainda mais para um canto obscuro, fornecendo várias conversões implícitas de tipo. Isso requer que o compilador altere o tipo de dados durante a verificação null. É por causa dessas conversões implícitas de tipo que o exemplo dele é diferente do seu.
user7116