Функции области видимости

Стандартная библиотека Kotlin содержит несколько функций, единственной целью которых является выполнение блока кода в контексте объекта. Эти функции формируют временную область видимости для объекта, к которому были применены, и вызывают код, указанный в переданном лямбда-выражении. В этой области видимости можно получить доступ к объекту без явного к нему обращения по имени. Такие функции называются функциями области видимости (англ. scope functions). Всего их пять: let, run, with, apply, и also.

По сути, все эти функции делают одно и то же: выполняют блок кода для объекта. Отличие состоит в том, как этот объект становится доступным внутри блока и каков результат всего выражения.

Пример обычного использования функции области видимости:

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

fun main() {
    Person("Alice", 20, "Amsterdam").let {
        println(it)
        it.moveTo("London")
        it.incrementAge()
        println(it)
    }
}

Если вы захотите написать то же самое без функции let, то вам придется объявить новую переменную и обращаться к ней всякий раз, когда она используется.

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

fun main() {
    val alice = Person("Alice", 20, "Amsterdam")
    println(alice)
    alice.moveTo("London")
    alice.incrementAge()
    println(alice)
}

Функции области видимости не предоставляют никаких новых технических возможностей, но они могут сделать ваш код более кратким и читабельным.

Из-за того, что все функции области видимости имеют схожий характер, выбрать правильную для вашего конкретного случая может быть затруднительно. В основном выбор зависит от ваших намерений и от структуры вашего проекта. Ниже приведено подробное описание того, чем функции области видимости отличаются между собой, а также соглашение об их использовании.

Отличительные особенности

Поскольку функции области видимости очень похожи друг на друга, важно понимать чем они различаются. Между ними есть два основных различия: * Способ ссылки на контекстный объект * Возвращаемое значение.

Контекстный объект: this или it

Внутри лямбда-выражения, которое передается функции области видимости, объект контекста доступен по краткой ссылке, а не по его фактическому имени. Каждая функция области видимости использует один из двух способов доступа к объекту контекста: как лямбда-получатель (this) или как лямбда-аргумент (it). Оба предоставляют одинаковые возможности, поэтому опишем плюсы и минусы каждого для разных случаев и дадим рекомендации по их использованию.

fun main() {
    val str = "Hello"
    // this
    str.run {
        println("Длина строки: $length")
        //println("Длина строки: ${this.length}") // делает то же самое
    }

    // it
    str.let {
        println("Длина строки: ${it.length}")
    }
}

this

run,with и apply ссылаются на объект контекста как лямбда-получатель - по ключевому слову this. Следовательно, в их лямбдах объект доступен, как это было бы в обычных функциях класса. В большинстве случаев this можно опустить при доступе к элементам объекта-получателя, что сделает код короче. С другой стороны, если this опущено, то будет сложнее различить элементы-получатели с внешними объектами или функциями. Таким образом, наличие this рекомендуется для лямбд, которые в основном работают с членами объекта: вызывают его функции или присваивают свойства.

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
    val adam = Person("Adam").apply {
        age = 20        // то же самое, что и this.age = 20 или adam.age = 20
        city = "London"
    }
    println(adam)
}

it

В свою очередь, let и also передают контекстный объект как аргумент в лямбду. Если имя аргумента не указано, то к объекту обращаются неявным способом при помощи ключевого слова it. it короче this и выражения с it более читабельны. Однако при вызове функций или свойств объекта у вас не будет доступа к такому неявному объекту как this. Следовательно, использовать контекстный объект it лучше, когда объект в основном используется в качестве аргумента для вызова функций. it также лучше, если вы используете несколько переменных в блоке кода.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

fun main() {
    fun getRandomInt(): Int {
        return Random.nextInt(100).also {
            writeToLog("Метод getRandomInt() сгенерировал значение $it")
        }
    }

    val i = getRandomInt()
}

Кроме того, когда вы передаете объект в качестве аргумента, вы можете указать пользовательское имя для этого объекта внутри области видимости.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

fun main() {
    fun getRandomInt(): Int {
        return Random.nextInt(100).also { value ->
            writeToLog("Метод getRandomInt() сгенерировал значение $value")
        }
    }

    val i = getRandomInt()
}

Возвращаемое значение

Функции области видимости также отличаются по значению, которое они возвращают: * apply и also возвращают объект контекста. * let, run и with возвращают результат лямбды.

Это отличие позволит выбрать правильную функцию в зависимости от того, что вы будете делать дальше в своем коде.

Контекстный объект

Функции apply и also возвращают объект контекста. Следовательно, с их помощью можно вызвать длинную цепочку функций относительно оригинального контекстного объекта. Такая цепочка функций известна как side steps.

fun main() {
    val numberList = mutableListOf<Double>()
    numberList.also { println("Заполнение списка") }
        .apply {
            add(2.71)
            add(3.14)
            add(1.0)
        }
        .also { println("Сортировка списка") }
        .sort()
    println(numberList)
}

Они также могут быть использованы совместно с ключевым словом return.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("Информация: $message")
}

fun main() {
    fun getRandomInt(): Int {
        return Random.nextInt(100).also {
            writeToLog("Метод getRandomInt() сгенерировал значение $it")
        }
    }

    val i = getRandomInt()
}

Результат лямбды

let, run и with возвращают результат лямбды. Таким образом, вы можете использовать их при присваивании переменной результата вычислений, либо использовать результат для последующего вызова цепочки операций и тд.

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    val countEndsWithE = numbers.run {
        add("four")
        add("five")
        count { it.endsWith("e") }
    }
    println("Элементы в $countEndsWithE, которые заканчиваются на e.")
}

Кроме того, вы можете игнорировать возвращаемое значение и использовать функцию для создания временной области видимости для переменной.

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        val firstItem = first()
        val lastItem = last()        
        println("Первый элемент: $firstItem, последний элемент: $lastItem")
    }
}

Функции

Чтобы помочь вам выбрать правильную функцию области видимости, ниже будет представлено их подробное описание и рекомендации по использованию. Во многих случаях они взаимозаменяемы, поэтому в примерах будет отражен общий стиль использования, а также соглашение по их применению.

let

Контекстный объект доступен в качестве аргумента (it). Возвращаемое значение - результат выполнения лямбды.

Если значение переменной вычислялось при помощи цепочки операций, то let позволяет использовать полученный результат для вызова одной или нескольких функций в блоке кода. Например, в следующем коде выполняется цепочка из двух операций, результат записывается в отдельную переменную, после чего она выводится на печать.

fun main() {
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    val resultList = numbers.map { it.length }.filter { it > 3 }
    println(resultList)    
}

С функцией let этот код может быть переписан следующим образом:

fun main() {
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    numbers.map { it.length }.filter { it > 3 }.let {
        println(it)
        // при необходимости можно вызвать больше функций
    }
}

Если блок кода содержит одну функцию, где it является аргументом, то лямбда-выражение может быть заменено ссылкой на метод (::):

fun main() {
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    numbers.map { it.length }.filter { it > 3 }.let(::println)
}

let часто используется для выполнения блока кода только с non-null значениями. Чтобы выполнить действия с non-null объектом, используйте оператор безопасного вызова ?. совместно с функцией let.

fun processNonNullString(str: String) {}

fun main() {
    val str: String? = "Hello"   
    //processNonNullString(str)       // compilation error: str может быть null
    val length = str?.let {
        println("Вызов функции let() для $it")        
        processNonNullString(it)      // OK: 'it' не может быть null внутри конструкции '?.let { }'
        it.length
    }
}

Еще один вариант использования let - это введение локальных переменных с ограниченной областью видимости для улучшения читабельности кода. Чтобы определить новую переменную для контекстного объекта, укажите ее имя в качестве аргумента лямбды, чтобы ее можно было использовать вместо ключевого слова it.

fun main() {
    val numbers = listOf("one", "two", "three", "four")
    val modifiedFirstItem = numbers.first().let { firstItem ->
        println("Первый элемент в списке: '$firstItem'")
        if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
    }.toUpperCase()
    println("Первый элемент списка после изменений: '$modifiedFirstItem'")
}

with

Не является функцией-расширением. Контекстный объект передается в качестве аргумента, а внутри лямбда-выражения он доступен как получатель (this). Возвращаемое значение - результат выполнения лямбды.

Функцию with рекомендуется использовать для вызова функций контекстного объекта без предоставления результата лямбды. В коде with может читаться как” с этим объектом, сделайте следующее.

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        println("'with' вызывает с аргументом $this")
        println("Список содержит $size элементов")
    }
}

Другой вариант использования with - введение вспомогательного объекта, свойства или функции которые будут использоваться для вычисления значения.

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    val firstAndLast = with(numbers) {
        "Первый элемент списка - ${first()}," +
        " последний элемент списка - ${last()}"
    }
    println(firstAndLast)
}

run

Контекстный объект доступен в качестве получателя (this). Возвращаемое значение - результат выполнения лямбды.

run делает то же самое, что и with, но вызывается как let - как функция расширения контекстного объекта.

run удобен, когда лямбда содержит и инициализацию объекта, и вычисление возвращаемого значения.

class MultiportService(var url: String, var port: Int) {
    fun prepareRequest(): String = "Запрос по умолчанию"
    fun query(request: String): String = "Результат запроса: '$request'"
}

fun main() {
    val service = MultiportService("https://example.kotlinlang.org", 80)

    val result = service.run {
        port = 8080
        query(prepareRequest() + " порт - $port")
    }

    // аналогичный код с использованием функции let():
    val letResult = service.let {
        it.port = 8080
        it.query(it.prepareRequest() + " порт - ${it.port}")
    }
    println(result)
    println(letResult)
}

Помимо вызова run для объекта-получателя, вы можете использовать его как функцию без расширения. В этом случае run позволяет выполнить блок из нескольких операторов там, где это требуется.

fun main() {
    val hexNumberRegex = run {
        val digits = "0-9"
        val hexDigits = "A-Fa-f"
        val sign = "+-"

        Regex("[$sign]?[$digits$hexDigits]+")
    }

    for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) {
        println(match.value)
    }
}

apply

Контекстный объект доступен в качестве получателя (this). Возвращаемое значение - контекстный объект.

Используйте apply для такого блока кода, который не возвращает значение и в основном работает с членами объекта-получателя. Типичный способ использования функции apply - настройка объекта-получателя. Это всеравно что мы скажем “примени перечисленные настройки к объекту.”

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
    val adam = Person("Adam").apply {
        age = 32
        city = "London"        
    }
    println(adam)
}

Так как возвращаемое значение - это сам объект, то можно с легкостью включить apply в цепочки вызовов для более сложной обработки.

also

Контекстный объект доступен в качестве аргумента (it). Возвращаемое значение - контекстный объект.

also хорош для выполнения таких действий, которые принимают контекстный объект в качестве аргумента. То есть, эту функции следует использовать, когда требуется ссылка именно на объект, а не на его свойства и функции. Либо, когда вы хотите, чтобы была доступна ссылка на this из внешней области видимости.

Когда вы видите в коде also, то это можно прочитать как “а также с объектом нужно сделать следующее.”

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    numbers
        .also { println("Элементы списка перед добавлением нового: $it") }
        .add("four")
}

Выбор функции

В таблице ниже приведены ключевые различия между функциями области видимости, что должно помочь вам сделать правильный выбор в пользу той или иной функции.

Функция Обращение к объекту Возвращаемое значение Является функцией-расширением
let it Результат лямбды Да
run this Результат лямбды Да
run - Результат лямбды Нет: может быть вызвана без контекстного объекта
with this Результат лямбды Нет: принимает контекстный объект в качестве аргумента.
apply this Контекстный объект Да
also it Контекстный объект Да

Краткое руководство по выбору функции области видимости в зависимости от предполагаемого назначения:

  • Выполнение лямбды для non-null объектов: let
  • Представление переменной в виде выражения со своей локальной областью видимости: let
  • Настройка объекта: apply
  • Настройка объекта и вычисление результата: run
  • Выполнение операций, для которых требуется выражение: run без расширения
  • Применение дополнительных значений: also
  • Группировка всех функций, вызываемых для объекта: with

Некоторые функции области видимости являются взаимозаменяемыми, поэтому вы можете выбирать функции исходя из соглашений, принятых в вашем проекте или команде.

Несмотря на то, что функции области видимости предназначены для того, чтобы сделать код более кратким, избегайте их чрезмерного использования: это может снизить читабельность кода и привести к ошибкам. Избегайте вложенности функций и будьте осторожны при их объединении: можно легко запутаться в текущем значении контекстного объекта и в значениях this или it.

takeIf и takeUnless

Помимо функций области видимости, стандартная библиотека содержит функции takeIf и takeUnless. Эти функции позволяют встроить проверку состояния объекта в цепочке вызовов.

При вызове takeIf для объекта с предикатом этот объект будет возвращен, если он соответствует предикату. В противном случае возвращается null. В свою очередь, takeUnless возвращает объект, если он не соответствует предикату, и null, если соответствует. Объект доступен как лямбда-аргумент (it).

import kotlin.random.*

fun main() {
    val number = Random.nextInt(100)

    val evenOrNull = number.takeIf { it % 2 == 0 }
    val oddOrNull = number.takeUnless { it % 2 == 0 }
    println("четный: $evenOrNull, нечетный: $oddOrNull")
}

При добавлении в цепочку вызовов других функций после takeIf и takeUnless, не забудьте выполнить проверку на null или используйте оператор безопасного вызова (?.), потому что их возвращаемое значение имеет тип nullable.

fun main() {
    val str = "Hello"
    val caps = str.takeIf { it.isNotEmpty() }?.toUpperCase()
   //val caps = str.takeIf { it.isNotEmpty() }.toUpperCase() //compilation error
    println(caps)
}

takeIf и takeUnless особенно полезны при совместном использовании с функциями области видимости. Хорошим примером является объединение их в цепочку с let для выполнения блока кода для объектов, которые соответствуют заданному предикату. Для этого вызовите takeIf для объекта, а затем вызовите let с оператором безопасного вызова (?). Для объектов, которые не соответствуют предикату, takeIf возвращает null, а let не вызывается.

fun main() {
    fun displaySubstringPosition(input: String, sub: String) {
        input.indexOf(sub).takeIf { it >= 0 }?.let {
            println("Подстрока $sub находится в $input.")
            println("Начинается с индекса $it.")
        }
    }

    displaySubstringPosition("010000011", "11")
    displaySubstringPosition("010000011", "12")
}

Тот же самый код, но без использования функций из стандартной библиотеки, выглядит следующим образом:

fun main() {
    fun displaySubstringPosition(input: String, sub: String) {
        val index = input.indexOf(sub)
        if (index >= 0) {
            println("Подстрок $sub находится в $input.")
            println("Начинается с индекса $index.")
        }
    }

    displaySubstringPosition("010000011", "11")
    displaySubstringPosition("010000011", "12")
}