П’ять причин, з яких вам варто використати Kotlin
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу поділитися з вами досвідом роботи з такою мовою програмування як Kotlin. Він уже став де-факто стандартом розробки мобільних додатків, аде для бекенд-проєктів його частка не така висока, але постійно зростає в останні роки. Особливість цієї мови в тому, що якщо ви, наприклад, пишете на Java, то можете використовувати її разом з Kotlin на одному проєкті. А це дозволяє еволюційно розвивати та покращувати ваш додаток.
У цій статті я хочу розповісти про ті фічі Kotlin, які дозволять вам писати більш простий, безпечний і читабельний код, при цьому пояснюючи, як це код буде компілюватися і працювати всередині JVM. Ну і за останні роки у нас накопичилося достатньо досвіду роботи з таким підходом, і ми розглядаємо ці технології на тренінгах з Kotlin. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче дізнатися щось нове про ООП мови та підходи.
Kotlin та Java. Історія взаємин
Kotlin з’явився у 2011 році як продукт розробки компанії JetBrains. Це були темні часи для Java, коли тільки вийшла Java 7 з мінімальними змінами, а вся команда інженерів більше займалася переходом під крило Oracle, а не власне розробкою. Дві поспіль версії Java без серйозних покращень (6 і 7), а також довгі ітерації розробки
Додатки на Kotlin компілювалися в Java байт-код і запускалися на JVM, тому його не можна назвати «вбивцею Java», а скоріше «натуральною еволюцією Java». Багато фіч, які розробники довгі роки просили включити в Java, з’явилися ж у перших версіях Kotlin. Щоправда, «вбивцею Java» якоюсь мірою він став. У 2017 році (після виходу 1.0) компанія Google додала його підтримку для розробки під Android, а в 2019 році офіційно віддала йому перевагу перед Java.
Сьогодні Kotlin переріс тісні штанці JVM, ви можете компілювати його як у JavaScript (для фронт-енд додатків) або Kotlin Native (нативний код, що не вимагає віртуальної машини). Але головне для розробників Java — ви можете поєднувати Java/Kotlin код в одному проєкті, викликаючи з Java коду Kotlin функції, і навпаки.
Чи варто зараз переходити з Java на Kotlin? Ситуація значно змінилася з 2011 року і не можна говорити про помітну перевагу Kotlin або технологічне відставання Java. По-перше, у рамках проєктів Panama, Loom, Valhalla та багатьох інших Java поповнилася тими фічами, якими вже були в інших мовах програмування. По-друге, інженери Oracle перейшли на
Тим не менш, реалізовані фічі — це надбудови (іноді синтаксичний цукор), а не ідеологічні зміни. Що робить Kotlin таким привабливим для розробників? Насамперед зміни в парадигмі програмування (в порівнянні з Java), включаючи:
- Робота з null-значеннями.
- Immutability.
- Функціональне програмування.
І Java поки що відстає в цьому плані. Спробуємо розповісти про найбільш затребувані фічі, які роблять ваш код простішим, зрозумілішим і безпечнішим. Але перед тим, задля пристойності, перерахуємо ті фічі Java, які з’явилися з 2011 року і дозволили уповільнити відставання від Kotlin, де ця функціональність підтримувалася спочатку:
- Ключове слово var — з’явилося у Java 10 для локальних змінних, а потім у Java 11 для лямбда-виразів.
- Switch expressions — з’явилися в Java 14, в Kotlin відомі як when expressions.
- Текстові блоки — з’явилися у Java 15, у Kotlin відомі як multiline strings.
- Pattern matching for instanceof — з’явився у Java 16.
- Записи — з’явилися у Java 16, у Kotlin називаються data classes.
- Запечатані класи — з’явилися у Java 16.
- String templates — з’являться в Java 21.
- Record patterns — з’являться у Java 21.
- Безіменні класи та main методи — з’являться у Java 21.
- Pattern matching for switch — з’явиться в Java 21.
Список досить значний, але слід зазначити, що багато фіч були реалізовані на більш простому рівні, ніж у Kotlin, знову ж таки через обмеження Java. У будь-якому випадку їх не буде у нашому порівняльному огляді Kotlin, тому що ви вже можете використовувати їх у Java. Разом з тим у Java з’явилася велика кількість інфраструктурних змін (наприклад, нові збирачі сміття), які можете використовувати в Kotlin додатках.
Отже, 5 фіч Kotlin, які принаймні переконають вас розпочати його вивчення.
1. Компактність
Коли ви переходите з Java на Kotlin, ви починаєте банально писати менше коду, а значить і читати менше коду, економити свій час. Наприклад, коли ви тільки почнете знайомитися з Kotlin, вас може здивувати відсутність крапки коми в кінці рядка, але це дрібниця. Ви можете писати класи без тіла:
class Person
Можна писати однорядкові функції (без вказівки return):
fun add(x: Int, y: Int): Int = x + y
І можна навіть не вказувати тип, що повертається, якщо компілятор може його обчислити (type inference):
fun add(x: Int, y: Int) = x + y
Не потрібно вказувати new для створення об’єкта:
val amount = Amount(10.0)
До елементів колекцій можна звертатися як до масивів за допомогою квадратних дужок:
val numbers = listOf(1, 2, 3)
val elem = numbers[0]
Зникли звичні ключові слова extends і implements, їх замінила двокрапка:
interface Logger {
fun log(message: String)
}
class ConsoleLogger: Logger {
Простіше описувати арифметичні операції за допомогою інтервалів:
if (i in 10..20) {
Постійний головний біль Java — неможливість використовувати два класи з однаковим ім’ям без явної вказівки пакета одного з них:
public static Date convert(Date date) {
return new java.sql.Date(date.getTime());
}
Але в Kotlin можна перевизначити назву типу при імпорті:
import java.sql.Date as SqlDate
І позбутися надокучливого пакету:
fun convert(date: Date): Date {
return SqlDate(date.time)
}
Часто в класах разом з оголошенням полів слідує їх ініціалізація в конструкторі. У Java для того, щоб прибрати цей boilerplate код, використовують анотації Lombok:
@RequiredArgsConstructor
class DefaultExecutor<T> implements Executor<T> {
private final Executor<T> executor;
Але все одно код виходить трохи громіздким. У Kotlin запис буде ще коротшим за допомогою первинного конструктора:
class DefaultExecutor<T>(private val executor: Executor<T>)
Цей код, до речі, генерує як властивості, так і геттера/сеттера для класу.
І мій улюблений приклад — те, як у Kotlin реалізовано делегування. Дуже часто потрібно в класі реалізувати інтерфейс і разом з тим зберігати поле цього класу для того, щоб передати йому керування. Якщо методів в інтерфейсі багато, то виходить простирадло коду, в якому не дуже багато сенсу. У Kotlin є ключове слово by, яке реалізує все перераховане:
class DefaultExecutor<T>(private val executor: Executor<T>)
: Executor<T> by executor
2. Безпечне програмування
Kotlin повинен був вирішити одну з головних проблем Java (і багатьох інших мов) — обробку null-значень. Хоча в Java 8 був доданий тип Optional, це повністю не вирішило цю проблему:
- Optional рекомендується використовувати тільки для значень методів, що повертаються.
- Створення великої кількості об’єктів Optional надає підвищене навантаження на JVM і збільшує витрати пам’яті.
- Сама змінна Optional може бути null, а отже, викликати NullPointerExcep-tion.
В Kotlin можна використовувати тип Optional, але наврядчи хтось це робитиме, хіба що якщо працювати з наявним Java-кодом. Вся справа в тому, що в Kotlin при оголошенні вказується, що змінна може бути nullable за допомогою додавання запитання після типу:
val elem: String? = null
Відповідно, якщо ви захочете далі працювати з такою змінною в Java-стилі, це викличе помилку компілятора:
val length = elem.length
Тут потрібно або вказати знак питання після elem, щоб компілятор пропустив весь наступний код у разі null:
val length = elem?.length
Можна вказати оператор Elvis (за аналогією з Optional.orElse):
val length = elem?.length ?: 0
Або вказати !!, підкреслюючи, що значення ніколи не може бути null (аналог Option-al.orElseThrow в Java)
val length = elem!!.length
Таким чином, nullability змінної є частиною її типу і відстежується компілятором.
Ще один хороший приклад — відкладена реалізація. Якщо Java потрібно реалізувати інтерфейс з великою кількістю методів, то часто можна зустріти такий код:
@Override
public void execute(Runnable runnable) {
// TODO implement
}
Потім про це забувають, а компілятор не може нагадати про цей технічний обов’язок. У Kotlin є спеціальний метод TODO, який викидає NotImplementedError, а значення такої функції, що повертається, — Nothing, що дозволяє відразу зрозуміти, що з нею не все в порядку:
class ConsoleLogger: Logger {
override fun log(message: String): Nothing {
TODO("Add implementation")
}
}
3. Функціональне програмування
У Java 8 була зроблена серйозна робота для того, щоб розробники почали застосовувати функціональне програмування. По-перше, з’явились лямбда-вирази, які можуть замінити анонімні класи. З’явилися посилання на методи і, нарешті, Streams API, де обробка даних здійснюється шляхом виклику ланцюжка проміжних та термінальних функцій.
Але що ви не можете зробити в Java, так це написати просто функцію або передати функцію кудись. Навіть у випадку лямбд ви можете використовувати її тільки для функціонального інтерфейсу. У Kotlin ви можете просто написати функцію:
fun minus(x:Int, y: Int) = x - y
Зрозуміло, у Java байт-коді це буде обернуто до класу, але всі ці деталі ховаються Kotlin компілятором. Ви можете передати функцію як аргумент, і тут вже немає потреби використовувати функціональний інтерфейс.:
fun log(msg: String) = println(msg)
fun handle(msg: String, log: (msg: String) -> String) {
log(msg);
}
Як же вся ця магія працюватиме у JVM? У Kotlin SDK є інтерфейси з Function1 до Function22 (за кількістю аргументів), відповідно в байт-коді другий аргумент буде перетворено на об’єкт типу Function1:
public interface Function1<in P1, out R> : Function<R> {
public operator fun invoke(p1: P1): R
}
Але це ще не все. Завдяки дефолтним значенням аргументів ви можете значно скоротити кількість методів. Якщо навіть взяти JDK і клас String, то можна зустріти два схожі методи:
public byte[] getBytes(Charset charset) {
public byte[] getBytes() {
Насправді вони відрізняються лише одним аргументом — кодування (charset). Якщо ви його не передали, то використовується дефолтний. У Kotlin можна обійтися однією функцією:
fun getBytes(charset: Charset = Charset.defaultCharset()): Array<Byte> {
Ще одна корисна фіча — іменовані аргументи у функціях. Коли у вас всі аргументи одного типу, дуже зручно вказати їх назви при виклику:
fun regexpMatches(msg: String, pattern: String): String {
щоб не заплутатися в порядку:
val matches = regexpMatches(msg = "ABCD", pattern = "[A-C]")
Особливо зручно це у data classes. Уявімо наступний клас:
data class Product(val name: String, val price: Double,
val description: String, val notes: String,
val weight: Double)
Серед властивостей цього є як і обов’язкові, так і опціональні, значення яких який не завжди відомі. У Java доводиться перевантажувати конструктори, щоб ініціалізувати опціональні аргументи, в Kotlin це завдання вирішується простіше за допомогою дефолтних значень.:
data class Product(val name: String, val price: Double,
val description: String = "", val notes: String = "",
val weight: Double = 0.0)
Уявімо, що ми хочемо клонувати об’єкт цього класу, але при цьому трохи його змінити. У Java метод clone() не підтримує такий API, доведеться реалізовувати патерн Builder, ускладнюючи код, тоді як у Kotlin це виглядає просто і витончено завдяки іменованим параметрам:
val product = Product("PC", 1000.0)
val copy = product.copy(price = 1200.0)
4. Immutability
ООП стоїть на трьох китах: інкапсуляція, поліморфізм, успадкування. Іноді до списку додають абстракцію. Інкапсуляція є дуже важливою для безпеки наших даних. Невипадково рекомендують поля класів робити за замовчанням private (як і методи). Проте у Java локальні змінні та аргументи методів не захищені від повторного присвоєння.
У Kotlin все набагато суворіше. По-перше, аргументи функцій насправді є final і захищені від повторного присвоювання. По-друге, коли ви оголошуєте локальну змінну або поле класу, ви повинні вказати, чи вона змінюється чи ні (за допомогою ключових слів var/val). Таким чином, розробник не може «забути» про це, як він може забути поставити final в Java.
Принцип «composition over inheritance» каже нам, що краще використовувати композицію класів, а не об’єднувати в ієрархії. У Java класи за замовчуванням не захищені від успадкування, якщо не поставити модифікатор final.
У Kotlin же ситуація зворотна. Будь-який клас є за замовчуванням final, і потрібно спеціально додати модифікатор open для його відкриття:
open class Employee {
fun getCategory() = "Employee"
}
class Developer() : Employee() {
}
Тим не менш, навіть у цьому випадку всі функції в класі Employee є final і для їх перевизначення потрібно також додати модифікатор open:
open class Employee {
open fun getCategory() = "Employee"
}
А в класі-спадкоємці ще й обов’язкове ключове слово override:
class Developer() : Employee() {
override fun getCategory() = "Developer"
}
Це дуже важливо, тому що в Java анотація @Override є опціональною, а її відсутність може призвести до проблем.
Одна з найбільших проблем Java — відсутість API для immutable колекцій, хоча самі такі колекції існують:
List<String> numbers = Arrays.asList("1", "2");
List<String> numbers = List.of("1", "2");
На жаль, але тут в останні роки та десятиліття немає покращень. І якщо ви передаєте в інший метод змінні List, Set або Map, то в ньому ніяк не можна визначити, чи ця колекція змінюється, хіба що спробувати щось змінити (отримавши виняток).
У Kotlin було дуже грамотно реалізовано ієрархію колекцій. У ній List, Collection, Set — це типи для незмінних колекцій, які спадкоємці — MutableList, MutableCollection, MutableSet — типи змінюваних. Це відразу дозволило вирішити багато фундаментальних проблем Java.
При переході з Java деякі речі можуть бути незвичними. Наприклад, в Java для сортування списків є метод sort(). Для MutableList він і залишився, а ось для immutable колекцій його не можна застосувати, зате з’явився метод sorted (), який повертає новий відсортований список. Тобто для однієї операції є два методи, які різняться за типом (змінністю) колекції.
5. Extensions
Одне з найпоширеніших завдань у програмуванні — це розширення функціональності чинного юніта. Його можна вирішити зміною або спадкуванням такого юніту. Але що якщо він (клас) final, а вихідники нам недоступні? Доводиться створювати нові утилітні класи, які беруть він це завдання. У Kotlin це можна зробити набагато простіше. Наприклад, потрібно додати метод, який повертає рядок випадкових символів:
fun String.random(size: Int): String {
Ми декларуємо та використовуємо цю функцію так, ніби вона поміщена до класу String:
val randomText = "".random(10)
Зрозуміло, Kotlin компілятор не змінює байт-код JDK, він просто створює нову функцію, яка приймає першим аргументом той об’єкт, який ми використовуємо, а потім усі аргументи виклику:
fun random(string: String, size: Int) {
Так само і з інтерфейсами:
interface Logger {
fun log(message: String)
}
Ви можете додати до нього будь-яку функцію, але вона має бути дефолтною:
fun Logger.log(message: String, level: Level) = println("$level.$message")
Абстрактні методи додавати не можна:
fun Logger.log(message: String, level: Level)
Якщо ви додаєте метод для класу і в ньому хочете використовувати його приватні властивості:
class DatabaseLogger(private val dbName: String, private val server: String) : Logger {
fun DatabaseLogger.log(message: String, level: Level) {
println(dbName)
}
Такий код не компілюватиметься, оскільки компілятор спробує звернутися до приватної властивості ззовні його об’єкта (що заборонено)
fun log(logger: DatabaseLogger, level: Level) {
println(logger.dbName)
}
Тут потрібно або змінювати модифікатор видимості, або створювати окремі гетери. Ну і приклад, який комусь може здатися вищим пілотажем. Уявімо, що у вас є перелік:
enum class Season {
WINTER, SPRING, SUMMER, AUTUMN
}
Вам потрібен універсальний метод, який зможе поєднати всі його значення. Але потім вам здалося гарною ідеєю додати метод не в Season, а створити його для всіх класів-перерахувань. Немає нічого простішого. Ви додаєте метод join у тип KClass (аналог Class у Java), при цьому вказуючи, що метод буде доступний тільки для тих класів, які успадковують клас Enum (тобто перерахувань):
fun <T : Enum<*>> KClass<T>.join(): String {
return this.java
.enumConstants.joinToString { e -> e.name }
}
І потім використовуєте його у будь-якому перерахуванні:
val text = Season::class.join()
Висновки
Як ви бачите, функціональність Kotlin багатша за Java, адже ми не розглянули ті його фічі, які спочатку були перевагою Kotlin, але потім були перенесені в Java в останні 12 років. І ще одна перевага, яка не виділена окремо, але постійно згадувалася в цій статті — можливість використовувати Java код у проєктах Kotlin і навпаки. Це дозволяє гармонійно поєднувати Java та Kotlin код в одному додатку без будь-яких серйозних колізій.
147 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів