que - Una guía definitiva para los cambios de ruptura de API en.NET
funcionamiento de la plataforma net (13)
Me gustaría recopilar tanta información como sea posible con respecto a la versión de la API en .NET / CLR, y específicamente cómo los cambios de la API interrumpen o no las aplicaciones de los clientes. Primero, definamos algunos términos:
Cambio de API : un cambio en la definición públicamente visible de un tipo, incluido cualquiera de sus miembros públicos. Esto incluye cambiar el tipo y los nombres de los miembros, cambiar el tipo base de un tipo, agregar / eliminar interfaces de la lista de interfaces implementadas de un tipo, agregar / eliminar miembros (incluidas las sobrecargas), cambiar la visibilidad de los miembros, cambiar el nombre del método y los parámetros de tipo, agregar valores predeterminados para los parámetros de método, agregar / eliminar atributos en tipos y miembros, y agregar / eliminar parámetros de tipo genérico en tipos y miembros (¿me perdí algo?). Esto no incluye ningún cambio en los organismos miembros, ni ningún cambio en los miembros privados (es decir, no tenemos en cuenta la Reflexión).
Interrupción de nivel binario : un cambio en la API que resulta en ensamblajes de clientes compilados contra una versión anterior de la API que posiblemente no se cargue con la nueva versión. Ejemplo: cambiar la firma del método, incluso si permite que se llame de la misma manera que antes (es decir, no válido para devolver las sobrecargas de valores predeterminados de tipo / parámetro).
Interrupción de nivel de origen : un cambio en la API que resulta en un código existente escrito para compilar contra una versión anterior de la API que posiblemente no se compile con la nueva versión. Sin embargo, los ensamblados de clientes ya compilados funcionan como antes. Ejemplo: agregar una nueva sobrecarga que puede resultar en ambigüedad en las llamadas de método que eran ambiguas anteriores.
Cambio de semántica silenciosa a nivel de origen : un cambio de API que resulta en un código existente escrito para compilar contra una versión anterior de la API cambia silenciosamente su semántica, por ejemplo, llamando a un método diferente. Sin embargo, el código debe continuar compilando sin advertencias / errores, y los ensamblajes previamente compilados deberían funcionar como antes. Ejemplo: implementación de una nueva interfaz en una clase existente que resulta en una sobrecarga diferente que se elige durante la resolución de sobrecarga.
El objetivo final es catalogar la mayor cantidad posible de cambios en la semántica silenciosa y de última hora, y describir el efecto exacto de la rotura, y qué idiomas son o no afectados por ella. Para ampliar esta última: mientras que algunos cambios afectan a todos los idiomas universalmente (por ejemplo, agregar un nuevo miembro a una interfaz romperá las implementaciones de esa interfaz en cualquier idioma), algunos requieren una semántica de lenguaje muy específica para entrar en juego para obtener un descanso. Esto generalmente implica la sobrecarga de métodos y, en general, cualquier cosa que tenga que ver con conversiones de tipo implícitas. No parece haber ninguna manera de definir el "mínimo denominador común" aquí, incluso para los idiomas conformes con CLS (es decir, los que cumplen al menos las reglas de "consumidor CLS" según se define en la especificación CLI), aunque apreciaré si alguien me corrige por estar equivocado aquí, así que esto tendrá que ir idioma por idioma. Los de mayor interés son, naturalmente, los que vienen con .NET fuera de la caja: C #, VB y F #; pero otros, como IronPython, IronRuby, Delphi Prism, etc. también son relevantes. Cuanto más importante sea el caso de la esquina, más interesante será: cosas como la eliminación de miembros son bastante evidentes, pero las interacciones sutiles entre, por ejemplo, la sobrecarga de métodos, los parámetros opcionales / predeterminados, la inferencia de tipo lambda y los operadores de conversión pueden ser muy sorprendentes. a veces.
Algunos ejemplos para poner en marcha esto:
Añadiendo nuevas sobrecargas de métodos.
Tipo: ruptura de nivel de fuente
Idiomas afectados: C #, VB, F #
API antes del cambio:
public class Foo
{
public void Bar(IEnumerable x);
}
API después del cambio:
public class Foo
{
public void Bar(IEnumerable x);
public void Bar(ICloneable x);
}
Código de cliente de muestra que funciona antes del cambio y roto después de él:
new Foo().Bar(new int[0]);
Agregar nuevas sobrecargas de operadores de conversión implícita
Tipo: ruptura de nivel de fuente.
Idiomas afectados: C #, VB
Idiomas no afectados: F #
API antes del cambio:
public class Foo
{
public static implicit operator int ();
}
API después del cambio:
public class Foo
{
public static implicit operator int ();
public static implicit operator float ();
}
Código de cliente de muestra que funciona antes del cambio y roto después de él:
void Bar(int x);
void Bar(float x);
Bar(new Foo());
Notas: F # no está dañado, porque no tiene soporte de nivel de idioma para operadores sobrecargados, ni explícito ni implícito; ambos deben llamarse directamente como métodos op_Explicit
y op_Implicit
.
Añadiendo nuevos métodos de instancia.
Tipo: semántica tranquila a nivel de fuente cambia.
Idiomas afectados: C #, VB
Idiomas no afectados: F #
API antes del cambio:
public class Foo
{
}
API después del cambio:
public class Foo
{
public void Bar();
}
Código de cliente de muestra que sufre un cambio semántico silencioso:
public static class FooExtensions
{
public void Bar(this Foo foo);
}
new Foo().Bar();
Notas: F # no está dañado, porque no tiene soporte de nivel de idioma para ExtensionMethodAttribute
, y requiere que los métodos de extensión de CLS sean llamados como métodos estáticos.
Añadiendo un parámetro con un valor por defecto.
Tipo de ruptura: ruptura de nivel binario
Incluso si el código fuente de la llamada no necesita cambiarse, aún debe ser recompilado (al igual que cuando se agrega un parámetro regular).
Esto se debe a que C # compila los valores predeterminados de los parámetros directamente en el ensamblaje llamante. Esto significa que si no recompila, obtendrá una MissingMethodException porque el ensamblaje anterior intenta llamar a un método con menos argumentos.
API antes del cambio
public void Foo(int a) { }
API después del cambio
public void Foo(int a, string b = null) { }
Código de cliente de muestra que se rompe después
Foo(5);
El código del cliente se debe volver a compilar en Foo(5, null)
en el nivel de bytecode. El ensamblado al que se llama solo contendrá Foo(int, string)
, no Foo(int)
. Esto se debe a que los valores de parámetros predeterminados son puramente una característica del idioma, el tiempo de ejecución de .Net no sabe nada sobre ellos. (Esto también explica por qué los valores predeterminados tienen que ser constantes en tiempo de compilación en C #).
Adición de espacio de nombres
Cambio de nivel de fuente / Cambio de semántica silenciosa de nivel de fuente
Debido a la forma en que funciona la resolución del espacio de nombres en vb.Net, agregar un espacio de nombres a una biblioteca puede hacer que el código de Visual Basic que se compiló con una versión anterior de la API no se compile con una nueva versión.
Código de muestra de cliente:
Imports System
Imports Api.SomeNamespace
Public Class Foo
Public Sub Bar()
Dim dr As Data.DataRow
End Sub
End Class
Si una nueva versión de la API agrega el espacio de nombres Api.SomeNamespace.Data
, entonces el código anterior no se compilará.
Se vuelve más complicado con las importaciones de espacio de nombres a nivel de proyecto. Si el Imports System
se omite del código anterior, pero el espacio de nombres del System
se importa al nivel del proyecto, entonces el código aún puede generar un error.
Sin embargo, si Api incluye una clase DataRow
en su espacio de nombres Api.SomeNamespace.Data
, entonces el código se compilará pero dr
será una instancia de System.Data.DataRow
cuando se compile con la versión antigua de la API y Api.SomeNamespace.Data.DataRow
cuando se compila con la nueva versión de la API.
Cambio de nombre de argumento
Salto de nivel de fuente
Cambiar los nombres de los argumentos es un cambio importante en vb.net de la versión 7 (?) (¿.Net versión 1?) Y c # .net de la versión 4 (.Net versión 4).
API antes del cambio:
namespace SomeNamespace {
public class Foo {
public static void Bar(string x) {
...
}
}
}
API después del cambio:
namespace SomeNamespace {
public class Foo {
public static void Bar(string y) {
...
}
}
}
Código de muestra de cliente:
Api.SomeNamespace.Foo.Bar(x:"hi"); //C#
Api.SomeNamespace.Foo.Bar(x:="hi") ''VB
Parámetros de referencia
Salto de nivel de fuente
Agregar un reemplazo de método con la misma firma, excepto que un parámetro se pasa por referencia en lugar de por valor, hará que vb source que haga referencia a la API no pueda resolver la función. Visual Basic no tiene manera (?) De diferenciar estos métodos en el punto de llamada a menos que tengan nombres de argumentos diferentes, por lo que tal cambio podría hacer que ambos miembros sean inutilizables desde el código vb.
API antes del cambio:
namespace SomeNamespace {
public class Foo {
public static void Bar(string x) {
...
}
}
}
API después del cambio:
namespace SomeNamespace {
public class Foo {
public static void Bar(string x) {
...
}
public static void Bar(ref string x) {
...
}
}
}
Código de muestra de cliente:
Api.SomeNamespace.Foo.Bar(str)
Campo a cambio de propiedad
Salto de nivel binario / salto de nivel de fuente
Además de la ruptura obvia de nivel binario, esto puede causar una ruptura de nivel de fuente si el miembro se pasa a un método por referencia.
API antes del cambio:
namespace SomeNamespace {
public class Foo {
public int Bar;
}
}
API después del cambio:
namespace SomeNamespace {
public class Foo {
public int Bar { get; set; }
}
}
Código de muestra de cliente:
FooBar(ref Api.SomeNamespace.Foo.Bar);
Agregar métodos de sobrecarga para eliminar el uso de parámetros predeterminados
Tipo de ruptura: cambio de semántica silenciosa a nivel fuente
Debido a que el compilador transforma las llamadas de método con valores de parámetros predeterminados faltantes a una llamada explícita con el valor predeterminado en el lado llamante, se proporciona compatibilidad para el código compilado existente; Se encontrará un método con la firma correcta para todo el código compilado previamente.
Por otro lado, las llamadas sin el uso de parámetros opcionales ahora se compilan como una llamada al nuevo método al que le falta el parámetro opcional. Todo sigue funcionando bien, pero si el código llamado reside en otro conjunto, el código recién compilado que lo llama ahora depende de la nueva versión de este conjunto. La implementación de conjuntos que llaman al código refactorizado sin implementar también el conjunto en el que reside el código refactorizado da lugar a excepciones de "método no encontrado".
API antes del cambio
public int MyMethod(int mandatoryParameter, int optionalParameter = 0)
{
return mandatoryParameter + optionalParameter;
}
API después del cambio
public int MyMethod(int mandatoryParameter, int optionalParameter)
{
return mandatoryParameter + optionalParameter;
}
public int MyMethod(int mandatoryParameter)
{
return MyMethod(mandatoryParameter, 0);
}
Código de muestra que seguirá funcionando.
public int CodeNotDependentToNewVersion()
{
return MyMethod(5, 6);
}
Código de muestra que ahora depende de la nueva versión al compilar
public int CodeDependentToNewVersion()
{
return MyMethod(5);
}
Cambiar un campo a una propiedad
Tipo de ruptura: API
Idiomas afectados: Visual Basic y C # *
Información: cuando cambias un campo o variable normal a una propiedad en Visual Basic, cualquier código externo que haga referencia a ese miembro de alguna manera tendrá que volver a compilarse.
API antes del cambio:
Public Class Foo
Public Shared Bar As String = ""
End Class
API después del cambio:
Public Class Foo
Private Shared _Bar As String = ""
Public Shared Property Bar As String
Get
Return _Bar
End Get
Set(value As String)
_Bar = value
End Set
End Property
End Class
Código de cliente de muestra que funciona pero se rompe después:
Foo.Bar = "foobar"
Renombrando una interfaz
Kinda of Break: Fuente y Binario
Idiomas afectados: muy probablemente todos, probados en C #.
API antes del cambio:
public interface IFoo
{
void Test();
}
public class Bar
{
IFoo GetFoo() { return new Foo(); }
}
API después del cambio:
public interface IFooNew // Of the exact same definition as the (old) IFoo
{
void Test();
}
public class Bar
{
IFooNew GetFoo() { return new Foo(); }
}
Código de cliente de muestra que funciona pero se rompe después:
new Bar().GetFoo().Test(); // Binary only break
IFoo foo = new Bar().GetFoo(); // Source and binary break
Reordenar valores enumerados
Tipo de ruptura: cambio de semántica silenciosa de nivel fuente / nivel binario
Idiomas afectados: todos
Reordenar los valores enumerados mantendrá la compatibilidad de nivel de fuente, ya que los literales tienen el mismo nombre, pero sus índices ordinales se actualizarán, lo que puede causar algunos tipos de interrupciones silenciosas de nivel de fuente.
Lo que es peor aún son los cortes silenciosos de nivel binario que pueden introducirse si el código del cliente no se compila contra la nueva versión de la API. Los valores de enumeración son constantes de tiempo de compilación y, por lo tanto, cualquier uso de ellos se incluye en la IL del ensamblaje del cliente. Este caso puede ser particularmente difícil de detectar a veces.
API antes del cambio
public enum Foo
{
Bar,
Baz
}
API después del cambio
public enum Foo
{
Baz,
Bar
}
Código de cliente de muestra que funciona pero se rompe después:
Foo.Bar < Foo.Baz
Cambiar una firma de método
Tipo: Descanso de nivel binario
Idiomas afectados: C # (VB y F # muy probablemente, pero sin probar)
API antes del cambio
public static class Foo
{
public static void bar(int i);
}
API después del cambio
public static class Foo
{
public static bool bar(int i);
}
Código de cliente de muestra que funciona antes del cambio
Foo.bar(13);
Convertir una implementación de interfaz explícita en una implícita.
Tipo de descanso: Fuente
Idiomas afectados: todos
La refactorización de una implementación de interfaz explícita en una implícita es más sutil en cómo puede romper una API. En la superficie, parece que esto debería ser relativamente seguro, sin embargo, cuando se combina con la herencia puede causar problemas.
API antes del cambio:
public class Foo : IEnumerable
{
IEnumerator IEnumerable.GetEnumerator() { yield return "Foo"; }
}
API después del cambio:
public class Foo : IEnumerable
{
public IEnumerator GetEnumerator() { yield return "Foo"; }
}
Código de muestra de cliente que funciona antes del cambio y se rompe después:
class Bar : Foo, IEnumerable
{
IEnumerator IEnumerable.GetEnumerator() // silently hides base instance
{ yield return "Bar"; }
}
foreach( var x in new Bar() )
Console.WriteLine(x); // originally output "Bar", now outputs "Foo"
Convertir una implementación de interfaz implícita en una explícita.
Tipo de ruptura: fuente y binario
Idiomas afectados: todos
Esto es solo una variación de cambiar la accesibilidad de un método, es un poco más sutil, ya que es fácil pasar por alto el hecho de que no todos los accesos a los métodos de una interfaz son necesariamente a través de una referencia al tipo de interfaz.
API antes del cambio:
public class Foo : IEnumerable
{
public IEnumerator GetEnumerator();
}
API después del cambio:
public class Foo : IEnumerable
{
IEnumerator IEnumerable.GetEnumerator();
}
Código de muestra de cliente que funciona antes del cambio y se rompe después:
new Foo().GetEnumerator(); // fails because GetEnumerator() is no longer public
Este es quizás un caso especial no tan obvio de "agregar / eliminar miembros de la interfaz", y pensé que merece su propia entrada a la luz de otro caso que voy a publicar a continuación. Asi que:
Refactorizar miembros de la interfaz en una interfaz base
Tipo: saltos tanto a nivel fuente como binario.
Idiomas afectados: C #, VB, C ++ / CLI, F # (para fuente, un binario afecta naturalmente a cualquier idioma)
API antes del cambio:
interface IFoo
{
void Bar();
void Baz();
}
API después del cambio:
interface IFooBase
{
void Bar();
}
interface IFoo : IFooBase
{
void Baz();
}
Código de cliente de muestra que se rompe por cambio en el nivel de origen:
class Foo : IFoo
{
void IFoo.Bar() { ... }
void IFoo.Baz() { ... }
}
Código de cliente de muestra que se rompe por cambio a nivel binario;
(new Foo()).Bar();
Notas:
Para la interrupción del nivel de origen, el problema es que C #, VB y C ++ / CLI requieren un nombre de interfaz exacto en la declaración de la implementación del miembro de la interfaz; por lo tanto, si el miembro se mueve a una interfaz base, el código ya no se compilará.
La ruptura binaria se debe al hecho de que los métodos de interfaz están completamente calificados en el IL generado para implementaciones explícitas, y el nombre de la interfaz también debe ser exacto.
La implementación implícita donde esté disponible (es decir, C # y C ++ / CLI, pero no VB) funcionará bien tanto en el nivel fuente como en el binario. Método de llamadas no se rompen tampoco.
Este fue muy poco obvio cuando lo descubrí, especialmente a la luz de la diferencia con la misma situación para las interfaces. No es un descanso en absoluto, pero es lo suficientemente sorprendente que decidí incluirlo:
Refactorizando miembros de la clase en una clase base
Tipo: no es un descanso!
Idiomas afectados: ninguno (es decir, ninguno está roto)
API antes del cambio:
class Foo
{
public virtual void Bar() {}
public virtual void Baz() {}
}
API después del cambio:
class FooBase
{
public virtual void Bar() {}
}
class Foo : FooBase
{
public virtual void Baz() {}
}
Código de ejemplo que sigue funcionando durante el cambio (aunque esperaba que se rompiera):
// C++/CLI
ref class Derived : Foo
{
public virtual void Baz() {{
// Explicit override
public virtual void BarOverride() = Foo::Bar {}
};
Notas:
C ++ / CLI es el único lenguaje .NET que tiene una construcción análoga a la implementación de la interfaz explícita para los miembros de la clase base virtual: "anulación explícita". Esperaba completamente que se produjera el mismo tipo de rotura que cuando se mueven los miembros de la interfaz a una interfaz base (ya que la IL generada para la anulación explícita es la misma que para la implementación explícita). Para mi sorpresa, este no es el caso, aunque el IL generado aún especifica que BarOverride
reemplaza a Foo::Bar
lugar de FooBase::Bar
, el cargador de ensamblajes es lo suficientemente inteligente como para sustituir uno por otro correctamente sin ninguna queja: aparentemente, el hecho de que Foo
es una clase que hace la diferencia. Imagínate...
Esto es realmente algo muy raro en la práctica, pero no obstante, es sorprendente cuando sucede.
Agregando nuevos miembros no sobrecargados
Tipo: cambio de nivel de fuente o semántica tranquila.
Idiomas afectados: C #, VB
Idiomas no afectados: F #, C ++ / CLI
API antes del cambio:
public class Foo
{
}
API después del cambio:
public class Foo
{
public void Frob() {}
}
Código de cliente de muestra que se rompe por cambio:
class Bar
{
public void Frob() {}
}
class Program
{
static void Qux(Action<Foo> a)
{
}
static void Qux(Action<Bar> a)
{
}
static void Main()
{
Qux(x => x.Frob());
}
}
Notas:
El problema aquí se debe a la inferencia de tipo lambda en C # y VB en presencia de resolución de sobrecarga. Aquí se emplea una forma limitada de tipificación de pato para romper los lazos donde coinciden más de un tipo, al verificar si el cuerpo de la lambda tiene sentido para un tipo dado; si solo un tipo da como resultado un cuerpo compilable, se elige uno.
El peligro aquí es que el código del cliente puede tener un grupo de métodos sobrecargado donde algunos métodos toman argumentos de su propio tipo, y otros toman argumentos de los tipos expuestos por su biblioteca. Si alguno de sus códigos se basa en el algoritmo de inferencia de tipos para determinar el método correcto basado únicamente en la presencia o ausencia de miembros, entonces agregar un nuevo miembro a uno de sus tipos con el mismo nombre que en uno de los tipos del cliente puede potencialmente generar inferencia apagado, lo que resulta en ambigüedad durante la resolución de sobrecarga.
Tenga en cuenta que los tipos Foo
y Bar
en este ejemplo no están relacionados de ninguna manera, ni por herencia ni de otra manera. El mero uso de ellos en un solo grupo de métodos es suficiente para desencadenar esto, y si esto ocurre en el código del cliente, no tiene control sobre él.
El código de ejemplo anterior muestra una situación más simple en la que se trata de un salto de nivel de fuente (es decir, resultados de error del compilador). Sin embargo, esto también puede ser un cambio semántico silencioso, si la sobrecarga que se eligió mediante inferencia tuvo otros argumentos que de otra manera harían que se clasifique a continuación (por ejemplo, argumentos opcionales con valores predeterminados, o discrepancia de tipo entre el argumento declarado y el real que requieren una implícita conversión). En tal escenario, la resolución de sobrecarga ya no fallará, pero el compilador seleccionará silenciosamente una sobrecarga diferente. En la práctica, sin embargo, es muy difícil encontrar este caso sin construir cuidadosamente las firmas de los métodos para causarlo deliberadamente.
Cambio de API:
- Agregando el atributo [Obsoleto] (usted cubrió esto con una mención de los atributos; sin embargo, esto puede ser un cambio importante cuando se utiliza la advertencia como error).
Rotura de nivel binario:
- Mover un tipo de un conjunto a otro
- Cambiando el espacio de nombres de un tipo
- Añadiendo un tipo de clase base de otro conjunto.
Agregar un nuevo miembro (evento protegido) que usa un tipo de otro conjunto (Class2) como una restricción de argumento de plantilla.
protected void Something<T>() where T : Class2 { }
Cambiar una clase secundaria (Class3) para derivar de un tipo en otro conjunto cuando la clase se usa como un argumento de plantilla para esta clase.
protected class Class3 : Class2 { } protected void Something<T>() where T : Class3 { }
Cambio de semántica silenciosa a nivel de fuente:
- Agregar / eliminar / cambiar anulaciones de Equals (), GetHashCode () o ToString ()
(No estoy seguro de dónde encajan)
Cambios en la implementación:
- Agregar / eliminar dependencias / referencias
- Actualización de dependencias a nuevas versiones.
- Cambiar la ''plataforma de destino'' entre x86, Itanium, x64 o anycpu
- Creación / prueba en una instalación de marco diferente (es decir, la instalación de 3.5 en un cuadro .Net 2.0 permite llamadas API que luego requieren .Net 2.0 SP2)
Bootstrap / cambios de configuración:
- Agregar / Eliminar / Cambiar opciones de configuración personalizadas (es decir, configuración de App.config)
- Con el uso intensivo de IoC / DI en las aplicaciones de hoy, es necesario reconfigurar y / o cambiar el código de arranque para el código dependiente de DI.
Actualizar:
Lo siento, no me di cuenta de que la única razón por la que esto no funcionaba era que las usaba en restricciones de plantilla.