Compatibilidad retroactiva de interfaces DLL y COM — Tabla de decisión sobre qué cambios rompen al lado que llama
· Actualizado el: · Go Komura · COM, DLL, .NET, C#, C++, Compatibilidad retroactiva, Versionado, Tecnología legada, Aprovechamiento de activos existentes, Tabla de decisión
«Para esta corrección, ¿basta con reemplazar la DLL, o también hay que recompilar el lado que llama?» — al mantener una DLL común o un componente COM al que hacen referencia varias aplicaciones, esta es la pregunta que hay que responder en cada versión. Si la respuesta es incorrecta, el EXE antiguo que corre en el cliente puede dejar de arrancar, o peor aún, puede seguir arrancando mientras los resultados de los cálculos cambian silenciosamente.
Lo complicado es que esta decisión suele tomarse con la sensación de «esto parece peligroso», sin más. En realidad, casi siempre se puede determinar de forma mecánica qué cambio rompe la compatibilidad. Las DLL nativas tienen las reglas de exportación y de convención de llamada; COM tiene la regla explícita de que «las interfaces son inmutables»1; y .NET cuenta con la lista de reglas de cambio de compatibilidad que la propia Microsoft usa en el desarrollo de sus bibliotecas .NET.2
En este blog ya explicamos los fundamentos de COM en «Qué es COM / ActiveX / OCX» y su filosofía de diseño en «Qué es COM - por qué el diseño de COM en Windows sigue siendo hermoso». En este artículo se organiza, para cada una de las DLL, COM y los ensamblados .NET, qué cambio rompe al lado que llama, en forma de tabla de decisión, y se detalla también el procedimiento a seguir cuando resulta inevitable romper la compatibilidad.
Terminología usada en este artículo
Para leer la tabla de decisión hace falta manejar algunos términos a nivel binario. Se tratan en detalle en cada capítulo, pero conviene repasarlos primero en una línea para facilitar la lectura.
| Término | Significado en una línea |
|---|---|
| ABI (Application Binary Interface) | El acuerdo que respetan entre sí los binarios ya compilados: la forma de pasar argumentos, la disposición en memoria de las estructuras, la convención de nombres de símbolos, etc. Es un contrato a nivel de código máquina, no de código fuente |
| Convención de llamada (calling convention) | Parte del ABI. Define si los argumentos se pasan por registros o por la pila, en qué orden, y quién limpia los argumentos apilados, si el lado que llama o el lado llamado (__cdecl / __stdcall, etc.) |
| vtable (tabla de funciones virtuales) | Una tabla que ordena punteros a función en una secuencia fija. Es la sustancia real de una interfaz COM, y el lado que llama invoca el método deseado según la posición, es decir, «qué número de slot ocupa» |
| Número ordinal de exportación (ordinal) | El número asignado a cada función en la tabla de exportación de la DLL. El lado que llama también puede importar usando este número en lugar del nombre de la función |
| IID / CLSID / ProgID | En orden: el identificador de la interfaz COM (el contrato), el identificador de la clase de implementación, y el alias legible para humanos asociado al CLSID (sección 4.1) |
| Nombre seguro (strong name) | El mecanismo que identifica de forma única un ensamblado .NET mediante «nombre + versión + cultura + token de clave pública» y una firma |
| Redirección de enlace (binding redirect) | En .NET Framework, la configuración en el archivo de configuración que hace que la versión del ensamblado solicitada por el lado que llama se reinterprete como otra versión distinta, que es la que realmente se carga |
1. La conclusión, primero
- La compatibilidad tiene tres niveles: compatibilidad binaria (funciona sin recompilar), compatibilidad de código fuente (funciona si se recompila) y compatibilidad de comportamiento (el comportamiento no cambia). No basta con “no requiere recompilación = seguro”; hay que evaluar también la compatibilidad de comportamiento.3
- La regla básica para las DLL nativas es: “agregar exportaciones es seguro; modificar o eliminar exportaciones existentes es una ruptura”. La firma de las funciones, la convención de llamada y el diseño de las estructuras son, en sí mismos, el contrato binario.
- Las interfaces COM son inmutables una vez publicadas. Agregar, eliminar o reordenar métodos después de la publicación viola la especificación; cualquier cambio se agrega como una nueva interfaz con un nuevo IID (IFoo → IFoo2).41
- Los clientes VB6/VBA, al usar enlace anticipado (early binding), graban en tiempo de compilación la posición dentro de la vtable, por lo que son los que más fácilmente se rompen ante un cambio en el diseño de la interfaz.
- En .NET, Microsoft publica sus propias reglas de cambio de compatibilidad, que definen qué cambios en una API pública son destructivos. No solo la eliminación de métodos o los cambios de firma; incluso agregar
virtualo cambiar el nombre de un parámetro se clasifican como cambios destructivos.2 - El versionado semántico es una convención que dice “si hay un cambio destructivo, sube la versión mayor”, pero solo funciona si se declara primero una definición de qué es un cambio destructivo.5 La tabla de decisión de este artículo puede usarse como esa definición.
- Cuando es inevitable romper la compatibilidad, se avanza en el orden: ofrecer lo nuevo y lo antiguo en paralelo → período de obsolescencia → inventario de quién llama → retiro. El principio es no reemplazar de golpe.
2. Los tres niveles de compatibilidad — quién se rompe y cuándo
Lo que se llama en general “compatibilidad retroactiva” en realidad se divide en tres niveles. Incluso la documentación oficial de .NET clasifica los cambios destructivos desde la perspectiva de la compatibilidad de código fuente, la compatibilidad binaria y la compatibilidad de comportamiento.3
| Nivel | Significado | Qué ocurre si se rompe | A quién afecta principalmente |
|---|---|---|---|
| Compatibilidad binaria | El lado que llama funciona con la nueva DLL sin recompilar | Punto de entrada no encontrado al iniciar, MissingMethodException en tiempo de ejecución, caídas |
El EXE antiguo que ya corre en el cliente, aplicaciones de terceros que no se pueden recompilar |
| Compatibilidad de código fuente | El lado que llama funciona si se recompila | Errores de compilación en la siguiente compilación | Otro equipo interno, desarrolladores que tienen el código fuente |
| Compatibilidad de comportamiento | El comportamiento especificado no cambia | El resultado, la temporización o el tipo de excepción cambian sin generar ningún error | Los usuarios finales (y todos los que investigan incidentes) |
Estos tres niveles son más fáciles de entender si se piensan como anidados.
[Compatibilidad de comportamiento]el comportamiento no cambia ← la más externa
└─[Compatibilidad de código fuente]funciona si se recompila
└─[Compatibilidad binaria]funciona sin recompilar ← la más interna
Cuanto más interno es el nivel, más estrictas son sus condiciones, y cuanto mejor se preserva el nivel interno, menor es el esfuerzo para el lado que llama. Lo importante de estos tres niveles es que el nivel externo puede romperse aunque el nivel interno permanezca intacto. Por ejemplo, una modificación que cambia el significado del valor de retorno de una función existente puede romper solo la compatibilidad de comportamiento, sin afectar ni la compatibilidad binaria ni la de código fuente. Este tipo de cambio no produce ni errores de enlace ni errores de compilación, por lo que es la fila de la tabla de decisión que más fácilmente se pasa por alto.
A la inversa, en un entorno donde todos los que llaman tienen el código fuente y pueden recompilar simultáneamente (por ejemplo, un sistema interno en un único repositorio), basta con preservar la compatibilidad de código fuente y de comportamiento, y se puede dejar fuera el requisito de compatibilidad binaria. “¿Existe, entre quienes llaman a mi DLL, algún binario que no se pueda recompilar?” es la primera bifurcación a la hora de leer la tabla de decisión.
3. Tabla de decisión de compatibilidad para DLL nativas (C/C++)
La compatibilidad de una DLL nativa está determinada por la tabla de exportación, la convención de llamada y el diseño de la memoria. Cómo se busca y se carga una DLL ya se explicó en «Cómo funciona la resolución de nombres de DLL en Windows», pero la compatibilidad una vez que la carga se realizó con éxito se puede evaluar con la siguiente tabla.
| Cambio | Compatibilidad binaria | Notas |
|---|---|---|
| Agregar una función exportada | No se rompe | El medio de extensión más seguro. Sin embargo, si se depende de números ordinales implícitos en el archivo .def, agregar una función puede reasignar los ordinales existentes según la posición; si hay clientes que enlazan por ordinal, hay que fijar explícitamente los ordinales existentes y agregar la nueva función al final |
| Eliminar o renombrar una función exportada | Se rompe | Falla la resolución de la importación; error al cargar o en GetProcAddress |
| Cambiar la firma de una función existente (agregar/quitar/cambiar el tipo de argumentos, cambiar el tipo de retorno) | Se rompe | El paso de parámetros por pila/registros deja de coincidir. Lo mismo ocurre con el valor de retorno: si se cambia de entero (RAX) a punto flotante (XMM0), el lado que llama lee basura con el ABI antiguo. Puede no generar error y simplemente descontrolarse |
Cambiar la convención de llamada (__cdecl ↔ __stdcall) |
Se rompe (32 bits) | En x86 se invierte la responsabilidad de limpiar la pila, lo que provoca corrupción de pila. En x64 hay una única convención de llamada y estas indicaciones prácticamente se ignoran, por lo que esta fila aplica solo a DLL de 32 bits |
| Cambiar el número ordinal de exportación | Se rompe de forma condicional | Un lado que llama que enlaza por ordinal termina llamando a otra función. Si solo se enlaza por nombre, no hay impacto |
| Agregar un miembro a una estructura que reserva el lado que llama | Se rompe | El lado que llama antiguo sigue reservando y pasando la estructura con el tamaño pequeño (se puede mitigar con la convención cbSize descrita más adelante) |
Cambiar el empaquetado o la alineación de una estructura pública (#pragma pack, /Zp, cambio de la cadena de herramientas) |
Se rompe | Aunque no se toque ni un miembro, cambian el desplazamiento de los miembros existentes y el tamaño total. cbSize no puede corregir un desplazamiento incorrecto, así que hay que fijar explícitamente el empaquetado en el encabezado público |
| Cambios internos a estructuras que solo la DLL reserva y libera | No se rompe | Con un diseño que solo expone un puntero (identificador), el interior se puede modificar libremente |
| Cambiar el significado del valor de retorno o del código de error | No se rompe (pero se rompe la compatibilidad de comportamiento) | El enlace tiene éxito pero el comportamiento cambia; el patrón que más tarda en descubrirse |
| Agregar miembros de datos o funciones virtuales al exportar directamente una clase C++ | Se rompe | Cambia el tamaño del objeto o el diseño de la vtable. Si solo se agregan funciones miembro no virtuales, el diseño no cambia y no rompe directamente a los clientes existentes, pero exportar directamente una clase C++ ya carece de compatibilidad entre compiladores, y el solo hecho de tener que hacer esta evaluación cada vez indica que es un ABI frágil |
Esta tabla sirve para decidir «qué se va a cambiar a partir de ahora», pero en la práctica es más frecuente usarla al revés: «este síntoma reportado por el cliente, ¿a qué fila de cambio corresponde?». A continuación se relaciona cómo se manifiesta en la práctica cada una de las filas anteriores.
| Síntoma observado en producción | Fila a sospechar |
|---|---|
Aparece un cuadro de diálogo del tipo «No se encontró el punto de entrada del procedimiento» al iniciar y la aplicación directamente no arranca. El NTSTATUS es 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND, cuyo texto original es “The procedure entry point %hs could not be located in the dynamic link library %hs.”)6 |
Eliminación o cambio de nombre de una función exportada. En C++, cambiar la firma también cambia el nombre decorado (mangled name), por lo que el caso de que una función haya «pasado a tener otro nombre» produce el mismo síntoma |
GetProcAddress devuelve NULL y la aplicación muestra su propio mensaje de error |
Lo mismo que arriba. En los lados que llaman con carga diferida o carga dinámica, se manifiesta de esta forma |
No se encuentra la DLL en sí y no arranca. El NTSTATUS es 0xC0000135 (STATUS_DLL_NOT_FOUND)6 |
No es un problema de compatibilidad, sino de ubicación o de orden de búsqueda. Antes de revisar la tabla de decisión, hay que sospechar de la ruta de búsqueda de la DLL |
Se cae justo después de volver de una función. En una compilación de depuración (/RTCs o /RTC1), la verificación en tiempo de ejecución lo detecta como corrupción del puntero de pila |
Cambio de la convención de llamada (32 bits). Microsoft también documenta explícitamente que la corrupción del puntero de pila puede producirse por una discrepancia en la convención de llamada7. En una compilación de lanzamiento no se detecta y la caída ocurre en un lugar completamente distinto |
| No aparece ningún error, pero el valor de un miembro específico de la estructura se corrompe. En raras ocasiones, desbordamiento de búfer | Agregar un miembro a la estructura, o cambiar el empaquetado o la alineación. Sin la convención cbSize (sección 3.1), es difícil aislar la causa |
En el lado que llama de .NET aparece MissingMethodException |
Eliminación, cambio de nombre o cambio de firma de un miembro del lado .NET (capítulo 5) |
| No aparece ningún error, pero los valores de un informe o de un agregado cambiaron | Cambio del significado del valor de retorno o del código de error. Es el estado en que solo se rompe la compatibilidad de comportamiento, y el que más tarda en descubrirse |
La directriz de diseño que se desprende de esta tabla no ha cambiado nunca: limitar el límite (boundary) al ABI de C (funciones extern "C" y estructuras simples) y realizar las extensiones agregando funciones. Esto vale igual si la DLL nativa se genera desde C#: la superficie de exportación tratada en «Cómo llamar a una DLL de C# Native AOT desde C/C++» también se gestiona siguiendo esta tabla.
3.1 La convención cbSize — la sabiduría de Win32 para hacer extensibles las estructuras
El remedio clásico frente a «agregar un miembro a una estructura rompe la compatibilidad» es la convención de Win32 de colocar un campo de tamaño al principio de la estructura. El lado que llama coloca en cbSize el tamaño de la estructura que conocía en tiempo de compilación, y la DLL usa ese tamaño para determinar «de qué generación de la estructura tiene conocimiento este lado que llama».
typedef struct KS_CONFIG {
DWORD cbSize; // el lado que llama establece sizeof(KS_CONFIG)
DWORD dwMode;
DWORD dwTimeout;
// los miembros futuros siempre se agregan al final
} KS_CONFIG;
// Lado de la DLL: determina la generación con cbSize y usa un valor por defecto para los llamadores antiguos
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
timeout = pConfig->dwTimeout; // llamador nuevo
} else {
timeout = DEFAULT_TIMEOUT; // llamador antiguo
}
El motivo de usar FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD) en lugar de sizeof(KS_CONFIG) para la comprobación es que así se puede evaluar, miembro por miembro final, si la generación llega hasta dwTimeout. Si se evaluara con sizeof, en el momento en que se agregue un nuevo miembro al final en el futuro, la condición se volvería más estricta y clasificaría como antiguos incluso a los llamadores de la generación que «conoce dwTimeout pero no el nuevo miembro». Evaluando de esta forma miembro por miembro, no hace falta reescribir las comprobaciones existentes cada vez que se extiende la estructura.
De hecho, la estructura NOTIFYICONDATA de la API de Windows gestiona sus generaciones exactamente con este mecanismo, y está documentado oficialmente que el valor asignado a cbSize permite mantener la compatibilidad con versiones antiguas de Shell32.dll.8 Si se incluye cbSize desde la primera versión en las estructuras públicas de una DLL propia, las extensiones futuras pasan de ser un «cambio destructivo» al «lado seguro de la tabla de decisión». Aun así, los miembros nuevos siempre deben agregarse al final, y sigue prohibido cambiar el tipo o el orden de los miembros existentes. Además, en las estructuras que se usan como salida, la responsabilidad de la DLL aumenta: la escritura y la inicialización deben mantenerse siempre dentro del rango del cbSize recibido. Si se escribe incondicionalmente el nuevo sizeof, se desborda el búfer pequeño reservado por un llamador antiguo, y la propia DLL provoca la ruptura que esta convención debía prevenir.
4. La regla de oro de las interfaces COM — prohibido modificar una vez publicadas
COM es la tecnología que dio la respuesta más clara a este problema. Según la especificación de COM, las interfaces siguen estas reglas:
- La interfaz tiene un IID (identificador de interfaz) único.1
- La interfaz es inmutable. Una vez creada y publicada, no se debe cambiar ninguna parte de su definición.1
- Agregar o eliminar métodos, o cambiar su semántica, no significa «una nueva versión de la interfaz antigua», sino que implica crear una nueva interfaz con un IID distinto.4
El motivo de ser tan estricto es que la interfaz COM, en la práctica, es un diseño binario: la vtable (la tabla de punteros a función). Los clientes en C++ o VB6 graban en tiempo de compilación una posición, como «el tercer slot es GetName». Si se inserta un método después de la publicación, el cliente antiguo, sin ningún error, termina llamando a otro método. Por eso COM eliminó de la especificación la operación misma de «modificar» y, en su lugar, estableció el siguiente procedimiento de extensión.
// v1: ya publicada. No se modifica nunca más
[object, uuid(1111....)]
interface ICalc : IUnknown {
HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};
// v2: nueva interfaz con un nuevo IID. Extiende ICalc por herencia
[object, uuid(2222....)]
interface ICalc2 : ICalc {
HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};
El IDL anterior es un ejemplo ilustrativo en el que uuid se abrevió como 1111...., y tal como está no se puede compilar. En la práctica hay que escribir el GUID completo generado con la herramienta de creación de GUID incluida en Visual Studio (guidgen) o con el comando uuidgen. Usar el mismo GUID en dos interfaces impide distinguir los contratos, así que cada vez que se agrega una interfaz hay que generar uno nuevo.
La clase de implementación (coclass) implementa tanto ICalc como ICalc2: los clientes antiguos siguen usando ICalc como hasta ahora, y los clientes nuevos solicitan ICalc2 mediante QueryInterface. La teoría oficial de versionado de RPC/COM también lo organiza así: «una nueva interfaz que hereda de la antigua equivale a una actualización de versión menor; si se cambian los métodos o tipos existentes, se necesita una interfaz completamente nueva que no herede (equivalente a una actualización de versión mayor)».9 Lo que hace posible este esquema es que QueryInterface permite al lado que llama comprobar de forma segura, en tiempo de ejecución, qué se admite. La belleza del diseño de este mecanismo se analizó en «Qué es COM».
4.1 El reparto de roles entre CLSID, ProgID e IID
Al pensar en el versionado de COM, conviene separar el rol de tres tipos de identificadores.10
- El IID es el identificador de la interfaz (el contrato). Si el contrato cambia, siempre debe ser un IID nuevo.
- El CLSID es el identificador de la clase de implementación. Reemplazar la implementación manteniendo el mismo CLSID es libre, siempre que se respete el contrato de las interfaces ya publicadas.
- El ProgID es un alias legible para humanos (
KomuraSoft.Calc.1) que se usa para buscar en el registro la correspondencia con el CLSID. Existe la convención de mantener tanto un ProgID con número de versión como un ProgID independiente de la versión (KomuraSoft.Calc) que siempre apunta a la última versión; este último se asocia a la versión más reciente medianteCurVer.10
Es decir, «la actualización de versión de la implementación» pertenece al mundo del CLSID y del ProgID, mientras que «el cambio de contrato» pertenece al mundo del IID, y no deben mezclarse. Si se quiere evitar por completo el registro en el registro de Windows, esa opción se trata en «Qué es Reg-Free COM».
4.2 Por qué los clientes VB6/VBA se rompen con especial facilidad
Cuando se usa un componente COM desde VB6 o VBA mediante una referencia establecida (enlace anticipado), la llamada se resuelve leyendo la biblioteca de tipos en tiempo de compilación. El enlace anticipado es la forma recomendada porque habilita IntelliSense y la verificación de tipos, además de ejecutarse más rápido11, pero a cambio queda fuertemente acoplado al diseño de la biblioteca de tipos. No solo si cambia la vtable de la interfaz: incluso un simple cambio en las definiciones de la biblioteca de tipos se manifiesta como «al abrir el proyecto, la referencia estaba rota» o como errores 430/438 en tiempo de ejecución.
Por eso, en los componentes que tienen como llamadores a VB6, VBA o macros de Excel, hay que respetar la regla de inmutabilidad de la interfaz con la máxima rigurosidad. La biblioteca de tipos también tiene una versión (mayor.menor), que se debe subir y gestionar cada vez que se amplía el contrato. La generación de la biblioteca de tipos cuando se expone desde .NET a VBA con tipado fuerte se explica en «Cómo usar una DLL de .NET 8 desde VBA con tipado - exposición COM y dscom TLB». Por otro lado, los clientes de enlace tardío que solo usan CreateObject resuelven por nombre y son resistentes a los cambios de diseño, pero de igual manera sufren el impacto de los cambios de significado de los métodos (compatibilidad de comportamiento).
5. Compatibilidad de los ensamblados .NET — evaluación mecánica con reglas oficiales
En .NET, Microsoft publica las «reglas de cambio para la compatibilidad» que usa en el desarrollo de sus propias bibliotecas .NET, y clasifica cada cambio como permitido (✔️), prohibido (❌) o a evaluar (❓).2 Como se indica explícitamente que se pueden adoptar tal cual como criterio de evaluación para las bibliotecas propias, aquí se extraen las filas principales.
| Cambio en la API pública | Veredicto | Notas |
|---|---|---|
| Agregar métodos, tipos o miembros | ✔️ Seguro en principio | Sin embargo, hay que tener cuidado si el agregado cambia la resolución de una sobrecarga existente. Agregar un campo de instancia a un struct público es una excepción: cambia el tamaño y el diseño, y rompe la interoperabilidad o a los usuarios de código unsafe |
| Eliminar o renombrar un tipo o miembro público | ❌ Destructivo | Se rompe en tiempo de ejecución con MissingMethodException, entre otros |
| Cambiar la firma (agregar/quitar/reordenar/cambiar el tipo de los argumentos, o el tipo de retorno) | ❌ Destructivo | Rompe tanto la compatibilidad binaria como la de código fuente |
| Cambiar el nombre de un parámetro | ❌ Destructivo | Rompe los argumentos con nombre de C# y el enlace tardío de VB. Es fácil pasarlo por alto |
Agregar virtual a un miembro |
❌ Destructivo | La trampa típica que «parece segura porque es un agregado». Puede producirse una discrepancia en el IL de llamada (call/callvirt) |
Eliminar virtual, o volver abstract un miembro virtual |
❌ Destructivo | Se rompe la sobrescritura en las clases derivadas |
Agregar un miembro abstracto a un tipo público no sellado (non-sealed) |
❌ Destructivo | Las clases derivadas existentes no tienen una implementación |
Sellar (sealed) un tipo |
❌ Destructivo | Las clases derivadas existentes dejan de compilar |
| Agregar un miembro a una interfaz | ❓ A evaluar | Con una implementación predeterminada (DIM) se puede evitar romper las clases que ya la implementan, pero hay muchas condiciones (ver más abajo) |
| Cambiar el valor de una constante o de un valor de enumeración, o renombrar/eliminar un miembro de la enumeración | ❌ Destructivo | El valor queda embebido en el lado que llama en tiempo de compilación |
| Cambiar para lanzar una excepción más derivada | ✔️ Permitido | Porque el catch existente sigue funcionando |
| Lanzar un nuevo tipo de excepción en una ruta de código existente | ❌ Destructivo | Es aceptable lanzarla solo con nuevos valores de parámetro |
La única fila de esta tabla marcada ❓ (a evaluar) es «agregar un miembro a una interfaz», y es también la que más dudas genera en la práctica. Con una implementación predeterminada (DIM: Default Interface Members, miembros de interfaz predeterminados) se puede agregar un miembro sin dejar sin implementar a las clases existentes, pero las reglas oficiales enumeran las siguientes condiciones.2
- El requisito mínimo del lado consumidor sube a .NET Core 3.0 / C# 8.0. Como DIM se introdujo en esa versión, en el momento en que se agrega una implementación predeterminada quedan fuera los usuarios que utilizan un runtime anterior. .NET Framework queda excluido, así que en cualquier biblioteca donde todavía quede aunque sea un cliente sobre .NET Framework, esta mitigación no se puede usar.
- Hay lenguajes que no admiten DIM. Como .NET se usa desde varios lenguajes, en las interfaces implementadas desde un lenguaje distinto de C# no se puede contar con la implementación predeterminada.
- Hay situaciones en las que el runtime no puede determinar qué implementación predeterminada invocar. En configuraciones donde intervienen varias interfaces, la resolución de la implementación predeterminada puede volverse ambigua.
- A partir de C# 13, agregar un miembro de instancia predeterminado a una interfaz implementada por un
ref structes un cambio destructivo a nivel de código fuente. Como unref structno admite boxing ni conversión al tipo de la interfaz, no puede recurrir a la implementación predeterminada, y los miembros de instancia deben implementarse siempre de forma explícita.
Por otro lado, agregar un miembro estático, no abstracto y no virtual sí está permitido.2 Si «se quiere agregar un miembro, pero entre los usuarios todavía queda .NET Framework», lo más seguro es no tocar la interfaz y, con el mismo enfoque que COM en el capítulo 4, agregar una nueva interfaz, o evaluar primero si se puede sustituir por un método de extensión.
No es tan simple como el «la interfaz de COM es inmutable», pero la filosofía es la misma: la API pública es un contrato; agregar al contrato está permitido, pero modificar el contrato existente no lo está. Y precisamente el hecho de que «cambios que a primera vista parecen seguros», como los métodos virtuales o los nombres de parámetros, estén clasificados del lado destructivo es la razón por la que hay que decidir con una tabla y no por intuición.
5.1 El nombre seguro y los tres números de versión
Los ensamblados de .NET tienen varios números de versión, cada uno con un rol distinto.12
- AssemblyVersion: la única versión que usa el runtime para identificar y cargar el ensamblado. En los ensamblados con nombre seguro, el CLR de .NET Framework exige una coincidencia exacta, así que cada vez que se sube requiere una redirección de enlace en el lado que llama (.NET/.NET Core acepta automáticamente versiones superiores). La guía oficial propone reflejar en AssemblyVersion únicamente la versión mayor para reducir la necesidad de redirecciones.
- FileVersion (AssemblyFileVersion): solo es visible en las propiedades del Explorador y no afecta el comportamiento del runtime. Se recomienda usarlo para registrar el número de compilación de la CI.
- InformationalVersion: una cadena libre pensada para humanos. Registra la versión del paquete en formato semver o el hash de commit del origen.
En definitiva, en la práctica resulta más manejable una estructura de tres capas: «la declaración de compatibilidad se hace con la versión del paquete/producto (semver), AssemblyVersion refleja solo la versión mayor, y FileVersion permite rastrear la compilación».
6. Cómo asignar números de versión — semver solo funciona si existe una «definición»
El versionado semántico (semver) se puede resumir en tres líneas: subir MAJOR ante un cambio incompatible, MINOR ante una funcionalidad nueva compatible con versiones anteriores, y PATCH ante una corrección de errores compatible con versiones anteriores.5
Lo que suele pasarse por alto es que el primer requisito de la especificación de semver es que «el software que usa semver debe declarar su API pública».5 Si no se declara qué es la API pública, no existe un criterio para juzgar qué es un «cambio incompatible», y la decisión de subir o no la versión mayor queda librada al ánimo de quien esté a cargo. En muchos de los equipos donde semver no funciona, lo que se omitió no fue la forma de numerar, sino esta declaración.
Para las DLL de distribución interna, una operación realista adopta la siguiente forma.
- Declarar el alcance de la API pública — en una DLL nativa, las funciones exportadas y los encabezados públicos; en COM, el IDL/la biblioteca de tipos; en .NET, los tipos y miembros públicos. Se deja escrito explícitamente que «todo lo demás es implementación interna y puede cambiar sin previo aviso».
- Adoptar una definición de cambio destructivo — se colocan en el repositorio, como «definición propia de la empresa», las tablas de decisión de los capítulos 3 y 5 de este artículo, junto con las reglas de cambio de .NET.2
- Automatizar la evaluación — en .NET, las herramientas Package Validation / ApiCompat permiten comprobar mecánicamente la compatibilidad binaria con la versión anterior.13 Esto elimina el «seguramente esté bien» de las revisiones.
- Incluir un campo de compatibilidad en las notas de la versión — se registra siempre uno de estos tres valores: «no requiere recompilación / se recomienda recompilar / hay un cambio destructivo». Es un mecanismo para dar por escrito, antes de que se pregunte, la respuesta a la pregunta inicial de «¿basta con reemplazar el archivo?».
7. Procedimiento cuando es inevitable romper la compatibilidad
Cuando resulta indispensable un cambio que la tabla de decisión clasifica como «destructivo», se avanza mediante la provisión en paralelo, no mediante un reemplazo directo.
- Ofrecer lo nuevo y lo antiguo en paralelo — en COM, se agrega
IFoo2y se conservaIFoo(capítulo 4). En una DLL nativa, se agrega una función nueva (FooEx) o se mantienen en paralelo una nueva DLL con otro nombre. En .NET, se publica como un nuevo paquete con la versión mayor incrementada, y la versión mayor anterior continúa recibiendo solo correcciones de errores. - Establecer un período de obsolescencia — en .NET, el atributo
[Obsolete]permite emitir una advertencia en tiempo de compilación. En nativo/COM, se declara mediante comentarios en el encabezado y en las notas de la versión, indicando explícitamente la fecha prevista de retiro. El punto clave es fijar una fecha, no decir simplemente «se eliminará en algún momento». - Hacer un inventario de quién llama — se elabora una lista de «quién sigue llamando a la API antigua» a partir de la búsqueda de código fuente interno, los registros de distribución del instalador y, en el caso de COM, el estado de referencias en el registro. Si aquí se encuentran binarios que no se pueden recompilar (herramientas de empleados que ya se fueron, aplicaciones de terceros), se extiende la vida de la API antigua solo para ese caso, o se tiende un puente con un wrapper.
- Eliminar la API antigua — se elimina solo después de confirmar, mediante el inventario, que no queda ningún llamador, y se sube la versión mayor.
Este procedimiento tiene un costo. Y por eso, paradójicamente, la mayor medida de compatibilidad es diseñar la API lo más pequeña posible desde la primera publicación, teniendo en cuenta la tabla de decisión (lo que no se publica no genera obligación de compatibilidad).
8. Resumen
- La compatibilidad se piensa en tres niveles: binaria, de código fuente y de comportamiento. Aunque no se requiera recompilar, la compatibilidad de comportamiento puede romperse.3
- En las DLL nativas, «agregar es seguro; modificar las exportaciones, firmas o el diseño de las estructuras existentes es destructivo». Se deja margen de extensión en las estructuras incluyendo
cbSize.8 - Las interfaces COM son inmutables una vez publicadas. Los cambios se agregan como una nueva interfaz con un nuevo IID (IFoo2), y se distinguen mediante
QueryInterface.149 Cuando hay clientes VB6/VBA con enlace anticipado, esto se respeta con especial rigor. - En .NET se puede evaluar de forma mecánica con las reglas oficiales de cambio de compatibilidad. Hay que prestar atención a que cambios «aparentemente seguros», como la virtualización, el cambio de nombre de parámetros o el sellado (
sealed), están clasificados como destructivos.2 - Es realista una estructura de tres capas: AssemblyVersion solo con la versión mayor, FileVersion para rastrear la compilación y semver para declarar la compatibilidad.12
- semver solo funciona si se declara una definición de la API pública y de los cambios destructivos.5 Se adopta la tabla de decisión como esa definición y se verifica automáticamente con herramientas como Package Validation.13
- Al romper la compatibilidad, se sigue el orden provisión en paralelo → período de obsolescencia → inventario → eliminación. No reemplazar de golpe es lo que protege al EXE antiguo que corre en el cliente.
Artículos relacionados
- Qué es COM / ActiveX / OCX - diferencias y relación explicadas
- Qué es COM - por qué el diseño de COM en Windows sigue siendo hermoso
- Cómo usar una DLL de .NET 8 desde VBA con tipado - exposición COM y dscom TLB
- Cómo funciona la resolución de nombres de DLL en Windows - orden de búsqueda y SxS
- Qué es Reg-Free COM - el mecanismo para usar COM sin registro
- Cómo llamar a una DLL de C# Native AOT desde C/C++
Áreas de consultoría relacionadas
KomuraSoft LLC se ocupa del diseño de compatibilidad de DLL, componentes COM y bibliotecas .NET referenciados por otros sistemas, del inventario de la API pública y la definición de una política de versionado, y del diseño e implementación de extensiones que no rompen a los clientes existentes (el método IFoo2, la provisión en paralelo).
- Aprovechamiento de activos existentes y soporte de migración
- Modernización y mantenimiento de software Windows existente
- Consultoría técnica y revisión de diseño
- Contacto
Referencias
-
Microsoft Learn, Interface Design Rules. Sobre que las interfaces que implementa un objeto COM deben tener un IID único, y que después de crearlas y publicarlas no se puede modificar ninguna parte de su definición (son inmutables). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Change rules for compatibility (.NET). Sobre que los cambios de API en .NET se clasifican en permitidos, prohibidos y a evaluar; que la eliminación o el cambio de nombre de tipos o miembros públicos, el cambio de firma, el cambio de nombre de parámetros, agregar o quitar
virtual, sellar tipos y cambiar el valor de constantes o enumeraciones, entre otros, están prohibidos (son destructivos); que agregar un miembro a una interfaz está sujeto a evaluación; y que los desarrolladores de bibliotecas pueden usarlas como criterio de evaluación para sus propias bibliotecas. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Microsoft Learn, Breaking changes (.NET library guidance). Sobre que los cambios destructivos se clasifican en destructivos de código fuente, de comportamiento y binarios, y que en los destructivos binarios, un ensamblado compilado contra la versión anterior falla en tiempo de ejecución con MissingMethodException, entre otros. ↩ ↩2 ↩3
-
Microsoft Learn, Interface Pointers and Interfaces. Sobre que las interfaces COM son inmutables, que agregar o eliminar métodos, o cambiar su semántica, significa crear una nueva interfaz y no una nueva versión de la antigua, y que el IID define de forma única el contrato. ↩ ↩2 ↩3
-
semver.org, Semantic Versioning 2.0.0. Sobre subir MAJOR ante un cambio de API incompatible, MINOR ante una funcionalidad nueva compatible con versiones anteriores y PATCH ante una corrección de errores compatible; que el software que usa semver debe declarar su API pública; y que ante un cambio incompatible con versiones anteriores en la API pública siempre se debe subir la versión MAJOR. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, MS-ERREF 2.3.1 NTSTATUS Values. Sobre que
STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139) corresponde a “The procedure entry point %hs could not be located in the dynamic link library %hs.” y queSTATUS_DLL_NOT_FOUND(0xC0000135) corresponde a “This application has failed to start because %hs was not found.” ↩ ↩2 -
Microsoft Learn, /RTC (Run-time error checks). Sobre que
/RTCs(y/RTC1) verifican el puntero de pila y detectan su corrupción, que dicha corrupción puede producirse por una discrepancia en la convención de llamada (por ejemplo, llamar mediante un puntero a función__cdecla una función que la DLL exporta como__stdcall), y que/RTCno se puede usar en compilaciones de lanzamiento (optimizadas). ↩ -
Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). Sobre que en el miembro cbSize se coloca el tamaño de la estructura, que la estructura se ha extendido a lo largo de las generaciones, y que asignar un valor adecuado a cbSize permite usarla manteniendo la compatibilidad con versiones antiguas de Shell32.dll. ↩ ↩2
-
Microsoft Learn, The Versioning Theory for RPC and COM. Sobre que en COM lo mejor para extender funcionalidad es crear una nueva interfaz, que una nueva interfaz que hereda de la antigua equivale a una versión menor, que cambiar métodos o tipos existentes requiere una interfaz completamente nueva que no herede (equivalente a una versión mayor), y que se puede comprobar el nivel de soporte con QueryInterface. ↩ ↩2
-
Microsoft Learn, COM Registry Keys. Sobre que el CLSID es el GUID que identifica una clase COM, que el ProgID asocia una cadena legible para humanos al CLSID pero sin garantizar unicidad, que el ProgID independiente de la versión se asocia a la clase de la versión más reciente mediante CurVer, y que la clave Interface registra el IID. ↩ ↩2
-
Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). Sobre que en VBA se recomienda el enlace anticipado mediante una referencia, que el enlace tardío (CreateObject/ProgID) no muestra los miembros al escribir el código y tiene un rendimiento inferior en tiempo de ejecución, y que el enlace anticipado requiere establecer una referencia a la biblioteca de objetos correspondiente. ↩
-
Microsoft Learn, Versioning (.NET library guidance). Sobre que AssemblyVersion se usa para la carga del runtime y que, con nombre seguro en .NET Framework, se exige una coincidencia exacta; que se propone incluir solo la versión mayor en AssemblyVersion; que FileVersion es solo para mostrarlo en Windows y no afecta el comportamiento en tiempo de ejecución; que InformationalVersion sirve para registrar información de versión adicional; y que se recomienda usar semver 2.0.0 para la versión de los paquetes NuGet. ↩ ↩2
-
Microsoft Learn, NuGet package compatibility rules. Sobre que se deben evitar los cambios binarios destructivos, que las herramientas Package Validation y ApiCompat permiten detectar automáticamente la compatibilidad con una versión base, y que AssemblyVersion no se debe bajar entre versiones. ↩ ↩2
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Cómo modificar con seguridad una aplicación de negocio legada sin pruebas — la práctica de las pruebas de caracterización y la refactorización
Con ejemplos en C#, muestra cómo fijar con pruebas de caracterización (método golden master) el comportamiento de una app legada sin prue...
Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión
Cómo elegir la comunicación entre procesos en Windows: comparamos en una tabla de decisión las canalizaciones con nombre, el TCP local, g...
Cómo invocar COM y .NET desde PowerShell en la práctica ── ampliar de un salto el alcance de sus scripts
Cómo invocar clases .NET desde PowerShell, integrar C# y la API Win32 con Add-Type, operar COM, gestionar los procesos residuales de Exce...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Cómo versionar el esquema de la base de datos de una aplicación empresarial — Migraciones que evitan que «cada cliente tenga una base de datos distinta»
Guía práctica para versionar el esquema de bases de datos de aplicaciones empresariales dispersas entre clientes: PRAGMA user_version, mi...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Migración de ActiveX
Decisiones para conservar, encapsular o sustituir componentes COM / ActiveX / OCX.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Reutilización y migración de activos existentes
Reutilización y migración de activos COM / ActiveX / OCX y dependencias de 32 o 64 bits.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Basta con agregar una función a la DLL para no tener que recompilar el lado que llama?
- Si solo se agrega una función exportada, la regla general es que los llamadores existentes siguen funcionando sin cambios, porque mientras no se modifiquen el nombre, la firma, la convención de llamada ni el número ordinal de exportación de las funciones existentes, la resolución de la importación se sigue cumpliendo como hasta ahora. Sin embargo, si se agrega un miembro a una estructura que reserva y pasa el lado que llama, o si se cambia el significado del valor de retorno o del código de error de una función existente, la compatibilidad de comportamiento puede romperse aunque funcione sin recompilar. La regla básica es: «agregar una función es seguro; modificar la firma existente es destructivo».
- ¿Por qué no se debe agregar un método a una interfaz COM después de publicarla?
- Porque la regla de la especificación de COM establece que, una vez publicada, una interfaz COM es inmutable. La interfaz es un contrato de diseño binario, la vtable (la secuencia de punteros a función), y si se insertan, eliminan o reordenan métodos, el binario antiguo termina llamando a otro método distinto en la posición que grabó en tiempo de compilación. Agregar al final no cambia la posición de los slots existentes, pero entonces puede darse el accidente de que un cliente nuevo obtenga un componente antiguo pensando que ya tiene los métodos «agregados», y termine invocando un slot que no existe; por eso tampoco se permite agregar manteniendo el mismo IID. Si se quiere ampliar la funcionalidad, se agrega una nueva interfaz con un nuevo IID (IFoo2) y se deja intacta la IFoo existente. El lado que llama puede determinar de forma segura, mediante QueryInterface, si el componente admite la interfaz nueva o solo la antigua.
- ¿Cómo se diferencian y se usan AssemblyVersion, FileVersion e InformationalVersion en .NET?
- AssemblyVersion es la única versión que usa el runtime para identificar y cargar el ensamblado; con nombre seguro, .NET Framework exige una coincidencia exacta, por lo que cada vez que se sube hace falta una redirección de enlace. Por eso, la guía oficial propone una operación en la que solo se refleja la versión mayor. FileVersion solo se muestra en las propiedades del Explorador y no afecta el comportamiento del runtime, por lo que es adecuada para registrar, por ejemplo, el número de compilación de la CI. InformationalVersion es una cadena libre pensada para humanos, que registra la versión en formato semver o el hash de commit.
- ¿Adoptar el versionado semántico (semver) resuelve los problemas de compatibilidad?
- No se resuelve solo con semver. semver es una convención que dice «si hay un cambio incompatible con versiones anteriores, sube la versión mayor», pero como requisito previo exige declarar «qué es la API pública y qué es un cambio destructivo». Si solo se asignan números de versión sin esta definición, el criterio varía según la persona y el sistema no funciona. semver cobra sentido recién cuando se adopta, como «la definición propia de cambio destructivo», un criterio como la tabla de decisión de este artículo para las DLL nativas, o las reglas de cambio de compatibilidad de Microsoft para .NET, y se lo incorpora en el procedimiento de publicación.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.