О Котлине
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, генераторов и всего такого.
В общем, пора писать на Котлине, если вы ещё не начали писать на Котлине. Котлин — прекрасен. Хотя не все с этим ещё согласны.
Объясняешь студентам:
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")
Именованные аргументы, да ещё и с дефолтными значениями — вообще хорошо. Иногда создаётся иллюзия, будто кодишь на Питоне.
Заметьте,
как правило дата классы в Котлине
не соответствуют соглашению 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
Совсем пустой конструктор в дата классе нельзя. Приходится делать конструктор с дефолтными (нуловыми) значениями для всех свойств. Некрасиво страшно.
Если посмотреть на котлиновые библиотеки, например, на 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
здесь.
Другая очень милая фича Котлина — функциональные литералы. Они сделали их гениально просто и красиво — просто фигурные скобки. Плюс возможность, если последний аргумент функции — функция, указать эти фигурные скобки после круглых скобок (а круглые вообще упустить, если нет других аргументов).
Поэтому можно написать вот такое:
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)
),
что лишний раз подтверждает,
что типы всё же нужно делать строже.
Мы юзаем Котлин вместе со Spring и Spring Boot. Оно работает.
Местами получается забавно:
@SpringBootApplication
open class Application
fun main(args: Array<String>) {
SpringApplication.run(Application::class.java, *args)
}
Главное,
не забывать делать классы и методы,
которые помечены спринговыми аннотациями,
open
.
Оказывается,
современный Спринг
очень любит заниматься кодогенерацией,
наследовать ваши бедные классы
и переопределять методы.
По умолчанию в Котлине все классы и методы
закрыты от переопределения.
Спрингу нужно явно открывать.
Есть,
конечно,
плагин к Gradle,
который сделает это неявно.
Но лишней магии лучше избегать.
Люблю Котлин за его лаконичность. Вот сколько строк на Яве займёт вот это?
val intVal = (doc.getValue("number") as? Number)?.toInt() ?: 0
Тут из некоего документа извлекается некое значение (Object, будь он неладен), которое может быть числом, целым или длинным. Нужно получить именно Int. А если не удалось, пусть будет нуль.