let, apply y similares en Kotlin

De mis características favoritas de Kotlin, una de las más top es que todos los tipos tengan como funciones de extensión una serie de métodos auxiliares: let, apply, also… Son una forma limpia de encadenar código y hasta de transformarlo. El problema es que nunca recuerdo qué diferencia hay entre ellos, así que voy a dejarlo por aquí escrito para la próxima.

Su nombre correcto es scope functions y aceptan como parámetro una lambda con el código que queremos que se evalúe a consecuencia de invocar esa scope function. Su principal gracia, como muestro ahora, es que desde dentro de la lambda se puede referenciar al objeto cuyo método de extensión es invocado. Bajo mi punto de vista, esto está muy bien porque permite no escribir tanto código cuando se usan expresiones largas.

Por ejemplo, supongamos que hay que llamar a varios métodos del objeto accesible desde context.server.settings. Tendríamos que escribir varias veces todo el chorizo de clases. Me invento el código:

context.server.settings.port = 8080
context.server.settings.protocol = Protocols.HTTPS
context.server.settings.resetLogger()

Para no cansarnos de escribir tanto context.server.settings, las opciones serían, o crear una variable local con val sett = context.server.settings para luego hacer sett.port y sett.protocol, o… usar las scope functions y que la variable se declare implícitamente.

Apply

La primera función de este estilo es la apply. Esta función está disponible en el objeto, y al llamar a apply lo que se hace es invocar la lambda que se pase como parámetro. El propio objeto desde el cual se llama a la función apply estará accesible usando this.

El ejemplo que puse antes lo podríamos reescribir con un apply llamado sobre la propia expresión context.server.settings. Como todo el objeto settings será accesible desde el this, y como el this es opcional si no hay ambigüedad, ahora se puede escribir tan simple como:

context.server.settings.apply {
  port = 8080
  protocol = Protocols.HTTPS
  resetLogger()
}

El método apply devuelve el propio objeto al cual llamamos apply en primer lugar. Esto significa que se puede encadenar una llamada a apply con otra, si apetece hacerlo. Esto lo podemos hacer incluso en medio de una asignación. Podemos beneficiarnos de esto para depurar a golpe de print cuando estamos haciendo código fluent.

val retorno = "Hola mundo"
    .apply { println("Cadena: $this") }
    .apply { println("Minúsculas: ${this.lowercase()}") }
    .apply { println("Mayúsculas: ${this.uppercase()}") }
println("Retorno: $retorno")

Esto imprime lo siguiente:

Cadena: Hola mundo
Minúsculas: hola mundo
Mayúsculas: HOLA MUNDO
Retorno: Hola mundo

Also

Also es otra función contextual que puede ser llamada desde cualquier objeto. Cuando se haga, se invocará a una lambda de un parámetro, que será el objeto desde el que se llamó a also. Por lo tanto, la principal diferencia entre apply y also es que en apply el objeto contextual se pasa como this, y en also se pasa como parámetro de la lambda, generalmente accesible mediante la variable it salvo que le cambiemos de nombre.

val cadena = "Hola".also {
  println("Mayúsculas: " + it.uppercase()) 
}
println("Retorno: $cadena")

En este caso, dentro del also hay que usar it para acceder a la propia cadena «Hola», que es accesible luego desde el identificador cadena tras la ejecución del also.

Son muy parecidos por lo demás. Esto me llevó a preguntarme qué sentido tiene entonces que coexistan ambos. Después de buscar un poco por SO, he aprendido sus diferencias y por qué se querría usar una u otra función.

Para empezar, como also te fuerza a darle un identificador, puede venir mejor para no confundir y hacer más claro el código. Por ejemplo, podríamos darle un nombre distinto de it a esa referencia, para dejar claro el propósito del bloque:

context.server.settings.also { sets ->
  sets.port = 8080
  sets.protocol = Protocol.HTTPS
  sets.resetLogger()
}

Viendo el manual oficial de Kotlin, se me hace entender que also viene bien cuando queremos mantener el valor original de this. Esto puede venir bien cuando estamos escribiendo el código dentro de una clase y queremos llamar a otros métodos de la misma o acceder a propiedades de fuera (getView() dentro de una actividad de Android, por ejemplo):

class Prueba {
    fun actuar() {
        val retorno = "Hola mundo"
            .also { println("Mayus: ${this.mayusculas(it)}") }
            .also { println("Minus: ${this.minusculas(it)}") }
        println("Retorno: $retorno")
    }

    fun mayusculas(t: String) = t.uppercase()
    fun minusculas(t: String) = t.lowercase()
}

La última diferencia parece ser una cuestión del nombre de cada método. Hay quien usa apply sólo cuando pretende aplicar propiedades a un objeto durante su inicialización, y also sólo para invocar operaciones. Sería un juego de palabras con apply («aplica») y also («además»):

val server = serverFactory()
  .apply { port = 8080 } // Apply this to the port
  .also { s -> s.enableDebug() } // Also enable debug
