wandres.dev
FUNCIONES · la unidad de trabajo

Parámetros por defecto y argumentos con nombre

Cómo una sola firma con valores por defecto sustituye a una escalera de sobrecargas, en qué orden se evalúan realmente los argumentos cuando los pasas con nombre, qué genera el compilador por debajo con su máscara de bits, y por qué añadir un parámetro con default rompe la compatibilidad binaria aunque no rompa la de fuente.

⏱ 17 min

En Java, una función con cinco parámetros opcionales se escribe cinco veces. Kotlin la escribe una vez y deja que el llamador elija qué omitir y qué nombrar. Parece pura comodidad de sintaxis, pero cambia tres cosas de fondo: el orden en que se evalúan tus argumentos, lo que el compilador genera en el bytecode, y qué partes de tu firma pasan a ser promesas públicas que ya no puedes cambiar sin romper a alguien.

🎯 Al terminar esta lección sabrás
  • Sustituir escaleras de sobrecargas por una firma única con valores por defecto.
  • Conocer el orden real de evaluación de los defaults y de los argumentos con nombre.
  • Entender la función sintética con máscara de bits que genera el compilador.
  • Saber qué cambios rompen la compatibilidad de fuente y cuáles la binaria.

De la escalera de sobrecargas a una firma

El patrón telescópico de Java existe porque el lenguaje no tiene otra forma de decir opcional: cada combinación de argumentos exige un método, y todos delegan en el más completo.

// El patron que Kotlin hace innecesario.
fun conectar(host: String) = conectar(host, 443, 5000, true)
fun conectar(host: String, puerto: Int) = conectar(host, puerto, 5000, true)
fun conectar(host: String, puerto: Int, timeout: Int) = conectar(host, puerto, timeout, true)
fun conectar(host: String, puerto: Int, timeout: Int, tls: Boolean) { /* ... */ }

Con valores por defecto queda una sola declaración, y esa declaración es además donde vive la documentación de qué significa “por defecto”:

fun conectar(
    host: String,
    puerto: Int = 443,
    timeout: Int = 5_000,
    tls: Boolean = true,
) { /* ... */ }

conectar("api.ejemplo.com")
conectar("api.ejemplo.com", 8080)
conectar("api.ejemplo.com", tls = false)          // saltamos dos parametros

La tercera llamada es la que la sobrecarga no puede replicar sin inventar una firma nueva: omitir parámetros del medio. En cuanto quieres saltarte uno y fijar el siguiente, necesitas nombrarlo. Esa es la simbiosis: los defaults hacen los parámetros opcionales y los nombres los hacen accesibles en cualquier posición.

Un default no tiene que ser una constante. Es una expresión arbitraria, evaluada en cada llamada que lo omita, y puede referirse a los parámetros declarados antes que él:

fun rango(desde: Int, hasta: Int = desde + 10, paso: Int = if (hasta > desde) 1 else -1)

Esa dependencia hacia atrás, y no hacia delante, es lo que fija el orden de resolución y evita cualquier circularidad.

🎚️

El default decide lo normal

Elegir el valor por defecto es elegir qué ocurre cuando el llamador no piensa. Pon siempre del lado seguro lo que se obtiene sin escribir nada.

🏷️

El nombre decide lo legible

Un argumento nombrado lleva su significado al sitio de la llamada. Con dos booleanos seguidos en la firma, nombrarlos deja de ser opcional en la práctica.

⚠️
Un default con efectos secundarios es una trampa

fun log(msg: String, ts: Long = System.currentTimeMillis()) se evalúa en cada llamada que omita ts, no una vez al declarar la función. Es lo que quieres aquí, pero convierte cualquier default con efectos, escritura o aleatoriedad en una fuente de comportamiento no reproducible escondida en la firma.

Los nombres mandan en la lectura y en la evaluación

Un argumento con nombre puede ir en cualquier posición, así que el orden de la llamada deja de coincidir con el de la declaración. Y aquí llega la sutileza que casi nadie comprueba: Kotlin evalúa los argumentos en el orden en que aparecen en la llamada, no en el orden en que están declarados.

fun trazar(a: Int, b: Int, c: Int) = println("$a $b $c")

var n = 0
fun sig(etq: String): Int { println("evaluo $etq"); return ++n }

trazar(c = sig("c"), a = sig("a"), b = sig("b"))
// Imprime: evaluo c, evaluo a, evaluo b  --> luego "2 3 1"

Mientras tus argumentos sean expresiones puras da igual. En cuanto uno abre un fichero, incrementa un contador o consume de un iterador, el orden textual de la llamada es el que decide el resultado. Los defaults omitidos se resuelven después, ya dentro de la función, y por eso un default nunca puede depender de un parámetro que venga más tarde.

Hay dos límites que conviene tener presentes. El primero: en una llamada posicional, todo lo que sigue al primer argumento nombrado debe ir también nombrado, salvo la lambda final. El segundo, y menos conocido: una función que sobrescribe a otra no puede redefinir los valores por defecto; los hereda de la declaración base, porque el default se resuelve estáticamente por la firma y no dinámicamente por el receptor.

open class Base { open fun f(x: Int = 10) = println(x) }
class Hija : Base() { override fun f(x: Int) = println(x * 2) }  // sin default

