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

В статье используется термин "функции области видимости" для "Scope Function". Это определение взято из перевода документации на русский язык.

По контекстным функциям в Kotlin есть много информации, включая на русском. Часть таких статей приведены в использованных материалах.

Что такое функции области видимости

В Kotlin есть 5 функций: let, run, with, apply и also, объединенных общим названием Scope Function (функции области видимости). Все они используются для одной цели - выполнить какой-то блок кода для конкретного объекта. Почему их так назвали? Потому что они меняют способ взаимодействия и видимость для этой переменной.

В основном они отличаются только 2 параметрами: способом ссылки на объект и возвращаемым параметром.

Давайте сначала приведем пример использования:

let

val length = "test".let{
    println(it)
  	it.length
}
  • Объект "test" внутри блока доступен как it

  • Возвращает результат выполнения lambda-функции

also

val test = "test".also{     
  println(it)   
}
  • Объект "test" внутри блока доступен как it

  • Возвращает контекстный объект ("test")

apply

val moscow = City("Moscow").apply{
  this.population = 15_000_000
  println(this)
}
  • Объект City("Moscow") внутри блока доступен как this (поэтому для поля popultaion - мы можем опустить обращения и будет population=15_000_000)

  • Возвращает контекстный объект (изменённый City("Moscow"))

run (с контекстным объектом)

val optimalSquare = City("Moscow").run {
    this.population = 15_000_000
  	this.solveOptimalSquare()
}
  • Объект City("Moscow") внутри блока доступен как this (поэтому для поля popultaion - мы можем опустить обращения и будет population=15_000_000)

  • Возвращает результат выполнения lambda-функции (solveOptimalSquare())

run (без контекстного объекта)

val length = run {
  val test = "test"
  test.length
}
  • Нет объекта на котором применятся

  • Возвращает результат выполнения lambda-функции (test.length)

with

val length = with("test"){
  this.length
}
  • Объект "test" внутри блока доступен как this

  • Возвращает результат выполнения lambda-функции (this.length)

Как видно, функции очень похожи друг на друга. Для того чтобы разобраться как они работают нужно разобраться в понятии extension function (здесь и далее будет использован перевод "функции расширения")

Функции расширения

Функции расширения в Kotlin позволяют расширять классы, не наследуясь от них. С помощью них мы можем добавить к существующим классам свои методы. Функции расширения таким образом заменяют утилитные классы (например, StringUtils от Apache).

Давайте рассмотрим упрощенный пример из стандартной библиотеки Kotlin

public fun CharSequence?.isNullOrBlank(): Boolean {
    return this == null || this.isBlank()
}

Как видно в качестве класса для расширения также можно использовать null-допустимые классы.

Как это работает:

  • Мы указываем тип получателя (reciever type)

  • Ссылаемся на объект этого типа как this

Давайте посмотрим, во что компилируется функция расширения.

Исходный код:

fun main() {
    println("test".firstSymbol())
}

public fun String.firstSymbol(): Char{
    return this[0]
}

Bytecode:

  public static final char firstSymbol(java.lang.String);
    descriptor: (Ljava/lang/String;)C
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: ldc           #27                 // String <this>
         3: invokestatic  #33                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
         6: aload_0
         7: iconst_0
         8: invokevirtual #39                 // Method java/lang/String.charAt:(I)C
        11: ireturn
      LineNumberTable:
        line 6: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0 $this$firstSymbol   Ljava/lang/String;
    RuntimeInvisibleParameterAnnotations:
      parameter 0:
        0: #25()
          org.jetbrains.annotations.NotNull

Соответствующий java-code:

public static final char firstSymbol(@NotNull String $this$firstSymbol) {
  Intrinsics.checkNotNullParameter($this$firstSymbol, "$this$firstSymbol");
  return $this$firstSymbol.charAt(0);
}

Как видно, она комплируется в статический метод, где первым параметром выступает объект, на котором применяется функция расширения.

Как раз поэтому функция расширения не может получить доступ к приватным полям и методам и поэтому функции расширения вычиляются статически.

//Код НЕ рабочий
fun main() {
    println(Test("test").firstSymbol())
}

class Test(private val value: String)

public fun Test.firstSymbol(): Char{
// поле является приватным и из статического метода к нему нет доступа
    return this.value[0] //ОШИБКА
}

Следующий пример взят из документации

open class Shape
class Rectangle: Shape()

fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"

fun printClassName(s: Shape) {
    println(s.getName())
}

printClassName(Rectangle())

Распечатается Shape

Как работают функции области видимости

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

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

let

public inline fun <T, R> T.let(block: (T) -> R): R {
    return block(this)
}

let - сама является функцией расширения и принимает обычную lambda-функцию, которая вызывается с параметром this (объектом, на котором вызывается let). Так как block - это обычная lambda-функция, то единственный аргумент в ней доступен как it. Возвращается результат выполнения block(this)

also

public inline fun <T> T.also(block: (T) -> Unit): T {
    block(this)
    return this
}

also - очень похож на let, но возвращается объект this (объект, на котором вызывается also)

apply

public inline fun <T> T.apply(block: T.() -> Unit): T {
    block()
    return this
}

