C #: compatibilidade com versões anteriores e sobrecarga

Olá colegas!

Lembramos a todos que temos um ótimo livro da Mark Price, " C # 7 e .NET Core. Desenvolvimento de plataforma cruzada para profissionais ". Observe: esta é a terceira edição, a primeira edição foi escrita na versão 6.0 e não apareceu em russo, e a terceira edição foi lançada no original em novembro de 2017 e cobre a versão 7.1.


Após o lançamento desse compêndio, que passou por uma edição científica separada para verificar a compatibilidade com versões anteriores e outra correção do material apresentado, decidimos traduzir um artigo interessante de John Skeet sobre quais dificuldades conhecidas e pouco conhecidas com a compatibilidade com versões anteriores podem surgir em C #. Boa leitura.

Em julho de 2017, comecei a escrever um artigo sobre versionamento. Logo o abandonou, porque o tópico era extenso demais para ser abordado em apenas um post. Nesse tópico, faz mais sentido destacar um site / wiki / repositório inteiro. Espero voltar a esse tópico um dia, porque o considero extremamente importante e acho que ele recebe muito menos atenção do que merece.

Portanto, no ecossistema .NET, o controle de versão semântico geralmente é bem-vindo - parece ótimo, mas exige que todos compreendam igualmente o que é considerado uma "mudança fundamental". É isso que venho pensando há muito tempo. Um dos aspectos que mais recentemente me impressionou é a dificuldade de evitar mudanças fundamentais ao sobrecarregar os métodos. É sobre isso (principalmente) que discutiremos o post que você está lendo; Afinal, este tópico é muito interessante.
Para começar - uma breve definição ...

Fontes e compatibilidade binária

Se eu puder recompilar meu código de cliente com a nova versão da biblioteca, e tudo funcionar bem, isso é compatibilidade no nível do código-fonte. Se eu puder reimplementar o binário do meu cliente com a nova versão da biblioteca sem recompilar, ele será compatível com o binário. Nada disso é um superconjunto do outro:

  • Algumas alterações podem ser incompatíveis com o código-fonte e o código binário ao mesmo tempo - por exemplo, você não pode excluir um tipo público inteiro do qual depende completamente.
  • Algumas alterações são compatíveis com o código fonte, mas incompatíveis com o código binário - por exemplo, se você converter um campo estático público somente leitura em uma propriedade.
  • Algumas alterações são compatíveis com o binário, mas não são compatíveis com a fonte - por exemplo, adicionando uma sobrecarga que pode causar ambiguidade durante a compilação.
  • Algumas alterações são compatíveis com o código-fonte e o código binário - por exemplo, uma nova implementação do corpo do método.

Então, do que estamos falando?

Suponha que tenhamos uma biblioteca pública da versão 1.0 e queremos adicionar várias sobrecargas para finalizar a versão 1.1. Aderimos ao controle de versão semântico, portanto, precisamos de compatibilidade com versões anteriores. O que isso significa que podemos e não podemos fazer, e todas as perguntas aqui podem ser respondidas "sim" ou "não"?

Em exemplos diferentes, mostrarei o código nas versões 1.0 e 1.1 e, em seguida, o código "cliente" (ou seja, código que usa a biblioteca), que pode ser interrompido como resultado de alterações. Não haverá corpos de métodos nem declarações de classe, pois eles não são, em essência, importantes - prestamos a atenção principal às assinaturas. No entanto, se você estiver interessado, todas essas classes e métodos podem ser facilmente reproduzidos. Suponha que todos os métodos descritos aqui estejam na classe Library .

A mudança mais simples concebível, adornada com a transformação de um grupo de métodos em um delegado
O exemplo mais simples que me vem à cabeça é adicionar um método parametrizado, onde já existe um método não parametrizado:

  //   1.0 public void Foo() //   1.1 public void Foo() public void Foo(int x) 


Mesmo aqui, a compatibilidade é incompleta. Considere o seguinte código do cliente:

  //  static void Method() { var library = new Library(); HandleAction(library.Foo); } static void HandleAction(Action action) {} static void HandleAction(Action<int> action) {} 

