Pull to refresh
VK
Building the Internet

Рассмотрим Kotlin повнимательнее

Reading time 11 min
Views 29K
Original author: Sebastiano Gottardo

image


https://trends.google.com/trends/explore?q=%2Fm%2F0_lcrx4


Выше приведён скриншот Google Trends, когда я искал по слову «kotlin». Внезапный всплеск — это когда Google объявила, что Kotlin становится главным языком в Android. Произошло это на конференции Google I/O несколько недель назад. На сегодняшний день вы либо уже использовали этот язык раньше, либо заинтересовались им, потому что все вокруг вдруг начали о нём говорить.


Одно из главных свойств Kotlin — его взаимная совместимость с Java: вы можете вызывать из Java код Kotlin, а из Kotlin код Java. Это, пожалуй, важнейшая черта, благодаря которой язык широко распространяется. Вам не нужно сразу всё мигрировать: просто возьмите кусок имеющейся кодовой базы и начните добавлять код Kotlin, и это будет работать. Если вы поэкспериментируете с Kotlin и вам не понравится, то всегда можно от него отказаться (хотя я рекомендую попробовать).


Когда я впервые использовал Kotlin после пяти лет работы на Java, некоторые вещи казались мне настоящим волшебством.


«Погодите, что? Я могут просто писать data class, чтобы избежать шаблонного кода?»
«Стоп, так если я пишу apply, то мне уже не нужно определять объект каждый раз, когда я хочу вызвать метод применительно к нему?»


После первого вздоха облегчения от того, что наконец-то появился язык, который не выглядит устаревшим и громоздким, я начал ощущать некоторый дискомфорт. Если требуется взаимная совместимость с Java, то как именно в Kotlin реализованы все эти прекрасные возможности? В чём подвох?


Этому и посвящена статья. Мне было очень интересно узнать, как компилятор Kotlin преобразует конкретные конструкции, чтобы они стали взаимосовместимы с Java. Для своих исследований я выбрал четыре наиболее востребованных метода из стандартной библиотеки Kotlin:


  1. apply
  2. with
  3. let
  4. run

Когда вы прочитаете эту статью, вам больше не надо будет опасаться. Сейчас я чувствую себя гораздо увереннее, потому что понял, как всё работает, и я знаю, что могу доверять языку и компилятору.


Apply


/**
 * Вызывает определённую функцию [block] со значением `this` в качестве своего получателя и возвращает значение `this`.
 */
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }

apply проста: это функция-расширение, которая выполняет параметр block применительно к экземпляру расширенного типа (extended type) (он называется «получатель») и возвращает самого получателя.


Есть много способов применения этой функции. Можно привязать создание объекта к его начальной конфигурации:


val layout = LayoutStyle().apply { orientation = VERTICAL }


Как видите, мы предоставляем конфигурацию для нового LayoutStyle прямо при создании, что способствует чистоте кода и реализации, гораздо менее подверженной ошибкам. Случалось вызывать метод применительно к неправильному экземпляру, потому что он имел то же наименование? Или, ещё хуже, когда рефакторинг был полностью ошибочным? С вышеуказанным подходом будет куда сложнее столкнуться с такими неприятностями. Также обратите внимание, что необязательно определять параметр this: мы находимся в той же области видимости, что и сам класс. Это как если бы мы расширяли сам класс, поэтому this задаётся неявно.


Но как это работает? Давайте рассмотрим короткий пример.


enum class Orientation {
  VERTICAL, HORIZONTAL
}

class LayoutStyle {
  var orientation = HORIZONTAL
}

fun main(vararg args: Array<String>) {
  val layout = LayoutStyle().apply { orientation = VERTICAL }
}

Благодаря инструменту IntelliJ IDEA «Show Kotlin bytecode» (Tools > Kotlin > Show Kotlin Bytecode) мы можем посмотреть, как компилятор преобразует наш код в JVM-байткод:


NEW kotlindeepdive/LayoutStyle
DUP
INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V
ASTORE 2
ALOAD 2
ASTORE 3
ALOAD 3
GETSTATIC kotlindeepdive/Orientation.VERTICAL : Lkotlindeepdive/Orientation;
INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V
ALOAD 2
ASTORE 1

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


Разберём по пунктам:


  1. Создаётся новый экземпляр LayoutStyle и дублируется в стек.
  2. Вызывается конструктор с нулевыми параметрами.
  3. Выполняются операции store/load (об этом — ниже).
  4. В стек передаётся значение Orientation.VERTICAL.
  5. Вызывается setOrientation, который поднимает из стека объект и значение.

