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:
apply
with
let
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
Если вы не слишком хорошо ориентируетесь в байткоде, то предлагаю почитать эти замечательные статьи, после них вы станете разбираться гораздо лучше (важно помнить, что при вызове каждого метода происходит обращение к стеку, так что компилятору нужно каждый раз загружать объект).
Разберём по пунктам:
- Создаётся новый экземпляр
LayoutStyle
и дублируется в стек. - Вызывается конструктор с нулевыми параметрами.
- Выполняются операции store/load (об этом — ниже).
- В стек передаётся значение
Orientation.VERTICAL
. - Вызывается
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):
Приложение: дополнительные операции 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
}
Выводы после сравнения двух листингов байткода:
- Дополнительные операции
astore/aload
в «Kotlin-байткоде» исчезли, потому что ProGuard счёл их избыточными и сразу удалил (любопытно, что для этого понадобилось сделать два оптимизационных прохода, после одного они не были удалены). - «Java-байткод» и «Kotlin-байткод» почти идентичны. В первом есть интересные/странные моменты при работе с enum-значением, а в Kotlin ничего подобного нет.
Заключение
Замечательно получить новый язык, предлагающий разработчикам настолько много возможностей. Но также важно знать, что мы можем полагаться на используемые инструменты, и чувствовать уверенность при работе с ними. Я рад, что могу сказать: «Я доверяю Kotlin», в том смысле, что я знаю: компилятор не делает ничего лишнего или рискованного. Он делает только то, что в Java нам нужно делать вручную, экономя нам время и ресурсы (и возвращает давно утраченную радость от кодинга для JVM). В какой-то мере это приносит пользу и конечным пользователям, потому что благодаря более строгой типобезопасности мы оставим меньше багов в приложениях.
Кроме того, компилятор Kotlin постоянно улучшается, так что генерируемый код становится всё эффективнее. Так что не нужно пытаться оптимизировать Kotlin-код с помощью компилятора, лучше сосредоточиться на том, чтобы писать более эффективный и идиоматичный код, оставляя всё остальное на откуп компилятору.