expect y actual: declaraciones esperadas y sus realizaciones
El par expect y actual es el único mecanismo del lenguaje que permite nombrar en código común algo que solo existe por plataforma, y funciona por emparejamiento en tiempo de compilación entre una declaración sin cuerpo y su realización en cada objetivo. Esta lección detalla qué puede declararse esperado, las reglas finas de emparejamiento que muerden en la práctica, el papel de la realización mediante alias de tipo, y el error de diseño más caro de todo el modelo: usar este mecanismo como si fuera un sistema de abstracción.
Hay un momento inevitable en todo proyecto multiplataforma en el que el código común necesita algo que solo existe en cada plataforma por separado: la hora del sistema, un generador de números aleatorios criptográfico, una ruta de almacenamiento, un identificador de dispositivo. La respuesta del lenguaje a ese momento es un par de palabras clave cuya elegancia consiste en no introducir ningún concepto nuevo en tiempo de ejecución: expect marca una declaración que existe para el analizador pero no tiene cuerpo, y actual marca la declaración que la realiza en un objetivo concreto. El compilador empareja ambas por nombre completamente cualificado y por firma, y si alguna compilación se queda sin pareja el proyecto no compila. No hay tabla de búsqueda, no hay coste de indirección, no hay decisión en ejecución: al terminar la compilación de un objetivo lo único que queda es la realización, exactamente como si la hubieras escrito ahí desde el principio. Esa ausencia total de coste es la virtud del mecanismo, y también la trampa que lleva a mucha gente a usarlo para cosas que exigen justo lo contrario.
- Enunciar la regla de emparejamiento entre declaración esperada y realización, y las condiciones que ambas deben cumplir.
- Enumerar qué categorías de declaración admiten
expecty qué restricciones tiene cada una. - Usar la realización mediante alias de tipo sabiendo qué se gana, qué se pierde y cuándo el compilador la rechaza.
- Distinguir los casos legítimos del mecanismo de los que exigen una interfaz, y reconocer los síntomas del abuso.
El emparejamiento y sus reglas
Una declaración esperada vive en un conjunto de fuentes compartido y carece de cuerpo. Su realización vive en un conjunto que cubre los objetivos restantes, lleva el mismo nombre en el mismo paquete y una firma compatible. La regla de completitud es total: para cada compilación, toda declaración esperada alcanzable debe tener exactamente una realización, y el error por omisión aparece en tiempo de compilación de ese objetivo, no en el común.
// commonMain
expect fun ahoraEnMilisegundos(): Long
expect val separadorDeRutas: Char
// jvmMain
actual fun ahoraEnMilisegundos(): Long = System.currentTimeMillis()
actual val separadorDeRutas: Char = java.io.File.separatorChar
Las reglas finas son pocas pero se olvidan todas. Los valores por defecto de los parámetros se declaran únicamente en la parte esperada, y la realización no debe repetirlos, porque el punto de llamada se resuelve contra la declaración común y tener dos fuentes de verdad para el mismo valor sería una divergencia esperando a ocurrir. La realización no puede ser menos visible que lo que se esperaba de ella. El paquete debe coincidir, aunque la carpeta no tenga por qué. Y algo que el nivel anterior deja preparado: la realización no está obligada a vivir en un conjunto hoja, de modo que una sola escrita en appleMain cubre todos los objetivos de esa familia.
// commonMain
expect fun aleatorioSeguro(bytes: Int): ByteArray
// appleMain: una sola realización para toda la familia de Apple
actual fun aleatorioSeguro(bytes: Int): ByteArray { /* SecRandomCopyBytes */ TODO() }
Hay además una condición que no es del lenguaje sino del grafo y que provoca uno de los errores más desconcertantes del nivel: la realización debe vivir en un conjunto que alcance el objetivo por una arista dependsOn, no por una dependencia ordinaria. Colocarla en otro módulo, por muy bien nombrado que esté, no sirve de nada: el compilador buscará dentro de la compilación y no la encontrará. Declaración esperada y realización pertenecen siempre al mismo módulo.
Conviene también saber qué no puede ser esperado, porque delimita el mecanismo mejor que la lista de lo que sí. No se puede declarar esperado nada local a una función, ni un miembro suelto de una clase que no sea esperada por completo, ni una función de extensión sobre un tipo esperado que pretenda variar por plataforma sin que el tipo lo haga. La granularidad mínima es la declaración de nivel superior, y esa restricción, lejos de ser arbitraria, es la que impide que el mecanismo se cuele por todas partes.
Puede declararse esperada casi cualquier categoría de declaración de nivel superior: funciones, propiedades, clases, interfaces, objetos, clases anotación y entradas de enumeración. Las clases esperadas admiten constructores, propiedades y funciones, todos sin cuerpo, y la realización puede añadir miembros propios que el código común simplemente no verá. Las anotaciones tienen un privilegio adicional mediante OptionalExpectation, que permite que una anotación esperada no tenga realización en algún objetivo: las llamadas se borran en lugar de fallar, y por eso existen anotaciones de serialización que se aplican en el común y solo significan algo en la plataforma que las entiende.
Un detalle menor con consecuencias mayores: los nombres de los parámetros también forman parte del emparejamiento. Como el punto de llamada se resuelve contra la declaración común, quien invoca con argumentos nombrados usa los nombres de la parte esperada, y una realización que los cambie produce una divergencia entre lo que el lector ve en la firma de plataforma y lo que realmente se pasa. El compilador lo señala, y hace bien: en un mecanismo cuyo único contrato son las firmas, cualquier grieta en la firma es una grieta en todo el contrato.
Durante buena parte del ciclo de las versiones dos, declarar una clase con expect ha producido un aviso del compilador que se silencia con una bandera explícita. No es un capricho burocrático: señala que las reglas de emparejamiento de miembros son la parte del mecanismo con más aristas y la que más ha cambiado.
La realización mediante alias de tipo
Existe una segunda forma de realizar una clase esperada que no consiste en escribirla, sino en apuntar a una que la plataforma ya tiene. Es el atajo más elegante del modelo y también el que exige más cuidado.
// commonMain
expect class Instante : Comparable<Instante> {
val epochMillis: Long
}
// jvmMain
actual typealias Instante = mi.paquete.InstanteJvm
El compilador acepta el alias solo si el tipo apuntado ofrece todos los miembros que la parte esperada prometía, con firmas compatibles y con la misma jerarquía de supertipos exigida. Lo que se gana es interoperabilidad perfecta: en el código específico de esa plataforma el tipo es el tipo nativo, sin envolturas, sin conversiones y sin coste. Lo que se pierde es control de superficie, y conviene enunciarlo sin eufemismos: el alias no oculta nada. Todos los miembros del tipo apuntado, incluidos los que tú nunca quisiste ofrecer, quedan disponibles en ese objetivo, de modo que el código de plataforma puede acabar apoyándose en detalles que no existen en las demás. El resultado es un módulo que compila en todas partes y cuyo uso solo es portátil en una.
Hay además una asimetría con la que se tropieza pronto: los miembros estáticos del mundo de la máquina virtual y los objetos acompañantes de Kotlin no se emparejan igual, y una clase esperada con acompañante puede resultar irrealizable mediante alias aunque el tipo apuntado parezca tenerlo todo. Cuando eso ocurre, la salida no es forzar el alias sino escribir una clase envoltorio delgada que delegue.
// jvmMain: cuando el alias no encaja, un envoltorio explícito
actual class Instante(private val delegado: java.time.Instant) : Comparable<Instante> {
actual val epochMillis: Long get() = delegado.toEpochMilli()
override fun compareTo(other: Instante) = delegado.compareTo(other.delegado)
}
La elección entre alias y envoltorio no es de comodidad, y merece un criterio explícito. El alias es correcto cuando el tipo de plataforma es conceptualmente el mismo que el tuyo y quieres que quien programe en esa plataforma pueda pasarlo de un lado a otro sin conversiones; es el caso de tipos fundamentales como fechas, instantes o identificadores. El envoltorio es correcto cuando tu tipo es una restricción del de plataforma, es decir, cuando parte de lo que aquel ofrece sería incorrecto en tu dominio. Formulado así, la pregunta es la de siempre en diseño de tipos: ¿estás renombrando o estás acotando?
Queda un detalle sobre el que casi todo el mundo se equivoca la primera vez: el alias no crea un tipo nuevo, de modo que no puedes darle miembros propios ni hacerlo distinto del original a efectos de sobrecarga. Si en algún momento necesitas que el tipo común y el de plataforma sean distinguibles, ya no querías un alias, y cuanto antes lo descubras menos código habrá que reescribir.
flowchart TD A[Necesito en comun algo que solo existe por plataforma] --> B[Es una hoja sin estado ni ciclo de vida] B -->|si| C[Declaracion esperada] B -->|no| D[Interfaz comun con implementacion inyectada] C --> E[Realizacion escrita a mano] C --> F[Realizacion mediante alias de tipo] F --> G[Superficie ampliada en esa plataforma]
El abuso clásico: creer que esto es un sistema de abstracción
El error más caro de este nivel no es sintáctico. Consiste en tratar el par de palabras clave como si fuera una interfaz, y por tanto declarar esperadas clases grandes con estado, ciclo de vida y una docena de miembros: un almacén, un cliente de red, una base de datos. El código compila, cada plataforma tiene su versión y la sensación es de arquitectura limpia. Los problemas llegan después y todos tienen la misma raíz, que es que el emparejamiento ocurre en tiempo de compilación y produce exactamente una realización por objetivo.
La primera consecuencia es que no hay polimorfismo: no puedes tener dos realizaciones distintas en la misma plataforma, así que no hay manera de sustituir esa clase por un doble en una prueba, salvo introduciendo banderas dentro de la propia realización, que es peor que el problema que resolvía. La segunda es que el contrato compartido se reduce a las firmas; nada obliga a que las realizaciones se comporten igual, y como no hay un tipo común que puedas someter a una batería de pruebas de contrato, la divergencia semántica no tiene dónde ser detectada. La tercera es que el mecanismo es cerrado: si esa clase se publica en una biblioteca, quien la consume no puede realizarla para un objetivo que tú no cubriste.
// Síntoma de abuso: una clase esperada que en realidad quería ser una interfaz
expect class AlmacenDeSesion {
fun guardar(token: String)
fun leer(): String?
fun borrar()
fun observarCambios(): Flow<String?>
}
La heurística que resiste el uso real es sencilla de enunciar y bastante afilada. Usa una declaración esperada cuando lo que necesitas es una hoja: una función pequeña, sin estado propio, que traduce una llamada del código común a la interfaz nativa correspondiente y de la que solo puede haber una versión sensata por plataforma. En cuanto aparezca estado que sobreviva entre llamadas, un ciclo de vida que alguien deba cerrar, o el deseo de sustituirla en pruebas, lo que tienes delante es una interfaz con implementaciones por plataforma, y el mecanismo correcto es el de la lección siguiente.
Legítimo
Hora del sistema, aleatoriedad criptográfica, identificador de instalación, nombre de la plataforma, separador de rutas. Funciones puras o casi, sin estado y sin sustituto razonable.
Dudoso
Un tipo con dos o tres miembros que envuelve una capacidad del sistema. Suele funcionar, pero conviene revisarlo cuando llegue la primera prueba que lo necesite falso.
Abuso
Clases con estado, con recursos que abrir y cerrar, o con más de un puñado de miembros. Aquí el mecanismo deja de ayudar y empieza a estorbar.
Publicado
Cualquier declaración esperada que salga en el interfaz público de una biblioteca. Quien la consuma no podrá realizarla jamás para un objetivo que tú no cubriste.
Hay un cuarto efecto, menos evidente y bastante insidioso, que aparece cuando la clase esperada crece: la firma común empieza a filtrar el vocabulario de la plataforma dominante. Como quien la escribe suele tener una plataforma en la cabeza, los nombres de los métodos, el orden de los parámetros y hasta el modelo de errores acaban calcados de la interfaz nativa que conocía mejor, y la realización de la otra plataforma se convierte en un ejercicio de traducción forzada. El síntoma es reconocible: una de las dos realizaciones es corta y directa, la otra es larga y llena de adaptaciones que existen solo para parecerse a la primera.
Una métrica útil y barata: el número de declaraciones esperadas de un proyecto debería crecer con el número de capacidades del sistema que usas, y mantenerse plano cuando crece el dominio. Si crece con el dominio, el mecanismo se está usando como sistema de abstracción.
Una abstracción, en el sentido fuerte del término, es un contrato que permite razonar sobre un programa sin conocer sus implementaciones: dado el contrato, cualquier implementación válida es intercambiable, verificable contra una batería común de pruebas y sustituible en el punto de uso. Una declaración esperada no cumple ninguna de esas tres condiciones. No es intercambiable, porque el compilador la fija a exactamente una realización por objetivo y la sustitución ocurre antes de que exista un programa. No es verificable como contrato, porque no hay ningún tipo que reciba a la vez a todas las realizaciones y por tanto no hay dónde escribir la prueba que compruebe que todas se comportan igual: puedes probar cada realización por separado, que es exactamente lo mismo que harías si no existiera el mecanismo, y lo único que el sistema garantiza es que las firmas coinciden, que es la propiedad menos interesante de un contrato. Y no es sustituible en el punto de uso, porque el punto de uso no recibe nada: consume un nombre global. Lo que sí es, y lo es de forma admirable, es un agujero perfectamente recortado en el código común, con la forma de una firma, que el compilador se niega a dejar sin rellenar. Vista así, la regla de diseño se vuelve casi trivial: los agujeros deben ser pequeños, poco numerosos y estar en el borde del sistema, porque un agujero grande en el centro de tu dominio no es una frontera, es un cisma. Cada miembro que añades a una clase esperada multiplica el número de comportamientos que deben coincidir sin que nadie los compare, y llegado cierto tamaño el código común deja de ser código compartido para convertirse en un archivo de cabeceras que documenta dos programas distintos. El síntoma clínico es reconocible desde fuera: cuando arreglar un error obliga a tocar cada realización una por una y a confiar en que no se olvidó ninguna, ya no estás compartiendo lógica, estás sincronizando copias a mano con ayuda del compilador para los nombres.
- Enumera todas las declaraciones esperadas de tu proyecto y clasifícalas por número de miembros. Marca las que superen tres.
- Para cada una de las marcadas, intenta escribir una prueba que se ejecute contra todas sus realizaciones a la vez. Anota por qué no puedes.
- Convierte la mayor de ellas en una interfaz común más implementaciones por plataforma, dejando una única función esperada como fábrica.
- Realiza una clase esperada pequeña mediante alias de tipo y comprueba, desde el código de esa plataforma, cuántos miembros no previstos quedaron accesibles.
- Escribe la regla de tu equipo sobre cuándo se admite una declaración esperada, en una sola frase que mencione estado, ciclo de vida y capacidad de sustitución.