Функция apply устроена довольно интересно. Она является функцией расширения, при этом как параметр она принимет lambda-функцию, которая тоже является расширением для того же типа. Поэтому вызов block() здесь нужно рассматривать как вызов this.block(). Возвращается объект, на котором была вызвана функция apply

run (с контекстным объектом)

public inline fun <T, R> T.run(block: T.() -> R): R {
    return block()
}

run очень похож на apply, но возвращает результат выполнения this.block()

run (без контекстного объекта)

public inline fun <R> run(block: () -> R): R {
    return block()
}

Не является функцией расширения, и принимет обычную lambda-функцию. Возвращает результат выполнения этой lambda-функции.

with

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    return receiver.block()
}

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

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

Исходный код:

fun main() {
    val length = "test".let {
        println(it)
        it.length
    }
    println(length)
}

Bytecode:

  public static final void main();
    descriptor: ()V
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    Code:
      stack=2, locals=7, args_size=0
         0: ldc           #8                  // String test
         2: astore_1
         3: iconst_0
         4: istore_2
         5: iconst_0
         6: istore_3
         7: aload_1
         8: astore        4
        10: iconst_0
        11: istore        5
        13: iconst_0
        14: istore        6
        16: getstatic     #14                 // Field java/lang/System.out:Ljava/io/PrintStream;
        19: aload         4
        21: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
        24: aload         4
        26: invokevirtual #26                 // Method java/lang/String.length:()I
        29: nop
        30: istore_0
        31: iconst_0
        32: istore_1
        33: getstatic     #14                 // Field java/lang/System.out:Ljava/io/PrintStream;
        36: iload_0
        37: invokevirtual #29                 // Method java/io/PrintStream.println:(I)V
        40: return

Близко-соответствующий java-code (часть служебных переменных удалено):

   public static final void main() {
      String var1 = "test";
      System.out.println(var1);
      int length = var1.length();
      System.out.println(length);
   }

Как видно, здесь нет никакого упоминания об let

Что когда применять

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

Здесь я постарался собрать те рекомендации, что встречал в разных источниках и что было удобно мне самому. Эти рекомендации не являются всеобъемлющими и каждая команда, как мне кажется, сама должна определять, когда что применять. Буду рад комментариям, каким рекомендациям следуете вы.

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

Основные грамматические отличия можно свести в таблицу:

Функция будет принимать this

Функция будет принимать it

Будет возвращен объект на котором вызывается функция (self)

apply

also

Будет возвращен результат функции (result)

run, with

let

С различием по тому, что возвращается,как мне кажется, все понятно. Давайте внимательно рассмотрим различие: что принимает функция (this или it). С точки зрения возможностей - this и it полностью одинаковы, так как они предоставляют доступ к одному и тому же набору параметров. this НЕ предоставляет доступ к приватным методам. Единственное различие в том, что this может быть опущено, а it в явном виде заменено на другое имя переменной. Поэтому this рекомендуется для тех случаев, когда вызываются функции и присваиваются свойства - для настройки объектов, it - когда объект используется в основном в качестве аргумента вызова функции.

Большую часть функций удобно использовать для реализации сокращенной записи (см. ниже пример с apply)

let

  • часто используется для безопасного выполнения блока кода с null-выражениями

val b: Int? = null

val a = b?.let { nonNullable -> nonNullable } ?: "Equal to 'null' or not set"
println(a)

also

  • используется для выполнения каких-либо дополнительных действий

val numbers = mutableListOf("one", "two", "three")
 numbers
 .also { println("The list elements before adding new one: $it") }
 .add("four")

apply

  • настроить объекта и не надо возвращать результат (удобно использовать для настройки Spring beans (бинов)

val registrar = DateTimeFormatterRegistrar().apply {
  setUseIsoFormat(true)
  registerFormatters(registry)
}

run, with

  • run и with очень похожи, поэтому не рекомендуется использовать их вместе.

  • run - используется для настройки объекта и вычисления результата

fun printAlphabet() = StringBuilder().run{
    for (letter in 'A'..'Z'){
        append(letter)
    }
    toString()
}
  • run без контекстного объекта - выполнение набора операций в отдельной зоне видимости

  • with - используется для объединения вызовов функций объекта

// выводим все буквы алфавита
fun printAlphabet() = with(StringBuilder()){
    for (letter in 'A'..'Z'){
        append(letter)
    }
    toString()
}

Использованные материалы

Комментарии (4)


  1. ris58h
    07.10.2021 13:30
    +1

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

    run - используется для настройки объекта и вычисления результата

    А дальше пример с apply, почему-то.


    1. pyltsinm Автор
      07.10.2021 13:41

      Спасибо, пропустил!.

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

      У нас же цель не только написать код, но и то, чтобы он был легко читаем)


  1. Maccimo
    09.10.2021 12:37
    +1

    29: nop

    Забавно.
    Проверил на kotlinc-jvm 1.5.31 и действительно есть nop.
    Похоже, где-то компилятор недокрутили.


  1. eksd
    09.10.2021 19:36

    val a = b.let { nonNullable -> nonNullable } ?: "Equal to 'null' or not set"

    Если мы хотим получить nonNullable внутри let, нужно использовать safe call operator. В противном случае у нас будет nullable)

    Правильно так:

    val a = b?.let { nonNullable -> nonNullable } ?: "Equal to 'null' or not set"