wandres.dev
WASM Y JS · los otros backends

El modelo de Wasm: memoria gestionada, frontera y tamaño

Kotlin/Wasm no coloca sus objetos en la memoria lineal sino en el montículo gestionado del motor, y esa decisión determina qué tipos pueden cruzar hacia JavaScript, qué se copia al cruzar y qué no puede cruzar de ninguna manera. Esta lección desarrolla el modelo de memoria del objetivo, cataloga la correspondencia de tipos en la frontera, explica el coste real de la interoperabilidad y su relación con el diseño de la API, y analiza de qué está hecho el artefacto que se publica.

⏱ 22 min

Todo lenguaje que compila a una plataforma ajena acaba definiendo, quiera o no, una frontera: el conjunto de valores que pueden atravesar el límite entre su mundo y el de al lado, y el precio que se paga por cada travesía. En la JVM esa frontera es casi invisible porque Kotlin y Java comparten representación de objetos. En Kotlin/Wasm no lo es en absoluto, y conviene entender por qué. Un módulo de WebAssembly con recolección de basura vive en el mismo montículo que JavaScript pero con un sistema de tipos propio, cerrado y verificado estáticamente por el motor antes de ejecutar la primera instrucción. Un objeto Kotlin es una estructura declarada en el módulo, y JavaScript no tiene forma de leer sus campos; un objeto JavaScript es un valor dinámico, y el módulo no tiene forma de razonar sobre su forma. Lo que existe entre ambos es un conjunto acotado de tipos que el compilador sabe traducir y unas cuantas cajas opacas para todo lo demás. Diseñar bien sobre este backend consiste, en buena medida, en decidir dónde poner esa frontera para cruzarla lo menos posible.

🎯 Al terminar esta lección sabrás
  • Distinguir la memoria lineal de los objetos gestionados y saber qué usa Kotlin para cada cosa.
  • Enumerar los tipos admitidos en la frontera con JavaScript y explicar por qué la lista es cerrada.
  • Elegir entre JsAny, JsReference y los tipos primitivos según lo que se quiera exponer.
  • Analizar de qué se compone el artefacto publicado y qué decisiones influyen en su tamaño.

Dos memorias que conviven

Un módulo de WebAssembly moderno dispone de dos espacios de almacenamiento con reglas incompatibles. La memoria lineal es el vector de bytes del diseño original: se direcciona con enteros, el programa la organiza como quiera y el motor no interpreta nada de su contenido. El montículo gestionado es el que aporta la propuesta de recolección de basura: contiene estructuras y vectores con campos tipados, a los que solo se accede mediante referencias que el motor conoce y recorre.

Kotlin coloca sus objetos, sus vectores y sus cadenas en el segundo. Ninguna instancia de una clase Kotlin tiene dirección numérica, y por tanto no existe aritmética de punteros ni nada que se le parezca. La memoria lineal sigue estando disponible, pero su papel es de puente hacia módulos ajenos: cuando se quiere invocar una biblioteca compilada desde C, hay que serializar los datos a bytes, escribirlos en la memoria lineal de ese módulo y pasar un desplazamiento entero.

⚠️
Consumir un módulo Wasm de terceros no es gratis

La imagen mental de que dos módulos de WebAssembly se llaman entre sí de forma directa y barata es falsa cuando uno de ellos es de Kotlin y el otro viene de C. Un objeto Kotlin no es visible desde la memoria lineal ajena, así que cada llamada exige copiar los datos a bytes, escribirlos en el espacio del otro módulo mediante interoperabilidad con JavaScript y devolver el resultado por el mismo camino. La ejecución dentro del módulo ajeno será rapidísima; el cruce, no. La regla de diseño es la de siempre: pocas llamadas gordas antes que muchas finas.

La frontera con JavaScript

Aquí es donde Kotlin/Wasm se aparta de forma más visible de Kotlin/JS. El compilador admite un conjunto cerrado de tipos en las firmas de toda declaración de interoperabilidad, y esa restricción se aplica por igual a las declaraciones external, a las funciones con cuerpo js y a lo que se anota con @JsExport.

Los tipos numéricos se corresponden con el número de JavaScript, salvo Long y ULong, que se corresponden con BigInt. Boolean y String cruzan de forma directa. Unit en posición de retorno se ve como indefinido. Los tipos función cruzan como funciones. Todo lo demás debe expresarse mediante JsAny o alguno de sus subtipos, o bien encapsularse en una referencia opaca.

external interface Usuario : JsAny {
    val nombre: String
    val edad: Int
}

external fun crearUsuario(nombre: String, edad: Int): Usuario

// Un fragmento de JavaScript incrustado como funcion de nivel superior
fun urlActual(): String = js("window.location.href")

El tipo JsAny representa cualquier valor de JavaScript, y la biblioteca estándar aporta subtipos concretos como JsBoolean, JsNumber, JsString, JsArray y Promise. Los vectores nativos de Kotlin no cruzan tal cual: hay que convertirlos a JsArray con funciones adaptadoras, y esa conversión es una copia, no una vista.

val lista: List<JsString> = listOf("Kotlin", "Wasm").map { it.toJsString() }
val jsArray: JsArray<JsString> = lista.toJsArray()   // copia
val vuelta: List<JsString> = jsArray.toList()        // otra copia

Cuando lo que se quiere es entregar a JavaScript un objeto Kotlin arbitrario, la herramienta es JsReference. El objeto viaja como una caja opaca que JavaScript puede guardar y devolver pero no inspeccionar, y toda operación sobre él exige exportar una función adicional que lo desenvuelva.

class Sesion(var token: String)

@JsExport
fun abrirSesion(token: String): JsReference<Sesion> = Sesion(token).toJsReference()

@JsExport
fun renovar(sesion: JsReference<Sesion>, token: String) {
    sesion.get().token = token
}
flowchart LR
A[Objeto Kotlin en el monticulo gestionado] -->|Primitivos y String| B[Valor JavaScript equivalente]
A -->|Coleccion| C[Copia hacia JsArray]
A -->|Objeto arbitrario| D[JsReference opaca]
E[Valor JavaScript] -->|external o JsAny| A
D -->|Requiere funcion exportada con get| A

Conviene detenerse en las cadenas, porque son el tipo que más cruza y el que menos se sospecha. Una cadena de Kotlin vive en el montículo gestionado como una estructura del módulo; una cadena de JavaScript es un valor del motor con su propia representación interna y sus optimizaciones de concatenación diferida. No son el mismo objeto y no pueden serlo, así que cada travesía implica una conversión que recorre los caracteres. En una llamada aislada es irrelevante; en un bucle que construya marcado a base de fragmentos pequeños, es el coste dominante y no aparece en ninguna línea del código fuente.

Las excepciones sí atraviesan la frontera en ambos sentidos. Un error lanzado desde JavaScript se captura en Kotlin como JsException, con acceso al valor lanzado y a la traza original; un fallo de Kotlin que escape hacia JavaScript se ve allí como un error corriente. Esta integración descansa en una propuesta adicional del motor, disponible en las versiones recientes de los tres navegadores principales.

🕸️

`JsAny`

La raíz de los valores JavaScript vistos desde Kotlin. Sustituye al tipo dynamic, que este backend no ofrece.

📦

`JsReference`

Caja opaca para objetos Kotlin que van a JavaScript. Se comporta como un objeto vacío y congelado hasta que se desenvuelve.

🧾

`external`

Describe en Kotlin la forma de algo que ya existe en JavaScript. Sin cuerpo, sin comprobación en ejecución.

Diseñar para cruzar poco

De todo lo anterior se sigue una regla de diseño que es más importante que cualquier detalle de sintaxis: en un objetivo con frontera cara, la unidad de optimización no es la función sino la interfaz. Una API de grano fino obliga a pagar la travesía una vez por elemento; la misma funcionalidad expresada con grano grueso la paga una vez por lote.

// Grano fino: una travesia por punto, mas una conversion de cadena por llamada
@JsExport
fun clasificar(etiqueta: String, valor: Double): String = modelo.clasificar(etiqueta, valor)

// Grano grueso: una sola travesia, la iteracion ocurre dentro del modulo
@JsExport
fun clasificarLote(entrada: String): String =
    Json.encodeToString(modelo.clasificarTodo(Json.decodeFromString(entrada)))

La segunda versión introduce una serialización que la primera no tenía y, aun así, suele ganar por márgenes amplios en cuanto el lote pasa de unas pocas decenas de elementos, porque sustituye un coste proporcional al número de llamadas por otro proporcional al volumen de datos. La lección no es que serializar sea bueno, sino que hay que medir el coste dominante antes de optimizar el que se ve.

Existe una variante todavía mejor cuando el estado puede quedarse dentro del módulo. En lugar de enviar y recibir el modelo completo en cada operación, se entrega a JavaScript un manejador opaco y se opera sobre él con funciones exportadas; los datos no cruzan nunca y lo único que viaja son órdenes y resultados escalares.

@JsExport
fun abrirSesionDeCalculo(): JsReference<Motor> = Motor().toJsReference()

@JsExport
fun alimentar(sesion: JsReference<Motor>, valor: Double) {
    sesion.get().acumular(valor)   // el estado nunca sale del modulo
}

@JsExport
fun resultado(sesion: JsReference<Motor>): Double = sesion.get().resultado()
💡
Mide travesías, no milisegundos

Al perfilar un módulo de Wasm, la métrica que predice el comportamiento no es el tiempo total sino el número de cruces de la frontera por unidad de trabajo. Es una magnitud que se puede contar leyendo el código, sin instrumentar nada, y que permite comparar dos diseños antes de escribir ninguno de los dos.

De qué está hecho el artefacto

