wandres.dev
FUNCIONES DE SCOPE · let, run, with, apply, also

takeIf, takeUnless y el resto de la familia

Junto a las cinco funciones de ámbito canónicas, la librería estándar ofrece un pequeño conjunto de funciones que comparten su forma inline y su vocación de encajar en cadenas de expresiones: takeIf y takeUnless, que aplican un predicado a un valor único y devuelven ese valor o nulo; use, que asocia un bloque al ciclo de vida de un recurso cerrable; y runCatching, que convierte una excepción en un valor. Esta lección estudia cómo se combinan con let, also, apply y run, qué coste tiene introducir un nulo deliberado en una cadena, y en qué punto exacto una cadena de funciones de ámbito deja de ser legible y hay que deshacerla.

⏱ 19 min

Las funciones de colecciones filtran muchos elementos con un predicado, y esa operación es tan común que resulta fácil olvidar que existe la versión de un solo valor. takeIf y takeUnless son exactamente eso: aplican una condición a un objeto individual y devuelven el objeto si la condición se cumple o nulo si no. La devolución de nulo es la clave de todo su comportamiento, porque convierte una condición booleana en algo que la maquinaria de nulabilidad del lenguaje ya sabe procesar, y por tanto en algo que encaja con la llamada segura, con el operador de elvis y con las funciones de ámbito estudiadas hasta aquí. Esa integración es su virtud y también su trampa: permite construir expresiones muy compactas que dicen mucho en poco espacio, y permite construir cadenas donde cada eslabón puede apagar el resto sin dejar rastro de cuál fue.

🎯 Al terminar esta lección sabrás
  • Aplicar takeIf y takeUnless para filtrar un valor único y convertir una condición en una ausencia procesable.
  • Encadenar esas funciones con let, also, run y apply para escribir expresiones condicionales sin ramificación explícita.
  • Reconocer las funciones vecinas de la familia, use y runCatching, y situarlas frente a las de ámbito.
  • Detectar el punto en que una cadena deja de ser legible y deshacerla en pasos con nombre.

El filtro de un valor único

Las firmas son mínimas y explican todo el comportamiento. Ambas reciben un predicado sobre el objeto y devuelven el mismo objeto o nulo, de modo que el tipo de la expresión es siempre nulable aunque el receptor no lo fuera. Esa conversión de un tipo definido en uno nulable es una decisión deliberada: la ausencia es el vehículo con el que el lenguaje transporta el resultado negativo de la condición.

public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T?
public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T?

El uso natural aparece cuando lo que se quiere no es ramificar sino producir un valor opcional a partir de una condición, casi siempre para rematarlo con un elvis que aporta la alternativa. La ganancia frente a un if es que la expresión se lee de izquierda a derecha, en el orden en que ocurren las cosas, y que no obliga a nombrar el sujeto dos veces.

val nombre = entrada.trim().takeIf { it.isNotBlank() } ?: "anonimo"

val ficheroValido = ruta
    .takeIf { it.exists() }
    ?.takeUnless { it.length() == 0L }

val descuento = pedido.takeIf { it.total > 100 }?.let { calcularDescuento(it) }

Conviene señalar un detalle de la firma que se pasa por alto: el predicado recibe el objeto como argumento, de modo que dentro se escribe it y nunca hay receptor implícito. Eso las hace seguras dentro de bloques anidados, donde el receptor de una función de ámbito exterior seguiría siendo alcanzable sin cualificar y podría producir predicados que examinan el objeto equivocado. Es una de las pocas decisiones del conjunto que elimina una fuente de error en lugar de introducirla.

La elección entre las dos formas es de redacción y no de lógica, ya que cada una es la negación de la otra. La regla que evita las dobles negaciones es preferir aquella cuyo predicado se pueda enunciar en positivo: takeUnless existe justamente para no tener que escribir un predicado negado dentro de takeIf, y usarlo con un predicado que ya lleva una negación produce la construcción más difícil de leer de todo el nivel.

⚠️
`takeIf` sobre un valor ya nulable multiplica los orígenes del nulo

