О Котлине

2017-11-28

Тихо и незаметно вот уже второй настоящий коммерческий проект пишем с нуля и полностью на Котлине. И это ещё не считая всякой персональной мелочи под Андроид. Тихо и незаметно Котлин стал моим основным языком. Весь новый код по возможности стараюсь писать на Котлине.

А ещё три года назад всё выглядело не так однозначно. Была старушка Scala. Был странный Ceylon. И был подающий надежды, но с непонятным будущим, Kotlin.

В мире Scala не всё так однозначно. Typesafe стала Lightbend, и для всех их Scala продуктов заявлена поддержка отличного Java API. Scala перестал считать себя супер-языком, и стремится выжить в мире JVM.

Про Ceylon вообще ничего не слышно. Википедия говорит, новые версии выпускались стабильно, а в августе 2017 его передали в Eclipse Foundation.

А вот Kotlin расцвёл. Его настолько сильно продвигали в сторону Android разработки, что в результате его назначили first-class supported language для Android. И это хорошо. А ещё появился Kotlin/Native, который позиционируют для разработки под iOS. А ещё появляется всё больше чисто котлиновых библиотек, которые вовсю используют плюшки языка. А ещё в Котлине 1.1 добавили (пока экспериментальную) весьма интересную поддержку корутин. Минимальными изменениями в самом языке, без введения кучи новых ключевых слов, стало можно делать библиотеки, реализующие всякую асинхронщину вроде async/await, генераторов и всего такого.

В общем, пора писать на Котлине, если вы ещё не начали писать на Котлине. Котлин — прекрасен. Хотя не все с этим ещё согласны.

Kotlin logo (latest)

Объясняешь студентам: State — это такой класс, от которого нужно, чтобы его экземпляры были различными, в той степени, в которой нужно. Поэтому достаточно в нём иметь лишь одно строковое поле name. Но также нужно переопределить метод equals(), чтобы корректно сравнивать, и метод hashCode(), чтобы использовать в качестве ключа HashMap, и метод toString(), для удобства.

То есть в Яве нужно вот так:

public final class State {

    private final String name;

    public State(String name) {
        this.name = name;
    }

    public boolean equals(Object object) {
        if (this == object) return true;
        if (!(object instanceof State)) return false;
        if (!super.equals(object)) return false;

        State state = (State) object;

        if (name != null ? !name.equals(state.name) : state.name != null) return false;

        return true;
    }

    public int hashCode() {
        int result = super.hashCode();
        result = 31 * result + (name != null ? name.hashCode() : 0);
        return result;
    }

    public String toString() {
        return "State{" +
                "name='" + name + '\'' +
                '}';
    }

}

А в Котлине для всего этого достаточно лишь:

data class State(
    private val name: String
)

Это дата классы. Они финальные. Из них нельзя построить красивую иерархию наследования. Но это и не нужно. Они идеально подходят для передачи и представления данных. Вот структуры и всё.

data class SampleData(
    val visibleReadOnly: String,
    private val invisibleReadOnly: String,
    var readWrite: String
) {
    val internalReadOnly = invisibleReadOnly.toUpperCase()
}

val instance = SampleData(
        visibleReadOnly = "A",
        invisibleReadOnly = "B",
        readWrite = "C"
)

instance.visibleReadOnly    // "A"
//instance.visibleReadOnly = "a"  // compilation failure
//instance.invisibleReadOnly  // compilation failure
instance.readWrite  // "C"
instance.readWrite = "c"
instance.internalReadOnly   // "B"
//instance.internalReadOnly = "b" // compilation failure

Большинство свойств нашего класса данных нужно задавать в конструкторе. Наличие именованных параметров позволяет делать конструкторы принимающими сколь угодно много аргументов. И это остаётся удобным. Просто всегда вызывайте конструкторы с именованными аргументами.

А ещё есть и дефолтные значения параметров. Это чертовски запутывает, если пользоваться старыми добрыми позиционными параметрами. Но если пользоваться именованными — опять всё ок.

