Хабрахабр

Kotlin: копаем глубже. Конструкторы и инициализаторы

Кто-то тогда впервые услышал название этого языка, кто-то на нём уже продолжительное время писал, но с того момента стало понятно, что все, кто близок к Android-разработке, теперь просто обязаны познакомиться с ним. В уже далёком мае 2017 года Google объявила о том, что Kotlin стал официальным языком для разработки под Android. Чем Java не угодила?» и т.д. Далее последовали как восторженные отклики «Наконец-то!», так и жуткое негодование «Зачем нам нам новый язык? и т.п.

И даже вполне консервативные разработчики тоже переходят на него. С тех пор прошло достаточно времени, и хоть споры о том, хороший Kotlin или плохой, до сих пор не утихли, всё больше кода под Android пишется именно на нём. Кроме того, в сети можно наткнуться на информацию, что скорость разработки после освоения этого языка увеличивается на 30% по сравнению с Java.

Невооружённым взглядом стали видны как его плюсы, так и слабые места. Сегодня Kotlin уже успел вылечиться от нескольких детских болезней, оброс большим количеством вопросов и ответов на Stack Overflow.

Обратить внимание на сложные моменты и сравнить их с Java для наглядности и лучшего понимания. И вот на этой волне мне пришла в голову идея подробно разобрать отдельные элементы молодого, но популярного языка. Если эта статья вызовет интерес, то, скорее всего, она положит начало целому циклу статей. Разобраться в вопросе несколько глубже, чем это можно сделать, прочитав документацию. Поговорим о конструкторах и инициализаторах в Kotlin. А пока начну с довольно базовых вещей, которые, тем не менее, скрывают массу подводных камней.

В конструктор также можно передавать аргументы, а конструкторов может быть несколько. Как и в Java, в Kotlin создание новых объектов 一 сущностей определённого типа 一 происходит при помощи вызова конструктора класса. Теперь заглянем глубже и разберёмся, что же происходит внутри класса. Если смотреть на этот процесс как бы снаружи, то здесь единственное отличие от Java — отсутствие ключевого слова new при вызове конструктора.

В случае если у первичного конструктора нет модификаторов доступа и аннотаций, ключевое слово можно опустить.
У класса может не быть конструкторов, объявленных явно. У класса могут быть первичный (primary) и дополнительные (secondary) конструкторы.
Конструктор объявляется с помощью ключевого слова constructor. Если проводить аналогию с Java, то это равнозначно отсутствию явного объявления конструкторов, в результате чего конструктор по умолчанию (без параметров) будет сгенерирован автоматически на этапе компиляции. В этом случае после объявления класса нет никаких конструкций, мы сразу переходим к телу класса. Выглядит это, ожидаемо, так:

class MyClassA

Это равносильно такой записи:

class MyClassA constructor()

Но если вы напишите именно так, то вам будет вежливо предложено удалить первичный конструктор без параметров.

Пока примем это к сведению, а подробнее разберём позже, когда перейдём к вторичным конструкторам. Первичный конструктор — тот, который всегда вызывается при создании объекта в случае, если он есть. Соответственно, помним, что если конструкторов нет совсем, то на самом деле один (первичный) есть, но мы его не видим.

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

не может содержать в себе исполняемого кода. Главная особенность первичного конструктора в том, что он не имеет тела, т.е. На уровне синтаксиса это выглядит так: Он просто принимает в себя параметры и передаёт их вглубь класса для дальнейшего использования.

class MyClassA constructor(param1: String, param2: Int, param3: Boolean){ // some code
}

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

class MyClassA constructor(val param1: String, var param2: Int, param3: Boolean){ // some code
}

Здесь param1 и param2 можно использовать в коде в качестве полей класса, что равносильно следующему:

class MyClassA constructor(p1: String, p2: Int, param3: Boolean){ val param1 = p1 var param2 = p2 // some code
}

Ну и если сравнивать с Java, то это выглядело бы вот так (и кстати, на этом примере можно оценить, как сильно Kotlin может сократить количество кода):

public class MyClassAJava public String getParam1() { return param1; } public Integer getParam2() { return param2; } public void setParam2(final Integer param2) { this.param2 = param2; } // some code
}

Поговорим о дополнительных конструкторах. Они больше напоминают обычные конструкторы в Java: и принимают параметры, и могут иметь исполняемый блок. При объявлении дополнительных конструкторов ключевое слово constructor обязательно. Как говорилось ранее, несмотря на возможность создания объекта через вызов дополнительного конструктора, первичный конструктор (если он есть) должен при этом также вызываться — с помощью ключевого слова this. На уровне синтаксиса это организовано так:

