Функции области видимости (Scope Function) в Kotlin

Статус
В этой теме нельзя размещать новые ответы.

Kate

Administrator
Команда форума
Сразу оговорюсь, что статья объясняет базовые понятия и если вы уже программируете на 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
8b0c9d09a5ad365bdfaf573a404f4fd0.png

Давайте посмотрим, во что компилируется функция расширения.
Исходный код:
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
89158fc35e8ccd89dfb7665b80047c3c.png

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

После того, как мы разобрались с функциями расширения, самое время взглянуть, что под капотом функций области видимости.
Для начала приведем исходный код всех рассматриваемых функций и разберем его
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);
}
Как видно, здесь нет никакого упоминания об also

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

Какую функцию когда применять - вопрос довольно сложный и дискуссионный.
Здесь я постарался собрать те рекомендации, что встречал в разных источниках и что было удобно мне самому. Эти рекомендации не являются всеобъемлющими и каждая команда, как мне кажется, сама должна определять, когда что применять. Буду рад комментариям, каким рекомендациям следуете вы.
Самая главная рекомендация - не переусложняйте код, он должен быть легко читаем и однозначен. Чем сложнее код - тем больше ошибок мы можем в нем совершить. И помним, что IDEA у нас не всегда под рукой, например, часто простые исправления проверяются online, например, в gitlab, где нет таких возможностей как в IDEA.
Основные грамматические отличия можно свести в таблицу:
Функция будет принимать thisФункция будет принимать it
Будет возвращен объект на котором вызывается функция (self)applyalso
Будет возвращен результат функции (result)run, withlet
С различием по тому, что возвращается,как мне кажется, все понятно. Давайте внимательно рассмотрим различие: что принимает функция (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()
}

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


 
Статус
В этой теме нельзя размещать новые ответы.
Сверху