Кроме параметров конструктора, можно задать и обычные проперти класса. Но тогда имеет смысл делать эти проперти чем-то производным от параметров конструктора.

Ещё можно и методы добавлять, не проблема. Но, как правило, это не нужно.

Если у вас на всех пропертях стоит val, вы получите честную иммутабельность. Задаёте все свойства в конструкторе, а потом можете их читать. Вполне солидно.

Но ещё есть метод copy(), который позволяет «мутировать» ваши иммутабельные дата классы. Он принимает тот же набор параметров, что и конструктор, только все с дефолтными значениями. И создаёт копию дата-объекта, с изменёнными свойствами.

val copy = instance.copy(visibleReadOnly = "AAA")

Именованные аргументы, да ещё и с дефолтными значениями — вообще хорошо. Иногда создаётся иллюзия, будто кодишь на Питоне.

Kotlin logo (previous)

Заметьте, как правило дата классы в Котлине не соответствуют соглашению Java Beans. В бинах подразумевается конструктор без аргументов. А в дата классах наоборот, все свойства принято передавать в конструкторе. В бинах подразумеваются сеттеры и геттеры. В дата классах, если хотите иммутабельность и ставите val, у вас будут только геттеры.

Не все сериализаторы умеют корректно работать с иммутабельными дата классами Котлина. Gson (или его котлиновая обёртка Kotson) — умеет. А вот в Spark соответствующий Encoder ещё не завезли.

Эмулировать Java Beans приходится как-то так:

data class JavaBean(
    var property1: String? = null,
    var property2: String? = null
)

val bean = JavaBean()
bean.property1 = bean.property2

Совсем пустой конструктор в дата классе нельзя. Приходится делать конструктор с дефолтными (нуловыми) значениями для всех свойств. Некрасиво страшно.

Kotlin logo (oldest)

Если посмотреть на котлиновые библиотеки, например, на Mockito-Kotlin, то окажется, что они интенсивно эксплуатируют две возможности Котлина: экстеншен функции и функциональные литералы.

Экстеншен функции — это штука, покраденная, вероятно, из C#. Ну или дальнейшее развитие идеи friend function из C++, если хотите. В общем, это совершенно левые функции, которые, тем не менее, ведут себя как методы объекта (любого нужного типа). Они имеют доступ к target объекту через this. Но они не имеют доступ к приватным свойствам и методам объекта. Технически это просто синтаксический сахар. Но удобный. И приятный.

target.doSomething()
//vs
doSomething(target)

Можно сделать так:

fun Long.asSeconds(): Instant = Instant.ofEpochSecond(this)
fun Long.asMillis(): Instant = Instant.ofEpochMilli(this)

val now = 1511759774L.asSeconds()
val nowMillis = 1511759774000L.asMillis()

«Отсутствующие» явно конструкторы в Котлине поначалу вызывают недоумение. Но потом как-то укладывается. Ведь, в конце концов, чаще всего конструктор используется, чтобы передать и сохранить параметры. А для этого вполне достаточно val/var объявлений в круглых скобках. Если нужно что-то посложнее, есть блок init { }, или даже возможность задать вторичные конструкторы.

Зато из-за такого упрощения конструкторов класс-исключение в Котлине записывается в одну строку.

class MyException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)

Кстати, любители сhecked exceptions, ваше время прошло. Как был Ява единственным языком с этой критикуемой концепцией, так им и остался. В Kotlin нет checked exceptions. И нет ключевого слова throws. Впрочем, как вы заметили, checked exceptions нет и в свежих API, добавленных в Яву, например, в java.time.

В Котлине можно писать inline функции. Это позволяет проделывать удивительные, для Явы, фокусы. Например, узнавать тип переменной типа в генериках. Например, вот какая магия запрятана в mockito-kotlin:

