let y also: el objeto como argumento
Las dos funciones de ámbito que entregan el objeto como argumento comparten la forma y se oponen en el propósito. Esta lección estudia `let` como instrumento de transformación y como pieza central del trabajo con valores nulables, donde la combinación con la llamada segura produce un idioma que no tiene equivalente directo en otros lenguajes de la JVM; y estudia `also` como el punto de intervención que permite registrar, validar o medir en mitad de una cadena sin alterar ni el valor ni el tipo que la atraviesa. Incluye el análisis de los errores frecuentes: el `let` que sustituye a un `if`, el que se usa por costumbre sobre valores no nulables y el que oculta una comprobación de nulidad detrás de una cadena larga.
Que let y also entreguen el objeto como argumento en lugar de como receptor parece un detalle menor de la firma, pero es la propiedad que determina en qué situaciones son la elección correcta. Un argumento tiene nombre, y tener nombre significa poder renombrarlo cuando hay anidamiento, poder pasarlo a otra función sin ambigüedad y no competir jamás con el this de la clase que rodea al bloque. A cambio, cada acceso al objeto cuesta un identificador y un punto, de modo que el bloque se vuelve ruidoso en cuanto hay muchos accesos. Esa asimetría define su terreno natural: bloques cortos, donde el objeto aparece una o dos veces, y donde lo importante no es tocar muchos miembros sino hacer una sola cosa con el valor entero. Dentro de ese terreno, la diferencia entre las dos es únicamente el eje del retorno, y de esa diferencia salen dos usos que no se parecen en nada.
- Usar
letpara transformar un valor y para acotar el ámbito de un resultado intermedio sin declarar una variable. - Combinar
letcon la llamada segura para tratar valores nulables de forma expresiva y sin anidar comprobaciones. - Usar
alsopara intervenir en una cadena con efectos laterales sin modificar el valor ni el tipo que la atraviesa. - Reconocer los usos degenerados de ambas y sustituirlos por construcciones más simples cuando corresponda.
let: transformar y acotar
La firma de let dice que recibe el objeto como argumento y devuelve lo que produzca el bloque. Su función primaria es, por tanto, la transformación: convertir un valor en otro dentro de una expresión, sin nombrar el intermedio y sin romper la cadena de llamadas. El segundo uso, menos comentado y a menudo más valioso, es el de acotar el ámbito de un valor: lo que entra en el bloque deja de existir al salir, y eso convierte una variable local de vida larga en un identificador confinado a tres líneas.
// Transformar dentro de una cadena
val iniciales = nombreCompleto
.split(" ")
.let { partes -> partes.joinToString("") { it.first().uppercase() } }
// Acotar: el intermedio no sobrevive al bloque
val resumen = cargarInforme(id).let { informe ->
"${informe.titulo} con ${informe.filas.size} filas"
}
Nótese en el primer ejemplo el argumento renombrado a partes. Cuando dentro del bloque de let hay otra lambda que también usa it, renombrar deja de ser una cortesía y pasa a ser obligatorio para que el código sea legible, porque el it interno oculta al externo sin que el compilador diga nada. Es la primera manifestación del problema de anidamiento que la última lección del nivel trata en profundidad, y la regla práctica es sencilla: en cuanto haya dos lambdas encajadas, ninguna de las dos debería seguir llamándose it.
Escribir valor.let { hacerAlgo(it) } sobre un valor no nulable es exactamente hacerAlgo(valor) con seis caracteres más y una lambda de por medio. Si el bloque no cambia el tipo, no confina un intermedio y no participa en una llamada segura, la construcción no aporta información al lector y conviene borrarla. El caso más frecuente de este ruido aparece cuando alguien adopta la costumbre de abrir un let antes de saber qué va a escribir dentro.
let en el camino de los nulables
El uso que hizo célebre a let no es la transformación sino su combinación con la llamada segura. Escrito como ?.let, el bloque se ejecuta solo cuando el receptor no es nulo, y dentro del bloque el argumento tiene ya el tipo no nulable, sin necesidad de comprobación ni de conversión. La expresión completa es nulable, porque su valor es nulo cuando el receptor lo era, y eso permite rematarla con un operador de elvis que provee la alternativa.
val longitud: Int = texto?.let { it.trim().length } ?: 0
// Encadenar varias etapas que pueden desaparecer
val ciudad: String? = usuario
?.direccionPrincipal
?.let { normalizar(it) }
?.takeIf { it.isNotBlank() }
Este idioma tiene una virtud que conviene nombrar con precisión, porque explica por qué se prefiere a la alternativa con if. Una comprobación con if sobre una propiedad mutable no habilita el smart cast, ya que el compilador no puede garantizar que el valor no cambie entre la comprobación y el uso; la llamada segura sí, porque evalúa el receptor una única vez y pasa esa evaluación al bloque como argumento inmutable. Por eso ?.let es la salida idiomática cuando el valor procede de una propiedad var, de una propiedad de otro módulo o de código Java, mientras que sobre un val local un if ordinario suele leerse mejor.
class Sesion { var token: String? = null }
fun usar(s: Sesion) {
if (s.token != null) enviar(s.token) // no compila: sin smart cast
s.token?.let { enviar(it) } // correcto
}
flowchart LR
A[Valor posiblemente nulo] --> B{Llamada segura}
B -- Es nulo --> C[Toda la expresion vale nulo]
B -- No es nulo --> D[Entra al bloque ya como tipo definido]
D --> E[Transformacion dentro de let]
C --> F[Elvis aporta la alternativa]
E --> G[Resultado final]
F --> GHay un matiz de evaluación que conviene tener presente porque se manifiesta en cadenas largas. La llamada segura no evalúa nada de lo que viene después cuando el receptor es nulo, de modo que un bloque con efectos laterales colocado detrás de un eslabón apagado simplemente no ocurre. Eso es deseable la mayoría de las veces y sorprendente cuando el bloque contenía un registro que alguien esperaba ver siempre. La regla que evita la sorpresa es no colocar detrás de una llamada segura ningún efecto que deba ejecutarse en ambos casos.
Cuando la construcción ?.let termina con un elvis que contiene varias líneas de lógica alternativa, la expresión ha dejado de ser una transformación y se ha convertido en una bifurcación disfrazada. Una bifurcación se escribe con if o con when, porque ahí las dos ramas quedan a la misma altura visual y el lector no tiene que reconstruir cuál es cuál. Reserva ?.let para el caso en que la rama nula es un valor por defecto breve o directamente la ausencia del valor.
also: intervenir sin alterar
also recibe el objeto como argumento igual que let, pero devuelve el objeto en lugar del resultado del bloque. Esa transparencia lo convierte en el punto de intervención de una cadena: todo lo que ocurre dentro es efecto lateral por definición, porque el valor calculado se descarta. Registrar, validar, medir, notificar y depurar son sus casos legítimos, y todos comparten la propiedad de poder eliminarse sin cambiar ni el tipo ni el valor de la expresión.
val pedido = construirPedido(carrito)
.also { log.debug("pedido construido: {}", it.id) }
.also { require(it.lineas.isNotEmpty()) { "pedido vacio" } }
.let { aplicarDescuentos(it) }
fun guardar(u: Usuario): Usuario =
repositorio.insertar(u).also { metricas.incrementar("usuarios.creados") }
El nombre está bien elegido y conviene leerlo literalmente: la cadena hace lo suyo y además hace esto. Esa lectura sugiere la prueba que decide si el uso es correcto, y es la prueba de la supresión: si al borrar el bloque el resto del programa sigue siendo válido en tipos y en semántica principal, also es la función adecuada. Si al borrarlo se pierde algo que el flujo necesita, entonces el bloque no era un añadido sino una etapa, y una etapa se escribe con la función que devuelve su resultado.
`let` transforma
Cambia el tipo, cierra una etapa y confina el intermedio. La cadena continúa sobre algo distinto de lo que entró.
`also` observa
Conserva tipo y valor. Todo lo que hay dentro es efecto lateral, y el bloque debería poder borrarse sin romper nada.
`?.let` protege
Evalúa el receptor una vez y entrega el valor ya no nulable. Es la salida correcta cuando el smart cast no está disponible.
Los usos degenerados de ambas
Hay tres patrones que aparecen con insistencia en las revisiones de código y que conviene reconocer de un vistazo. El primero es el let sobre un valor no nulable cuyo bloque llama a una función con it: es una llamada normal con envoltorio. El segundo es el also cuyo bloque modifica el objeto en lugar de observarlo, que es exactamente el trabajo de apply y que engaña al lector porque el nombre promete un añadido y entrega una mutación. El tercero, más sutil, es la cadena de tres o más ?.let consecutivos, donde cada eslabón puede apagar el resto y el lector acaba sin saber cuál de los pasos produjo el nulo final; ahí la construcción honesta suele ser una función con salidas anticipadas, o el uso de runCatching si lo que se está modelando en realidad es un fallo y no una ausencia.
// Degenerado: envoltorio sin funcion
val n = texto.let { it.length } // texto.length
// Degenerado: also que muta
val u = Usuario().also { it.activo = true } // deberia ser apply
// Sospechoso: tres apagados en serie sin diagnostico posible
val v = a?.let { f(it) }?.let { g(it) }?.let { h(it) }
El tercer patrón merece un comentario adicional porque su corrección no es mecánica. Una cadena de llamadas seguras encadenadas es perfectamente legítima cuando todos los eslabones representan la misma ausencia, es decir, cuando el nulo final significa siempre lo mismo para quien recibe el resultado. Deja de serlo en cuanto los eslabones representan condiciones distintas, porque entonces la expresión ha comprimido varias preguntas en una sola respuesta y ha perdido la información de cuál falló. La señal más fiable de que se ha cruzado esa línea es tener que añadir un registro dentro de la cadena para averiguar dónde se apagó: si hace falta instrumentar la expresión para entenderla, la expresión ya era demasiado larga.
// Legitimo: un unico significado para la ausencia
val ciudad = usuario?.direccion?.ciudad?.nombre
// Ilegitimo: tres condiciones distintas comprimidas en un solo nulo
val listo = entrada?.let { validar(it) }?.let { autorizar(it) }?.let { firmar(it) }
Conviene detenerse en la distancia real que separa una comprobación de nulidad de una llamada segura, porque quien la entiende deja de elegir entre ambas por costumbre y empieza a elegirla por lo que cada una garantiza. Una comprobación con if es una afirmación sobre el mundo en el instante en que se evalúa la condición, y el compilador solo puede propagar esa afirmación al cuerpo si demuestra que nada puede invalidarla mientras tanto; por eso funciona sobre un val local, cuya inmutabilidad es verificable, y por eso se niega sobre una propiedad var de otra clase, sobre una propiedad de un módulo distinto o sobre cualquier cosa que provenga de código Java, donde otro hilo o un accesor sobreescrito podrían cambiar el valor entre la comprobación y el uso. La llamada segura no hace ninguna afirmación sobre el mundo: evalúa el receptor exactamente una vez, decide con ese valor concreto y lo entrega al bloque como un argumento que ya nadie puede cambiar. La diferencia, dicho de otro modo, es que el if razona sobre una expresión y el ?.let razona sobre un valor, y solo el segundo sobrevive a la posibilidad de que la expresión no sea estable. De aquí se sigue una consecuencia de diseño que va mucho más allá de este par de funciones y que conviene llevarse: el compilador de Kotlin no rechaza el smart cast por conservadurismo gratuito, sino porque el sistema de tipos se niega a firmar garantías que no puede sostener, y cada vez que hace falta recurrir a ?.let para rodearlo hay, escondida detrás, una propiedad mutable compartida cuya existencia es la verdadera decisión de diseño. Arreglar esa propiedad, convirtiéndola en un val capturado localmente o sacándola del estado compartido, suele mejorar el código mucho más que elegir bien entre las dos formas.
- Busca en tu código tres
lety clasifícalos: transforman, acotan un intermedio o son ruido. Borra los del tercer grupo. - Coge una comprobación con
ifsobre una propiedadvarque no compile por falta de smart cast y reescríbela con?.let. Explica qué garantiza la segunda que no garantizaba la primera. - Toma una cadena tuya y añade un
alsoque registre el valor intermedio. Comprueba que borrarlo deja el programa intacto en tipos y en comportamiento principal. - Localiza un
alsocuyo bloque mute el objeto y conviértelo enapply. Argumenta qué comunica cada nombre a quien lea la línea sin contexto. - Reescribe una cadena de tres
?.letconsecutivos como una función con salidas anticipadas y compara cuál permite diagnosticar dónde se produjo la ausencia.