Funciones estándar (Scope Functions) I: let

Funciones estándar (Scope Functions) I: let

La biblioteca estándar de Kotlin contiene varias funciones cuyo único propósito es ejecutar un bloque de código dentro del contexto de un objeto. Cuando se llama a una función de este tipo con una expresión lambda se forma un ámbito (scope) temporal dentro del cual se puede acceder al objeto sin su nombre. Kotlin dispone de cinco funciones de este tipo: let, run, with, apply y also, que en conjunto reciben el nombre de Scope Functions (funciones de alcance) o funciones estándar.

Estas funciones hacen básicamente lo mismo: ejecutar un bloque de código en un objeto, con las diferencias entre ellas de cómo ese objeto se vuelve disponible dentro del bloque y cuál es el resultado de toda la expresión.

Aquí hay un ejemplo de una función de alcance (let):
data class Persona(var nombre: String, var edad: Int, var ciudad: String) {
    fun cambiarCiudad(nuevaCiudad: String) { ciudad = nuevaCiudad }
    fun cambiarEdad() { edad++ }
}

fun main() {
    Persona("Alicia", 20, "Barcelona").let {
        println(it)
        it.cambiarCiudad("Madrid")
        it.cambiarEdad()
        println(it)
    }
}

// Persona(nombre=Alicia, edad=20, ciudad=Barcelona)
// Persona(nombre=Alicia, edad=21, ciudad=Madrid)

Pero podemos escribir lo mismo sin la función let de esta manera:
fun main() {
    val persona = Persona("Alice", 20, "Barcelona")
    println(persona)
    persona.cambiarCiudad("Madrid")
    persona.cambiarEdad()
    println(persona)
}

Por tanto, quizá estas funciones no aportan nuevas posibilidades técnicas, pero hacen que el código sea más conciso y legible. A continuación veremos, primero, las principales diferencias entre ellas y después (en esta y posteriores entradas) una descripción de cada función estándar para facilitar la elección entre ellas, si bien en muchos pueden resultar intercambiables.

Las principales diferencias entre las funciones estándar se refieren a dos aspectos:
  1. La forma de referirse al objeto: this o it.
  2. El valor de retorno: el objeto o el resultado de la función lambda.

Dentro de la lambda de una función estándar el objeto está disponible, en lugar de por su nombre real, por una referencia, ya sea un receptor (this) o un argumento (it) de la función lambda:
fun main() {
    val frase = "Funciones Estándar en Kotlin Doc"

    // this
    frase.run {
        println("Letras: ${this.length}")
        // más conciso:
        // println("Letras: ${length}")
    }

    // it
    frase.let {
        println("Letras: ${it.length}")
    }
}

run , with y apply se refieren al objeto como un receptor lambda utilizando this. Por lo tanto en sus lambdas el objeto está disponible como lo estaría en las funciones normales de clase y en la mayoría de los casos this se puede omitir al acceder a los miembros del objeto receptor. No obstante, si se omite puede ser difícil distinguir entre los miembros del receptor y objetos o funciones externos, por lo que se recomienda explicitar this para las lambdas que operan principalmente con los miembros del objeto.
data class Persona(var nombre: String, var edad: Int = 0, var ciudad: String = "")

fun main() {
    val persona1 = Persona("Felipe").apply {
        edad = 20  // this.edad = 20
        ciudad = "Valencia"
    }
    println(persona1)
    // Persona(nombre=Felipe, edad=20, ciudad=Valencia)
}

Por su parte, let y also hacen referencia al objeto como un argumento de lambda, y cuando no se especifica el nombre del argumento, se accede al objeto con el nombre implícito por defecto it:
import kotlin.random.Random

fun infoLog(mensaje: String) {
    println("INFO: $mensaje")
}

fun main() {
    fun getRandomInt(): Int {
        return Random.nextInt(100).also {
            infoLog("Número aleatorio: $it")
        }
        // return Random.nextInt(100).also { it -> infoLog("Número aleatorio: $it") }
    }
    getRandomInt()
}

Además, cuando pasamos el objeto como un argumento podemos referirnos a él con un nombre personalizado dentro del ámbito de la lambda:
fun getRandomInt(): Int {
    return Random.nextInt(100).also { numero ->
        infoLog("Número aleatorio: $numero")
    }
}

Las funciones estándar también difieren según el resultado que devuelven: mientras que apply y also devuelven el propio objeto, let, run y with devuelven el resultado de la lambda.

