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áriaSe 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:
Mesmo aqui, a compatibilidade é incompleta. Considere o seguinte código do cliente:
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 relacionadosConsidere 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:
À 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
:
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:
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áveisSuponha 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?
À 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 ...
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 opcionaisParâ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.
Mas e se o cliente fizer duas chamadas:
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 GeneralizadosA 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.
À primeira vista, não é tão assustador ... mas vamos ver o que acontece no código do cliente:
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âmicaDesculpe, 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árioTentei 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.