val b: Base = Hija()
b.f()   // 20: el default 10 sale de Base, el cuerpo de Hija

Por debajo: una función sintética con máscara

Para una función con defaults el compilador emite dos métodos. Uno con la firma completa, que es el que ejecuta el cuerpo. Y otro sintético, con el sufijo de defaults, que recibe todos los parámetros más un entero que funciona como máscara de bits, más un marcador. Cada bit a uno significa “este argumento no vino, rellénalo”.

fun saludar(nombre: String, saludo: String = "Hola", signo: Char = '!') { }

// Equivalente conceptual de lo que se genera:
// static void saludar(String nombre, String saludo, char signo) { ...cuerpo... }
// static void saludar$default(String nombre, String saludo, char signo,
//                             int mask, Object marker) {
//     if ((mask & 2) != 0) saludo = "Hola";
//     if ((mask & 4) != 0) signo  = '!';
//     saludar(nombre, saludo, signo);
// }

De ahí salen tres consecuencias medibles. Los defaults se resuelven en el destino, no en el sitio de la llamada, así que actualizar una biblioteca cambia los valores por defecto sin recompilar a quien la usa. La llamada que omite argumentos pasa por un salto extra e insignificante que el JIT elimina casi siempre. Y desde Java no existen los defaults: hay que pasar todos los argumentos, o pedir al compilador que genere sobrecargas reales con @JvmOverloads.

flowchart TD
L[Llamada que omite argumentos] --> D[Metodo sintetico de defaults]
D --> M[Lee la mascara de bits]
M --> R[Rellena los que faltan]
R --> C[Metodo con la firma completa]
L2[Llamada con todos los argumentos] --> C
C --> B[Ejecuta el cuerpo]
style D fill:#cba6f7,color:#11111b
style B fill:#a6e3a1,color:#11111b

La factura: qué rompes y a quién

Los defaults y los nombres amplían tu superficie pública de un modo que no se ve en la firma. Tres reglas que valen para cualquier biblioteca.

Añadir un parámetro con valor por defecto es compatible en fuente y no lo es en binario. El código Kotlin que ya existe sigue compilando sin tocar una línea, pero la firma completa del método cambió y la máscara tiene otra aridad, así que un .class compilado contra la versión anterior fallará al enlazar. Quien consuma tu biblioteca sin recompilar verá un error de método no encontrado.

Renombrar un parámetro rompe la fuente sin romper el binario: los nombres no viajan en la llamada compilada, pero cualquiera que escribiera tls = false deja de compilar. En cuanto publicas una función, los nombres de sus parámetros son API.

Reordenar parámetros lo rompe todo, silenciosamente en el peor caso: si dos tienen el mismo tipo, las llamadas posicionales existentes siguen compilando y pasan los valores cambiados de sitio.

💡
El truco de la sobrecarga de compatibilidad

Si necesitas añadir un parámetro sin romper el binario, conserva la firma antigua como una sobrecarga marcada @Deprecated(level = DeprecationLevel.HIDDEN) que delegue en la nueva. Sigue existiendo en el bytecode para los binarios viejos y desaparece del autocompletado para los nuevos.

Un parámetro con nombre y default es una decisión de diseño, no una comodidad

Lo que de verdad ocurre al añadir defaults y nombres es que la firma deja de ser una tupla de tipos y se convierte en un vocabulario. En una función posicional pura, la unidad de significado es la posición: el llamador debe saber que el tercer entero es el timeout, y ese conocimiento vive fuera del código, en la documentación o en la memoria de quien escribe. Con nombres, el significado viaja en el sitio de la llamada, y con defaults el diseñador declara además cuál de todos los valores posibles es el normal. Fíjate en lo que eso implica: conectar("host", tls = false) no solo dice qué hace, dice que apagar TLS es lo excepcional y que la seguridad es lo que ocurre si no haces nada. Los valores por defecto son la forma más barata que tiene un lenguaje de codificar buenos valores predeterminados en el sistema de tipos, y por eso la decisión de cuál es el default no es técnica sino ética: es la que tomarán todos los que no piensen. El precio de esa expresividad es que ahora los nombres, el orden y los valores concretos forman parte del contrato con la misma fuerza que los tipos, y sin que el compilador te avise de que acabas de publicarlos. Puedes cambiar un cuerpo cuantas veces quieras; renombrar un parámetro de una función pública es tan destructivo como borrarla. Diseñar una firma en Kotlin es, por tanto, un acto irreversible en más dimensiones de las que la firma muestra.

⚔️ Mide el contrato de una firma
  1. Convierte una escalera de cuatro sobrecargas de tu código a una sola función con defaults. Cuenta las líneas eliminadas y las llamadas que ahora necesitan nombre.
  2. Reproduce el experimento del orden de evaluación con tres funciones que impriman al llamarse. Predice la salida antes de ejecutarla.
  3. Escribe un default que dependa de un parámetro anterior, y luego intenta que dependa de uno posterior. Anota el error.
  4. Compila una función con defaults y examínala con javap -p. Localiza el método sintético, la máscara y el marcador.
  5. Publica una función en un módulo, compílala, añade un parámetro con default, recompila solo ese módulo y ejecuta el consumidor antiguo. Documenta el fallo exacto que obtienes.