server.start()

let

El método let es otro de esta saga de métodos. A primera vista, parecería similar a also, porque recibe el objeto en el que se invoca a let a través del parámetro de la lambda:

"Hola mundo".let { println("Cadena: $it") }

Pero entonces, ¿qué diferencia hay entre let y also? La novedad que se introduce es que let devuelve el retorno de la lambda que se le pase. Es decir, que esa lambda que le pasamos a let puede tener un retorno, que será lo que retorne la propia función let. Esto la convierte en una forma poderosa de transformar objetos individualmente, de una manera parecida a como hacen las pipelines en un lenguaje como Haskell o Elixir, o de una forma similar a como lo hace una función map.

val retorno = "Hola mundo"
    .let { listOf(it) }
    .let { it.size }
println("retorno: $retorno")

Este ejemplo imprime la cadena retorno: 1. En el primer let, fabricamos una lista a partir de la cadena de caracteres, que le entra como identificador it. Sin embargo, ese primer let devuelve esa misma lista. Por lo tanto, el segundo let es consecuencia de llamar al método sobre la lista, no sobre la cadena. En la segunda lambda, it hace referencia a la lista de un elemento. Además, como este segundo let tiene una lambda que devuelve la longitud de la lista, la variable retorno adopta el valor Int 1.

De acuerdo con mi investigación, el principal uso de let suele ser extraer la referencia de forma a un objeto que podría valer null mediante el uso del operador ?. a base de invocar su método let:

// context.server es una variable nullable.
context.server?.let {
  // Este bloque sólo se evalúa cuando hay un servidor.
  // Además, it ya no es una variable nullable así que
  // sabemos que tiene valor.
  it.resetDebug()
}

Esto es algo que también se puede conseguir con also. Sin embargo, let permite además devolver cosas, por si queremos usar ese let para construir otras variables e identificadores a partir de la primera:

fun ejemplo(s: String?) {
    val tam = s?.let { it.length }
    println("Longitud de $s = $tam")
}

Si s vale null, nunca se ejecutará la lambda dada como parámetro al let. A tener en cuenta que tam valdrá también null en ese caso. Sin embargo, si s tiene un valor real, entonces se invocará la lambda de let y además ahora tam tendrá como valor la longitud de la cadena de caracteres s:

ejemplo(null): Longitud de null = null
ejemplo("Hola mundo"): Longitud de Hola mundo = 10

run

Si has llegado hasta aquí, mereces que vaya al grano con esta, porque tampoco hay mucho que contar ya. Las partes claves de la función run son:

  • El objeto al que llamamos al método run se pasa como this. No es necesario poner it. De hecho, no es necesario poner nada, porque la lambda no tiene parámetro.
  • Lo que devuelva la función lambda que se le pase a run, será lo que se devuelva en el propio run.

La guía oficial de Kotlin muestra que run puede usarse de forma parecida a let:

val server = ServerBuilder().run {
  port = 8080
  debug = true
  protocol = Protocols.HTTPS
  build()
}

Aunque lo que realmente hace útil el método run es que puede ser llamado incluso sin usar métodos de extensión. Se le puede llamar como un run «flotante», y esto es útil para invocar sobre la marcha una lambda anónima, evaluando todo el run a lo que sea que devuelva la lambda. Un caso de uso interesante es fabricar una variable a base de evaluar código anónimo sin crear una función auxiliar:

val fibonaccis = run {
    var (a, b) = Pair(1, 1)
    val result = mutableListOf(a, b)
    for (i in 3..10) {
        val next = a + b
        a = b
        b = next
        result.add(next)
    }
    result
}
println(fibonaccis)

Bonus: with

with no puede usarse como método de extensión. O sea, no puedes hacer foo.with. Invocas with usándolo sin más en medio de tu código, como mostraba antes que se puede hacer con run.

La diferencia entre ese run y el with es que mientras que no se puede cambiar el this de un run que no sea método de extensión, a un with sí que se le pasa como parámetro lo que sea que queramos que sea accesible desde dentro de la lambda. Un caso de uso de with puede ser actuar con un valor al que se accede mediante una expresión un poco complicada de escribir, ahorrando la declaración de una variable local:

with(Prueba.Ejemplo.Anidado.cadena) {
    println("La cadena es $this")
    println("La longitud de la cadena es $length")
}

with retorna lo que sea que retorne la lambda. Esto le asimila a let y a run. Sin embargo, la guía oficial no recomienda usar with cuando nos importe el valor, y en verdad existen otras formas de escribir esa lambda si realmente hay que guardarlo en una variable. Aun así, se puede hacer si fuese necesario.

Diferencias clave

Entonces, ¿cuál es la diferencia entre estos métodos?

MétodoAcceder al objeto…La lambda retorna…
applythisel propio objeto
alsoitel propio objeto
letitla evaluación de la lambda
runthisla evaluación de la lambda
withthisla evaluación de la lambda