Cuando el receptor puede ser nulo y además se le aplica takeIf, el resultado es nulo por dos motivos distintos que la expresión ya no distingue: porque el valor no estaba o porque no cumplía la condición. Si la diferencia importa para el diagnóstico, para el mensaje de error o para la métrica, la expresión compacta está borrando información que después habrá que reconstruir. En ese caso conviene separar las dos preguntas en dos pasos con nombre.

Encadenar con las funciones de ámbito

La combinación productiva de estas funciones con las de ámbito nace de que todas devuelven algo que encaja con la siguiente. takeIf produce un nulable, la llamada segura lo atraviesa, let transforma lo que sobrevive, also interviene sin alterar y el elvis cierra con la alternativa. El resultado es un vocabulario pequeño con el que se escriben condicionales completos como una sola expresión.

fun procesar(bruto: String): Informe =
    bruto.trim()
        .takeIf { it.isNotBlank() }
        ?.also { log.debug("entrada aceptada de {} caracteres", it.length) }
        ?.let { analizar(it) }
        ?: Informe.vacio()

Hay un idioma concreto que merece nombrarse porque resuelve un problema muy frecuente: usar takeIf para decidir si un objeto recién configurado con apply debe seguir adelante. La construcción funciona porque apply devuelve el objeto y takeIf opera sobre él, de modo que la validación queda inmediatamente después de la configuración y antes de que el valor escape.

val sesion = Sesion()
    .apply { renovar(); cargarPermisos() }
    .takeIf { it.esValida }
    ?: throw IllegalStateException("no se pudo abrir la sesion")
flowchart LR
A[Valor] --> B[takeIf con predicado]
B -- Cumple --> C[Mismo valor no nulo]
B -- No cumple --> D[Nulo]
C --> E[let transforma o also observa]
D --> F[Elvis aporta la alternativa]
E --> G[Resultado]
F --> G

Las vecinas: use y runCatching

Dos funciones más comparten la forma de las de ámbito sin pertenecer a la tabla, y conviene situarlas porque compiten por los mismos huecos en una cadena. use se declara sobre un tipo cerrable, ejecuta el bloque y garantiza el cierre del recurso al salir, ocurra lo que ocurra; es el equivalente en librería del bloque con recursos de Java, y su bloque recibe el recurso como argumento igual que let. runCatching ejecuta un bloque capturando cualquier excepción y devuelve un Result, es decir, convierte un fallo en un valor con el que se puede seguir operando.

val lineas = fichero.bufferedReader().use { it.readLines() }

val puerto = runCatching { propiedades.getValue("puerto").toInt() }
    .getOrElse { 8080 }

De use conviene retener un detalle que la diferencia de todas las demás y que a veces se olvida: su bloque no es opcional en el sentido de que la salida siempre pasa por el cierre, incluso cuando se abandona por una excepción o por un retorno no local. Esa garantía es la razón de su existencia y también la razón de que no deba usarse como un let cualquiera sobre objetos que no son recursos, porque el nombre promete una gestión de ciclo de vida que en ese caso no hay.

// El recurso se cierra aunque el bloque lance
conexion.use { c ->
    c.preparar("select 1").use { sentencia -> sentencia.ejecutar() }
}

La distinción que conviene tener clara es la que separa la ausencia del fallo. takeIf produce ausencia, y la ausencia es adecuada cuando no haber valor es un resultado legítimo del dominio. runCatching produce un fallo con causa, y eso es adecuado cuando algo salió mal y la causa importa. Modelar un error como ausencia es la forma más habitual de perder el diagnóstico, y aparece con frecuencia en cadenas donde alguien envolvió una conversión que podía lanzar dentro de un takeIf para no tener que tratarla.

🔭

`takeIf` filtra un valor

Convierte una condición en ausencia procesable. Encaja con la llamada segura y con el elvis, y su resultado es siempre nulable.

🛡️

`use` gestiona un recurso

Ata el ciclo de vida del recurso al bloque y garantiza el cierre. Entrega el recurso como argumento, igual que let.

🧭

`runCatching` captura

Transforma una excepción en un valor con causa. Elígelo cuando el diagnóstico importe y la ausencia no baste.

Cuándo la cadena deja de leerse

Toda esta maquinaria comparte una propiedad peligrosa: encadena bien. Como cada función devuelve algo que la siguiente acepta, nada impide construir expresiones de ocho eslabones que compilan sin protesta y que resultan imposibles de depurar, porque no hay ningún punto intermedio con nombre donde poner una condición de parada ni ningún valor que inspeccionar. Hay tres síntomas que anuncian ese punto y conviene reaccionar al primero de ellos.