inline fun <reified T : Any> mock(
      // тут много опциальных параметров
): T = Mockito.mock(T::class.java, withSettings(
      // сюда эти параметры передаются
))!!

Не пугайтесь !!. Это просто требование получить не null, и выкинуть NullPointerException, если получился всё же null.

Интересно, что тип T известен. Можно получить его класс, и передать в Mockito.

Используется это так:

val mock: MyInterface = mock()

Магия в том, что это inline функция. И reified можно использовать только с inline. Тело функции подставляется в место вызова. И вывод типов Котлина вполне может определить (а в данном примере тип задан весьма явно), что это за T здесь.

Android with Kotlin

Другая очень милая фича Котлина — функциональные литералы. Они сделали их гениально просто и красиво — просто фигурные скобки. Плюс возможность, если последний аргумент функции — функция, указать эти фигурные скобки после круглых скобок (а круглые вообще упустить, если нет других аргументов).

Поэтому можно написать вот такое:

class RunNotTooFrequently(
    val interval: Long
) {

    private var lastRun = 0L

    fun run(block: () -> Unit) {
        val now = System.currentTimeMillis()
        if ((now - lastRun) > interval) {
            lastRun = now
            block()
        }
    }

}

val runner = RunNotTooFrequently(2000)
runner.run {
    // do something
}

В стандартной библиотеке полным-полно таких функций, принимающих функции.

val stream = Files.newInputStream(Paths.get(logFile))

stream.bufferedReader(StandardCharsets.UTF_8).useLines { lines ->
    for (line in lines) {
        // process line
    }
}

В Котлине, в отличие от Явы, где тоже теперь есть всякие лямбды, функции — более полноправные члены языка. Можно просто фигачить функции в .kt файле, снова и питонячьем стиле, и это будет работать. Только будет не очень удобно, потому что все функции тогда будут объявлены в пакете, сам .kt файл никакого неймспейса не создаёт. Соответственно, и при импорте придётся их использовать по их имени. И беда-беда, если имена пересекутся.

А ещё в Котлине есть синтаксис для описания типа функции. Ну там, количество и типы аргументов, и тип возвращаемого значения. Это гораздо удобнее костылей в виде функциональных интерфейсов, что остался в Яве. Но из-за этого бывает, что при вызове какого-нибудь Ява-метода, который ожидает именно что функциональный интерфейс, иногда нужно функциональный литерал Котлина приводить к этому интерфейсу.

Интероперабилити с Явой просто отличный. Не помню серьёзных проблем, чтобы что-нибудь явовое вызвать из Котлина. Наоборот делать не приходилось.

Ну разве что один раз поймал багоособенность с varargs. В Яве что массив указать, что кучу аргументов в vararg метод передать — результат одинаков. В Котлине, если передаёшь массив, нужно не забыть звёздочку перед ним. Иначе передастся именно что один аргумент-массив, что просто невозможно в Яве. Впрочем, там был vararg на Object (each(Object... values)), что лишний раз подтверждает, что типы всё же нужно делать строже.

Kotlin island

Мы юзаем Котлин вместе со Spring и Spring Boot. Оно работает.

Местами получается забавно:

@SpringBootApplication
open class Application

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}

Главное, не забывать делать классы и методы, которые помечены спринговыми аннотациями, open. Оказывается, современный Спринг очень любит заниматься кодогенерацией, наследовать ваши бедные классы и переопределять методы. По умолчанию в Котлине все классы и методы закрыты от переопределения. Спрингу нужно явно открывать. Есть, конечно, плагин к Gradle, который сделает это неявно. Но лишней магии лучше избегать.

Kotlin vs Java

Люблю Котлин за его лаконичность. Вот сколько строк на Яве займёт вот это?

val intVal = (doc.getValue("number") as? Number)?.toInt() ?: 0

Тут из некоего документа извлекается некое значение (Object, будь он неладен), которое может быть числом, целым или длинным. Нужно получить именно Int. А если не удалось, пусть будет нуль.