class MyClassA(val p1: String) { constructor(p1: String, p2: Int, p3: Boolean) : this(p1) { // some code } // some code
}

Т.е. дополнительный конструктор как бы является наследником первичного.
Теперь, если мы создадим объект вызовом дополнительного конструктора, произойдёт следующее:

вызов дополнительного конструктора;
вызов основного конструктора;
инициализация поля класса p1 в основном конструкторе;
выполнение кода в теле дополнительного конструктора.

Это похоже на такую конструкцию в Java:

class MyClassAJava { private final String param1; public MyClassAJava(String p1) { param1 = p1; } public MyClassAJava(String p1, Integer p2, Boolean param3) { this(p1); // some code } // some code
}

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

Суть в том, чтобы в конце цепочки мы всё же добрались до главного. Дополнительный конструктор всегда должен ссылаться на главный (при его наличии), но может делать это косвенно, ссылаясь на другой дополнительный конструктор. Срабатывание конструкторов, очевидно, будет происходить в порядке, обратном обращению конструкторов друг к другу:

class MyClassA(p1: String) { constructor(p1: String, p2: Int, p3: Boolean) : this(p1) { // some code } constructor(p1: String, p2: Int, p3: Boolean, p4: String) : this(p1, p2, p3) { // some code } // some code
}

Теперь последовательность такая:

  • вызов дополнительного конструктора с 4 параметрами;
  • вызов дополнительного конструктора с 3 параметрами;
  • вызов первичного конструктора;
  • инициализация поля класса p1 в первичном конструкторе;
  • выполнение кода в теле конструктора с 3 параметрами;
  • выполнение кода в теле конструктора с 4 параметрами.

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

Тогда дополнительные конструкторы не обязаны ссылаться на кого-то, но при этом могут ссылаться на другие дополнительные конструкторы этого класса. Бывает так, что класс не имеет первичного конструктора, при этом может иметь один или несколько дополнительных. Если есть хотя бы один дополнительный конструктор, первичный конструктор без параметров не создаётся: Ранее мы выяснили, что основной конструктор, не указанный явно, генерируется автоматически, но это касается случаев, когда в классе нет вообще никаких конструкторов.

class MyClassA {
// some code
}

Можем создать объект класса вызовом:

val myClassA = MyClassA()

В этом случае:

class MyClassA { constructor(p1: String, p2: Int, p3: Boolean) { // some code } // some code
}

Можем создать объект только таким вызовом:

val myClassA = MyClassA(“some string”, 10, True)

В этом плане в Kotlin по сравнению с Java ничего нового нет.

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

class MyClassA { constructor(p1: String, p2: Int, p3: Boolean) : this(p1, p2, p3, "") constructor(p1: String, p2: Int, p3: Boolean, p4: String) { // some code } // some code
}

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

class MyClassA { constructor(val p1: String, var p2: Int, p3: Boolean){ // some code } // some code
}

Отдельно стоит отметить, что дополнительный конструктор, как и первичный, вполне может быть без параметров:

class MyClassA { constructor(){ // some code } // some code
}

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

Приведу пример на Java: Теперь допустим, что мы имеем класс с несколькими конструкторами, имеющими разное количество аргументов.

public class MyClassAJava { private String param1; private Integer param2; private boolean param3; private int param4; public MyClassAJava(String p1) { this (p1, 5); } public MyClassAJava(String p1, Integer p2) { this (p1, p2, true); } public MyClassAJava(String p1, Integer p2, boolean p3) { this(p1, p2, p3, 20); } public MyClassAJava(String p1, Integer p2, boolean p3, int p4) { this.param1 = p1; this.param2 = p2; this.param3 = p3; this.param4 = p4; } // some code
}

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

class MyClassA (var p1: String, var p2: Int = 5, var p3: Boolean = true, var p4: Int = 20){ // some code }

Теперь давайте дружно похлопаем Kotlin за то, как сильно он сократил код. Кстати, кроме уменьшения количества строк мы получаем больше порядка. Вспомните, наверняка вы не раз видели что-то такое:

public MyClassAJava(String p1, Integer p2, boolean p3) { this(p3, p1, p2, 20); } public MyClassAJava(boolean p1, String p2, Integer p3, int p4) { // some code }

Когда видишь подобное, хочется найти человека, который это написал, взять за пуговицу, подвести к экрану и грустным голосом спросить: «Зачем?»
Хотя можно повторить такой подвиг и на Kotlin, но не надо.

Есть, правда, одна деталь, которую в случае такой сокращённой записи на Kotlin необходимо учесть: если мы хотим вызывать конструктор со значениями по умолчанию из Java, то мы должны добавить к нему аннотацию @JvmOverloads:

class MyClassA @JvmOverloads constructor(var p1: String, var p2: Int = 5, var p3: Boolean = true, var p4: Int = 20){ // some code
}

В противном случае мы получим ошибку.

Теперь поговорим про инициализаторы.

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

В них мы не можем, как в Kotlin, передать значение извне (аргументы первичного конструктора). В Java также есть инициализирующие блоки, но это не одно и то же. Но это с первого взгляда. Инициализатор очень похож на тело первичного конструктора, вынесенного в отдельный блок. Давайте разбираться. На самом деле это не совсем так.

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

Приведу несколько интересных случаев работы с инициализаторами.

class MyClassB { init { testParam = "some string" showTestParam() } init { testParam = "new string" } var testParam: String = "after" constructor(){ Log.i("wow", "in constructor testParam = $testParam") } fun showTestParam(){ Log.i("wow", "in showTestParam testParam = $testParam") }
}

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

public class MyClassB { @NotNull private String testParam = "some string"; @NotNull public final String getTestParam() { return this.testParam; } public final void setTestParam(@NotNull String var1) { Intrinsics.checkParameterIsNotNull(var1, "<set-?>"); this.testParam = var1; } public final void showTestParam() { Log.i("wow", "in showTestParam testParam = " + this.testParam); } public MyClassB() { this.showTestParam(); this.testParam = "new string"; this.testParam = "after"; Log.i("wow", "in constructor testParam = " + this.testParam); }
}

Здесь мы видим, что первое обращение к полю в ходе инициализации (в блоке init или вне его) равносильно обычной его инициализации в Java. Все остальные действия, связанные с присвоением значения в процессе инициализации, кроме первого (первое присвоение значение объединяется с объявлением поля), выносятся в конструктор.
Если проводить эксперименты с декомпиляцией, то выясняется, что если конструктора нет, то генерируется первичный конструктор, и вся магия происходит в нём. Если есть несколько дополнительных конструкторов, не ссылающихся друг на друга, и при этом нет первичного, то в Java-коде этого класса все последующие присвоения значения полю testParam дублируются во всех дополнительных конструкторах. Если же первичный конструктор при этом есть, то только в первичном. Фуф…

И на закуску самое интересное: поменяем сигнатуру testParam с var на val:

class MyClassB { init { testParam = "some string" showTestParam() } init { testParam = "new string" } val testParam: String = "after" constructor(){ Log.i("wow", "in constructor testParam = $testParam") } fun showTestParam(){ Log.i("wow", "in showTestParam testParam = $testParam") }
}

А где-то в коде вызовем:

MyClassB myClassB = new MyClassB();

Всё скомпилировалось без ошибок, запустилось, и вот мы видим вывод логов:

in showTestParam testParam = some string
in constructor testParam = after

Почему так? Получается, что поле, объявленное как val, поменяло значение в процессе исполнения кода. Думаю, что это недочёт компилятора Kotlin, и в будущем, возможно, такое не скомпилируется, но на сегодняшний день всё так, как есть.

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

Инициализаторы — это некий блок кода, обязательно выполняемый при создании объекта независимо от того, с помощью какого конструктора этот объект создаётся. Итак.

Рассмотрим взаимодействие конструкторов и инициализаторов. Вроде разобрались. В рамках одного класса всё довольно просто, но надо запомнить:

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

Более интересным выглядят случаи с наследованием.

При этом Any и Object 一 это не одно и то же. Стоит отметить, что как Object является базовым для всех классов в Java, так Any является таковым в Kotlin.

Класс-наследник, как и родительский класс, может иметь или не иметь первичного конструктор, но при этом должен ссылаться на определённый конструктор родительского класса. Для начала о том, как происходит наследование.

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

class MyClassC(p1: String): MyClassA(p1) { constructor(p1: String, p2: Int): this(p1) { //some code } //some code
}

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

class MyClassC : MyClassA { constructor(p1: String): super(p1) { //some code } constructor(p1: String, p2: Int): super(p1, p2) { //some code } //some code
}

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

class MyClassC : MyClassA{ constructor(p1: String): super(p1){ //some code } constructor(p1: String, p2: Int): this (p1){ //some code } //some code
}

Если же класс-наследник не имеет никаких конструкторов, то просто добавляем вызов конструктора родительского класса после имени класса-наследника:

class MyClassC: MyClassA(“some string”) { //some code
}

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

class MyClassC : MyClassB { constructor(){ //some code } constructor(p1: String){ } //some code
}

Но только в том случае, если у родительского класса есть конструктор без параметров, который является конструктором по умолчанию (первичный или дополнительный — не важно).

Теперь рассмотрим порядок вызова инициализаторов и конструкторов при наследовании:

  • вызов дополнительного конструктора наследника;
  • вызов первичного конструктора наследника;
  • вызов дополнительного конструктора родителя;
  • вызов первичного конструктора родителя;
  • выполнение блоков init родителя;
  • выполнение кода тела дополнительного конструктора родителя;
  • выполнение блока init наследника;
  • выполнение кода тела дополнительного конструктора наследника.

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть