wandres.dev
FUNCIONES · la unidad de trabajo

Funciones de nivel superior y ficheros

Qué clase fabrica el compilador cuando escribes funciones sueltas en un fichero, cómo controlar su nombre con JvmName y fundir varios ficheros en una sola fachada, y cómo diseñar una API organizada por paquetes en lugar de por clases envoltorio llenas de estáticos.

⏱ 17 min

Kotlin te deja escribir una función sin clase que la contenga. Es una de esas libertades que se disfrutan sin pensar hasta que alguien llama a tu código desde Java y descubre una clase llamada UtilidadesKt que tú nunca escribiste. Esa clase existe, tiene un nombre concreto, depende del nombre de tu fichero y forma parte de tu API pública en la JVM. Aprender a controlarla es la diferencia entre una biblioteca que se organiza sola y una que se rompe cuando alguien renombra un archivo.

🎯 Al terminar esta lección sabrás
  • Entender el fichero como unidad de compilación y el paquete como espacio de nombres.
  • Saber qué clase sintética genera el compilador y cómo se llama.
  • Controlar ese nombre con @JvmName y unificar ficheros con @JvmMultifileClass.
  • Diseñar una API por paquetes y extensiones en vez de clases de utilidades.

El fichero no es la clase

En Java el fichero y la clase pública están atados: mismo nombre, misma unidad, un tipo por archivo. En Kotlin esa atadura no existe. Un fichero puede contener cero, una o veinte declaraciones de nivel superior, de cualquier clase, y su nombre no tiene que coincidir con ninguna.

// fichero: texto/Normalizacion.kt
package com.ejemplo.texto

private val ESPACIOS = Regex("\\s+")

fun normalizar(s: String): String = s.trim().replace(ESPACIOS, " ")

fun String.sinAcentos(): String = /* ... */ this

internal fun claveDeBusqueda(s: String) = normalizar(s).lowercase()

El espacio de nombres real es el paquete, no una clase artificial. Eso elimina de un plumazo la familia de clases con constructor privado y métodos estáticos que Java obliga a inventar, y hace que la importación sea granular: se importa una función concreta, y con as se le pone un alias local si choca con otra.

A diferencia de lo que ocurría con las funciones locales, aquí el orden de declaración es irrelevante: dentro de un fichero, cualquier declaración de nivel superior ve a todas las demás, estén arriba o abajo. Y las visibilidades cambian de significado: private confina la declaración a su fichero, e internal la abre a todo el módulo pero la cierra al resto del mundo.

La clase que no escribiste

La JVM no admite métodos fuera de una clase, así que el compilador fabrica una fachada. La regla es mecánica: se toma el nombre del fichero, se quita la extensión, se pone la inicial en mayúscula y se añade el sufijo. Un fichero Normalizacion.kt produce la clase NormalizacionKt, con todas las funciones de nivel superior como métodos estáticos públicos y las propiedades de nivel superior como campos estáticos con sus accesores.

// Lo que ve Java del fichero anterior:
String limpio = NormalizacionKt.normalizar("  hola   mundo ");
String plano  = NormalizacionKt.sinAcentos("canción");

Fíjate en el segundo caso: una función de extensión no es un método del tipo receptor, es un método estático cuyo primer parámetro es el receptor. Desde Kotlin se lee como si perteneciera a String; desde Java se ve la verdad.

De aquí sale una trampa que muerde a las bibliotecas. El nombre del fichero es parte de tu API binaria en la JVM. Renombrar Normalizacion.kt a Texto.kt no cambia una sola línea de código Kotlin ni rompe a ningún consumidor Kotlin, pero cambia la clase de NormalizacionKt a TextoKt y todo binario Java compilado contra la versión anterior falla al enlazar. Es una refactorización aparentemente cosmética con consecuencias de versión mayor.

flowchart TD
F[Fichero Normalizacion punto kt] --> C[Clase NormalizacionKt]
C --> M1[normalizar como metodo estatico]
C --> M2[sinAcentos con el receptor como primer parametro]
C --> P[Propiedades como campos estaticos]
R[Renombrar el fichero] --> N[Cambia el nombre de la clase]
N --> B[Rompe a los consumidores Java ya compilados]
style B fill:#f38ba8,color:#11111b
style C fill:#89b4fa,color:#11111b

@JvmName y la fachada multifichero

La solución es dejar de depender del nombre del archivo y fijarlo explícitamente. La anotación va al principio del fichero, con el prefijo de destino de fichero, y antes de la declaración package.

@file:JvmName("Textos")

package com.ejemplo.texto

fun normalizar(s: String): String = /* ... */ s

Ahora Java escribe Textos.normalizar(...) y tú puedes renombrar el .kt cuantas veces quieras. Para una biblioteca con consumidores Java, esta anotación no es un adorno: es lo que convierte el nombre de un archivo en un detalle interno.

El siguiente problema aparece al crecer. Quieres partir un fichero de dos mil líneas en cinco, pero que Java siga viendo una sola clase de utilidades. Poner el mismo @JvmName en los cinco es un error de compilación por nombre duplicado, salvo que añadas también la anotación de clase multifichero:

// Cadenas.kt
@file:JvmName("Textos")
@file:JvmMultifileClass
package com.ejemplo.texto

