C #: compatibilidad con versiones anteriores y sobrecarga

Hola colegas

Les recordamos a todos que tenemos un excelente libro de Mark Price, " C # 7 y .NET Core. Desarrollo multiplataforma para profesionales ". Tenga en cuenta: esta es la tercera edición, la primera edición se escribió en la versión 6.0 y no apareció en ruso, y la tercera edición se lanzó en el original en noviembre de 2017 y cubre la versión 7.1.


Después del lanzamiento de dicho compendio, que pasó por una edición científica separada para verificar la compatibilidad con versiones anteriores y otras correcciones del material presentado, decidimos traducir un artículo interesante de John Skeet sobre las dificultades conocidas y poco conocidas con la compatibilidad con versiones anteriores que pueden surgir en C #. Que tengas una buena lectura.

En julio de 2017, comencé a escribir un artículo sobre versiones. Pronto lo abandonó, porque el tema era demasiado extenso para cubrirlo en una sola publicación. Sobre este tema, tiene más sentido resaltar un sitio / wiki / repositorio completo. Espero volver a este tema algún día, porque lo considero extremadamente importante y creo que recibe mucha menos atención de la que merece.

Por lo tanto, en el ecosistema .NET, las versiones semánticas generalmente son bienvenidas: suena genial, pero requiere que todos comprendan igualmente lo que se considera un "cambio fundamental". Esto es lo que he estado pensando durante mucho tiempo. Uno de los aspectos que más recientemente me ha llamado la atención es lo difícil que es evitar cambios fundamentales al sobrecargar los métodos. Es sobre esto (principalmente) que discutiremos la publicación que está leyendo; Después de todo, este tema es muy interesante.
Para comenzar: una breve definición ...

Fuentes y compatibilidad binaria

Si puedo recompilar mi código de cliente con la nueva versión de la biblioteca, y todo funciona bien, entonces esto es compatibilidad a nivel de código fuente. Si puedo volver a implementar mi binario de cliente con la nueva versión de la biblioteca sin volver a compilar, entonces es compatible con binarios. Nada de esto es un superconjunto del otro:

  • Algunos cambios pueden ser incompatibles con el código fuente y el código binario al mismo tiempo; por ejemplo, no puede eliminar un tipo público completo del que es completamente dependiente.
  • Algunos cambios son compatibles con el código fuente, pero incompatibles con el código binario; por ejemplo, si convierte un campo estático público de solo lectura en una propiedad.
  • Algunos cambios son compatibles con binarios, pero no son compatibles con la fuente; por ejemplo, agregar una sobrecarga que puede causar ambigüedad durante la compilación.
  • Algunos cambios son compatibles con el código fuente y el código binario; por ejemplo, una nueva implementación del cuerpo del método.

¿De qué estamos hablando?

Supongamos que tenemos una biblioteca pública de la versión 1.0, y queremos agregarle varias sobrecargas para finalizar a la versión 1.1. Nos atenemos a las versiones semánticas, por lo que necesitamos compatibilidad con versiones anteriores. ¿Qué significa esto que podemos y no podemos hacer, y todas las preguntas aquí pueden ser respondidas "sí" o "no"?

En diferentes ejemplos, mostraré el código en las versiones 1.0 y 1.1, y luego el código "cliente" (es decir, el código que usa la biblioteca), que puede romperse como resultado de los cambios. No habrá cuerpos de método ni declaraciones de clase, ya que, en esencia, no son importantes: prestamos la mayor atención a las firmas. Sin embargo, si está interesado, todas estas clases y métodos se pueden reproducir fácilmente. Supongamos que todos los métodos descritos aquí están en la clase Library .

El cambio más simple concebible, adornado con la transformación de un grupo de métodos a un delegado
El ejemplo más simple que se me ocurre es agregar un método parametrizado donde ya hay uno no parametrizado:

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


Incluso aquí, la compatibilidad es incompleta. Considere el siguiente código de cliente:

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

En la primera versión de la biblioteca, todo está bien. Al llamar al método HandleAction , el grupo de métodos se convierte en la library.Foo Delegado HandleAction y, como resultado, se crea una Action . En la versión 1.1, la situación se vuelve ambigua: un grupo de métodos se puede convertir en Acción o Acción. Es decir, estrictamente hablando, tal cambio es incompatible con el código fuente.

En esta etapa, es tentador simplemente rendirse y prometerse a sí mismo que nunca volverá a agregar sobrecargas. O podemos decir que tal caso es lo suficientemente improbable como para no tener miedo de tal fracaso. Llamemos a las transformaciones de un grupo de métodos fuera de alcance por ahora.

Tipos de referencia no relacionados

Considere otro contexto en el que debe usar sobrecargas con el mismo número de parámetros. Se puede suponer que dicho cambio en la biblioteca no será destructivo:

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

A primera vista, todo es lógico. Mantenemos el método original, por lo que no romperemos la compatibilidad binaria. La forma más sencilla de romperlo es escribir una llamada que funcione en v1.0, pero que no funcione en v1.1, o que funcione en ambas versiones, pero de diferentes maneras.
¿Qué incompatibilidad entre v1.0 y v1.1 puede dar tal llamada? Debemos tener un argumento compatible con string y FileStream . Pero estos son tipos de referencia no relacionados entre sí ...

El primer error es posible si hacemos una conversión implícita definida por el usuario tanto a la string como a 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 el problema sea obvio: el código que anteriormente no era ambiguo y funcionaba con string ahora es ambiguo, porque el tipo OddlyConvertible se puede convertir implícitamente en string y FileStream (ambas sobrecargas son aplicables, ninguna de las dos es mejor que la otra).

