wandres.dev
FUNCIONES · la unidad de trabajo

Funciones locales e infix

Anidar una función dentro de otra para reutilizar su ámbito sin ampliar la superficie pública, qué cuesta realmente capturar variables mutables, y cuándo el modificador infix convierte una llamada en una frase legible frente a cuándo la convierte en un acertijo de precedencias.

⏱ 16 min

Dos características que parecen menores y que en realidad hablan de lo mismo: dónde colocas una función y cómo se lee al invocarla. Una función local reduce el alcance de un nombre hasta hacerlo invisible fuera del cuerpo que lo necesita. Una función infix elimina el punto y los paréntesis para que la llamada suene a lenguaje natural. Ambas mejoran la lectura cuando se usan por la razón correcta y la destrozan cuando se usan por costumbre.

🎯 Al terminar esta lección sabrás
  • Declarar funciones locales y aprovechar la captura del ámbito envolvente.
  • Conocer el coste real de capturar variables, sobre todo mutables.
  • Saber qué requisitos exige infix y qué precedencia tiene en las expresiones.
  • Aplicar un criterio para decidir cuándo infix ayuda y cuándo estorba.

Funciones dentro de funciones

Kotlin permite declarar una función en el cuerpo de otra. Su nombre solo existe ahí dentro, y su cuerpo ve los parámetros y las variables locales de quien la contiene.

fun validarUsuario(usuario: Usuario, reglas: Reglas) {
    fun exigir(condicion: Boolean, campo: String) {
        // 'usuario' y 'reglas' estan capturados: no hay que pasarlos
        if (!condicion) throw ValidacionError(usuario.id, campo)
    }

    exigir(usuario.nombre.length >= reglas.minNombre, "nombre")
    exigir(usuario.email.contains('@'), "email")
    exigir(usuario.edad >= reglas.minEdad, "edad")
}

El beneficio no es ahorrar una línea, es eliminar un canal de error. Si exigir fuese una función privada de nivel superior, habría que pasarle usuario y reglas en cada llamada, y cada punto de llamada sería una oportunidad de pasar el argumento equivocado. Al capturarlos, esa clase de fallo desaparece del código porque desaparece del vocabulario.

Hay una regla de orden que sorprende a quien viene de las funciones miembro: una función local debe declararse antes del punto donde se usa. Los miembros de una clase son visibles en todo su cuerpo, pero dentro de una función el análisis es secuencial, como con cualquier declaración local. Una función local puede llamarse a sí misma sin problema, pero dos funciones locales mutuamente recursivas no se pueden escribir directamente.

Sobre el return conviene ser preciso: dentro de una función local, return sale de la función local, no de la envolvente. Para salir de la exterior hace falta una etiqueta con return@nombreExterior, exactamente igual que en las lambdas.

El precio de capturar

En la JVM no existen las funciones anidadas, así que el compilador tiene que bajarlas a algo. El caso habitual es un método sintético estático al que las variables capturadas se le añaden como parámetros extra: el ahorro de escritura es real y el coste de ejecución, nulo. Si la función local se usa como valor, por ejemplo pasándola como referencia a otra función, entonces sí hace falta un objeto que la represente, con su asignación correspondiente.

La diferencia importante está en qué capturas. Java solo deja capturar variables efectivamente finales; Kotlin permite capturar y modificar una var. Esa capacidad no sale gratis: como el valor debe sobrevivir y ser compartido entre dos ámbitos, el compilador lo encierra en un objeto contenedor de referencia y todos los accesos pasan a ser lecturas y escrituras de un campo.

fun contarPares(nums: List<Int>): Int {
    var total = 0                 // capturada y mutada: se envuelve en un objeto
    fun visitar(n: Int) {
        if (n % 2 == 0) total++   // en el bytecode: total.element++
    }
    nums.forEach(::visitar)
    return total
}

Un contador así es irrelevante. Ese mismo patrón dentro de un bucle que se ejecuta millones de veces sí aparece en un perfilador, y la solución no es abandonar las funciones locales sino devolver un valor en lugar de mutar uno capturado.

💡
Local, privada de fichero o miembro: elige por alcance

Si el ayudante necesita el ámbito de la función, hazlo local. Si no lo necesita pero solo lo usa ese fichero, hazlo función privada de nivel superior: es testeable y no vuelve a compilarse conceptualmente en cada llamada. Si lo necesitan varias clases, ya es parte de tu API y merece un nombre pensado. Un tercer nivel de anidamiento casi siempre significa que la función exterior hace demasiado.

infix: la llamada que se lee como una frase

El modificador infix permite invocar una función sin el punto y sin los paréntesis. No añade capacidad al lenguaje: cambia cómo se lee la llamada.

infix fun Int.elevadoA(exp: Int): Int {
    var r = 1
    repeat(exp) { r *= this }
    return r
}

val a = 2 elevadoA 10      // forma infija
val b = 2.elevadoA(10)     // exactamente la misma llamada

Los requisitos son estrictos y todos se deducen de que la notación tiene dos operandos y solo dos. La función debe ser miembro o extensión, debe tener exactamente un parámetro, ese parámetro no puede ser vararg ni tener valor por defecto, y la llamada exige receptor explícito: dentro de una clase se escribe this mas 5, nunca mas 5. Tampoco se pueden usar argumentos con nombre en una llamada infija.