fun normalizar(s: String) = s.trim()
// Comparacion.kt
@file:JvmName("Textos")
@file:JvmMultifileClass
package com.ejemplo.texto

fun similares(a: String, b: String) = normalizar(a) == normalizar(b)

El compilador genera entonces una clase fachada Textos que reexpone todo, más una clase parte por cada fichero con nombres derivados que no debes tocar. Para Kotlin no cambia nada; para Java aparece una única puerta de entrada. Es exactamente el mecanismo con el que la propia stdlib presenta cientos de extensiones repartidas en decenas de ficheros bajo nombres como StringsKt o CollectionsKt.

ℹ️
Las anotaciones de fichero van antes del package

El orden correcto es: anotaciones con prefijo de fichero, luego package, luego los import. Si las colocas después del paquete no compilan, y es el error más común la primera vez que se usan.

⚠️
internal no es privado para Java

Una declaración internal es invisible desde otros módulos Kotlin, pero en el bytecode acaba siendo pública: Java puede llamarla. Kotlin lo mitiga añadiendo un sufijo con el nombre del módulo al nombre JVM de los miembros internal, lo que hace la llamada incómoda y evidentemente accidental, pero no imposible. Si algo debe ser inalcanzable de verdad, la frontera es el .kt con private.

Organizar una API sin clases envoltorio

🏷️

JvmName fija el nombre

Desacopla la clase generada del nombre del archivo. Una línea al principio del fichero que convierte un renombrado en una operación segura.

🧩

JvmMultifileClass funde ficheros

Varios .kt con el mismo @JvmName se presentan a Java como una fachada única. Es como la stdlib reparte cientos de extensiones sin multiplicar puertas de entrada.

Con estas piezas, el diseño de una biblioteca Kotlin deja de girar en torno a clases contenedoras. Cuatro criterios que se sostienen bien.

Agrupa por concepto y no por naturaleza técnica. Un paquete pedidos con sus tipos, sus extensiones y sus funciones libres se lee mucho mejor que un paquete utils con todo lo que no cabía en otro sitio. El nombre utils es casi siempre la confesión de que no se encontró el concepto.

Prefiere la extensión a la función suelta cuando haya un receptor natural. fun String.sinAcentos() aparece al escribir un punto tras cualquier cadena; fun sinAcentos(s: String) solo lo encuentra quien ya sabe que existe. El descubrimiento en el IDE es una propiedad de diseño, no un accidente.

Marca la frontera con visibilidades explícitas. Todo lo que no sea contrato debería ser internal o private. En una biblioteca, activar el modo de API explícita obliga además a anotar visibilidad y tipo de retorno de cada declaración pública, y convierte esa disciplina en un error de compilación en lugar de una intención.

Y fija los nombres JVM desde el primer día si esperas consumidores Java. Poner @file:JvmName cuando el fichero se crea cuesta una línea; ponerlo después de publicar significa mantener las dos clases durante un ciclo de deprecación.

Tu API pública es más grande que la lista de lo que declaraste público

La lección profunda de este nivel no es cómo se llama una clase generada, sino que la superficie de compatibilidad de una biblioteca incluye cosas que nunca escribiste como parte de su diseño. Al publicar un artefacto no estás publicando solo los nombres, tipos y visibilidades que aparecen en el código: publicas también los nombres de los parámetros, porque alguien los usará con argumentos nombrados; los valores por defecto, porque quedan compilados en tu lado y no en el suyo; el nombre de cada fichero, porque se convierte en una clase que otros enlazan; y hasta la aridad exacta de los métodos sintéticos que el compilador emitió a tus espaldas. La mayoría de las roturas serias de compatibilidad no vienen de cambiar un tipo, que el compilador señala en el acto, sino de mover una de estas piezas invisibles, que no genera ningún aviso hasta que un binario de un tercero falla en producción con un error de enlazado. De ahí sale la única regla operativa que sirve: si algo puede ser observado desde fuera de tu módulo, es API, lo hayas diseñado o no, y la manera de recuperar el control no es recordarlo sino atarlo explícitamente, con @JvmName que fija el nombre, con internal que reduce lo observable, con el modo de API explícita que impide que un tipo inferido se cuele en la firma, y con una herramienta de validación binaria que compare cada versión con la anterior. Escribir una función suelta en un fichero es cómodo justamente porque el lenguaje decide muchas cosas por ti; convertirla en biblioteca consiste en volver a tomar, una por una, todas esas decisiones.

⚔️ Toma el control de tu fachada
  1. Crea un fichero con tres funciones de nivel superior y una propiedad, compílalo e inspecciona la clase generada con javap. Anota el nombre y los modificadores exactos.
  2. Llama a esas funciones desde una clase Java. Después renombra el fichero, recompila solo Kotlin y ejecuta el Java antiguo. Documenta el error de enlazado.
  3. Añade @file:JvmName para fijar el nombre y repite el experimento anterior. Comprueba que ya no se rompe.
  4. Parte el fichero en dos y únelos con @file:JvmMultifileClass. Localiza en la salida compilada la fachada y las clases parte.
  5. Elige un paquete utils de tu código y reorganízalo por conceptos, convirtiendo en extensiones todo lo que tenga un receptor natural. Cuenta cuántas funciones dejaron de necesitar su primer parámetro.