vararg y el operador de propagación
Un parámetro que acepta cualquier número de argumentos y el asterisco que despliega un array dentro de la llamada: cómo se representa realmente en el bytecode, qué pasa al mezclarlo con parámetros normales y con valores por defecto, y dónde están sus límites duros dentro del sistema de tipos.
vararg es la única parte de una firma Kotlin donde la aridad deja de ser fija. Escribes un parámetro y el llamador pasa cero, uno o mil argumentos. La abstracción es tan cómoda que se usa sin pensar, y ahí empiezan las sorpresas: por debajo no hay ninguna aridad variable, hay un array que alguien asigna en cada llamada. Entender esa traducción explica de golpe por qué solo puede haber un vararg, por qué no admite valor por defecto, por qué no puedes propagar una lista y por qué el asterisco a veces cuesta una copia.
- Declarar y consumir un parámetro
varargsabiendo qué tipo recibe el cuerpo. - Explicar su representación exacta en el bytecode y el coste por llamada.
- Combinar
varargcon parámetros normales, con nombres y con valores por defecto. - Reconocer los límites del operador de propagación y sus alternativas.
Un parámetro, cualquier número de argumentos
El modificador vararg marca el parámetro que absorbe todos los argumentos sueltos que el llamador pase en esa posición. Dentro del cuerpo, ese parámetro es un array.
fun unir(separador: String, vararg partes: String): String =
partes.joinToString(separador)
unir("-") // cero argumentos: array vacio
unir("-", "a") // uno
unir("-", "a", "b", "c") // tres
El tipo que ve el cuerpo no es Array<String> sino Array<out String>. Esa proyección no es un detalle cosmético: impide escribir en el array, y por tanto impide que la función corrompa datos que el llamador podría estar compartiendo. partes[0] se lee; partes[0] = "x" no compila.
Para los tipos primitivos Kotlin no usa el array genérico sino el array especializado. vararg n: Int entrega un IntArray, no un Array<Int>, y así se evita el boxing de cada elemento. Con un parámetro de tipo genérico, en cambio, el borrado hace su trabajo: vararg t: T acaba siendo un array de objetos, con boxing incluido para los primitivos.
fun suma(vararg n: Int): Int = n.sum() // n es IntArray: sin boxing
fun <T> primero(vararg t: T): T = t.first() // t es Array<out T>: con boxing
Lo que ve la JVM: siempre un array
En el bytecode no existe la aridad variable. El compilador emite un método cuyo último parámetro relevante es un array, marcado con el indicador de varargs de la JVM, y en cada llamada inserta la creación del array. Es exactamente el mecanismo de Java, lo que explica que la interoperabilidad sea perfecta en ambos sentidos: un vararg de Kotlin se llama desde Java como varargs y viceversa.
La consecuencia práctica es que cada invocación asigna. Un vararg en un bucle caliente es un array por iteración, y la presión sobre el recolector puede ser real aunque el análisis de escape del JIT rescate muchos casos. La stdlib lo sabe: por eso listOf tiene una sobrecarga sin argumentos que devuelve la lista vacía compartida, y una de un solo elemento que no crea array alguno.
El patrón de la stdlib es el remedio: deja el vararg como caso general y añade sobrecargas con uno, dos y tres parámetros normales. La resolución de sobrecarga prefiere siempre la de aridad fija frente a la variádica, así que las llamadas comunes dejan de asignar sin que nadie cambie una línea en el sitio de llamada.
Para pasar un array ya existente a un vararg está el operador de propagación, el asterisco delante del argumento. Sin él estarías pasando un argumento que resulta ser un array; con él, el array se despliega en tantos argumentos como elementos tenga.
val extras = arrayOf("b", "c")
unir("-", *extras) // a nivel logico: unir("-", "b", "c")
unir("-", "a", *extras, "d") // se puede mezclar en cualquier posicion
La propagación tampoco es gratis. Cuando el asterisco convive con elementos sueltos, el compilador no puede reutilizar el array del llamador: construye uno nuevo del tamaño total y copia. Cuando el * es el único argumento de esa posición, la copia puede evitarse y el array viajar tal cual, que es justo la razón por la que el cuerpo recibe Array<out T> y no puede escribirlo.
flowchart TD A[Llamada con argumentos sueltos] --> N[El compilador crea un array nuevo] B[Llamada con asterisco solo] --> P[Puede pasar el array tal cual] C[Asterisco mezclado con sueltos] --> N N --> F[Metodo con parametro de tipo array] P --> F F --> U[El cuerpo lo lee como Array de solo lectura] style N fill:#f9e2af,color:#11111b style U fill:#a6e3a1,color:#11111b
Convivir con el resto de la firma
vararg no está obligado a ir al final, aunque sea lo habitual. Puede ocupar cualquier posición, con una regla que se deduce sola: todo parámetro declarado después del vararg solo puede recibirse con nombre, porque los argumentos posicionales ya se los tragó el variádico.
fun informe(vararg lineas: String, titulo: String = "Informe", ancho: Int = 80) {
println(titulo.padEnd(ancho, '.'))
lineas.forEach(::println)
}
informe("uno", "dos", titulo = "Resumen") // titulo obligatoriamente nombrado
Esa combinación es idiomática y muy útil: el variádico recoge el contenido y los parámetros con nombre y default recogen la configuración. La excepción a la regla es la lambda final, que puede ir fuera de los paréntesis sin nombrarse.
Hay dos restricciones duras que conviene memorizar. Solo puede haber un vararg por firma, porque con dos el compilador no tendría forma de saber dónde termina el primero. Y un vararg no admite valor por defecto: si se omite, el llamador recibe un array vacío, que ya es su valor por defecto natural y no se puede cambiar.
// fun malo(vararg a: Int, vararg b: Int) {} // dos vararg: no compila
// fun malo2(vararg a: Int = intArrayOf(1)) {} // default: no compila
Los límites del asterisco
El operador de propagación solo entiende arrays. Una List, que es lo que de verdad tienes casi siempre, no se puede propagar directamente y hay que convertirla, con la copia que eso implica.
val nombres: List<String> = cargarNombres()
// unir("-", *nombres) // no compila
unir("-", *nombres.toTypedArray()) // copia la lista a un array
val numeros: List<Int> = listOf(1, 2, 3)
suma(*numeros.toIntArray()) // para primitivos, el array especializado
Cuando esa conversión aparece en tu código, suele ser la señal de que el vararg estaba mal elegido: si el llamador siempre tiene una colección, un parámetro Collection<T> es más honesto, más barato y más flexible.
vararg si se escriben a mano
Los elementos aparecen literalmente en el sitio de la llamada, como en listOf o printf. La aridad variable ahorra ceremonia justo donde el humano escribe.
Colección si vienen de fuera
Los elementos llegan de otra función, de una consulta o de un fichero. Un parámetro Collection evita la conversión, evita la copia y admite cualquier implementación.
El segundo límite vive en el sistema de tipos. Un tipo función no puede declarar vararg. No existe el tipo (vararg Int) -> Unit; una referencia a una función variádica se ve como una función que toma un array, y no se puede propagar al invocar un valor de tipo función. Por eso vararg es una propiedad de la declaración, no del tipo, y se pierde en cuanto la función pasa a ser un valor.
Desde Kotlin, f("a") y f(arrayOf("a")) son llamadas distintas y sin ambigüedad. Desde Java, ambas colapsan en el mismo método, porque la firma es idéntica. Si publicas una biblioteca con consumidores Java, no ofrezcas a la vez una versión vararg y otra que reciba el array explícito.
Merece la pena mirar vararg como lo que realmente es: una grieta controlada en la uniformidad del lenguaje. Kotlin construye con enorme cuidado la ficción de que todo es un valor con un tipo y de que una función es un elemento de primera clase que puedes guardar, pasar y componer. vararg no encaja del todo en esa ficción, y sus limitaciones son exactamente las que predice esa incompatibilidad, ninguna más. No puede haber dos porque el desempaquetado sería ambiguo, y esa ambigüedad es un hecho sobre arrays, no sobre tipos. No admite default porque su valor vacío no es una elección de diseño sino la consecuencia de que un array de longitud cero ya es el caso base. No existe en los tipos función porque la aridad variable es una convención del sitio de la llamada, resuelta en compilación, y un tipo función describe una invocación en tiempo de ejecución donde esa convención ya se consumió. Y el operador de propagación necesita un array, no una lista, porque no es un operador del lenguaje de valores sino una instrucción para el generador de código: dile al compilador que este array es la lista de argumentos. Cuando una característica tiene un ramillete de restricciones que parecen arbitrarias, casi siempre es una abstracción con una fuga, y el modo de dejar de memorizar sus reglas es identificar de dónde fuga. Aquí fuga por la representación: bajo la aridad variable no hay aridad variable, hay un array, y todas las reglas se deducen de esa única frase.
- Escribe una función con
vararg n: Inty otra convararg t: T. Compila ambas, inspecciónalas conjavapy compara el tipo del parámetro generado. - Intenta escribir en el parámetro
varargdentro del cuerpo. Lee el error y relaciónalo con la proyección de solo lectura. - Mide en un microbenchmark la diferencia entre llamar un millón de veces a una función
varargde dos argumentos y a una sobrecarga de aridad fija. - Declara un parámetro después del
varargy llama a la función sin nombrarlo. Explica el error con tus palabras. - Convierte una API que recibe
vararga una que recibeCollection, y decide en qué lado de la frontera queda cada llamada existente.