En el caso de apply y also, esto permite que estas funciones pueden ser incluidas dentro del bloque de la función como pasos consecutivos, esto es, se pueden concatenar estas funciones en el mismo objeto después de apply y also.
fun main() {
    val numeros = mutableListOf<Double>()
    numeros.also { println("Añadiendo elementos a la lista") }
        .apply {
            add(2.71)
            add(3.14)
            add(1.0)
        }
        .also { println("Ordenando la lista") }
        .sort()
    println(numeros) // [1.0, 2.71, 3.14]
}

Además, como ya como vimos en el código anterior con la función getRandomInt() que devuelve un entero, apply y also también se pueden usar para devolver el objeto en la declaración de retorno de una función.

Por su parte, let, run y with devuelven el resultado de lambda y por tanto se pueden usar, por ejemplo, cuando se asigna el resultado a una variable o encadenando operaciones en el mismo resultado:
fun main() {
    val numeros = mutableListOf("uno", "dos", "tres")
    val nFinConO = numeros.run {
        add("cuatro") // this.add("cuatro")
        add("cinco")
        count { it.endsWith("o") }
    }
    println("Hay $nFinConO elementos que terminan con la letra o.")
    // Hay 3 elementos que terminan con la letra o.
}

También podemos ignorar el valor de retorno y usar estas funciones para crear un ámbito temporal para las variables:
fun main() {
    val numeros = mutableListOf("uno", "dos", "tres")
    with(numeros) {
        val primero = first() // val primero = this.first()
        val ultimo = last()
        println("Primer elemento: $primero, último elemento: $ultimo")
        // Primer elemento: uno, último elemento: tres
    }
}

let

Como ya sabemos, en la función estándar let el objeto está disponible como un argumento (it) y el valor de retorno es el resultado de lambda.

let puede utilizarse para invocar una o más funciones en los resultados de llamadas encadenadas. Por ejemplo, el siguiente código imprime los resultados de dos operaciones en una colección (calcula la longitud de cada elemento y luego los filtra):
fun main() {
    val numeros = mutableListOf("uno", "dos", "tres", "cuatro", "cinco")
    val resultadoLista = numeros.map { it.length }.filter { it > 3 }
    println(resultadoLista) // [4, 6, 5]
}

Lo mismo con let lo podemos escribir así:
fun main() {
    val numeros = mutableListOf("uno", "dos", "tres", "cuatro", "cinco")
    numeros.map { it.length }.filter { it > 3 }.let {
        println(it)
        // ... más llamadas a otras funciones
    }
}

Después del println podemos añadir más llamadas a otras funciones, lo que le daría realmente sentido al uso de let en este ejemplo, porque en caso contrario podríamos prescindir del let y hacer el código más conciso:
println(numeros.map { it.length }.filter { it > 3 })

Si el bloque de código contiene una sola función con it como argumento, en lugar de la lambda podemos usar la referencia del método (::), por ejemplo:
numeros.map { it.length }.filter { it > 3 }.let(::println)

A menudo se usa let para ejecutar un bloque de código solo con valores no nulos, para lo cual usamos el operador de llamada segura ? como vimos en Gestión de tipos nulos en Kotlin:
fun procesarNonNullString(str: String) = println("im$str")

fun main() {
    val str: String? = "posible Null"
    //procesarNonNullString(str)   // ERROR: str puede ser null
    val noNull = str?.let {
        procesarNonNullString(it)  // OK: 'it' no es null dentro de '?.let { }'
    }
}

Otro caso habitual para el uso de let es la introducción de variables locales para mejorar la legibilidad del código. Así, para definir una nueva variable para el objeto facilitamos un nombre personalizado como argumento de lambda para que se pueda usar en lugar de it:
fun main() {
    val numeros = listOf("uno", "dos", "tres", "cuatro")
    val modificarPrimero = numeros.first().let { primero ->
        println("El primer elemento de la lista es '$primero'")
        if (primero.length >= 5) primero else "!" + primero + "!"
    }.toUpperCase()
    println("Ahora el primer elemento de la lista es '$modificarPrimero'")
}
/* EQUIVALENTE A:
fun main() {
    val numeros = listOf("uno", "dos", "tres", "cuatro")
    val modificarPrimero = numeros.first().let {
        println("El primer elemento de la lista es '$it'")
        if (it.length >= 5) it else "!" + it + "!"
    }.toUpperCase()
    println("Ahora el primer elemento de la lista es '$modificarPrimero'")
}*/

Comentarios

Entradas populares

Recursos gratis para aprender Kotlin

I/O: entrada y salida de datos en consola

Lectura y escritura de archivos