В уже далёком мае 2017 года Google объявила о том, что Kotlin стал официальным языком для разработки под Android. Кто-то тогда впервые услышал название этого языка, кто-то на нём уже продолжительное время писал, но с того момента стало понятно, что все, кто близок к 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 {
private final String param1;
private Integer param2;
public MyClassAJava(String p1, Integer p2, Boolean param3) {
this.param1 = p1;
this.param2 = p2;
}
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
. В данном блоке можно выполнять некую логику по инициализации элементов класса, в том числе с использованием значений аргументов, пришедших в первичный конструктор. Ещё из этого блока мы можем вызывать функции.
В Java также есть инициализирующие блоки, но это не одно и то же. В них мы не можем, как в Kotlin, передать значение извне (аргументы первичного конструктора). Инициализатор очень похож на тело первичного конструктора, вынесенного в отдельный блок. Но это с первого взгляда. На самом деле это не совсем так. Давайте разбираться.
Инициализатор может существовать и тогда, когда первичного конструктора нет. Если это так, то его код, как и все инициализирующие процессы, выполняется раньше кода дополнительного конструктора. Инициализаторов может быть более одного. В этом случае порядок их вызова будет совпадать с порядком их расположения в коде. Также следует учитывать, что инициализация полей класса может происходить за пределами блоков 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-блоков только чистые функции. Всё это делается во избежании возможной путаницы.
Итак. Инициализаторы — это некий блок кода, обязательно выполняемый при создании объекта независимо от того, с помощью какого конструктора этот объект создаётся.
Вроде разобрались. Рассмотрим взаимодействие конструкторов и инициализаторов. В рамках одного класса всё довольно просто, но надо запомнить:
- вызов дополнительного конструктора;
- вызов первичного конструктора;
- инициализация полей класса и блоков инициализаторов в порядке их расположения в коде;
- выполнение кода в теле дополнительного конструктора.
Более интересным выглядят случаи с наследованием.
Стоит отметить, что как Object является базовым для всех классов в Java, так Any является таковым в Kotlin. При этом Any и Object 一 это не одно и то же.
Для начала о том, как происходит наследование. Класс-наследник, как и родительский класс, может иметь или не иметь первичного конструктор, но при этом должен ссылаться на определённый конструктор родительского класса.
Если класс-наследник имеет первичный конструктор, то этот конструктор должен указывать на конкретный конструктор базового класса. При этом все дополнительные конструкторы класса-наследника должны ссылаться на основной конструктор своего класса.
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 же мы можем этого явно не указывать. Будьте внимательны: в этом случае вызовется конструктор по умолчанию родительского класса (при его наличии).
На этом этапе будем считать, что конструкторы и инициализаторы мы изучили довольно глубоко и теперь знаем про них почти всё. Немножко отдохнём и будем копать в другую сторону!
Автор: Pablitto