Na primeira versão da biblioteca, está tudo bem. Chamar o método HandleAction converte o grupo de métodos na library.Foo delegado e, como resultado, uma Action é criada. Na versão 1.1, a situação se torna ambígua: um grupo de métodos pode ser convertido em Ação ou Ação. Ou seja, estritamente falando, essa alteração é incompatível com o código fonte.

Nesse estágio, é tentador desistir e prometer a si mesmo simplesmente nunca mais adicionar sobrecargas. Ou podemos dizer que tal caso é improvável o suficiente para não ter medo de tal falha. Vamos chamar as transformações de um grupo de métodos fora de escopo por enquanto.

Tipos de referência não relacionados

Considere outro contexto em que você deve usar sobrecargas com o mesmo número de parâmetros. Pode-se supor que essa alteração na biblioteca seja não destrutiva:

 //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(FileStream x) 

À primeira vista, tudo é lógico. Mantemos o método original, para não quebrar a compatibilidade binária. A maneira mais simples de quebrar isso é escrever uma chamada que funcione na v1.0, mas que não funcione na v1.1, ou que funcione nas duas versões, mas de maneiras diferentes.
Que incompatibilidade entre a v1.0 e a v1.1 pode ser chamada? Nós devemos ter um argumento compatível com a string e o FileStream . Mas estes são tipos de referência não relacionados entre si ...

A primeira falha é possível se fizermos uma conversão implícita definida pelo usuário para a string e o FileStream :

 //  class OddlyConvertible { public static implicit operator string(OddlyConvertible c) => null; public static implicit operator FileStream(OddlyConvertible c) => null; } static void Method() { var library = new Library(); var convertible = new OddlyConvertible(); library.Foo(convertible); } 

Espero que o problema seja óbvio: o código que anteriormente não era ambíguo e trabalhava com string agora é ambíguo, pois o tipo OddlyConvertible pode ser implicitamente convertido em string e FileStream (ambas as sobrecargas são aplicáveis, nenhuma delas é melhor que a outra).

Talvez neste caso seja razoável proibir conversões definidas pelo usuário ... mas esse código pode ser reduzido e muito mais fácil:

 //  static void Method() { var library = new Library(); library.Foo(null); } 

Podemos converter implicitamente um literal nulo em qualquer tipo de referência ou em qualquer tipo significativo anulável ... portanto, novamente, a situação na versão 1.1 é ambígua. Vamos tentar de novo ...

Parâmetros de tipos de referência e tipos significativos não anuláveis

Suponha que não nos importemos com transformações definidas pelo usuário, mas não gostamos de literais nulos problemáticos. Como, neste caso, adicionar sobrecarga com tipo significativo não nulo?

  //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(int x) 

À primeira vista, é bom - library.Foo(null) funcionará bem na v1.1. Então ele está seguro? Não, apenas não no C # 7.1 ...

  //  static void Method() { var library = new Library(); library.Foo(default); } 

