Compatibilidad de fuente frente a binaria
Que un cambio siga compilando no significa que siga funcionando. Añadir un parámetro por defecto, ensanchar un tipo de retorno o mover una función de fichero son operaciones que ningún compilador señala y que hacen estallar en tiempo de ejecución a todo consumidor que no recompile. Esta lección separa las tres compatibilidades que casi siempre se confunden, recorre el catálogo corto de cambios que rompen el binario sin tocar la fuente, muestra el caso simétrico y explica por qué la distinción decide cómo numeras tus versiones.
Existe una prueba que casi todos los equipos usan para decidir si un cambio en una biblioteca es seguro, y esa prueba es sistemáticamente insuficiente: recompilar los consumidores y ver si pasa. Es insuficiente porque el consumidor que importa no recompila. El consumidor que importa es una aplicación que fijó tu versión hace ocho meses, que arrastra tu biblioteca por vía transitiva a través de otras tres, y cuyo gestor de dependencias resolvió silenciosamente un conflicto de versiones subiendo la tuya sin que nadie lo pidiera. Ese consumidor ejecuta bytecode compilado contra una firma que ya no existe, y lo descubre en producción, en el momento exacto en que se toca la línea afectada. La distinción entre lo que compila y lo que enlaza no es una sutileza académica: es la diferencia entre un cambio menor y un incidente.
- Separar con precisión compatibilidad de fuente, compatibilidad binaria y compatibilidad de comportamiento.
- Reconocer los cambios que compilan sin error y provocan un fallo de enlazado en tiempo de ejecución.
- Identificar el caso simétrico, en el que la fuente se rompe y el binario aguanta, y decidir qué gravedad tiene.
- Traducir la distinción a una política de numeración de versiones y de publicación defendible.
Tres preguntas que casi nadie separa
La compatibilidad de fuente pregunta si el código de un consumidor, tal cual está escrito, sigue compilando contra la versión nueva. La compatibilidad binaria pregunta algo distinto y más exigente: si el bytecode ya compilado del consumidor, sin recompilar, sigue enlazando y ejecutándose contra la versión nueva. La compatibilidad de comportamiento pregunta lo último que queda: si, compilando y enlazando, el programa sigue haciendo lo mismo. Las tres son independientes, y un cambio puede satisfacer cualquier subconjunto de ellas.
La independencia entre las tres no es una curiosidad de manual, sino la fuente concreta de los peores errores de juicio. Un cambio puede ser compatible en fuente y romper el binario, que es la combinación traicionera; puede romper la fuente y respetar el binario, que es molesta pero visible; puede respetar ambas y alterar el comportamiento, que es la más difícil de detectar de todas; y puede, por supuesto, romper las tres a la vez, que es el único caso en el que todo el mundo se da cuenta a tiempo.
La razón técnica de que la segunda sea más estricta en la plataforma Java cabe en una frase: la resolución de un método en el bytecode se hace por un descriptor que incluye el nombre de la clase contenedora, el nombre del método, la lista de tipos de los parámetros y el tipo de retorno. Cualquier alteración de cualquiera de esos cuatro elementos produce un símbolo distinto, y el cargador de clases no tiene forma de saber que se parecen. Lo que el consumidor recibe entonces es un NoSuchMethodError o un NoSuchFieldError, dos errores que ninguna cláusula de captura razonable espera y que aparecen a mitad de una operación en curso.
// Version 1.0 de la biblioteca
public fun conectar(host: String) { /* ... */ }
// Version 1.1: parece un cambio aditivo e inocente
public fun conectar(host: String, tiempoLimite: Int = 30) { /* ... */ }
Este es el caso canónico y el que más víctimas se cobra, porque su forma es exactamente la de un cambio compatible. El código fuente de todos los consumidores sigue compilando sin tocar una coma. Pero el compilador de Kotlin genera para la firma con parámetro por defecto un método con la lista completa de parámetros más un método sintético auxiliar que resuelve los valores omitidos; lo que desaparece del artefacto es el método de un solo parámetro que los consumidores ya compilados invocan. El resultado es un fallo de enlazado en el primer uso.
Conviene subrayar dónde y cuándo aparece ese fallo, porque su geografía es parte del problema. No aparece al arrancar la aplicación, ya que la resolución de un símbolo es perezosa y ocurre la primera vez que se ejecuta la instrucción que lo invoca. Aparece, por tanto, en la ruta de código concreta que usa esa función, que puede ser un camino poco frecuente ejercitado solo en producción y solo bajo ciertas condiciones. Un cambio incompatible publicado un martes puede manifestarse tres semanas después en el sistema de un tercero, sin que nada en el rastro apunte hacia su origen.
Kotlin protege la compatibilidad de fuente con bastante celo, y por eso el instinto engaña: la experiencia diaria enseña que los cambios aditivos son seguros, porque en el mundo donde todo se recompila lo son. La compatibilidad binaria es una propiedad distinta que ningún hábito adquirido dentro de un repositorio único entrena.
Compila y revienta: el catálogo corto
Merece la pena tener memorizados los cambios de esta familia, porque son pocos, se repiten y ninguno de ellos levanta sospechas durante la revisión de un cambio.
El primero es el parámetro por defecto que se acaba de ver, junto con su variante de reordenar parámetros existentes o cambiar el tipo de uno de ellos por un supertipo. El segundo es el cambio de tipo de retorno, incluida la variante que parece más benigna de todas, que es ensancharlo hacia un supertipo. Pasar de devolver List a devolver Collection es compatible en fuente para casi cualquier llamante, y es incompatible en binario para todos ellos sin excepción, porque el descriptor del método cambió.
// 1.0
public fun etiquetas(): List<String> = emptyList()
// 1.1: mas general y por tanto mas amable... y sin embargo incompatible
public fun etiquetas(): Collection<String> = emptyList()
El tercero es el más contraintuitivo: mover una función de nivel superior de un fichero a otro dentro del mismo paquete. Para el compilador de Kotlin no cambia nada, porque el paquete es el mismo y la importación del consumidor sigue siendo válida. Para la máquina virtual cambia todo, porque las funciones de nivel superior viven en una clase fachada cuyo nombre deriva del nombre del fichero: al mudar la función de Utilidades.kt a Red.kt el símbolo pasa de una clase a otra y el consumidor ya compilado busca donde no está. La defensa consiste en fijar el nombre de la fachada con una anotación de nombre en la cabecera del fichero desde el primer día, de modo que el nombre del fichero deje de ser parte de la API.
@file:JvmName("Utilidades")
package com.ejemplo.red
public fun resolver(host: String): String = host.trim()
El cuarto grupo lo forman los cambios sobre miembros de clases que parecen refactorizaciones internas: convertir una propiedad en una función o al revés, subir un miembro a una clase base, cambiar un val público por una función get, o convertir una clase normal en clase de datos y viceversa cuando eso altera los métodos generados. Y el quinto, propio de las clases de datos, es añadir una propiedad al constructor primario, que es probablemente el cambio incompatible más frecuente de todos porque parece la operación más natural del mundo.
// 1.0
public data class Usuario(val id: String, val nombre: String)
// 1.1: el constructor cambia, copy cambia y aparece un tercer componente
public data class Usuario(val id: String, val nombre: String, val activo: Boolean = true)
El valor por defecto en la propiedad nueva salva la compatibilidad de fuente de quien construía la instancia con dos argumentos, y no salva nada más. El constructor de dos parámetros desaparece del artefacto, la firma de copy pasa a tener tres y la desestructuración gana un componente. Un consumidor ya compilado que hiciera cualquiera de las tres cosas deja de enlazar, y las tres son operaciones habituales sobre una clase de datos. La conclusión práctica, incómoda pero firme, es que una clase de datos publicada es una estructura congelada: si el conjunto de sus propiedades va a crecer, no debería ser una clase de datos.
flowchart TD
A[Cambio propuesto en la API] --> B{Cambia el nombre de la clase contenedora}
B -->|Si| X[Ruptura binaria]
B -->|No| C{Cambia la lista de tipos de parametros}
C -->|Si| X
C -->|No| D{Cambia el tipo de retorno}
D -->|Si| X
D -->|No| E{Desaparece o pierde visibilidad algun simbolo}
E -->|Si| X
E -->|No| F[Compatible en binario: verifica el comportamiento]La dirección contraria y el tercer eje
El caso simétrico existe y conviene saber medirlo, porque suele tratarse con más severidad de la que merece o con menos. Renombrar el parámetro de una función pública es perfectamente compatible en binario, ya que el nombre del parámetro no forma parte del descriptor; y es incompatible en fuente para todo consumidor que lo invocara con argumentos nombrados, que en Kotlin es un estilo frecuente y recomendado. La consecuencia práctica es que el nombre de un parámetro público es parte del contrato aunque la máquina virtual no lo sepa, y que renombrarlo pertenece al mismo cajón que renombrar la función.
En la misma familia entran añadir una entrada a un tipo enumerado público, que puede volver no exhaustivo un when escrito por el consumidor sin cláusula final, y añadir un miembro abstracto a una interfaz que terceros implementan. Un caso especialmente valioso de conocer es el de una clase sellada: añadirle un subtipo es compatible en binario y rompe la fuente de todo consumidor que discriminara exhaustivamente sobre ella, motivo por el que la exhaustividad y la evolución de una jerarquía sellada publicada están en tensión permanente.
// Compatible en binario y rompedor en fuente para quien use argumentos nombrados
public fun conectar(servidor: String, opciones: Opciones): Conexion = TODO()
public fun conectar(host: String, opciones: Opciones): Conexion = TODO()
// Compatible en binario y rompedor en fuente para todo when exhaustivo del consumidor
public sealed interface Resultado {
public data class Exito(val valor: String) : Resultado
public data class Fallo(val causa: String) : Resultado
public data object Cancelado : Resultado // el subtipo nuevo
}
Queda el tercer eje, el de comportamiento, que ninguna herramienta puede verificar y que produce las incidencias más caras. Una función que empieza a lanzar una excepción donde antes devolvía nulo, un orden de iteración que deja de ser estable, una operación que pasa de silenciosa a registrar en el hilo de llamada, un valor por defecto que cambia: todo eso conserva la firma intacta y rompe programas. La única defensa es documentar la garantía en lugar de la implementación, y tratar cualquier promesa que los consumidores hayan podido observar como parte del contrato aunque nunca se escribiera.
Qué le hace esto a tu numeración de versiones
La traducción práctica de los tres ejes es una política de publicación, y la política se sostiene sobre una única decisión: qué clase de riesgo asume alguien que actualiza una versión menor de tu biblioteca. La formulación que el ecosistema entiende sin explicaciones es la más estricta de todas: dentro de una misma versión mayor, ninguna actualización rompe el enlazado, punto. Bajo esa promesa, un consumidor puede aceptar versiones menores automáticamente, y una biblioteca cuyas versiones menores se aceptan automáticamente se propaga; una cuyas versiones menores hay que auditar una por una se queda anclada durante años en la que ya estaba.
Esa promesa tiene un corolario que conviene aceptar de antemano, porque es donde se rompen casi todas las políticas: si el enlazado no puede romperse en versión menor, entonces todo cambio de firma sin excepción exige o bien una sobrecarga nueva que conviva con la vieja, o bien esperar a la próxima versión mayor. No hay una tercera opción, y buscarla es la actividad que produce la mayoría de los incidentes de este tipo. Lo que sí existe es un procedimiento para llegar hasta la versión mayor con el ecosistema entero ya migrado y sin sobresaltos, que es exactamente lo que se estudia en la cuarta lección del nivel.
Vale la pena, por último, distinguir la promesa del artefacto de la promesa del proyecto. Un módulo interno que solo consumen otros módulos del mismo repositorio no necesita ninguna de estas garantías, y aplicárselas es puro coste. La frontera exacta es la publicación en un repositorio del que otros resuelven dependencias, y merece la pena que esa frontera esté escrita en algún sitio del proyecto, porque el módulo que hoy es interno y mañana se publica arrastra consigo todas sus decisiones anteriores.
Una aplicación puede acabar ejecutando tu versión 1.1 aunque haya declarado la 1.0, porque otra dependencia pidió la superior y el resolvedor unificó. Ese es el escenario real en el que se manifiestan estos fallos, y explica por qué la promesa binaria no es un lujo para bibliotecas grandes sino el requisito mínimo de cualquiera que se publique.
Cuatro elementos
Clase contenedora, nombre, tipos de parámetros y tipo de retorno. Si tocas uno, rompiste el binario, por muy aditivo que parezca el cambio.
El fichero es API
El nombre del fichero determina la clase fachada de las funciones de nivel superior. Fíjalo con una anotación de nombre antes de publicar.
Los nombres cuentan
El nombre de un parámetro no está en el descriptor, pero sí en las llamadas con argumentos nombrados. Renombrarlo rompe fuente.
La distinción entre compatibilidad de fuente y compatibilidad binaria parece un tecnicismo del formato de clases de la máquina virtual, y en cierto sentido lo es, pero lo que revela es algo mucho menos técnico y bastante más incómodo: en el momento de publicar un artefacto, la unidad de cambio deja de ser tu repositorio y pasa a ser un grafo de dependencias que no controlas, cuyos nodos toman decisiones que no consultas y cuyas versiones se resuelven según reglas que no escribiste. Dentro de un monorepositorio, todos los conceptos de esta lección se disuelven, porque todo se recompila a la vez y la compatibilidad de fuente es la única que existe; ahí, ensanchar un tipo de retorno es una mejora sin coste y mover una función de fichero es higiene. Fuera, exactamente los mismos dos cambios son incidentes. Nada en el código distingue ambas situaciones, y esa es justamente la trampa: la intuición que un ingeniero desarrolla durante años trabajando sobre un repositorio único es sistemáticamente errónea aplicada a una biblioteca, y lo es en la dirección peligrosa, porque hace parecer inocentes los cambios que no lo son. De aquí salen dos consecuencias que conviene adoptar como reflejo. La primera es que la revisión de un cambio en una biblioteca no puede preguntarse si el código está bien, sino si el símbolo que había sigue estando y con el mismo descriptor exacto, lo que exige una herramienta y no una lectura, que es el asunto de la lección siguiente. La segunda es que la numeración de versiones deja de ser un adorno y pasa a ser la interfaz mediante la cual comunicas a un ecosistema entero qué clase de riesgo asume al actualizar: si tus versiones menores pueden romper el enlazado, has convertido la promesa implícita de tu propio esquema de versionado en ruido, y a partir de ese momento el ecosistema deja de actualizarte, que es la forma más silenciosa en que muere una biblioteca.
- Publica en un repositorio local una biblioteca mínima con una función de un parámetro, compila contra ella un consumidor y guarda su artefacto sin recompilarlo.
- Añade a esa función un segundo parámetro con valor por defecto, republica y ejecuta el consumidor antiguo. Anota el error exacto y en qué punto aparece.
- Repite el experimento ensanchando el tipo de retorno hacia un supertipo y confirma que el consumidor compila y no ejecuta.
- Mueve la función de nivel superior a otro fichero del mismo paquete, comprueba el fallo y arréglalo fijando el nombre de la fachada.
- Clasifica los últimos veinte cambios de una biblioteca que uses según los tres ejes y comprueba si su numeración de versiones fue honesta.