laitimes

《Kotlin技巧:作用域函数let、run、with、apply和also》

author:Not bald programmer
《Kotlin技巧:作用域函数let、run、with、apply和also》

About Kotlin's scoped functions: let, run, with, apply, and also. They all have one thing in common: they are both called on objects, and within the scope of these functions, the object itself can be accessed without the need for its name.

Use the let scope function to transform the object and return the result

The first tip is about the let scope function, which is especially useful when you want to perform a transformation on an object and assign the result:

fun main() {
    val name = "   Kotlin  "
    val cleanName = name.let { 
      val trimmed = it.trim()
      val reversed = trimmed.reversed() 
      reversed
    }
    println(cleanName) // This will print "niltoK"
}           

Operations within a let block are constrained within that block and do not affect the object that calls it. In the example above, you can see that the operation on the name does not affect the variable itself. Instead, we store the result of the conversion in the cleanName variable.

The let function can also be used to ensure that a piece of code is executed only if the variable that calls it is not empty:

fun main() {
    val name: String? = "Kotlin"
    name?.let {
        println("The name has ${it.length} characters.")
    }
}           

However, even though this is a common use case, there are misrepresentations that this is what let is actually for. Actually, the word let is derived from mathematical logic and set theory, where it is used to introduce a new variable or constant.

For example, in mathematics, you might say "Let a satisfy x²=a", which introduces a as a new variable and defines a condition for it.

fun main() {
    val x = 3

    // Let a be such that x²=a
    val a = x.let { x ->
        x * x
    }

    println(a)
}           

Essentially, the let function in Kotlin is saying "let this variable equal this value". At the same time, it allows you to define a variable in a certain range without polluting the outer scope, making the code safer and more predictable.

Use the with function to initialize or configure the object

Consider a situation where you are setting multiple properties for a new object. In the traditional way, you might find yourself referencing an object repeatedly when configuring its fields:

fun main() {
    val stringBuilder = StringBuilder()
    stringBuilder.append("Hello, ")
    stringBuilder.append("world!")
    stringBuilder.append(" How ")
    stringBuilder.append("are ")
    stringBuilder.append("you?")

    println(stringBuilder.toString())
}           

By using with, what might have been verbose and repetitive code is turned into clear and concise chunks:

fun main() {
    val stringBuilder = StringBuilder()

    with(stringBuilder) {
        append("Hello, ")
        append("world!")
        append(" How ")
        append("are ")
        append("you?")
    }

    println(stringBuilder.toString())
}           

The with function takes two parameters: the object you're working on and a lambda that contains the operation performed on that object. Within this lambda, members of an object can be accessed directly, without the need to prefix the object's name or reference. By reducing verbose writing, not only does it reduce lengthy code, but it also reduces the likelihood of errors when referencing objects.

Use the run function to combine initialization and processing

The run function is a particularly useful tool when you're looking to perform a block within the context of an object.

Consider a situation where you're initializing a variable and also want to perform a set of actions immediately. run excels here, allowing you to encapsulate both requirements into an elegant expression:

class DatabaseConfiguration() {
    var host: String? = null
    var port: String? = null
    
    fun isValid() = host == null || port == null 
    
    fun connect() = DatabaseConnection(host, port)
}

class DatabaseConnection(val host: String?, val port: String?) {
    fun runQuery(query: String) = println("Running query: $query")
}

fun main() {
    val databaseConfiguration = DatabaseConfiguration()
    val connection = databaseConfiguration.run {
        // Initialization or configuration
        host = "localhost"
        port = "5739"
        
        // Execution of a block of code
        if (isValid()) error("Invalid Database Configuration")
        connect()
    }
    
    connection.runQuery("SELECT * FROM table")
}           

run acts as an object (receiver) and contains a lambda expression. In this lambda, the members of the object can be accessed directly, without the need for explicit naming. However, the charm of run is not limited to this;

It returns the results of the lambda, combining the benefits of context-aware operations with the output of the function.

run excels in scenarios where you need to combine initialization/configuration and executable blocks that produce results. Its use not only promotes the simplicity of the code, but also enhances the clarity and expressiveness that Kotlin advocates.

Use the APPLY function to configure complex objects

The apply function acts as a mechanism for configuring objects. However, unlike the run function, it returns the context object itself.

This feature is especially beneficial when dealing with objects that need to be initialized or configured multiple times. Apply allows direct access to the members of an object without explicitly referencing the object, making the code more concise and fluid.

class DatabaseConnection {
    var host: String = ""
    var port: Int = 0
    var dbName: String = ""
    var username: String = ""
    var password: String = ""

    fun connect() {
        println("Connecting to database $dbName at $host:$port with username $username")
    }
}

fun main() {
    val dbConnection = DatabaseConnection().apply {
        host = "localhost"
        port = 5432
        dbName = "myDatabase"
        username = "admin"
        password = "password"
    }

    dbConnection.connect()
}           

Here's how it works: call apply on an object and take a lambda expression. In this lambda, every call or access to an object's property or function is implicitly made on the context object. Once done, apply returns the context object, which is now fully configured.

This not only reduces the overall verbosity of the code, but also improves clarity. Each property is set in a block that clearly indicates its purpose: to configure the DatabaseConnection object.

Use the also function to perform the operation

Also is especially useful for performing operations that shouldn't interfere with the state of the master object, or for adding logging and other side features.

class File {
    var name: String = ""
    var extension: String = ""
    var content: String = ""

    fun save() {
        println("Saving file $name.$extension with content: $content")
    }
}

fun main() {
    val report = File().apply {
        name = "Report"
        extension = "txt"
        content = "This is the content of the report."
    }.also {
        println("Preparing to save file: ${it.name}.${it.extension}")
        it.save()
    }
}           

In this example, it's also used to record the details of the File object, and then save it as soon as it's initialized with apply. Use it in the also block to reference the File object, allowing direct access to its properties. The advantage also lies in its simplicity and ability to keep the code readable while providing the flexibility to add meaningful secondary actions.

Additionally, the ability to return the original object makes it the perfect tool for linking multiple operations, where each operation is independent, and keeping the core business logic clean and independent.

Regardless of the auxiliary operation, the also function exemplifies Kotlin's commitment to clean, readable, and expressive code.