Covariância e Contravariância em C#, Parte Dez: Lidando Com Ambiguidades

06/24/2008 10:01:00 By Felipe Pessoto

Suponha que fizemos IEnumerable covariante em T. O que este código deve fazer?

class C : IEnumerable<Girafa>, IEnumerable<Tartaruga>
{
    IEnumerator<Girafa> IEnumerable<Girafa>.GetEnumerator()
    {
        yield return new Girafa();
    }
    IEnumerator<Tartaruga> IEnumerable<Tartaruga>.GetEnumerator()
    {
        yield return new Tartaruga();
    }
    // [etc.]
}
 
class Program
{
    static void Main()
    {
        IEnumerable<Animal> animais = new C();
        Console.WriteLine(animais.First().GetType().ToString());
    }
}

Opções:

1) Erro em tempo de compilação.
2) Erro em tempo de execução.
3) Sempre enumera Girafas.
4) Sempre enumera Tartarugas.
5) Escolha entre Girafas e Tartarugas em tempo de execução.
6) Nenhuma das opções acima.

Se você escolheu outra opção além da 1, deveríamos ter um warning em tempo de compilação?

Covariância e Contravariância em C#, Parte Nove: Criando Incompatibilidades

06/24/2008 09:02:00 By Felipe Pessoto

Nesta parte vamos discutir quais incompatibilidades teremos ao adicionar este recurso.

Simplesmente adicionando variância às regras de conversão nunca deveria causar nenhuma incompatibilidade. Entretanto, a combinação de se adicionar variância às regras de conversão e fazer alguns tipos terem parâmetros variantes causa uma potencial quebra de compatibilidade.

Geralmente as pessoas sabem que não se deve fazer isso:

if (x is Animal) 
  DoSomething();
else if (x is Girafa) 
  DoSomethingElse(); // nunca executa

porque a segunda condição é totalmente englobada pela primeira. Mas hoje no C# 3.0 é perfeitamente normal escrever:

if (x is IEnumerable<Animal>) 
  DoSomething();
else if (x is IEnumerable<Girafa>) 
  DoSomethingElse();

porque não há qualquer conversão sendo usada entre IEnumerable<Animal> e IEnumerable<Girafa>. Se nós adicionarmos covariância no IEnumerable<T> e o programa compilado contendo o fragmento acima usar a nova biblioteca então o comportamento quando dado um IEnumerable<Girafa> irá mudar. O objeto passará a ser atribuível à IEnumerable<Animal>, e por isso o "is" irá retornar "true", mudando a lógica do programa.

Há também a questão de mudar a semântica dos códigos fonte existentes ou tornar programas compiláveis em programas com erros de compilação. Por exemplo, a resolução da sobrecarga pode falhar onde deveria ser usada com sucesso. Se nós temos:

interface IBar<T>{} // Vindo de outro assembly
...
void M(IBar<Tigre> x){}
void M(IBar<Girafa> x){}
void M(object x) {}
...
IBar<Animal> y = qualquer;
M(y);
Então a resolução da sobrecarga pega a versão que recebe um object porque é a única escolha aplicável. Se nós mudarmos a definição de IBar para

interface IBar<-T>{}


e recompilar então teremos um erro de ambiguidade porque agora todos os três são aplicáveis e não há uma única melhor escolha.

Sempre queremos evitar incompatibilidades se possível, mas as vezes novos recursos são suficientemente atraentes e as incompatibilidades são tão raras que vale a pena. Acho que criando a variância em interfaces e delegates vamos permitir muito mais cenários interessantes do que incompatibilidades.

Será que este recurso irá trazer tantos benefícios que vale a pena gastar tempo desenvolvendo-o pra uma futura versão do C#?

Covariância e Contravariância em C#, Parte Oito: Opções de Sintaxe

06/23/2008 14:13:00 By Felipe Pessoto

Como discutimos anteriormente, nós introduzimos variância de interface e delegate em uma hipotética futura versão do C#, então precisamos de uma sintaxe para ela. Aqui estão algumas possibilidades.

Opção 1:

interface IFoo<+T, -U> { T Foo(U u); }

A CLR usa a convenção que estamos usando em toda a série de "+ sendo covariante e - contravariante”. Embora isto tenha algum valor mnemónico (porque + quer dizer "é compatível com um tipo maior"), muitas pessoas (incluindo membros da comissão de design do C#!) têm dificuldades em se lembrar o que é exatamente.

