К большой моей радости, мне наконец выдалась возможность поработать с популярным языком Kotlin — конвертировать простенькое приложение из Java при помощи инструмента Convert Java File to Kotlin из Android Studio. Я опробовал язык и хотел бы рассказать о своем опыте.
Я быстро убедился, что этот инструмент конвертирует большую часть классов в Java практически безукоризненно. Но кое-где пришлось подчистить за ним код, и в процессе я выучил несколько новых ключевых слов!
Ниже я поделюсь своими наблюдениями. Прежде, чем мы начнем, замечу: если вы в какой-то момент захотите взглянуть, что происходит «под капотом», Android Studio позволяет отслеживать все процессы; просто перейдите в панели по следующему пути: Tools → Kotlin → Show Kotlin Bytecode.
Первое изменение я сперва даже не заметил — настолько оно было незначительным. Волшебным образом конвертер заменил константу long в одном из классов на int и преобразовывал ее обратно в long при каждом обращении. Брр!
Хорошая новость: Константа все равно распознавалась благодаря ключевому слову val.
Плохая новость: многие процессы сопровождались ненужными преобразованиями. Я ожидал, что безопасность типов в Kotlin будет на более высоком уровне, что там все будет реализовано лучше. Может быть, я переоценил, насколько умен этот конвертер?
Решение оказалось простым: нужно было просто добавить «L» в конце объявления переменой (примерно как в Java).
Одно из главных преимуществ Kotlin — безопасность null, которая устраняет угрозу возникновения нулевых ссылок. Осуществляется это при помощи системы типов, которая различает ссылки, допускающие и не допускающие значение null. В большинстве случаев для вас предпочтительнее ссылки, не допускающие значения null, с которыми нет риска столкнуться с NPE (Null Pointer Exceptions). Однако в некоторых ситуациях нулевые ссылки могут быть полезны, например, при инициализации из события onClick(), такого как AsyncTask.
Существует несколько способов наладить работу с нулевыми ссылками:
Определить, на каком именно паттерне остановиться, чтобы обеспечить безопасность null — непростая задача, поэтому конвертер по умолчанию выбирает самое простое решение (третье), позволяя разработчику справляться с проблемой оптимальным для его кейса образом.
Я понимал, что разрешить коду Kotlin выбрасывать null pointer exception — это как-то идет вразрез с преимуществами, которые дает данный язык, и стал копать глубже в надежде найти решение, которое оказалось бы лучше уже имеющихся.
Так я обнаружил мощное ключевое слово lateinit. При помощи lateinit в Kotlin можно инициализировать ненулевые свойства после вызова конструктора, что дает возможность вообще отойти от нулевых свойств.
Это значит, что я получаю все плюсы второго подхода без необходимости прописывать дополнительные «?.». Я просто обращаюсь с методами так, будто они в принципе не бывают нулевыми, не тратя время на шаблонные проверки и используя тот синтаксис, к которому привык.
Использование lateinit — это простой способ убрать операторы!!! из кода на Kotlin. Если вам интересны другие советы о том, как от них избавиться и сделать код аккуратнее, рекомендую пост David Vávra.
Так как конвертацию я проводил от класса к классу, мне стало интересно, как уже конвертированные классы будут взаимодействовать с теми, которые пока еще остаются на Java. Я читал, что Kotlin идеально совместим с Java, так что, по логике вещей, все должно было бы работать без видимых изменений.
У меня был публичный метод в одном фрагменте, который конвертировался в функцию internal в Kotlin. На Java у него не было никаких модификаторов доступа, и, соответственно, он был package private.
Конвертер заметил отсутствие модификаторов доступа и решил, что метод должен быть видимым только в пределах модуля / пакета, применив ключевое слово internal, чтобы задать параметры видимости.
Что означает это новое ключевое слово? Заглянув в декомпилированный биткод мы тут же увидим, что название метода из setErrorContent() превратилось в setErrorContent$production_sources_for_module_app().
Хорошая новость: в других классах Kotlin достаточно знать исходное название метода.
Kotlin сам переведет его в сгенерированное имя. Если снова взглянуть на декомпилированный код, можно увидеть, как осуществлялся перевод.
Таким образом, Kotlin разбирается с изменениями в названиях своими силами. А как насчет остальных классов на Java?
Из Java класса вызвать метод errorFragment.setErrorContent() нельзя — ведь этого «внутреннего» метода на самом деле не существует (так как изменилось название).
Метод setErrorContent() теперь невидим для классов на Java, как можно увидеть и в API, и в окошке Intellisense в Android Studio. Так что придется использовать сгенерированное (и очень громоздкое) название метода.
Несмотря на то, что Java и Kotlin обычно взаимодействуют без проблем, при вызове классов Kotlin из классов Java могут возникнуть непредвиденные сложности с ключевым словом internal. Если вы планируете переходить на Kotlin поэтапно, имейте это в виду.
Kotlin не допускает публичных статических переменных и методов, которые столь типичны для Java. Вместо этого он предлагает такой концепт, как объект-компаньон, который отвечает за поведение статических объектов и интерфейсов в Java.
Если вы создаете константу в классе на Java, а затем конвертируете его в Kotlin, то конвертер не распознает, что переменная static final должна применяться как константа, что может привести к помехам в совместимости Java и Kotlin.
Когда вам нужна константа в классе Java, вы создаете переменную static final:
Как видите, после конвертирования, все они оказались в классе компаньона:
Когда их используют другие классы Kotlin, все происходит так, как и следовало ожидать:
Однако, так как Kotlin, конвертируя константу, помещает ее в собственный класс компаньона, доступ к таким константам из Java класса не интуитивен.
Декомпилируя класс в Kotlin, мы можем заметить, что константы стали приватными и раскрываются через класс-оболочку компаньона.
В результате код получается куда сложнее, чем хотелось бы.
Хорошая новость: частично исправить положение и добиться желаемого поведения мы можем, введя ключевое слово const в класс компаньона.
Теперь, если взглянуть на декомпилированный код, мы увидим наши константы! Но увы, в конечном счете мы все равно создаем пустой класс компаньона.
Зато доступ из Java классов происходит по обычной схеме!
Прошу заметить, что это метод работает только для примитивов и строк. Чтобы узнать больше о не-примитивах, почитайте JvmField и статью Kotlin’s hidden costs.
По умолчанию Kotlin конвертирует циклы в диапазоне с границами 0..N-1, чем затрудняет сопровождение кода, увеличивая вероятность возникновения ошибок на единицу.
В моем коде, к примеру, был вложенный цикл for, чтобы добавлять карты в каждый ряд — самый обычный пример цикла for на Android.
Конвертирование прошло без особых ухищрений.
Код, который получается в итоге, может показаться Java разработчикам непривычным — будто его писали на Ruby или Python.
Как пишет в своем блоге Dan Lew, у Kotlin функция диапазона инклюзивна по умолчанию. Однако, ознакомившись с характеристиками диапазона у Kotlin, я нашел их очень хорошо проработанными и гибкими. Мы можем упростить код и сделать его читабельнее, воспользовавшись возможностями, которые они предлагают.
Функция until делает циклы неинклюзивными и более простыми для чтения. Можно наконец выкинуть все эти нелепые -1 из головы!
Для ленивых
Иногда бывает полезно лениво загрузить переменную member. Представьте, что у вас класс типа singleton, который управляет списком данных. Каждый раз создавать этот список заново нет необходимости, так что мы зачастую обращаемся к ленивому геттеру. Паттерн получается в таком духе:
Если конвертер попытается конвертировать этот паттерн, код не скомпилируется, так как list прописан как неизменяемый, при том что у createMovies() изменяемый тип возвращаемого значения. Компилятор не позволит вернуть изменяемый объект, если сигнатура метода задает неизменяемый.
Это очень мощный паттерн для делегирования загрузки объекта, поэтому Kotlin подключает особую функцию, lazy, чтобы упростить загрузку ленивым способом. С ее помощью код компилируется.
Так как последняя строка — это возвращаемый объект, теперь мы можем создавать объект, который требует меньше кода, чтобы лениво его загрузить!
Деструктуризация
Если вам приходилось деструктурировать массивы или объекты на javascript, то объявления по деструктуризации покажутся вам знакомыми.
На Java мы постоянно создаем и перемещаем объекты. Однако в некоторых случаях нам нужно буквально несколько свойств объекта, и бывает жаль времени на то, чтобы извлекать их в переменные. Если же речь идет о большом количестве свойств, проще получить к ним доступ через геттер. Например, так:
Kotlin, однако, предлагает мощное объявление деструктора, которое упрощает процесс извлечения свойств объекта, сокращая объем кода, необходимый, чтобы закрепить за каждым свойством отдельную переменную.
Не приходится удивляться, что в декомпилированном коде методы у нас ссылаются на геттеры в классах данных.
Конвертер оказался достаточно умным, чтобы упростить код путем деструктуризации объекта. Тем не менее, я бы посоветовал почитать про лямбды и деструктуризацию. В Java 8 существует распространенная практика заключать параметры лямбда-функции в скобки, если их больше одного, но в Kotlin это может быть интерпретировано как деструктуризация.
Использование инструмента для конвертирования в Android Studio стало для меня отличным первым шагом в освоении Kotlin. Но, проглядев некоторые участки полученного кода, я был вынужден начать глубже вникать в этот язык, чтобы найти более эффективные способы писать на нем.
Хорошо, что меня предупредили: после конвертации код нужно обязательно вычитать. Иначе на Kotlin у меня получилось бы нечто маловразумительное! Хотя, честно говоря, у меня и на Java с этим не лучше.
Если вы хотите узнать другую полезную информацию о Kotlin для начинающих, советую прочитать этот пост и посмотреть видео.
Я быстро убедился, что этот инструмент конвертирует большую часть классов в Java практически безукоризненно. Но кое-где пришлось подчистить за ним код, и в процессе я выучил несколько новых ключевых слов!
Ниже я поделюсь своими наблюдениями. Прежде, чем мы начнем, замечу: если вы в какой-то момент захотите взглянуть, что происходит «под капотом», Android Studio позволяет отслеживать все процессы; просто перейдите в панели по следующему пути: Tools → Kotlin → Show Kotlin Bytecode.
Константа long
Первое изменение я сперва даже не заметил — настолько оно было незначительным. Волшебным образом конвертер заменил константу long в одном из классов на int и преобразовывал ее обратно в long при каждом обращении. Брр!
companion object {
private val TIMER_DELAY = 3000
}
//...
handler.postDelayed({
//...
}, TIMER_DELAY.toLong())
Хорошая новость: Константа все равно распознавалась благодаря ключевому слову val.
Плохая новость: многие процессы сопровождались ненужными преобразованиями. Я ожидал, что безопасность типов в Kotlin будет на более высоком уровне, что там все будет реализовано лучше. Может быть, я переоценил, насколько умен этот конвертер?
Решение оказалось простым: нужно было просто добавить «L» в конце объявления переменой (примерно как в Java).
companion object {
private val TIMER_DELAY = 3000L
}
//...
handler.postDelayed({
//...
}, TIMER_DELAY)
Лучше поздно, чем никогда
Одно из главных преимуществ Kotlin — безопасность null, которая устраняет угрозу возникновения нулевых ссылок. Осуществляется это при помощи системы типов, которая различает ссылки, допускающие и не допускающие значение null. В большинстве случаев для вас предпочтительнее ссылки, не допускающие значения null, с которыми нет риска столкнуться с NPE (Null Pointer Exceptions). Однако в некоторых ситуациях нулевые ссылки могут быть полезны, например, при инициализации из события onClick(), такого как AsyncTask.
Существует несколько способов наладить работу с нулевыми ссылками:
- Старые-добрые операторы if, которые будут проверять свойства на наличие нулевых ссылок, прежде чем дать к ним доступ (Java должен был вас уже к ним приучить).
- Крутой Safe Call Operator (синтаксис ?.), который проводит за вас проверку на нулевые значения в фоновом режиме. Если объект — нулевая ссылка, то он возвращает ноль (не NPE). Никаких больше надоедливых операторов if!
- Насильственное возвращение NPE при помощи оператора !!.. В этом случае вы фактически пишете знакомый по Java код и вам необходимо вернуться к первому шагу.
Определить, на каком именно паттерне остановиться, чтобы обеспечить безопасность null — непростая задача, поэтому конвертер по умолчанию выбирает самое простое решение (третье), позволяя разработчику справляться с проблемой оптимальным для его кейса образом.
Я понимал, что разрешить коду Kotlin выбрасывать null pointer exception — это как-то идет вразрез с преимуществами, которые дает данный язык, и стал копать глубже в надежде найти решение, которое оказалось бы лучше уже имеющихся.
Так я обнаружил мощное ключевое слово lateinit. При помощи lateinit в Kotlin можно инициализировать ненулевые свойства после вызова конструктора, что дает возможность вообще отойти от нулевых свойств.
Это значит, что я получаю все плюсы второго подхода без необходимости прописывать дополнительные «?.». Я просто обращаюсь с методами так, будто они в принципе не бывают нулевыми, не тратя время на шаблонные проверки и используя тот синтаксис, к которому привык.
Использование lateinit — это простой способ убрать операторы!!! из кода на Kotlin. Если вам интересны другие советы о том, как от них избавиться и сделать код аккуратнее, рекомендую пост David Vávra.
Internal и его внутренний мир
Так как конвертацию я проводил от класса к классу, мне стало интересно, как уже конвертированные классы будут взаимодействовать с теми, которые пока еще остаются на Java. Я читал, что Kotlin идеально совместим с Java, так что, по логике вещей, все должно было бы работать без видимых изменений.
У меня был публичный метод в одном фрагменте, который конвертировался в функцию internal в Kotlin. На Java у него не было никаких модификаторов доступа, и, соответственно, он был package private.
public class ErrorFragment extends Fragment {
void setErrorContent() {
//...
}
}
Конвертер заметил отсутствие модификаторов доступа и решил, что метод должен быть видимым только в пределах модуля / пакета, применив ключевое слово internal, чтобы задать параметры видимости.
class ErrorFragment : Fragment() {
internal fun setErrorContent() {
//...
}
}
Что означает это новое ключевое слово? Заглянув в декомпилированный биткод мы тут же увидим, что название метода из setErrorContent() превратилось в setErrorContent$production_sources_for_module_app().
public final void setErrorContent$production_sources_for_module_app() {
//...
}
Хорошая новость: в других классах Kotlin достаточно знать исходное название метода.
mErrorFragment.setErrorContent()
Kotlin сам переведет его в сгенерированное имя. Если снова взглянуть на декомпилированный код, можно увидеть, как осуществлялся перевод.
// Accesses the ErrorFragment instance and invokes the actual method
ErrorActivity.access$getMErrorFragment$p(ErrorActivity.this)
.setErrorContent$production_sources_for_module_app();
Таким образом, Kotlin разбирается с изменениями в названиях своими силами. А как насчет остальных классов на Java?
Из Java класса вызвать метод errorFragment.setErrorContent() нельзя — ведь этого «внутреннего» метода на самом деле не существует (так как изменилось название).
Метод setErrorContent() теперь невидим для классов на Java, как можно увидеть и в API, и в окошке Intellisense в Android Studio. Так что придется использовать сгенерированное (и очень громоздкое) название метода.
Несмотря на то, что Java и Kotlin обычно взаимодействуют без проблем, при вызове классов Kotlin из классов Java могут возникнуть непредвиденные сложности с ключевым словом internal. Если вы планируете переходить на Kotlin поэтапно, имейте это в виду.
Сложности с компаньоном
Kotlin не допускает публичных статических переменных и методов, которые столь типичны для Java. Вместо этого он предлагает такой концепт, как объект-компаньон, который отвечает за поведение статических объектов и интерфейсов в Java.
Если вы создаете константу в классе на Java, а затем конвертируете его в Kotlin, то конвертер не распознает, что переменная static final должна применяться как константа, что может привести к помехам в совместимости Java и Kotlin.
Когда вам нужна константа в классе Java, вы создаете переменную static final:
public class DetailsActivity extends Activity {
public static final String SHARED_ELEMENT_NAME = "hero";
public static final String MOVIE = "Movie";
//...
}
Как видите, после конвертирования, все они оказались в классе компаньона:
class DetailsActivity : Activity() {
companion object {
val SHARED_ELEMENT_NAME = "hero"
val MOVIE = "Movie"
}
//...
}
Когда их используют другие классы Kotlin, все происходит так, как и следовало ожидать:
val intent = Intent(context, DetailsActivity::class.java)
intent.putExtra(DetailsActivity.MOVIE, item)
Однако, так как Kotlin, конвертируя константу, помещает ее в собственный класс компаньона, доступ к таким константам из Java класса не интуитивен.
intent.putExtra(DetailsActivity.Companion.getMOVIE(), item)
Декомпилируя класс в Kotlin, мы можем заметить, что константы стали приватными и раскрываются через класс-оболочку компаньона.
public final class DetailsActivity extends Activity {
@NotNull
private static final String SHARED_ELEMENT_NAME = "hero";
@NotNull
private static final String MOVIE = "Movie";
public static final DetailsActivity.Companion Companion = new DetailsActivity.Companion((DefaultConstructorMarker)null);
//...
public static final class Companion {
@NotNull
public final String getSHARED_ELEMENT_NAME() {
return DetailsActivity.SHARED_ELEMENT_NAME;
}
@NotNull
public final String getMOVIE() {
return DetailsActivity.MOVIE;
}
private Companion() {
}
// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
В результате код получается куда сложнее, чем хотелось бы.
Хорошая новость: частично исправить положение и добиться желаемого поведения мы можем, введя ключевое слово const в класс компаньона.
class DetailsActivity : Activity() {
companion object {
const val SHARED_ELEMENT_NAME = "hero"
const val MOVIE = "Movie"
}
//...
}
Теперь, если взглянуть на декомпилированный код, мы увидим наши константы! Но увы, в конечном счете мы все равно создаем пустой класс компаньона.
public final class DetailsActivity extends Activity {
@NotNull
public static final String SHARED_ELEMENT_NAME = "hero";
@NotNull
public static final String MOVIE = "Movie";
public static final DetailsActivity.Companion Companion = new DetailsActivity.Companion((DefaultConstructorMarker)null);
//...
public static final class Companion {
private Companion() {
}
// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
Зато доступ из Java классов происходит по обычной схеме!
Прошу заметить, что это метод работает только для примитивов и строк. Чтобы узнать больше о не-примитивах, почитайте JvmField и статью Kotlin’s hidden costs.
Циклы, и как Kotlin их совершенствует
По умолчанию Kotlin конвертирует циклы в диапазоне с границами 0..N-1, чем затрудняет сопровождение кода, увеличивая вероятность возникновения ошибок на единицу.
В моем коде, к примеру, был вложенный цикл for, чтобы добавлять карты в каждый ряд — самый обычный пример цикла for на Android.
for (int i = 0; i < NUM_ROWS; i++) {
//...
for (int j = 0; j < NUM_COLS; j++) {
//...
}
//...
}
Конвертирование прошло без особых ухищрений.
for (i in 0..NUM_ROWS - 1) {
//...
for (j in 0..NUM_COLS - 1) {
//...
}
//...
}
Код, который получается в итоге, может показаться Java разработчикам непривычным — будто его писали на Ruby или Python.
Как пишет в своем блоге Dan Lew, у Kotlin функция диапазона инклюзивна по умолчанию. Однако, ознакомившись с характеристиками диапазона у Kotlin, я нашел их очень хорошо проработанными и гибкими. Мы можем упростить код и сделать его читабельнее, воспользовавшись возможностями, которые они предлагают.
for (i in 0 until NUM_ROWS) {
//...
for (j in 0 until NUM_COLS) {
//...
}
//...
}
Функция until делает циклы неинклюзивными и более простыми для чтения. Можно наконец выкинуть все эти нелепые -1 из головы!
Полезные советы для продвинутых
Для ленивых
Иногда бывает полезно лениво загрузить переменную member. Представьте, что у вас класс типа singleton, который управляет списком данных. Каждый раз создавать этот список заново нет необходимости, так что мы зачастую обращаемся к ленивому геттеру. Паттерн получается в таком духе:
public static List<Movie> getList() {
if (list == null) {
list = createMovies();
}
return list;
}
Если конвертер попытается конвертировать этот паттерн, код не скомпилируется, так как list прописан как неизменяемый, при том что у createMovies() изменяемый тип возвращаемого значения. Компилятор не позволит вернуть изменяемый объект, если сигнатура метода задает неизменяемый.
Это очень мощный паттерн для делегирования загрузки объекта, поэтому Kotlin подключает особую функцию, lazy, чтобы упростить загрузку ленивым способом. С ее помощью код компилируется.
val list: List<Movie> by lazy {
createMovies()
}
Так как последняя строка — это возвращаемый объект, теперь мы можем создавать объект, который требует меньше кода, чтобы лениво его загрузить!
Деструктуризация
Если вам приходилось деструктурировать массивы или объекты на javascript, то объявления по деструктуризации покажутся вам знакомыми.
На Java мы постоянно создаем и перемещаем объекты. Однако в некоторых случаях нам нужно буквально несколько свойств объекта, и бывает жаль времени на то, чтобы извлекать их в переменные. Если же речь идет о большом количестве свойств, проще получить к ним доступ через геттер. Например, так:
final Movie movie = (Movie) getActivity()
.getIntent().getSerializableExtra(DetailsActivity.MOVIE);
// Access properties from getters
mMediaPlayerGlue.setTitle(movie.getTitle());
mMediaPlayerGlue.setArtist(movie.getDescription());
mMediaPlayerGlue.setVideoUrl(movie.getVideoUrl());
Kotlin, однако, предлагает мощное объявление деструктора, которое упрощает процесс извлечения свойств объекта, сокращая объем кода, необходимый, чтобы закрепить за каждым свойством отдельную переменную.
val (_, title, description, _, _, videoUrl) = activity
.intent.getSerializableExtra(DetailsActivity.MOVIE) as Movie
// Access properties via variables
mMediaPlayerGlue.setTitle(title)
mMediaPlayerGlue.setArtist(description)
mMediaPlayerGlue.setVideoUrl(videoUrl)
Не приходится удивляться, что в декомпилированном коде методы у нас ссылаются на геттеры в классах данных.
Serializable var10000 = this.getActivity().getIntent().getSerializableExtra("Movie");
Movie var5 = (Movie)var10000;
String title = var5.component2();
String description = var5.component3();
String videoUrl = var5.component6();
Конвертер оказался достаточно умным, чтобы упростить код путем деструктуризации объекта. Тем не менее, я бы посоветовал почитать про лямбды и деструктуризацию. В Java 8 существует распространенная практика заключать параметры лямбда-функции в скобки, если их больше одного, но в Kotlin это может быть интерпретировано как деструктуризация.
Заключение
Использование инструмента для конвертирования в Android Studio стало для меня отличным первым шагом в освоении Kotlin. Но, проглядев некоторые участки полученного кода, я был вынужден начать глубже вникать в этот язык, чтобы найти более эффективные способы писать на нем.
Хорошо, что меня предупредили: после конвертации код нужно обязательно вычитать. Иначе на Kotlin у меня получилось бы нечто маловразумительное! Хотя, честно говоря, у меня и на Java с этим не лучше.
Если вы хотите узнать другую полезную информацию о Kotlin для начинающих, советую прочитать этот пост и посмотреть видео.