wandres.dev
LAMBDAS · funciones como valores

Funciones de orden superior y diseño de API

Una función de orden superior recibe funciones, devuelve funciones o ambas cosas, y con ella se diseña buena parte de la superficie pública de una librería en Kotlin. Esta lección cubre la escritura de funciones que aceptan comportamiento como parámetro, la construcción de fábricas que devuelven funciones cerradas sobre su entorno, la composición, las decisiones concretas de diseño de una API basada en callbacks, y el criterio para decidir cuándo un tipo función deja de ser suficiente y hay que declarar una interfaz o una interfaz funcional.

⏱ 20 min

Al final del recorrido por las lambdas queda la pregunta que de verdad importa, y no es cómo escribirlas sino cuándo ofrecerlas. Una función de orden superior es simplemente una función que trata a otras funciones como datos, recibiéndolas o devolviéndolas, y en ese enunciado tan modesto cabe casi todo el diseño de API moderno de Kotlin: los operadores de colecciones, los constructores de configuraciones, los reintentos, las mediciones, los observadores, los interceptores. La técnica se aprende en diez minutos. Lo que cuesta años, y lo que separa a una librería agradable de una que hay que consultar cada vez, es el juicio sobre cuándo un parámetro debe ser un comportamiento y cuándo debe ser un dato, cuántas lambdas puede soportar una firma antes de volverse ilegible, y en qué punto exacto un tipo función deja de servir y hace falta un nombre.

🎯 Al terminar esta lección sabrás
  • Escribir funciones que aceptan comportamiento como parámetro y razonar sobre su firma, su orden y sus valores por defecto.
  • Construir funciones que devuelven funciones, entendiendo qué entorno queda capturado en el resultado.
  • Diseñar una API de callbacks legible, con criterio sobre número de lambdas, nombres y opcionalidad.
  • Decidir con argumentos cuándo un tipo función es insuficiente y conviene una interfaz o una interfaz funcional.

Recibir comportamiento como parámetro

Una función de orden superior que recibe una lambda está declarando que hay un hueco en su algoritmo y que quien la llama decide qué va dentro. La firma es el contrato completo de ese hueco: los tipos de entrada dicen qué información se le entrega a quien decide, y el tipo de retorno dice qué se espera a cambio. Escribir bien esa firma es más importante que el cuerpo, porque el cuerpo se puede reescribir y la firma no.

inline fun <T> medirYDevolver(etiqueta: String, bloque: () -> T): T {
    val inicio = System.nanoTime()
    val resultado = bloque()
    println("$etiqueta tardo ${System.nanoTime() - inicio} ns")
    return resultado
}

inline fun <T> reintentar(veces: Int, bloque: (intento: Int) -> T): T {
    var ultimo: Throwable? = null
    repeat(veces) { i ->
        runCatching { return bloque(i) }.onFailure { ultimo = it }
    }
    throw ultimo ?: IllegalStateException("sin intentos")
}

Hay tres decisiones visibles en esas dos firmas y conviene nombrarlas. La primera es el orden: la lambda va la última para que la llamada pueda escribirse fuera de los paréntesis y la construcción se lea como un bloque del lenguaje. La segunda es el nombre del parámetro dentro del tipo función, intento, que no cambia nada para el compilador pero aparece en la ayuda del editor y le dice a quien escribe la lambda qué está recibiendo. La tercera es el genérico de retorno, que permite que la función sea transparente respecto del valor producido en lugar de imponer Unit y obligar a capturar el resultado desde fuera.

💡
Marca `inline` casi siempre en una función de orden superior pequeña

Si tu función recibe una lambda, la invoca dentro de su propio cuerpo y no la guarda en ninguna parte, márcala inline. Con eso desaparece la asignación del objeto, desaparece la llamada virtual, quien la use puede escribir un retorno no local si le conviene y el resultado se comporta como una construcción del lenguaje. La marca deja de ser recomendable cuando la función es grande, porque su cuerpo se copia en cada punto de llamada, o cuando la lambda debe almacenarse, en cuyo caso el parámetro necesita noinline y el beneficio desaparece.

Devolver funciones: el entorno viaja dentro

Una función que devuelve una función produce un valor que lleva dentro todo lo que capturó en el momento de crearse. Es el mecanismo de la clausura visto desde el lado de quien fabrica, y sirve para configurar una vez y usar muchas.

fun multiplicadorPor(factor: Int): (Int) -> Int = { n -> n * factor }

fun validadorDeLongitud(minimo: Int, maximo: Int): (String) -> Boolean =
    { texto -> texto.length in minimo..maximo }

