Conjuntos de fuentes y la jerarquía que decide qué ve cada compilación
Un conjunto de fuentes no es una carpeta: es un nodo de un grafo dirigido acíclico que aporta declaraciones, dependencias y opciones de lenguaje a todas las compilaciones que lo incluyen. Esta lección reconstruye ese grafo desde su única arista primitiva, explica por qué existen los conjuntos intermedios y cómo se calcula la superficie de plataforma que cada uno puede ver, y demuestra que añadir un objetivo puede romper código común sin haber tocado una sola línea de ese código.
Casi todo el mundo aprende los conjuntos de fuentes por imitación: hay una carpeta que se llama commonMain, hay otras que se llaman como los objetivos, uno pone el código donde el proyecto de ejemplo lo puso y la cosa compila. Esa comprensión aguanta hasta el primer día en que no compila y el mensaje del compilador dice que un símbolo perfectamente existente no está disponible aquí, o hasta el día en que añadir un objetivo nuevo rompe un archivo que nadie ha tocado en meses. En ese momento hay que sustituir la intuición de carpeta por la definición real, que es más simple y mucho más predictiva: un conjunto de fuentes es un nodo con nombre que agrupa archivos, dependencias y ajustes de lenguaje, y que se conecta con otros nodos mediante una sola clase de arista. Todo lo demás —la visibilidad de las declaraciones, la disponibilidad de las bibliotecas de plataforma, el lugar donde puede vivir una realización— se deduce mecánicamente de la forma de ese grafo y del conjunto de objetivos que cada nodo alcanza.
- Definir un conjunto de fuentes como nodo del grafo de compilación y distinguirlo de la carpeta que lo respalda.
- Explicar la arista
dependsOny en qué se diferencia de una dependencia ordinaria entre módulos. - Calcular qué declaraciones y qué bibliotecas de plataforma ve un conjunto intermedio a partir del conjunto de objetivos que participa en él.
- Anticipar cómo cambia esa visibilidad al añadir o quitar un objetivo, y por qué la jerarquía por defecto no siempre basta.
El nodo, la arista y la compilación
Empecemos por separar tres cosas que la costumbre confunde. Un objetivo es una plataforma concreta para la que quieres producir artefactos, con su arquitectura y su backend. Una compilación es la ejecución del compilador que produce esos artefactos para un objetivo, y cada objetivo tiene normalmente dos, la principal y la de pruebas. Un conjunto de fuentes es la unidad de agrupación de código, y su rasgo esencial es que no pertenece a un objetivo: participa en todas las compilaciones que lo incluyen, que pueden ser una o veinte.
// build.gradle.kts, escrito dentro del bloque kotlin
jvm()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
val iosMain by getting // conjunto intermedio, creado por la plantilla
}
La única arista primitiva del grafo se llama dependsOn y va del conjunto más específico al más general. Su semántica no es la de una dependencia entre módulos, y ahí está el malentendido más frecuente. Una dependencia ordinaria dice quiero usar lo que ese artefacto ya compiló; dependsOn dice algo mucho más fuerte: mis archivos y los suyos se compilan juntos en la misma compilación, con tres consecuencias inmediatas. La primera, que el conjunto hijo ve las declaraciones internal del padre, cosa imposible entre módulos. La segunda, que las dependencias declaradas en el padre se propagan al hijo. La tercera, y la que da nombre al mecanismo del siguiente nivel, que una declaración esperada en el padre puede realizarse en el hijo.
El grafo debe ser dirigido y acíclico, y su raíz práctica es commonMain, del que todo lo demás desciende. Los conjuntos de pruebas forman un grafo paralelo con la misma forma, con una asimetría que conviene tener presente: commonTest depende de commonMain en el sentido ordinario, es decir, ve su código pero no comparte compilación con él, mientras que dentro del eje de pruebas las aristas vuelven a ser dependsOn.
Hay una tercera propiedad del nodo que se pasa por alto y que produce fallos difíciles de atribuir: cada conjunto de fuentes tiene sus propios ajustes de lenguaje. La versión del lenguaje, la versión de la interfaz de la biblioteca estándar y, sobre todo, la lista de anotaciones de adopción explícita que están permitidas se declaran por nodo. Es perfectamente posible que un archivo compile en el conjunto de la máquina virtual y falle en el común no por qué llama sino porque el permiso para llamarlo estaba concedido solo en uno de los dos.
sourceSets.all {
languageSettings {
optIn("kotlin.experimental.ExperimentalNativeApi")
}
}
La última pieza del vocabulario es la relación entre nodo y compilación, que es de muchos a muchos y en ambos sentidos. Un conjunto participa en tantas compilaciones como objetivos alcance por debajo, y una compilación consume tantos conjuntos como haya en el camino desde su nodo por defecto hasta la raíz. De ahí se sigue algo que conviene tener presente al diagnosticar tiempos de compilación: un archivo colocado en commonMain de un proyecto con ocho objetivos se compila ocho veces, más una novena para los metadatos. La jerarquía no es solo semántica; también es la variable que multiplica el trabajo.
flowchart BT F[iosArm64Main] --> E[iosMain] G[iosSimulatorArm64Main] --> E E --> D[appleMain] H[macosArm64Main] --> D D --> C[nativeMain] C --> A[commonMain] B[jvmMain] --> A I[jsMain] --> A
Conjuntos intermedios y el cálculo de la superficie visible
Si el grafo solo tuviera dos niveles, común y objetivos, cualquier código que sirviera para las dos variantes de iOS habría que escribirlo dos veces, o forzarlo hacia el común aunque no valiera para la máquina virtual. Los conjuntos intermedios existen exactamente para eso: son nodos que agrupan un subconjunto de objetivos y permiten compartir código entre ellos. nativeMain, appleMain, iosMain y jvmAndAndroidMain son ejemplos habituales, y desde hace varias versiones el complemento de Gradle los crea automáticamente mediante una plantilla de jerarquía por defecto que se aplica sin que tengas que escribirla.
Esa plantilla no inventa nombres al azar: describe una taxonomía de familias de plataformas que refleja lo que las plataformas realmente comparten. Todo lo compilado por el backend nativo cuelga de un nodo común; dentro de él, los sistemas de Apple forman su propia rama porque comparten las bibliotecas fundacionales; dentro de esa rama, cada sistema concreto agrupa sus arquitecturas. La consecuencia es que la mayoría de proyectos nunca necesita declarar una sola arista, y que quien lo hace suele estar reescribiendo a mano algo que ya existía.
Ahora viene la parte que hay que interiorizar, porque explica casi todos los errores desconcertantes de este territorio. Un conjunto de fuentes puede ver dos cosas distintas. Las declaraciones de Kotlin le llegan desde arriba: ve todo lo que declaran los conjuntos de los que depende, transitivamente. Las bibliotecas de plataforma, en cambio, le llegan desde abajo, y no como unión sino como intersección: un conjunto intermedio solo puede usar aquellas interfaces del sistema que están presentes en todos los objetivos que participan en él.
// iosMain: compila, porque Foundation existe en todos los objetivos de iOS
import platform.Foundation.NSUUID
internal fun nuevoIdentificador(): String = NSUUID().UUIDString()
De la regla de intersección se deduce un comportamiento que sorprende la primera vez y que conviene provocar a propósito en un proyecto de juguete: añadir un objetivo puede romper código que no has tocado. Si appleMain contenía una llamada disponible en iOS y en macOS, y mañana declaras un objetivo de reloj cuyo sistema no la ofrece, esa llamada deja de estar en la intersección y el archivo deja de compilar. No es un fallo del compilador; es la definición de lo que significa compartir código entre plataformas hecha visible. La solución nunca es forzar la llamada, sino bajarla a un conjunto más específico o introducir una frontera declarada.
La misma regla de intersección se aplica, con un matiz importante, a las dependencias externas. Una biblioteca declarada en commonMain solo es utilizable desde ahí si publica variantes para todos los objetivos del proyecto; si le falta uno, el error no aparece en la resolución sino al compilar el conjunto común, y el mensaje habla de un símbolo que no se resuelve en una plataforma concreta. Declararla en un nodo intermedio en lugar de en la raíz es a menudo la respuesta correcta, y es también la razón por la que las bibliotecas del ecosistema se apresuran a publicar variantes en cuanto aparece un objetivo nuevo: mientras no lo hagan, su presencia en el común impide a sus usuarios adoptarlo.
sourceSets {
// Solo los objetivos que tienen sistema de ficheros de verdad
val posixMain by creating { dependsOn(commonMain.get()) }
linuxX64Main.get().dependsOn(posixMain)
macosArm64Main.get().dependsOn(posixMain)
}
Queda por precisar cómo se compila realmente un nodo intermedio, porque ahí está la explicación última del comportamiento. Ese nodo no se compila una sola vez sino dos veces en sentidos distintos: participa en la compilación de cada objetivo que cuelga de él, donde ve la plataforma completa de ese objetivo, y participa además en una compilación de metadatos que lo analiza contra la intersección de todas ellas. Esa segunda pasada es la que emite el error de disponibilidad, y por eso a veces se ve el desconcertante efecto de que el proyecto compile para un objetivo concreto y falle al construirlo entero. No es una incoherencia: son dos preguntas distintas, y la que importa para compartir es la segunda.
Raíz común
Ve solo la biblioteca estándar común y las dependencias con variante para todos los objetivos. Es el nodo que más promete y por tanto el más frágil ante un objetivo nuevo.
Intermedio de familia
Creado por la plantilla. Agrupa objetivos que comparten bibliotecas fundacionales, y es donde deben vivir la mayoría de las realizaciones.
Intermedio propio
Declarado a mano para agrupaciones que la plantilla no contempla. Legítimo, pero exige poder enunciar qué propiedad comparten sus objetivos.
Hoja de plataforma
Un solo objetivo, superficie completa y ninguna restricción de intersección. El único sitio donde nada te impide llamar a lo que quieras.
Cuando el compilador dice que un símbolo no se resuelve en un conjunto intermedio, la pregunta no es dónde está la biblioteca, sino qué objetivos participan en ese nodo. Casi siempre hay uno inesperado dentro, y el símbolo desapareció el día que entró.
Cuándo la plantilla por defecto no basta
La jerarquía por defecto cubre las agrupaciones que la mayoría de proyectos necesita, y mientras te sirva lo mejor es no tocarla. Deja de servir en dos situaciones. La primera, cuando quieres compartir código entre un grupo de objetivos que la plantilla no contempla, como la máquina virtual y Android juntos frente a todo lo demás. La segunda, cuando declaras un objetivo que la plantilla incluiría en un nodo que tú no quieres poblar, momento en el que el complemento avisa de que hay conjuntos creados que no contienen archivos. Ese aviso, dicho sea de paso, es informativo y no un error: un nodo vacío no cuesta nada salvo ruido, y silenciarlo desactivando la plantilla entera suele salir mucho más caro que ignorarlo.
sourceSets {
val jvmAndAndroidMain by creating { dependsOn(commonMain.get()) }
jvmMain.get().dependsOn(jvmAndAndroidMain)
androidMain.get().dependsOn(jvmAndAndroidMain)
}
Escribir aristas a mano es legítimo, pero hay que hacerlo entendiendo que se combinan con las que la plantilla ya puso, no las sustituyen; añadir un nodo propio y colgar de él dos hijos que ya dependían de commonMain produce un grafo con dos caminos hacia la raíz, lo cual es correcto y esperado, porque dependsOn describe alcanzabilidad y no exclusividad. Lo que no puedes hacer es crear un ciclo, ni colgar un conjunto de plataforma de dos intermedios que aporten superficies contradictorias sin asumir que la intersección resultante será la de todos sus descendientes.
Una advertencia sobre nombres, porque cuesta horas a mucha gente: el nombre de un conjunto de fuentes y el nombre de la carpeta que lo respalda son cosas independientes. La convención por defecto asocia el nodo llamado de una forma con el directorio homónimo, pero el nodo existe aunque la carpeta no, y la carpeta no crea nada si el nodo no está declarado. Media hora de desconcierto por un archivo que el compilador ignora se explica casi siempre por un directorio escrito con una letra distinta a la del nodo que se esperaba poblar.
Antes de crear un conjunto intermedio, formula en voz alta la propiedad que comparten sus objetivos. Si la frase resultante nombra dispositivos en lugar de capacidades, probablemente el nodo correcto sea otro y más arriba.
Hay además una consecuencia de diseño que se aprecia poco: la jerarquía es el lugar donde se decide la granularidad de las realizaciones. Una declaración esperada en commonMain no tiene por qué realizarse en cada objetivo hoja; basta con que exista una realización en algún conjunto que cubra cada objetivo. Colocarla en appleMain en lugar de repetirla en cuatro nodos hoja es la diferencia entre un proyecto que escala al añadir arquitecturas y uno que multiplica archivos cada vez que aparece un procesador nuevo.
Merece la pena ilustrar esa diferencia con el caso más común de todos, el de los simuladores. Un proyecto que soporte dispositivos y simuladores de Apple tiene al menos tres objetivos de la familia, y quien no conozca los nodos intermedios acabará con tres copias idénticas de cada realización, mantenidas a mano y divergiendo en cuanto alguien corrija un error en solo dos de ellas. Con un nodo iosMain bien colocado, esas tres copias son una sola y añadir una arquitectura futura no cuesta nada.
// Una sola realización en iosMain cubre dispositivo y simuladores
// iosArm64Main, iosSimulatorArm64Main y iosX64Main la heredan sin escribir nada
Hay finalmente una asimetría entre los dos ejes del grafo que conviene aprovechar deliberadamente: el eje de pruebas puede tener una forma distinta a la del eje principal. Nada obliga a que exista un nodo de pruebas por cada nodo principal, y en la práctica casi todas las pruebas de contrato deberían vivir en commonTest, porque escritas ahí se ejecutan una vez por objetivo y por tanto verifican, sin coste adicional, que todas las realizaciones se comportan igual. Una jerarquía principal muy ramificada acompañada de un eje de pruebas casi plano suele ser señal de un proyecto bien diseñado; la situación inversa suele indicar que hay lógica de dominio atrapada en nodos de plataforma.
En la mayoría de sistemas, las suposiciones que hace un módulo sobre su entorno viven en la cabeza de quien lo escribió, se documentan con suerte en un comentario y se descubren cuando alguien lo reutiliza en un contexto para el que nunca se pensó. En un proyecto multiplataforma esas suposiciones están escritas, son verificables y el compilador las impone en cada compilación: un archivo colocado en commonMain afirma que su contenido tiene sentido bajo cualquier combinación de recolector de basura, modelo de hilos, sistema de ficheros y representación de texto que tus objetivos impongan, y esa afirmación no es retórica, porque si es falsa el proyecto no compila. Verlo así reordena la manera de trabajar. La pregunta al escribir una clase deja de ser dónde la pongo para que compile y pasa a ser qué estoy dispuesto a prometer sobre ella, porque subir un archivo hacia la raíz del grafo es fortalecer una promesa y bajarlo es debilitarla. Un equipo que empuja código hacia el común sin pensar acaba con un conjunto raíz que promete demasiado y que se rompe cada vez que entra un objetivo nuevo; un equipo que lo evita acaba duplicando lógica en nodos hermanos que divergen en silencio. La jerarquía bien construida hace visible el compromiso: cada nodo intermedio es literalmente el enunciado de un grupo de plataformas que comparten una propiedad relevante para tu dominio —tienen sistema de ficheros, tienen Foundation, tienen hilos de verdad—, y ese enunciado sobrevive al cambio de personas mucho mejor que cualquier diagrama. La prueba definitiva de que un grafo está bien hecho es que la lista de nodos se pueda leer en voz alta como una taxonomía de tu problema y no como una lista de dispositivos, porque cuando ocurre lo segundo el grafo está describiendo el catálogo de un fabricante en lugar de describir tu sistema.
- Dibuja el grafo real de tu proyecto listando todos los conjuntos de fuentes y sus aristas. Comprueba si coincide con el que creías tener.
- Añade un objetivo nuevo que la plantilla por defecto coloque dentro de un nodo intermedio poblado y observa qué llamadas dejan de compilar. Explica cada una en términos de intersección.
- Mueve una de esas llamadas al conjunto más específico posible que siga compilando, y anota cuántos objetivos perdieron acceso a ella.
- Crea un conjunto intermedio propio para dos objetivos que la plantilla no agrupa y mueve allí código que hoy está duplicado en ambos.
- Escribe, para cada nodo intermedio de tu grafo, la frase que enuncia qué propiedad comparten sus objetivos. Si algún nodo no admite esa frase, considera eliminarlo.