O literal padrão é exatamente nulo, mas se aplica a qualquer tipo. Isso é muito conveniente - e uma verdadeira dor de cabeça quando se trata de sobrecarga e compatibilidade :(

Parâmetros opcionais

Parâmetros opcionais são outro problema. Suponha que tenhamos um parâmetro opcional e desejemos adicionar um segundo. Temos três opções, identificadas abaixo como 1.1a, 1.1be 1.1c.

  //  1.0 public void Foo(string x = "") //  1.1a //   ,         public void Foo(string x = "") public void Foo(string x = "", string y = "") //  1.1b //          public void Foo(string x = "", string y = "") //  1.1c //   ,    ,   //  ,     . public void Foo(string x) public void Foo(string x = "", string y = "") 


Mas e se o cliente fizer duas chamadas:

 //  static void Method() { var library = new Library(); library.Foo(); library.Foo("xyz"); } 

A biblioteca 1.1a mantém a compatibilidade no nível binário, mas viola no nível do código fonte: agora library.Foo() ambíguo. De acordo com as regras de sobrecarga em C #, são preferidos métodos que não exigem que o compilador “preencha” todos os parâmetros opcionais disponíveis; no entanto, ele não regula quantos parâmetros opcionais podem ser preenchidos.

A biblioteca 1.1b mantém a compatibilidade no nível da fonte, mas viola a compatibilidade binária. O código compilado existente foi projetado para chamar um método com um único parâmetro - e esse método não existe mais.

A biblioteca 1.1c mantém a compatibilidade binária, mas está repleta de possíveis surpresas no nível do código-fonte. Agora, a chamada library.Foo() é resolvida em um método com dois parâmetros, enquanto library.Foo("xyz") resolvida em um método com um parâmetro (do ponto de vista do compilador, é preferível a um método com dois parâmetros, principalmente porque não há parâmetros opcionais não é necessário preenchimento). Isso pode ser aceitável se uma versão com um parâmetro simplesmente delegar versões com dois parâmetros e, em ambos os casos, o mesmo valor padrão for usado. No entanto, parece estranho que o valor da primeira chamada seja alterado se o método para o qual ela foi resolvida anteriormente ainda existir.

A situação com parâmetros opcionais se torna ainda mais confusa se você deseja adicionar um novo parâmetro não no final, mas no meio - por exemplo, tente aderir ao contrato e mantenha o parâmetro CancellationToken opcional no final. Eu não vou entrar nisso ...

Métodos Generalizados

A conclusão dos tipos nos melhores tempos não foi uma tarefa fácil. Quando se trata de resolver sobrecargas, esse trabalho se transforma em um pesadelo uniforme.

Suponha que temos apenas um método não generalizado na v1.0 e na v1.1 adicionamos outro método generalizado.

 //  1.0 public void Foo(object x) //  1.1 public void Foo(object x) public void Foo<T>(T x) 

À primeira vista, não é tão assustador ... mas vamos ver o que acontece no código do cliente:

 //  static void Method() { var library = new Library(); library.Foo(new object()); library.Foo("xyz"); } 

Na biblioteca v1.0, as duas chamadas são resolvidas no Foo(object) - o único método disponível.

A biblioteca v1.1 é compatível com versões anteriores: se você pegar o arquivo executável do cliente compilado para a v1.1, as duas chamadas ainda usarão Foo(object) . Mas, no caso de recompilação, a segunda chamada (e somente a segunda) mudará para trabalhar com o método generalizado. Ambos os métodos se aplicam a ambas as chamadas.

Na primeira chamada, a inferência de tipo mostrará que T é um object , portanto, a conversão do argumento no tipo de parâmetro nos dois casos será reduzida para object no object . Ótimo. O compilador aplicará a regra de que métodos não genéricos são sempre preferíveis a métodos genéricos.

Na segunda chamada, a inferência de tipo mostrará que T sempre será string , portanto, ao converter um argumento em um parâmetro de tipo, obtemos string em object para o método original ou string em string para o método generalizado. A segunda transformação é "melhor", então o segundo método é escolhido.

Se os dois métodos funcionarem da mesma maneira, tudo bem. Caso contrário, você quebrará a compatibilidade de uma maneira muito óbvia.

Herança e digitação dinâmica

Desculpe, eu já estou sem fôlego. Tanto a herança quanto a digitação dinâmica ao resolver sobrecargas podem se manifestar da maneira mais "legal" e misteriosa.
Se adicionarmos esse método em um nível da hierarquia de herança que sobrecarregará o método da classe base, o novo método será processado primeiro e será preferido ao método da classe base, mesmo que o método da classe base seja mais preciso ao converter um argumento em um parâmetro de tipo. Há espaço suficiente para misturar tudo.

O mesmo vale para digitação dinâmica (no código do cliente); até certo ponto, a situação se torna imprevisível. Você já sacrificou seriamente a segurança durante a compilação ... portanto, não se surpreenda se algo quebrar.

Sumário

Tentei simplificar os exemplos deste artigo. Tudo se torna muito complicado, e muito rapidamente, quando você tem muitos parâmetros opcionais. O controle de versão é uma questão complicada, minha cabeça incha com isso.

Source: https://habr.com/ru/post/pt414223/


All Articles