Здесь отметим пару вещей. Во-первых, не задействовано никакой магии, всё происходит так, как ожидается: применительно к созданному нами экземпляру LayoutStyle вызывается метод setOrientation. Кроме того, нигде не видно функции apply, потому что компилятор инлайнит её.


Более того, байткод почти идентичен тому, который генерируется при использовании одного лишь Java! Судите сами:


// Java

enum Orientation {
    VERTICAL, HORIZONTAL;
}

public class LayoutStyle {
    private Orientation orientation = HORIZONTAL;

    public Orientation getOrientation() {
        return orientation;
    }

    public void setOrientation(Orientation orientation) {
        this.orientation = orientation;
    }

    public static void main(String[] args) {
        LayoutStyle layout = new LayoutStyle();
        layout.setOrientation(VERTICAL);
    }
}

// Bytecode

NEW kotlindeepdive/LayoutStyle
DUP
ASTORE 1
ALOAD 1
GETSTATIC kotlindeepdive/Orientation.VERTICAL : kotlindeepdive/Orientation;
INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (kotlindeepdive/Orientation;)V

Совет: вы могли заметить большое количество операций ASTORE/ALOAD. Они вставлены компилятором Kotlin, так что отладчик работает и для лямбд! Об этом мы поговорим в последнем разделе статьи.


With