Tal vez en este caso es razonable prohibir las conversiones definidas por el usuario ... pero este código se puede eliminar y mucho más fácil:

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

Podemos convertir implícitamente un literal nulo en cualquier tipo de referencia o en cualquier tipo significativo anulable ... por lo tanto, nuevamente, la situación en la versión 1.1 es ambigua. Intentemos de nuevo ...

Parámetros de tipos de referencia y tipos significativos no anulables

Supongamos que no nos importan las transformaciones definidas por el usuario, pero no nos gustan los literales nulos problemáticos. ¿Cómo en este caso agregar sobrecarga con un tipo significativo no anulable?

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

A primera vista, es bueno: library.Foo(null) funcionará bien en v1.1. Entonces, ¿está a salvo? No, solo que no en C # 7.1 ...

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

El literal predeterminado es exactamente nulo, pero se aplica a cualquier tipo. Esto es muy conveniente, y un verdadero dolor de cabeza cuando se trata de sobrecarga y compatibilidad :(

Parámetros opcionales

Los parámetros opcionales son otro problema. Supongamos que tenemos un parámetro opcional y queremos agregar un segundo. Tenemos tres opciones, identificadas a continuación como 1.1a, 1.1b y 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 = "") 


Pero qué pasa si el cliente hace dos llamadas:

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

La biblioteca 1.1a mantiene la compatibilidad a nivel binario, pero viola a nivel de código fuente: ahora library.Foo() ambiguo. De acuerdo con las reglas de sobrecarga en C #, se prefieren los métodos que no requieren que el compilador "complete" todos los parámetros opcionales disponibles, sin embargo, no regula cuántos parámetros opcionales se pueden llenar.

La biblioteca 1.1b mantiene la compatibilidad a nivel fuente, pero viola la compatibilidad binaria. El código compilado existente está diseñado para llamar a un método con un solo parámetro, y dicho método ya no existe.

La biblioteca 1.1c conserva la compatibilidad binaria, pero está llena de posibles sorpresas a nivel del código fuente. Ahora la llamada library.Foo() se resuelve en un método con dos parámetros, mientras que library.Foo("xyz") resuelve en un método con un parámetro (desde el punto de vista del compilador, es preferible a un método con dos parámetros, principalmente porque no hay parámetros opcionales no se requiere relleno). Esto puede ser aceptable si una versión con un parámetro simplemente delega versiones con dos parámetros, y en ambos casos se utiliza el mismo valor predeterminado. Sin embargo, parece extraño que el valor de la primera llamada cambie si el método con el que se resolvió anteriormente todavía existe.

La situación con parámetros opcionales se vuelve aún más confusa si desea agregar un nuevo parámetro no al final, sino en el medio; por ejemplo, intente cumplir con el acuerdo y mantenga el parámetro CancellationToken opcional al final. No voy a entrar en esto ...

Métodos generalizados

La conclusión de tipos en el mejor de los casos no fue una tarea fácil. Cuando se trata de resolver sobrecargas, este trabajo se convierte en una pesadilla uniforme.

Supongamos que solo tenemos un método no generalizado en v1.0, y en v1.1 agregamos otro método generalizado.

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

A primera vista, no da tanto miedo ... pero veamos qué sucede en el código del cliente:

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

En la biblioteca v1.0, ambas llamadas se resuelven en Foo(object) , el único método disponible.

La biblioteca v1.1 es compatible con versiones anteriores: si toma el archivo ejecutable del cliente compilado para v1.1, ambas llamadas seguirán utilizando Foo(object) . Pero, en caso de recompilación, la segunda llamada (y solo la segunda) cambiará a trabajar con el método generalizado. Ambos métodos se aplican a ambas llamadas.

En la primera llamada, la inferencia de tipo mostrará que T es un object , por lo que la conversión del argumento al tipo de parámetro en ambos casos se reducirá a object en object . Genial El compilador aplicará la regla de que los métodos no genéricos siempre son preferibles a los genéricos.

En la segunda llamada, la inferencia de tipos mostrará que T siempre será una string , por lo que al convertir un argumento en un parámetro de tipo, obtenemos string a object para el método original o string a string para el método generalizado. La segunda transformación es "mejor", por lo que se elige el segundo método.

Si los dos métodos funcionan de la misma manera, bien. Si no, romperá la compatibilidad de una manera muy poco obvia.

Herencia y tipificación dinámica

Lo siento, ya estoy sin aliento. Tanto la herencia como la escritura dinámica cuando se resuelven sobrecargas pueden manifestarse de la manera más "genial" y misteriosa.
Si agregamos dicho método en un nivel de la jerarquía de herencia que sobrecargará el método de la clase base, el nuevo método se procesará primero y se preferirá al método de la clase base, incluso si el método de la clase base es más preciso al convertir un argumento en un parámetro de tipo. Hay suficiente espacio para mezclar todo.

Lo mismo ocurre con la escritura dinámica (en el código del cliente); hasta cierto punto, la situación se vuelve impredecible. Ya has sacrificado seriamente la seguridad durante la compilación ... así que no te sorprendas si algo se rompe.

Resumen

Traté de hacer que los ejemplos en este artículo sean lo suficientemente simples. Todo se vuelve muy complicado y muy rápido cuando tienes muchos parámetros opcionales. El control de versiones es un asunto complicado; mi cabeza se hincha por eso.

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


All Articles