El primero es la aparición de más de un origen posible para el nulo final, que hace que el elvis del cierre atienda a causas distintas sin distinguirlas. El segundo es el anidamiento de lambdas dentro de la cadena, en cuanto un bloque contiene otro bloque y el it empieza a significar dos cosas según la línea. El tercero es la necesidad de leer la cadena dos veces para saber qué tipo tiene la expresión completa, que ocurre siempre que se mezclan funciones que transforman con funciones que atraviesan. Deshacerla es siempre barato: basta con extraer los pasos a valores locales con nombre, que además son gratuitos porque el compilador no crea nada para ellos.

// Compacta y opaca
val r = e?.trim()?.takeIf { it.isNotBlank() }?.let { p(it) }?.also { g(it) } ?: d()

// Explicita y depurable
val limpio = entrada?.trim()
val valido = limpio?.takeIf { it.isNotBlank() }
val resultado = if (valido == null) porDefecto() else procesar(valido).also(::guardar)

Nótese que la versión explícita no es más lenta ni consume más memoria: los valores locales que introduce no generan objeto alguno y el compilador produce esencialmente el mismo código. Lo único que cambia es que ahora existen tres nombres donde antes había una expresión anónima, y esos nombres son puntos donde se puede detener la ejecución, imprimir un valor o escribir una prueba. Cambiar densidad por observabilidad es casi siempre un buen negocio, y quien no lo ve suele estar valorando el código por cómo se escribe en lugar de por cómo se depura.

Encadenar es componer, y componer solo compensa mientras cada eslabón sea una decisión que el lector pueda nombrar

Hay una razón profunda por la que estas funciones invitan al exceso, y entenderla evita la mitad de las discusiones de estilo que provocan. Todas comparten una firma que las hace componibles: reciben un valor y devuelven un valor, de manera que la salida de cualquiera encaja en la entrada de casi cualquier otra. Esa propiedad es matemáticamente elegante y prácticamente adictiva, porque cada eslabón nuevo cuesta una línea y parece no cobrar nada; pero el coste existe y no se paga en tiempo de ejecución, donde todo se inserta y no queda rastro, sino en la capacidad de un lector para reconstruir el estado del programa en un punto intermedio. Una cadena de expresiones no tiene puntos intermedios con nombre, y un programa sin puntos intermedios con nombre es un programa donde no se puede poner una condición de parada, no se puede registrar un valor sin insertar un also que altera la línea, no se puede explicar en una revisión sin recitar la cadena entera y no se puede modificar sin releerla completa. El criterio que sobrevive a los años no tiene que ver con el número de eslabones sino con si cada uno corresponde a una decisión que el lector podría nombrar en voz alta: limpiar, validar, transformar, registrar, resolver. Mientras cada eslabón tenga nombre en la cabeza del lector, la cadena es una frase y se lee como tal, y da igual que tenga seis. En cuanto un eslabón exista por razones de fontanería, porque hacía falta un nulable ahí o porque el tipo no encajaba de otro modo, la frase se ha roto y ningún grado de compacidad compensa. La misma prueba explica por qué la librería estándar tiene funciones tan pequeñas y con nombres tan concretos: cada nombre es una palabra del vocabulario con el que se escriben esas frases, y usar una función por su firma en lugar de por su significado es lo mismo que usar una palabra por su longitud.

⚔️ Filtra, encadena y desmonta
  1. Sustituye un if que devuelve un valor o un valor por defecto por una expresión con takeIf y elvis. Decide cuál publicarías y por qué.
  2. Busca un takeIf cuyo predicado esté negado y conviértelo en takeUnless. Lee ambas versiones en voz alta y compáralas.
  3. Escribe una cadena que configure con apply, valide con takeIf y falle con elvis, y explica en qué orden se evalúa cada parte.
  4. Coge una cadena tuya con dos orígenes posibles del nulo final y sepárala en pasos con nombre hasta poder distinguir la causa.
  5. Localiza un takeIf que en realidad esté ocultando una excepción y reescríbelo con runCatching, comparando qué información conserva cada versión.