/**
 * Вызывает определённую функцию [block] с данным [receiver] в качестве своего получателя и возвращает результат.
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()

with выглядит аналогичным apply, но есть некоторые важные отличия. Во-первых, with не является функцией-расширением типа: получатель должен явным образом передаваться в качестве параметра. Более того, with возвращает результат функции block, а apply — самого получателя.


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


val layout = with(contextWrapper) { 
  // `this` is the contextWrapper
  LayoutStyle(context, attrs).apply { orientation = VERTICAL }
}

Здесь можно опустить префикс contextWrapper. для context и attrs, потому что contextWrapper — получатель функции with. Но даже в этом случае способы применения вовсе не так очевидны по сравнению с apply, эта функция может оказаться полезна при определённых условиях.


Учитывая это, вернёмся к нашему примеру и посмотрим, что будет, если воспользоваться with:


enum class Orientation {
  VERTICAL, HORIZONTAL
}

class LayoutStyle {
  var orientation = HORIZONTAL
}

object SharedState {
  val previousOrientation = VERTICAL
}

fun main() {
  val layout = with(SharedState) {
    LayoutStyle().apply { orientation = previousOrientation }
  }
}

Получатель with — синглтон SharedState, он содержит параметр ориентации (orientation parameter), который мы хотим задать для нашего макета. Внутри функции block создаём экземпляр LayoutStyle, и благодаря apply мы можем просто задать ориентацию, считав её из SharedState.


Посмотрим снова на сгенерированный байткод:


GETSTATIC kotlindeepdive/SharedState.INSTANCE : Lkotlindeepdive/SharedState;
ASTORE 1
ALOAD 1
ASTORE 2
NEW kotlindeepdive/LayoutStyle
DUP
INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V
ASTORE 3
ALOAD 3
ASTORE 4
ALOAD 4
ALOAD 2
INVOKEVIRTUAL kotlindeepdive/SharedState.getPreviousOrientation ()Lkotlindeepdive/Orientation;
INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V
ALOAD 3
ASTORE 0
RETURN

Ничего особенного. Извлечён синглтон, реализованный в виде статичного поля в классе SharedState; экземпляр LayoutStyle создаётся так же, как и раньше, вызывается конструктор, ещё одно обращение для получения значения previousOrientation внутри SharedState и последнее обращение для присвоения значения экземпляру LayoutStyle.


Совет: при использовании «Show Kotlin Bytecode» можно нажать «Decompile» и посмотреть Java-представление байткода, созданного для компилятора Kotlin. Спойлер: оно будет именно таким, как вы ожидаете!


Let


/**
 * Вызывает заданную функцию [block] со значением `this` в качестве аргумента и возвращает результат.
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R = block(this)

let очень полезен при работе с объектами, которые могут принимать значение null. Вместо того чтобы создавать бесконечные цепочки выражений if-else, можно просто скомбинировать оператор ? (называется «оператор безопасного вызова») с let: в результате вы получите лямбду, у которой аргумент it является не-nullable-версией исходного объекта.


val layout = LayoutStyle()
SharedState.previousOrientation?.let { layout.orientation = it }

Рассмотрим пример целиком:


enum class Orientation {
  VERTICAL, HORIZONTAL
}

class LayoutStyle {
  var orientation = HORIZONTAL
}

object SharedState {
  val previousOrientation: Orientation? = VERTICAL
}

fun main() {
  val layout = LayoutStyle()
  // layout.orientation = SharedState.previousOrientation -- this would NOT work!
  SharedState.previousOrientation?.let { layout.orientation = it }
}

Теперь previousOrientation может быть null. Если мы попробуем напрямую присвоить его нашему макету, то компилятор возмутится, потому что nullable-тип нельзя присваивать не-nullable-типу. Конечно, можно написать выражение if, но это приведёт к двойной ссылке на выражение SharedState.previousOrientation. А если воспользоваться let, то получим не-nullable-ссылку на тот же самый параметр, которую можно безопасно присвоить нашему макету.
С точки зрения байткода всё очень просто:


NEW kotlindeepdive/let/LayoutStyle
DUP
INVOKESPECIAL kotlindeepdive/let/LayoutStyle.<init> ()V
GETSTATIC kotlindeepdive/let/SharedState.INSTANCE : Lkotlindeepdive/let/SharedState;
INVOKEVIRTUAL kotlindeepdive/let/SharedState.getPreviousOrientation ()Lkotlindeepdive/let/Orientation;
DUP
IFNULL L2
ASTORE 1
ALOAD 1
ASTORE 2
ALOAD 0
ALOAD 2
INVOKEVIRTUAL kotlindeepdive/let/LayoutStyle.setOrientation (Lkotlindeepdive/let/Orientation;)V
GOTO L9
L2
 POP
L9
 RETURN

Здесь используется простой условный переход IFNULL, который, по сути, вам бы пришлось делать вручную, за исключением этого раза, когда компилятор эффективно выполняет его за вас, а язык предлагает приятный способ написания такого кода. Думаю, это замечательно!


Run


Есть две версии run: первая — простая функция, вторая — функция-расширение обобщённого типа (generic type). Поскольку первая всего лишь вызывает функцию block, которая передаётся как параметр, мы будем анализировать вторую.


/**
 * Вызывает определённую функцию [block] со значением `this` в качестве получателя и возвращает результат.
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R = block()

Пожалуй, run — самая простая из рассмотренных функций. Она определена как функция-расширение типа, чей экземпляр затем передаётся в качестве получателя и возвращает результат исполнения функции block. Может показаться, что run — некий гибрид let и apply, и это действительно так. Единственное отличие заключается в возвращаемом значении: в случае с apply мы возвращаем самого получателя, а в случае с run — результат функции block (как и у let).


В этом примере подчёркивается тот факт, что run возвращает результат функции block, в данном случае это присваивание (Unit):


enum class Orientation {
    VERTICAL, HORIZONTAL
}

class LayoutStyle {
    var orientation = HORIZONTAL
}

object SharedState {
    val previousOrientation = VERTICAL
}

fun main() {
    val layout = LayoutStyle()
    layout.run { orientation = SharedState.previousOrientation } // returns Unit
}

Эквивалентный байткод:


NEW kotlindeepdive/LayoutStyle
DUP
INVOKESPECIAL kotlindeepdive/LayoutStyle.<init> ()V
ASTORE 0
ALOAD 0
ASTORE 1
ALOAD 1
ASTORE 2
ALOAD 2
GETSTATIC kotlindeepdive/SharedState.INSTANCE : Lkotlindeepdive/SharedState;
INVOKEVIRTUAL kotlindeepdive/SharedState.getPreviousOrientation ()Lkotlindeepdive/Orientation;
INVOKEVIRTUAL kotlindeepdive/LayoutStyle.setOrientation (Lkotlindeepdive/Orientation;)V
RETURN

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




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


Чтобы помочь вам разобраться со стандартной библиотекой, я нарисовал таблицу, в которой сведены все отличия между основными рассмотренными функциями (за исключением also):


image


Приложение: дополнительные операции store/load


Я ещё кое-что не мог до конца понять при сравнении «Java-байткода» и «Kotlin-байткода». Как я уже говорил, в Kotlin, в отличие от Java, были дополнительные операции astore/aload. Я знал, что это как-то связано с лямбдами, но мог разобраться, зачем они нужны.


Похоже, эти дополнительные операции необходимы отладчику для обработки лямбд как стековых фреймов, что позволяет нам вмешиваться (step into) в их работу. Мы можем видеть, чем являются локальные переменные, кто вызывает лямбду, кто будет вызван из лямбды и т. д.


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


Для этого может подойти ProGuard, инструмент всем известный и всеми «любимый». Он работает на уровне байткода и, помимо запутывания и урезания, также выполняет оптимизационные проходы, чтобы сделать байткод компактнее. Я написал одинаковый кусок кода на Java и Kotlin, применил к обеим версиям ProGuard с одним набором правил и сравнил результаты. Вот что обнаружилось.


Конфигурация ProGuard


-dontobfuscate
-dontshrink
-verbose
-keep,allowoptimization class kotlindeepdive.apply.LayoutStyle
-optimizationpasses 2
-keep,allowoptimization class kotlindeepdive.LayoutStyleJ

Исходный код


Java:


package kotlindeepdive

enum OrientationJ {
    VERTICAL, HORIZONTAL;
}

class LayoutStyleJ {
    private OrientationJ orientation = HORIZONTAL;

    public OrientationJ getOrientation() {
        return orientation;
    }

    public LayoutStyleJ() {
        if (System.currentTimeMillis() < 1) { main(); }
    }

    public void setOrientation(OrientationJ orientation) {
        this.orientation = orientation;
    }

    public OrientationJ main() {
        LayoutStyleJ layout = new LayoutStyleJ();
        layout.setOrientation(VERTICAL);
        return layout.orientation;
    }
}

Kotlin:


package kotlindeepdive.apply

enum class Orientation {
  VERTICAL, HORIZONTAL
}

class LayoutStyle {
  var orientation = Orientation.HORIZONTAL

  init {
    if (System.currentTimeMillis() < 1) { main() }
  }

  fun main() {
    val layout = LayoutStyle().apply { orientation = Orientation.VERTICAL }
    layout.orientation
  }
}

Байткод


Java:


 sgotti@Sebastianos-MBP ~/Desktop/proguard5.3.3/lib/PD/kotlindeepdive > javap -c LayoutStyleJ.class
Compiled from "SimpleJ.java"
final class kotlindeepdive.LayoutStyleJ {
  public kotlindeepdive.LayoutStyleJ();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: getstatic     #6                  // Field kotlindeepdive/OrientationJ.HORIZONTAL$5c1d747f:I
       8: putfield      #5                  // Field orientation$5c1d747f:I
      11: invokestatic  #9                  // Method java/lang/System.currentTimeMillis:()J
      14: lconst_1
      15: lcmp
      16: ifge          34
      19: new           #3                  // class kotlindeepdive/LayoutStyleJ
      22: dup
      23: invokespecial #10                 // Method "<init>":()V
      26: getstatic     #7                  // Field kotlindeepdive/OrientationJ.VERTICAL$5c1d747f:I
      29: pop
      30: iconst_1
      31: putfield      #5                  // Field orientation$5c1d747f:I
      34: return
}

Kotlin:


 sgotti@Sebastianos-MBP ~/Desktop/proguard5.3.3/lib/PD/kotlindeepdive > javap -c apply/LayoutStyle.class
Compiled from "Apply.kt"
public final class kotlindeepdive.apply.LayoutStyle {
  public kotlindeepdive.apply.LayoutStyle();
    Code:
       0: aload_0
       1: invokespecial #13                 // Method java/lang/Object."<init>":()V
       4: aload_0
       5: getstatic     #11                 // Field kotlindeepdive/apply/Orientation.HORIZONTAL:Lkotlindeepdive/apply/Orientation;
       8: putfield      #10                 // Field orientation:Lkotlindeepdive/apply/Orientation;
      11: invokestatic  #14                 // Method java/lang/System.currentTimeMillis:()J
      14: lconst_1
      15: lcmp
      16: ifge          32
      19: new           #8                  // class kotlindeepdive/apply/LayoutStyle
      22: dup
      23: invokespecial #16                 // Method "<init>":()V
      26: getstatic     #12                 // Field kotlindeepdive/apply/Orientation.VERTICAL:Lkotlindeepdive/apply/Orientation;
      29: putfield      #10                 // Field orientation:Lkotlindeepdive/apply/Orientation;
      32: return
}

Выводы после сравнения двух листингов байткода:


  1. Дополнительные операции astore/aload в «Kotlin-байткоде» исчезли, потому что ProGuard счёл их избыточными и сразу удалил (любопытно, что для этого понадобилось сделать два оптимизационных прохода, после одного они не были удалены).
  2. «Java-байткод» и «Kotlin-байткод» почти идентичны. В первом есть интересные/странные моменты при работе с enum-значением, а в Kotlin ничего подобного нет.

Заключение


Замечательно получить новый язык, предлагающий разработчикам настолько много возможностей. Но также важно знать, что мы можем полагаться на используемые инструменты, и чувствовать уверенность при работе с ними. Я рад, что могу сказать: «Я доверяю Kotlin», в том смысле, что я знаю: компилятор не делает ничего лишнего или рискованного. Он делает только то, что в Java нам нужно делать вручную, экономя нам время и ресурсы (и возвращает давно утраченную радость от кодинга для JVM). В какой-то мере это приносит пользу и конечным пользователям, потому что благодаря более строгой типобезопасности мы оставим меньше багов в приложениях.


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

Tags:
Hubs:
+21
Comments 24
Comments Comments 24

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен