В этой статье эксперт сообщества Spring АйО – Михаил Поливаха рассмотрит процесс миграции с компилятора Kotlin старой версии на новый компилятор K2. В предыдущей статье Михаил рассмотрел компилятор K2, а здесь сфокусировался только на процедуре миграции.
1. Введение
В этой статье мы рассмотрим процесс миграции с компилятора Kotlin старой версии на новый компилятор K2. В другой статье мы сделали обзор компилятора K2 в общем, а здесь мы сфокусируемся только на процедуре миграции. Далее, под K2, очевидно, мы будем подразумевать новый компилятор, а под K1 - старую версию компилятора, так как его мы будем тоже довольно часто упоминать.
2. Процедура миграции
K2 не полностью обратно совместим с K1. Нам нужно выполнить некоторые дополнительные шаги, чтобы наш код компилировался на K2. Подробное объяснение миграции описано в официальном руководстве по миграции. Здесь же мы просто объясним наиболее важные изменения, которые могут затронуть обычных пользователей.
3. Инициализация open свойств
K2 требует, чтобы все open свойства с backing полями были инициализированы в момент декларации. Раньше компилятор требовал инициализации в месте объявления только для свойств open var. Например:
open class BaseEntity {
open val points: Int
open var pages: Long?
init {
points = 1
pages = 12
}
}
Такой код теперь компилироваться не будет. Это, конечно, несколько странно, поскольку приведенный выше код и следующий:
open class BaseEntity {
open val points: Int = 1
open var pages: Long? = 12
}
компилируются в один и тот же Java bytecode - инициализация переменных происходит внутри конструктора в обоих случаях. Тем не менее, один из этих примеров кода компилируется, а другой - нет. Исключением из этого правила являются свойства типа lateinit open var
. Они все еще могут иметь отложенную инициализацию:
open class WithLateinit {
open lateinit var point: Instant
}
K2 успешно скомпилирует приведенный выше фрагмент кода.
4. Синтетические сеттеры на Projected типах
Чтобы понять это изменение, нам нужно немного уйти в сторону и поговорить о том, какие ограничения могут иметь generic типы.
4.1 Ограничения generic типов
Предположим, у нас есть следующий код на Java:
public void add(List<?> list, Object element) {
list.add(element);
}
Этот код не компилируется. И не без причины: ссылка типа List<?> может указывать на объект типа List<Number>, List<List<Object>>, List<File> и т.д. Было бы безопасно позволить добавить объект любого конкретного типа к этому списку? Нет, мы не можем добавлять ничего кроме null к этому списку, поскольку на самом деле мы не знаем: какие именно объекты представлены в List<?>. В Kotlin у нас есть star projections, похожие на wildcards в Java. Поэтому в Kotlin такой код тоже не будет компилироваться:
fun execute(list: MutableList<*>, element: Any) {
list.add(element)
}
По той же самой причине. Пока что все понятно.
4.2 Дефект в компиляторе Kotlin
Теперь представьте себе, что у нас есть следующий Java класс:
public class Box<E> {
private E value;
public E getValue() {
return value;
}
public void setValue(E value) {
this.value = value;
}
}
И если мы попытаемся использовать этот Java класс в Kotlin следующим образом:
fun explicitSetter() {
val box = Box<String>()
val tmpBox : Box<*> = box
tmpBox.setValue(12) // Compile Error! That's unsafe!
val myValue : String? = box.value
}
Компилятор также выбросит сообщение об ошибке. Причина все та же: небезопасно выполнять такую операцию, поскольку Box<*> может содержать все что угодно. Компилятор спасает нас, поскольку следующая строка вызвала бы ошибку. Но вот такой код:
fun syntheticSetter() {
val box = Box<String>()
val tmpBox : Box<*> = box
tmpBox.value = 12 // That compiles!
val foo : String? = box.value // And here we fail with ClassCastException
}
успешно компилировался старым компилятором Kotlin! Хотя такой код компилируется, он всегда вызывает ошибку ClassCastException во время выполнения. Причина состоит в том, что мы использовали синтетический сеттер для поля value через ссылку типа Box<*> чтобы установить значение 12, которое является значением типа Int, объекту box, который в действительности должен содержать тип String. Следовательно, код, содержащий ссылку на Box<String> по праву ожидает, что значение внутри Box будет экземпляром класса String, и компилятор Kotlin, как и javac, добавит bytecode инструкцию cast, когда мы выполним функцию getValue(). И это приведение типов (cast), естественно, терпит неудачу, поскольку значение внутри Box не является String, а является Int. Kotlin K2 исправляет данный дефект. Это применимо не только к start projection type, но и к другим projected типам, например контравариантным типам с in-аннотацией:
fun syntheticSetter_inVariance() {
val box = Box<String>()
val tmpBox : Box<in String> = box
tmpBox.value = 12 // Wow, thats a trap again!
val foo : String? = box.value // Blast! ClassCastException
}
Здесь точно такая же проблема, но с контравариантным типом, помеченным in
аннотацией. Компилятор K2 не скомпилирует такой код, в то время как K1 не выдаст никаких ошибок.
5. Постоянный порядок разрешения (resolution) свойств
При использовании Kotlin, мы можем расширять Java классы и наоборот. И может случиться, что как суперкласс, так и базовый класс будут иметь одинаковые поля. Поскольку Kotlin не позволяет нам объявлять простые поля, а требует использовать вместо них свойства (properties), под “полем” следует понимать стандартное поле языка Java как члена класса, а также backing поле свойства в случае с Kotlin. Таким образом, предположим, что у нас есть 2 класса, один из них базовый Java класс:
public class AbstractEntity {
public String type = "ABSTRACT_TYPE";
public String status = "ABSTRACT_STATUS";
}
А второй дочерний Kotlin класс:
class AbstractEntitySubclass(val type: String) : AbstractEntity() {
val status: String
get() = "CONCRETE_STATUS"
}
fun main() {
val sublcass = AbstractEntitySubclass("CONCRETE_TYPE")
println(sublcass.type)
println(sublcass.status)
}
Если бы мы попытались скомпилировать этот код компилятором K1, у нас бы все получилось, но во время выполнения мы получили бы java.lang.IllegalAccessError. В то же время, компилятор K2 тоже скомпилировал бы такой код успешно, но и во время выполнения не было бы никаких ошибок. Давайте попробуем понять почему.
5.1 Resolution свойств объекта
Давайте сначала поймем во что именно компилируется приведенный выше код. Родительский класс очень простой, в bytecode он бы действительно содержал два публичных поля, ничего необычного. Но дочерний класс содержал бы одно поле - type - и два геттера - getType и getStatus. Сам status не имел бы backing поля, потому что нет причины его создавать - свойство status в Kotlin немутабельно, и его значение является строковым литералом. Таким образом, у нас есть два поля type - одно у родителя, другое у дочернего класса. Разница, однако, состоит в том, что в дочернем классе это поле было бы private, а в родительском type было бы public полем. Таким образом, в K1 была проблема при разрешении полей в тех случаях, когда у нас существовала иерархия расширяющихся классов в Java и Kotlin. И это действительно несколько запутывающий случай. Например, AbstractEntitySubclass().type, в мире Kotlin, может ссылаться как на поле родительского класса type, так и на свойство дочернего класса type. И если к свойству в родительском классе в bytecode можно обращаться через простую байткод инструкцию getfield, то обращение к свойству type в дочернем классе требует вызова синтетически сгенерированного геттера. Следовательно, компилятор должен решить, к каким именно полям мы хотим получить доступ и как - напрямую или через геттер. Таким образом, у K1 был недостаток - он компилировал описанный выше код в простую bytecode-инструкцию getfield, но для дочернего класса. Это, конечно, неправильно, поскольку, как мы уже сказали, в дочернем классе поле type является private. К нему надо обращаться через синтетический геттер. Соответственно, код компилировался, но во время выполнения JVM замечала, что мы пытаемся обратиться к чему-то нелегальным способом, и выбрасывала IllegalAccessError.
5.2. Resolution свойств объекта в K2
K2 решает эту проблему и устанавливает конкретный порядок разрешения свойств для таких случаев. Общее правило таково – свойства дочерних классов имеют приоритет. Это значит, что свойства более конкретных классов имеют высший приоритет перед свойствами предков при равном уровне видимости. Поэтому, при компиляции с помощью K2 упомянутого выше кода, мы получим следующее:
CONCRETE_TYPE
CONCRETE_STATUS
Как видите, значения из дочернего класса имеют приоритет перед полями родителей.
6. Сохранение Nullability для примитивных массивов
Компиляторы Kotlin (как K1, так и K2) поддерживают nullability аннотации в Java коде, такие как, например, @Nullable
и @NotNull
от JetBrains. Следовательно, если мы имеем следующий Java метод:
public static @Nullable String fromCharArray(char[] s) {
if (s == null || s.length == 0) return null;
return new String(s);
}
Компилятор Kotlin (как K1 так и K2) успешно распознает, что возвращаемое значение метода fromCharArray - это nullable строка, а не просто String. Следовательно, в Kotlin следующий код работать не будет:
val resultString : String = fromCharArray(null) // Correct type should be String?
Проблема, однако, состояла в том, что K1 не мог вывести информацию о nullability для примитивных массивов с аннотациями для уточнения типа (аннотации со значением ElementType.TYPE_USE
в @Target
). Например, для этой функции на Java:
public static char @Nullable [] toCharArray(String s) {
if (s == null) return null;
return s.toCharArray();
}
K1 не смог бы сделать вывод по информации о nullability. Поэтому это привело бы к NullPointerException во время выполнения кода на Kotlin:
val array : CharArray = toCharArray(null) // That compiles fine in K1
println(array[0]) // NPE
K2, напротив, успешно делает вывод о nullability для примитивных массивов. Таким образом, K2 не стал бы компилировать приведенный выше код, поскольку возвращаемое значение toCharArary()
на самом деле CharArray?
, а не CharArray
.
7. Вывод
K2 привнес много улучшений в процессы компиляции кода на Kotlin. В общем, если подвести итог, он может выявить множество проблем на этапе компиляции. Улучшились взаимодействие с синтетическими сеттерами и дженериками, разрешение свойств, взаимодействие с Java в плане обработки null и многое другое.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Ждем всех, присоединяйтесь!