Новый компилятор K2 в Kotlin. Часть 2. Гайд по миграции

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, List>, List и т.д. Было бы безопасно позволить добавить объект любого конкретного типа к этому списку? Нет, мы не можем добавлять ничего кроме null к этому списку, поскольку на самом деле мы не знаем: какие именно объекты представлены в List. В Kotlin у нас есть star projections, похожие на wildcards в Java. Поэтому в Kotlin такой код тоже не будет компилироваться:

fun execute(list: MutableList<*>, element: Any) { 
	list.add(element) 
}

По той же самой причине. Пока что все понятно.

4.2 Дефект в компиляторе Kotlin 
Теперь представьте себе, что у нас есть следующий Java класс:

public class Box { 
 
	private E value; 
 
	public E getValue() { 
    	return value; 
	} 
 
	public void setValue(E value) { 
    	this.value = value; 
	} 
}

И если мы попытаемся использовать этот Java класс в Kotlin следующим образом:

 fun explicitSetter() { 
	val box = Box() 
	val tmpBox : Box<*> = box 
	tmpBox.setValue(12) // Compile Error! That's unsafe! 
	val myValue : String? = box.value 
}

Компилятор также выбросит сообщение об ошибке. Причина все та же: небезопасно выполнять такую операцию, поскольку Box<*>  может содержать все что угодно. Компилятор спасает нас, поскольку следующая строка вызвала бы ошибку. Но вот такой код:

fun syntheticSetter() { 
	val box = Box() 
	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 по праву ожидает, что значение внутри Box будет экземпляром класса String, и компилятор Kotlin, как и javac, добавит bytecode инструкцию cast, когда мы выполним функцию getValue ().  И это приведение типов (cast), естественно, терпит неудачу, поскольку значение внутри Box не является String, а является Int.  Kotlin K2 исправляет данный дефект.  Это применимо не только к start projection type, но и к другим projected типам, например контравариантным типам с in-аннотацией:

fun syntheticSetter_inVariance() { 
	val box = Box() 
	val tmpBox : Box = 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 и многое другое.

1341c47d60d24c5bf6e1e7431f04377f.png

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!

© Habrahabr.ru