Esta convenção também é utilizada pela linguagem de programação Scala.

Opção 2:

interface IFoo<T:*, *:U> {x

Este indica mais graficamente "alguma coisa que é extendida por T" e "alguma coisa a qual extende U". É similiar as keywords Java, onde elas dizem "extends U" ou "super T".

Embora isto não seja terrível, penso que isto funde um pouco as noções de extensão e compatibilidade de atribuição. Eu não quero implicar que IEnumerable<Animal> é a base de IEnumerable<Girafa>, mesmo que Animal seja a base de Girafa. Em vez disso, quero dizer que IEnumerable<Girafa> é convertível para IEnumerable<Animal>, ou a atribuição é compatível. Eu não quero exceder os conceitos o mecanismo de herança. Isto é ruim, nós estamos unindo classes base com interfaces base.

Opção 3:

interface IFoo<T, U> where T: covariant, U: contravariant {

Novamente, nada mal. O perigo aqui é similar ao dos sinais mais e menos: que ninguém se lembra o que "contravariante" e "covariante" significa. Este tem a vantagem de que pelo menos você pode buscar pelas palavras e achar alguma explicação.

Opção 4:

interface IFoo<[Covariant] T, [Contravariant] U>  {

Similar à opção 3.

Opção 5:

interface IFoo<out T, in U> {

Estamos tomando um rumo diferente com essa sintaxe. Nas opções anteriores nós estávamos descrevendo como o usuário da interface pode trata-lá, respeitando as regras do sistema de conversão de tipos para conversão implícita – que é, quais são as variâncias válidas nos tipos dos parâmetros. Em vez disso, aqui nós estamos descrevendo como o implementador da interface tem a intenção de usar os tipos dos parâmetros.

A desvantagem é que, como discutido em artigos anteriores, você acaba em situações como essa:

delegate void Meta<out T>(Action<T> action);

onde o "out" T seja claramente utilizada em uma posição de entrada.

Covariância e Contravariância em C#, Parte Sete: Por que precisamos de uma sintaxe para tudo?

05/16/2008 09:12:00 By Felipe Pessoto

Suponha que estamos implementando interface genérica e variância delegada em um futura versão hipotética do C#. Como, hipoteticamente, deveria ser a sintaxe? Há muitas opções que poderíamos considerar.

Antes de pensarmos nas opções, nos perguntamos, "E se não tivermos uma sintaxe para tudo?" Por que não inferimos a variância para o desenvolvedor, de modo que tudo apenas magicamente funcione?

Infelizmente isso não existe, por várias razões.

Primeira, assim parece que a variância devia ser algo que você deliberadamente implementa em sua interface ou delegate. Fazendo isso você começa a perder o controle do objetivo que o usuário busca.

Fazer isso "automagicamente" também significa que, assim como o processo de desenvolvimento e métodos forem adicionados às interfaces, a variância da interface pode mudar inesperadamente. Isto pode introduzir inesperadas e profundas mudanças em outros pontos do programa.

Segundo, tentar fazer isso introduz um novo tipo de ciclo à análise da linguagem. Nós já temos que detectar coisas como ciclos nas classes base, ciclos na interfaces base e ciclos nas regras de tipos genéricos, então em teoria não tem nenhuma novidade. Mas na prática, há alguns problemas.

No artigo anterior não discutimos sobre restrições adicionais que nós precisamos para criar interfaces variantes. Uma restrição importante é que a interface variante que herda de outra interface variante deve fazer de uma maneira que não introduza problemas no sistema de tipos. Basicamente, todas as regras para quando um tipo de parâmetro pode ser covariante ou contravariante precisam "fluir" para a interface base.

Por exemplo, suponha que o compilador esteja tentando deduzir a variância neste programa:

interface IFrob<T> : IBlah<T> { }
interface IBlah<U>
{
    IFrob<U> Frob();
}

E nos perguntamos "é válido para T ser variante em IFrob<T>?" Para responder esta pergunta, nós precisamos determinar se é válido para U ser variante em IBlah. E para responder esta pergunta nós precisamos saber se válido para U ser variante no tipo de saída IFrob<U>, e...voltamos do ponto que começamos!

Não queremos que o compilador entre em um loop infinito quando compilar este programa. Mas esta claro que este programa é perfeitamente válido. Quando detectarmos um ciclo nas classes base, podemos parar e dizer "Seu programa é inválido". Mas não podemos fazer isso aqui. É uma questão complicada.

Terceiro, mesmo se pudéssemos descobrir uma maneira de resolver o problema do ciclo, nós ainda teríamos um problema com o caso acima. Ou seja, há três possíveis respostas logicamente consistentes: "ambas invariantes", "+T, +U" e "-T, -U" todas produzem programas que seriam typesafe. Como podemos escolher?

Podíamos começar em situações ainda piores:

interface IRezrov<V, W>
{
    IRezrov<V, W> Rezrov(IRezrov<W, V> x);
}

Nesta interface louca podemos deduzir que "ambas invariantes", <+V, -W> e <-V, +W> são todas as possibilidades. Novamente, como escolher?

E quarto, mesmo se pudéssemos resolver todos esses problemas, suspeito que o desempenho de tal algoritmo seria potencialmente muito ruim. Isto tem tudo para ter um "crescimento exponencial". Temos outros algoritmos exponenciais no compilador, mas prefiro não adicionar mais, se podemos evitar.

Assim, se adicionarmos interface e variância delegada em uma hipotética futura versão do C#, precisamos dar uma sintaxe para ela.

Covariância e Contravariância em C#, Parte Seis:Variância de Interface

05/14/2008 16:12:00 By Felipe Pessoto

Nos posts passados nós discutimos como seria possível tratar um delegate como contravariante nos seus argumentos e covariante no seu tipo de retorno. Um delegate é basicamente um objeto que representa uma chamada de função. Nós podemos fazer estes mesmos tipos de coisas para outros que representam chamadas de funções. Interfaces, por exemplo, são contratos que especificam qual o conjunto de chamadas de função que estão disponíveis em um objeto em particular.

Isto quer dizer que nós devemos extender a noção de variância para definições de interface também, usando as mesmas regras que nós temos para delegates. Por exemplo, considere:

public interface IEnumerator<T> : IDisposable, IEnumerator
{
    new T Current { get; }
}

Aqui nós temos uma interface genérica onde o único uso de parâmetro está em uma posição de saída. Poderíamos, assim, considerar o parâmetro covariante. Isso significaria que seria válido atribuir um objeto que implementa IEnumerator<Girafa> para uma variável do tipo IEnumerator<Animal>. Desde que o usuário daquela variável irá sempre esperar um Animal, e a nossa implementação irá sempre produzir uma Girafa, fica tudo ok.

Então temos IEnumerator<+T>, então nós podemos perceber que IEnumerable<T> é definido como:

public interface IEnumerable<T> : IEnumerable
{
    new IEnumerator<T> GetEnumerator();
}

Novamente, o parâmetro aparece somente em uma posição de saída, então nós poderiamos ter IEnumerable<+T> covariante também.

Isto então abre uma janela de assassinatos de bons cenários. Hoje, este código deve falhar na compilação:

void AlimentarAnimais(IEnumerable<Animal> animals)
{
    foreach (Animal animal in animals)
        if (animal.Faminto)
            Alimentar(animal);
}
...
IEnumerable<Girafa> GirafasAdultas = from g in girafas where g.Age > 5 select g;
AlimentarAnimais(GirafasAdultas);

Porque GirafasAdultas implementa IEnumerable<Girafa>, não IEnumerable<Animal>. No C# 3.0 você tem que fazer um idiota e dispendiosa operação de conversão para fazer isto compilar, algo como:

AlimentarAnimais(GirafasAdultas.Cast<Animal>());

ou

AlimentarAnimais(from g in GirafasAdultas select (Animal)g);

Ou seja o que for. Esta declaração explícita não deveria ser necessária. Diferente de arrays (que são leitura-escrita) é perfeitamente seguro tratar uma lista de Girafas read-only como uma lista de Animais.

Do mesmo modo, poderíamos tornar:

public interface IComparer<-T>
{
    int Compare(T x, T y);
}

uma interface contravariante, desde que o tipo fosse usado somente em posições de entrada. Você poderia, então, implementar um objeto que compara dois Animais e usá-lo em um contexto onde você precisa de um objeto que compare duas Girafas sem se preocupar com problemas no sistema de tipos.