Содержание статьи
Уже много лет как JVM — это не просто виртуальная машина, в байт-код которой компилируется язык программирования Java, а нечто куда большее. Сегодня JVM — это платформа, для которой существует множество популярных языков программирования, таких как Scala, Groovy и Clojure. Kotlin — еще один язык в этом ряду, и он обладает целым рядом преимуществ и особенностей. Сейчас это уже не только язык для JVM, есть варианты для JavaScript и даже Kotlin Native, что очень добавляет ему привлекательности.
Меня вряд ли можно назвать оголтелым патриотом, но мне очень приятно думать, что языком программирования, который создается в России, пользуются люди по всему миру.
Почему «создается», а не «создан»? Дело в том, что языки программирования эволюционируют, и это нормально (кроме случая C++ — ну же, ребята, хватит уже). Поэтому все, что здесь описано, верно здесь и сейчас (а дальше, уверен, будет только лучше).
Классы и объекты
Начнем с простого — с ООП. В статье я буду акцентировать внимание на необычных с точки зрения Java моментах.
В языке Kotlin первичный конструктор не может содержать кода и код должен быть вынесен в блок инициализации.
class Customer(name: String) {
init {
logger.info("Customer initialized with value ${name}")
}
}
Однако дополнительные конструкторы мало чем отличаются от аналогов в Java:
class Person {
constructor(parent: Person) {
parent.children.add(this)
}
}
Если же первичный конструктор содержит параметры, то другие конструкторы должны делегировать (явно или неявно через другой, вторичный конструктор) создание объекта первичному конструктору:
class Person(val name: String) {
constructor(name: String, parent: Person) : this(name) {
parent.children.add(this)
}
}
Здесь еще есть неизменяемое поле name
, теперь оно часть класса. Конечно, можно сделать конструктор непубличным, например так:
class DontCreateMe private constructor () {
}
Наверное, самая интересная особенность — это отсутствие ключевого слова new
в Kotlin. Чтобы создать объект, нужно просто вызвать соответствующий конструктор:
val invoice = Invoice()
val customer = Customer("Joe Smith")
В Kotlin аналог Object
— Any
, базовый класс для всех классов, хотя это аналог только в смысле корня иерархии, он не имеет equals()
, hashCode()
и toString()
.
Все классы в Kotlin по умолчанию final
, от них нельзя наследовать, и, чтобы сделать класс открытым для наследования, нужно объявить его как open
:
open class Base(p: Int)
class Derived(p: Int) : Base(p)
Это же касается и методов, которые можно переопределять, они также должны быть помечены как open
:
open class Base {
open fun canOverride() {}
fun cannotOverride() {}
}
class Derived() : Base() {
override fun canOverride() {}
}
С этим связано несколько правил, в том числе следующее: только открытый класс может иметь открытые методы. Более того, методы, помеченные override
, являются также open
-методами. Для того чтобы закрыть override
-метод, нужно использовать final
:
open class AnotherDerived() : Base() {
final override fun v() {}
}
Kotlin лаконичен и последователен, override
может быть частью первичного конструктора:
interface Foo {
val count: Int
}
class Bar1(override val count: Int) : Foo
class Bar2 : Foo {
override var count: Int = 0
}
Теперь пару слов про ООП, скажем так, в экзотическом стиле: абстрактные классы, конечно, есть и в Kotlin, но, что интересно, непустой открытый метод может быть переопределен пустым в абстрактном наследнике!
В Kotlin нет статических методов, и создатели языка рекомендуют пользоваться функциями на уровне пакета (то есть не привязанными к конкретному классу). Если мы все-таки хотим иметь что-то похожее на статические методы в Java, то есть ненаследуемые, но как-то привязанные к классу, то типичный пример здесь — это фабричный метод (factory method), когда мы можем воспользоваться возможностью создания объекта-компаньона. Для тех, кто программирует на Scala, это все очень знакомо:
interface Factory<T> {
fun create(): T
}
class MyClass {
companion object : Factory<MyClass> {
override fun create(): MyClass = MyClass()
}
}
val instance = MyClass.create()
Если же мы хотим получить сам объект-компаньон, это можно сделать так:
class MyClass {
companion object {
}
}
val x = MyClass.Companion
Более того, существует еще так называемое объектное выражение, но эту тему мы оставим до следующей статьи, а желающие могут найти все, что необходимо, в первоисточнике — KotlinLang.org.
Свойства
Свойства (properties) — весьма полезный инструмент программирования, и надо сказать, что в Kotlin этот вопрос решен очень интересно:
val isEmpty: Boolean
get() = this.size == 0
Это довольно простой пример, но вполне актуальный. Здесь isEmpty
— это вычислимое свойство (предполагается, что у класса, внутри которого находится функция, есть свойство size
). Также для свойства можно задать и сеттер через set() = ...
, и устанавливать значение можно будет через присваивание имя_свойства=значение
.
var stringRepresentation: String
get() = this.toString()
set(value) {
// Здесь мы можем сделать что-нибудь с value
}
С одной стороны, это менее прозрачно, чем использование явных get/set-методов в стиле Java, однако это здорово разгружает код.
Более того, начиная с версии Kotlin 1.1 можно не прописывать тип свойства явно, если он может быть выведен автоматически:
val isEmpty get() = this.size == 0 // Имеет тип Boolean
Если нам нужно поменять только видимость или аннотировать get
или set
, то это довольно легко сделать с помощью указания области видимости или аннотации перед get
или set
:
var setterVisibility: String = "abc"
private set // The setter is private and has the default implementation
var setterWithAnnotation: Any? = null
@Inject set // Annotate the setter with Inject
В каком-то смысле в Kotlin нет полей, а есть только свойства, но опять же за деталями настоятельно рекомендую обратиться к источнику.
Интерполяция строк
Это просто, понятно и очень приятно:
val x = 10
val y = 20
println("x=$x y=$y")
Значения и вывод типов
Вместо модификатора final в языке Kotlin явным образом различаются константы и переменные через различные ключевые слова. Для объявления констант (значений) используется ключевое слово val
, а для переменных — ключевое слово var
. И снова это должно быть хорошо знакомо тем, кто программирует на Scala. Вообще, определение в Kotlin синтаксически решено иначе, чем в Java:
val x: Int = 10
Использование подобной конструкции дает большую свободу, потому что в языке присутствует механизм вывода типов и во многих случаях указание типа можно опустить.
val result = sequenceOf(10).map { it.toString() }.flatMap { it.toCharArray().asSequence() }.toList()
В данном примере компилятор самостоятельно выведет тип как List<Char>
. Несмотря на все радости вывода типов, все-таки в сложных случаях я рекомендую прописывать тип явно, просто для того, чтобы получить ошибку компиляции как можно ближе к ее реальному источнику. Потому что, например, если в условном выражении типы для разных ветвей различаются, то компилятор унифицирует тип до какого-то общего, в худшем случае до Any
. В результате ошибка компиляции будет там, где ты передаешь соответствующее значение, а не там, где оно было описано. Возможность быстрой локализации ошибок очень важна, особенно в больших системах, что приводит нас к одной из самых интересных фишек языка Kotlin.
Null Safety
Вот правда, когда кто-то говорит про Kotlin, то сразу думает про Null Safety, а тот, кто знает, что такое Null Safety, вспоминает про Kotlin. Для начала разберемся, что это вообще такое. Те, кто программирует на Java, очень не любят получать NullPointerException
(NPE) где-нибудь на сервере, просто потому, что stack trace часто недостаточно информативен для выявления ошибки. Это в первую очередь связано с императивной природой языка Java: место, где определена (или объявлена, но не определена) переменная, и то место, где она используется, могут находиться очень далеко друг от друга как в пространственном (в коде), так и во временном (по времени выполнения) отношении.
В языке Kotlin этот вопрос решен довольно интересно, так как ты на уровне языка контролируешь, можно ли присваивать null
-значение соответствующей переменной. Причем по умолчанию это запрещено:
fun f(s: String): Int {
return s.indexOf('=')
}
// f(null) // Ошибка!
f("Hello World") // Все хорошо
Если же мы хотим, чтобы функция (переменная) принимала нулевые значения, то надо бы написать так:
fun f(s: String?): Int {
return s?.indexOf('=') ?: -1
}
Выражение в return — это специальный вариант оператора «элвис» (elvis — он так называется, потому что ?: похоже на emoji в стиле Элвиса Пресли), который представляет собой не что иное, как синтаксический сахар для конструкции
if (s != null) s.indexOf('=') else -1
Последнее условное выражение — это пример так называемого smart cast в Kotlin, и если в условии была проверка на null, то вызов s.indexOf() считается абсолютно легитимным. Этот же механизм работает, если мы будем передавать аргументы, которые могут быть null, в качестве ненулевого формального параметра в функции.
fun g(cannotBeNull: String) {
println(cannotBeNull)
}
fun f(canBeNull: String?) {
// g(canBeNull) ошибка!
if (canBeNull != null) {
// Теперь все абсолютно корректно
g(canBeNull)
}
}
А что, если мы в каком-то месте точно знаем, что переменная будет проинициализирована, но вначале она равна null (к этому вопросу в более общем случае мы вернемся чуть позднее)?
var canBeNull: String? = null
// ...
canBeNull = "Hello"
// ...
val cannotBeNull: String = canBeNull!!
Здесь мы явным образом берем на себя ответственность за небезопасное преобразование. Если все-таки переменная canBeNull не будет определена, как предполагается, то мы получим KotlinNullPointerException
. И в чем, собственно, плюсы такого подхода по сравнению с обычной Java? Такой подход позволяет локализовать ошибку. Даже если мы будем писать canBeNull!!.length
, то сам синтаксис помогает нам понять, где именно проблема. Строго говоря, возможна сложная цепочка, a!!.b.c!!.d
к примеру, и в этом случае также понятно, где источник потенциальных проблем. Правильное использование такого подхода позволяет изолировать крупные участки кода от потенциального NPE! Можно использовать конструкцию canBeNull?.length
, но в отличие от предыдущего случая тип этого выражения будет Int?
, а не Int
. Это означает, что если canBeNull
равен null
, то и само значение будет равно null
. Таким образом, a?.b?.c?.d
будет null
, если любое из значений в цепочке равно null. Тем, кто знаком с функциональным программированием, это напоминает аппликативные функторы и монаду Maybe в Haskell.
Еще одна интересная особенность Kotlin в работе с null
— это использование let
. Если мы хотим, чтобы для ненулевых значений была выполнена какая-то операция, то можно сделать так:
val listWithNulls: List<String?> = listOf("A", null)
for (item in listWithNulls) {
item?.let { println(it) } // Печатаем A и игнорируем null
}
Для приведения типов в Kotlin используется оператор as
, однако в случае преобразования из типа, который не допускает null, можно получить kotlin.TypeCastException: null cannot be cast to non-null type kotlin.Int
(если мы преобразуем в Int). Есть и более «умный» вариант:
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»