La stdlib la usa donde la lectura gana de verdad: 1 to "uno" para construir un par, 0 until n y 10 downTo 1 para rangos, a and b o x shl 2 para operaciones de bits, cadena matches regex.

Lo que casi nadie tiene presente es la precedencia. Las funciones infijas se sitúan en un escalón concreto: por debajo de los operadores aritméticos y del rango, y por encima de las comparaciones, la igualdad y los operadores lógicos.

// La aritmetica se evalua antes que el infix:
val x = 1 shl 2 + 3        // es  1 shl (2 + 3)  ->  32, no 4 + 3

// El infix se evalua antes que el logico:
val y = p && q xor r       // es  p && (q xor r)

Todas las funciones infijas comparten precedencia y asocian por la izquierda, así que a f b g c es (a f b) g c sin que nada en el texto lo insinúe.

flowchart TD
T[Mas fuerte aritmetica y rangos] --> I[Funciones infijas]
I --> CMP[Comparaciones]
CMP --> EQ[Igualdad]
EQ --> AND[Y logico]
AND --> OR[O logico]
OR --> ELV[Mas debil elvis y asignacion]
style I fill:#cba6f7,color:#11111b

Cuándo infix empeora la lectura

El criterio para usarla es único: el nombre debe leerse como una relación entre dos cosas del mismo nivel, y la operación debe ser tan conocida que nadie necesite mirar la firma. to, until, matches lo cumplen. Un repositorio guarda usuario no lo cumple, porque no es una relación simétrica sino un mandato con un sujeto y un objeto, y la forma con punto ya lo dice mejor.

🔗

Merece infix

La relación es simétrica o universalmente conocida, y el nombre es un verbo o preposición corta: 1 to "uno", 0 until n, texto matches regex.

🔻

No merece infix

Hay un sujeto que actúa sobre un objeto, el nombre es largo, el receptor puede ser nulo o el argumento se beneficiaría de ir nombrado. La forma con punto dice más.

Hay tres costes concretos que pagas al declarar una función infija. El primero es la precedencia: quien lea total descuento 10 + 5 tendrá que recordar el escalón exacto o poner paréntesis a ciegas. El segundo es el descubrimiento: escribir un punto tras un objeto despliega en el IDE todo lo que puede hacerse con él, mientras que una función infija solo se encuentra si ya sabes que existe. El tercero es que la forma infija no se combina con la llamada segura: con un receptor nulable hay que volver a la forma con punto.

⚠️
Un infix con dos operandos de tipos distintos suele leerse al revés

Cuando el receptor y el parámetro no son intercambiables, la mitad de tus lectores adivinará la dirección equivocada. usuario tiene permiso y permiso tiene usuario se leen igual de bien y significan cosas opuestas. Si tienes que pensar cuál era, no marques la función como infix.

Colocar y nombrar una función son decisiones sobre quién debe pensar

Estas dos características parecen atender a preocupaciones distintas, alcance una y sintaxis la otra, pero apuntan al mismo blanco: cuánto contexto obligas a sostener a quien lee. Una función local es una afirmación fuerte: esto no existe fuera de aquí, no hay que buscar otros usos, no hay que preservar su comportamiento para nadie más, y por tanto puede tener un nombre corto y una firma mínima porque su universo entero cabe en la pantalla. Ese estrechamiento del alcance es la forma más barata de reducir carga cognitiva que ofrece cualquier lenguaje, y no cuesta nada en ejecución. El infix opera en la dirección contraria y por eso es mucho más peligroso: elimina información sintáctica del sitio de la llamada, el punto que decía quién es el receptor y los paréntesis que decían dónde empieza y acaba el argumento, y confía en que el lector reponga esa información de memoria. Cuando la operación es universal, la reposición es instantánea y el texto queda más limpio; cuando no lo es, has trasladado trabajo desde el momento de escribir, que ocurre una vez, hasta el momento de leer, que ocurre cientos. La regla que unifica ambas es la misma que gobierna todo el diseño de API: cada elemento del texto debe pagar su sitio en comprensión, y quien decide si lo paga no eres tú sino el que lo lee dentro de un año sin tu contexto en la cabeza. Reducir alcance siempre paga. Reducir sintaxis solo paga cuando lo que quitas ya estaba en la cabeza del lector.

⚔️ Ajusta alcance y sintaxis
  1. Localiza en tu código una función privada que reciba tres parámetros siempre iguales desde un único sitio y conviértela en función local. Cuenta los parámetros que desaparecen.
  2. Captura una var en una función local, compila y busca en el bytecode el objeto contenedor de referencia. Reescríbelo devolviendo un valor y compara.
  3. Intenta llamar a una función local declarada más abajo en el cuerpo. Lee el error y compáralo con lo que ocurriría entre dos métodos de una clase.
  4. Escribe 1 shl 2 + 3 y (1 shl 2) + 3, predice ambos resultados antes de ejecutarlos y comprueba tu modelo de precedencias.
  5. Elige tres funciones de tu código y decide cuál merece infix. Escribe una frase justificando cada descarte.