val triple = multiplicadorPor(3)
val esNombreValido = validadorDeLongitud(2, 40)

Aquí hay una propiedad que se olvida con facilidad: la función devuelta captura, así que se crea un objeto nuevo por cada llamada a la fábrica. Fabricar el validador una vez y reutilizarlo es correcto; fabricarlo dentro del bucle que valida es una asignación por vuelta sin ninguna ganancia. Y como el objeto devuelto mantiene vivas sus capturas, una fábrica que capture un contexto pesado produce valores que retienen ese contexto mientras existan.

La composición es el caso particular más útil de este patrón, y en Kotlin se escribe como una extensión sobre el propio tipo función.

infix fun <A, B, C> ((A) -> B).luego(siguiente: (B) -> C): (A) -> C =
    { entrada -> siguiente(this(entrada)) }

val normalizar = String::trim luego String::lowercase
flowchart LR
A[Entrada A] --> B[Primera funcion]
B --> C[Valor intermedio B]
C --> D[Segunda funcion]
D --> E[Salida C]
F[La composicion devuelve una funcion nueva] -.-> B

Diseñar la API: el número de lambdas manda

Una API basada en callbacks empieza a degradarse mucho antes de lo que sus autores suelen creer, y la variable que la degrada es la cantidad de lambdas en la misma firma. Con una, la llamada se lee como un bloque. Con dos, solo una puede salir de los paréntesis y la otra queda dentro con su nombre, lo que ya obliga a nombrar argumentos para que se entienda. Con tres o más, quien llama tiene que leer la documentación cada vez y quien mantiene la librería no puede añadir una cuarta sin romper la legibilidad.

// Aceptable: una sola lambda
fun cargar(id: Id, alTerminar: (Datos) -> Unit)

// Frágil: dos lambdas, el orden y los nombres empiezan a pesar
fun cargar(id: Id, alTerminar: (Datos) -> Unit, alFallar: (Throwable) -> Unit)

// Mejor: un solo resultado que ya distingue los casos
suspend fun cargar(id: Id): Result<Datos>

La progresión anterior no es un juicio estético sino una cuenta de estados posibles. Con dos callbacks independientes, nada en el sistema de tipos impide invocar los dos, ni ninguno, ni el de error dos veces; el contrato de exclusividad vive en la documentación y en la disciplina de quien implemente la función. Con un tipo de resultado, la exclusividad es estructural y el compilador la sostiene sin que nadie tenga que recordarla.

sealed interface Resultado {
    data class Exito(val datos: Datos) : Resultado
    data class Fallo(val causa: Throwable) : Resultado
    data object Vacio : Resultado
}

when (val r = cargar(id)) {
    is Resultado.Exito -> mostrar(r.datos)
    is Resultado.Fallo -> avisar(r.causa)
    Resultado.Vacio -> mostrarVacio()
}

Esa jerarquía sellada es la salida preferible en Kotlin moderno cuando el número de callbacks crece por razones de éxito y de error: modelar el resultado como un tipo, sea Result, sea una jerarquía propia, y devolverlo. Con eso desaparecen las dos lambdas, la ramificación pasa a ser un when en el lugar donde se conoce el contexto, y el compilador comprueba que se cubren todos los casos. Los callbacks siguen siendo apropiados cuando el evento puede ocurrir muchas veces, no una sola, y en ese terreno la alternativa idiomática es un flujo en lugar de una lista de funciones.

λ

Valor por defecto en vez de nulable

Un parámetro de tipo función con valor por defecto vacío evita al cuerpo comprobar la ausencia en cada uso y evita a quien llama pasar una lambda vacía. Un tipo función nulable solo se justifica si la ausencia significa algo distinto de no hacer nada.

🧭

Nombra los parámetros del tipo función

Los nombres dentro del tipo función no afectan a la compilación pero aparecen al escribir la lambda. Un tipo con dos parámetros del mismo tipo sin nombres es una invitación al error de orden.

🧱

La lambda siempre la última

Colocar el parámetro de comportamiento al final es una decisión de diseño, no de estilo: habilita la sintaxis de bloque en todos los puntos de uso de tu API.

Cuándo una interfaz es mejor que un tipo función

Un tipo función es anónimo y estructural: cualquier cosa con la forma correcta encaja. Eso es una virtud cuando la operación es genérica y una desventaja cuando tiene significado propio, porque (String) -> Boolean no dice si valida, si filtra o si autoriza, y dos operaciones incompatibles con la misma forma son intercambiables sin que el compilador proteste. Hay cuatro situaciones en las que conviene declarar un tipo con nombre.

La primera es la del significado: cuando la operación pertenece al dominio y merece un nombre que aparezca en las firmas, en los mensajes de error y en la navegación del editor. La segunda es la de la cardinalidad: en cuanto hay más de dos operaciones relacionadas que van juntas, agruparlas como miembros de una interfaz es más claro que arrastrarlas como parámetros sueltos. La tercera es la del estado: un observador que necesita recordar algo entre invocaciones cabe mucho mejor en una clase con propiedades que en una lambda que captura variables mutables. Y la cuarta es la de la evolución: añadir un miembro a una interfaz con implementación por defecto es una operación mucho más manejable que cambiar la aridad de un tipo función que ya está publicado.

// Interfaz funcional: un nombre de dominio y conversion desde lambda
fun interface Autorizador {
    fun autoriza(usuario: Usuario, recurso: Recurso): Boolean
}

fun proteger(autorizador: Autorizador) { /* ... */ }

proteger { usuario, recurso -> usuario.rol == Rol.Admin }   // conversion SAM
proteger(Autorizador { u, r -> u.puede(r) })                // forma explicita

// Interfaz normal: varias operaciones que viajan juntas y estado propio
interface OyenteDeDescarga {
    fun alProgresar(porcentaje: Int)
    fun alTerminar(destino: Ruta)
    fun alFallar(causa: Throwable) {}
}

La construcción declarada con fun interface es la respuesta de Kotlin a la falsa disyuntiva entre nombre y comodidad: es una interfaz con un único método abstracto, tiene nombre de dominio, se puede implementar con una clase, y aun así admite que quien la use escriba una lambda gracias a la conversión automática. Es también la forma correcta de recibir comportamiento desde código Java, donde los tipos función de Kotlin resultan incómodos de escribir. El precio es que la conversión crea una instancia igual que cualquier lambda y que una función que reciba una interfaz funcional no puede ser inline de forma provechosa, por lo que las utilidades diminutas y muy usadas siguen prefiriendo el tipo función desnudo.

Elegir entre una lambda y una interfaz es elegir entre estructura y nombre, y esa elección se paga durante años

Conviene cerrar el nivel con el criterio que más difícil resulta de adquirir, porque no es técnico sino de juicio, y porque casi todo el dolor de mantenimiento de una librería en Kotlin nace de haberlo resuelto mal. Un tipo función es tipado estructural puro: dice qué forma tiene la operación y nada más, no tiene identidad, no tiene nombre y no puede llevar documentación adherida, de modo que dos conceptos totalmente distintos del dominio que casualmente reciben una cadena y devuelven un booleano son, para el compilador, exactamente el mismo tipo. Esa indiferencia es precisamente lo que hace que la librería estándar funcione, porque filter no debe saber nada sobre tu dominio y su predicado debe aceptar cualquier cosa con la forma adecuada; pero es también lo que convierte una API de aplicación en un jeroglífico cuando se abusa de ella, porque las firmas dejan de comunicar intención y pasan a comunicar únicamente aritmética de tipos. Una interfaz con nombre hace lo contrario: renuncia a la compatibilidad estructural para ganar identidad, y esa identidad es lo que permite que el error de compilación diga que hace falta un autorizador y no que hace falta una función de dos parámetros, que el editor sepa llevarte a todas las implementaciones, que la documentación tenga un sitio donde vivir y que el tipo pueda crecer con miembros nuevos sin romper a quienes ya lo usan. La heurística que resiste el paso del tiempo es sencilla de enunciar y exige honestidad para aplicarla: usa un tipo función cuando la operación sea genérica respecto de tu dominio y su firma sea autoexplicativa, y usa una interfaz en el instante en que te sorprendas escribiendo un comentario para explicar qué significa el booleano que devuelve. Ese comentario es la señal inequívoca de que el concepto ya existía en tu cabeza y todavía no existía en el sistema de tipos, y el trabajo de un buen diseño consiste exactamente en cerrar esa distancia.

⚔️ Diseña una API, no solo una función
  1. Escribe una función de orden superior genérica que mida y devuelva el resultado, márcala inline y explica las tres ventajas concretas que aporta la marca.
  2. Convierte una configuración repetida de tu código en una fábrica que devuelva una función, y razona qué queda capturado en el valor devuelto.
  3. Coge una firma tuya con dos lambdas de éxito y error y rediséñala con un tipo de resultado sellado. Compara qué comprueba el compilador en cada versión.
  4. Declara la misma operación como tipo función y como fun interface, y argumenta cuál publicarías en una librería que otros van a consumir desde Java.
  5. Busca en tu código un tipo función cuyo significado hayas tenido que explicar con un comentario y sustitúyelo por una interfaz con nombre.