Publicar un módulo de Kotlin/Wasm no produce un fichero sino dos piezas solidarias: el binario propiamente dicho y un módulo de JavaScript que hace de cargador y de tabla de conexiones. Cada declaración external y cada fragmento js que aparezca en el código se materializa como una función en ese cargador, importada por el binario. La interoperabilidad, por tanto, no es solo un coste de ejecución: también es peso.

El binario se beneficia de dos hechos estructurales. El primero es que no lleva recolector dentro, que era la partida más cara del modelo anterior. El segundo es que el compilador elimina el código no alcanzable con una agresividad que en la JVM no sería posible, porque aquí conoce el programa entero y no existe carga dinámica de clases. Esto convierte la reflexión y cualquier construcción que oscurezca la alcanzabilidad en un enemigo directo del tamaño.

Conviene además no confundir el peso del lenguaje con el del marco de trabajo. Una aplicación de Compose Multiplatform en la web arrastra su motor de dibujo, que es una biblioteca gráfica completa y domina por completo el tamaño total; un módulo de lógica de negocio expuesto a una interfaz escrita en JavaScript pesa un orden de magnitud menos. Las cifras que circulan sobre Kotlin/Wasm suelen medir lo primero y aplicarse indebidamente a lo segundo.

Hay un matiz de despliegue que compensa parte del peso y que se olvida con frecuencia. Un binario de WebAssembly se transmite comprimido y el motor puede compilarlo mientras aún se está descargando, en lugar de esperar a tenerlo entero como ocurre con el análisis sintáctico de un fichero de JavaScript. Comparar tamaños en disco entre ambos objetivos, por tanto, no responde a la pregunta que de verdad importa, que es cuánto tarda la aplicación en estar lista para atender al usuario.

⚠️
La reflexión y el tamaño son enemigos aquí

La eliminación de código muerto en este objetivo es agresiva porque el compilador asume que conoce el programa entero. Toda construcción que rompa esa suposición, desde la reflexión hasta las tablas de despacho construidas a partir de cadenas, obliga a conservar código que de otro modo se habría descartado. Un patrón de registro dinámico que en la máquina virtual de Java resulta idiomático puede aquí duplicar el tamaño del artefacto sin que nada lo advierta.

La frontera de tipos no es una carencia del backend: es la traducción, al sistema de tipos, de una diferencia ontológica entre dos modelos de objeto

Vale la pena preguntarse por qué Kotlin/Wasm impone una lista cerrada de tipos en el límite mientras Kotlin/JS admite prácticamente cualquier cosa en sus declaraciones externas, porque la respuesta no es que un backend esté menos terminado que el otro sino que ambos se apoyan en representaciones incompatibles. En Kotlin/JS un objeto Kotlin es un objeto JavaScript, sin más: comparte el mismo modelo de propiedades dinámicas, y por eso el compilador puede permitirse ser laxo, ya que la travesía de la frontera no es una conversión sino un cambio de punto de vista sobre el mismo valor. En Kotlin/Wasm un objeto Kotlin es una estructura declarada en el sistema de tipos del módulo, verificada por el motor antes de ejecutar nada y sin propiedades nombradas accesibles desde fuera; JavaScript literalmente no dispone de operación alguna para leer un campo de esa estructura. Ante esa asimetría solo caben tres salidas y el diseño de Kotlin las usa las tres a la vez: enumerar los valores para los que sí existe una traducción canónica, que son los primitivos y las cadenas; ofrecer una envoltura opaca para los objetos que solo necesitan viajar de ida y vuelta sin ser inspeccionados, que es JsReference; y proporcionar un tipo raíz que represente lo ajeno visto desde dentro, que es JsAny. La restricción, leída así, no empobrece el backend sino que hace explícito en el sistema de tipos un coste que en otros lenguajes queda oculto en la biblioteca de enlace y se descubre tarde, en forma de copias inesperadas o de fugas. El corolario práctico es de diseño de API y no de sintaxis: en un objetivo con frontera cara, la calidad de una arquitectura se mide por cuánto trabajo consigue mantener de un solo lado, y la peor decisión posible es una interfaz de grano fino que obligue a cruzar el límite en cada iteración de un bucle.

⚔️ Cartografía tu propia frontera
  1. Toma un módulo de lógica que quieras exponer y clasifica cada tipo de su API pública en las tres categorías: cruza directo, exige copia, exige JsReference.
  2. Rediseña una función de grano fino que se llamaría en un bucle desde JavaScript para que cruce la frontera una sola vez. Mide ambas versiones.
  3. Provoca una excepción en un fragmento js y captúrala en Kotlin como JsException. Comprueba qué información conserva la traza.
  4. Compila con y sin declaraciones external innecesarias y observa cómo cambia el módulo cargador generado.
  5. Compara el tamaño de un artefacto con interfaz de Compose frente al de un módulo que solo exporta funciones, y explica a qué se debe la diferencia.