Как стать автором
Поиск
Написать публикацию
Обновить
80.58
Райффайзен Банк
Развеиваем мифы об IT в банках

Java vs Kotlin: у кого больше преимуществ в 2025 году

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров1.6K

Я уже несколько лет пишу бэкенд на Kotlin. До этого писал на Java, но переход совершил практически одномоментно, потому что тогда мне  казалось, что Kotlin закрывает множество минусов Java и привносит более простой и лаконичный синтаксис.  Но при этом я стараюсь следить за тем, как развивается Java.

В 2018 я впервые познакомился с Kotlin, и он мне практически сразу понравился. Да и как можно не полюбить язык, названный в честь тотемного животного всех программистов? Шучу, на самом деле в честь острова.

В JetBrains поступили очень мудро, сделав Kotlin полностью совместимым c Java на уровне байт-кода. Это позволяет запускать Kotlin на той же Java-машине и использовать все библиотеки, которые уже были разработаны на Java. Чем-то мне это напомнило подход Бьёрна Страуструпа, который в свое время сделал C++ расширением языка C. Да ещё вспомним, что именно JetBrains сделала лучшую интегрированную среду разработки для Java, поэтому IDEA сразу получила поддержку Kotlin. А это ещё одно конкурентное преимущество.

Давайте проведем небольшую ретроспективу и сравним эти два языка между собой по состоянию на 2025 год. Я сгруппировал основные фичи Kotlin'a по трём группам: преимущества, нейтральные фичи и недостатки. Это далеко не полный список, и это мое субъективное деление. Поэтому предлагайте в комментах свой вариант.

Преимущества Kotlin перед Java

Здесь собраны фичи, которые на текущий момент остаются конкурентными преимуществами Kotlin'а по сравнению с Java.

Отказ от точки с запятой

Возможно, когда-то точка с запятой в конце инструкции позволяла писать компактные исходники в одну строку, но с появлением IDE и функции автоформатирования, это из преимущества превратилось в недостаток. Вряд ли кому-то сейчас придёт в голову писать на Java такой код:

public static void main(String[] args){String text="Hello!";System.out.println(text);}

Поэтому более строгие правила форматирования как плата за улучшение читаемости сейчас выглядят оправданно.

fun main() {

    println("Hello!")

}

Null-safety

Первое, что приходит на ум, когда мы сравниваем Kotlin — это контроль за nullability типов и элвис-оператор.

Сравним метод на Java, печатающий в консоли строку текста:

public void displayNullable(String message) {

    if (message != null) {

        System.out.println(message);

    } else {

        System.out.println("Нет текста");

    }

}

С эквивалентом на Kotlin:

fun displayNullable(param: String?) {

    println(param ?: "Нет текста")

}

Помимо того, что код в целом выглядит компактнее, компилятор Kotlin-а будет контролировать нуллабельность типов на всём пути использования этого объекта. Причем это именно контроль за контекстом, так как на уровне Java-машины никаких специальных not null типов нет. Под капотом это будет всё тот же nullable String.

В Java, конечно, можно использовать Optional, но это не синтаксис языка, а часть стандартной библиотеки. К тому же активное использование типизированного Optional загромождает код.

public void displayNullable(Optional<String> message) {

    System.out.println(message.orElse("Нет текста"));

}

Подстановка переменных в строку

В настоящий момент в Java есть несколько стандартных способов подстановки значений переменных в строку, но у каждого из них свои недостатки.

public static void main(String[] args) {

    String name = "Вася";

    int age = 25;

    System.out.println("Меня зовут " + name + ", мне " + age + " лет.");

    System.out.printf("Меня зовут %s, мне %s лет.%n", name, age); // аналогично String.format()

}

Конкатенация через плюс заметно «раздувает» строку кода и усложняет чтение, а использование printf() или String.format() отделяет переменные от конкретного места их использования, что тоже ухудшает чтение. Кроме того, во втором случае компилятор перестает контролировать соответствие количества плейсхолдеров в шаблоне количеству переданных переменных, что может обнаружиться только во время выполнения.

В Kotlin есть механизм интерполяции строк, когда мы можем подставлять переменную непосредственно в строку с помощью знака доллара ($). Если над переменной при этом нужно выполнить еще какое-то действие (например, прибавить 1), вся конструкция заключается в фигурные скобки. В них можно производить более сложные вычисления и даже вызывать методы.

fun main() {

    val name = "Петя"

    val age = 25

    println("Меня зовут $name, мне $age лет.")

    println("Скоро мне исполнится ${age + 1} лет.")

}

Подобный синтаксис повышает читаемость кода и используется во многих скриптовых языках, а потому в целом выглядит привычным для разработчиков.

В Java версии 21 и 22 шаблоны строк были в статусе preview с другим, более гибким синтаксисом. Однако, собрав обратную связь и взвесив все плюсы и минусы, разработчики удалили эту фичу из Java 23. То есть на текущий момент, если не считать сторонних библиотек, нам доступны все те же инструменты, которые появились еще во времена Java 1.5.

Разделение всех коллекций на два типа

В Java любой базовый тип коллекций (в широком смысле, а не только Collection), будь то List, Set или Map, обеспечивает и чтение, и изменение элементов. Kotlin под капотом использует всё те же коллекции из Java, но предложил изящное решение: отделить методы изменения элементов от методов чтения путем расширения интерфейса.

Поэтому в Kotlin интерфейс List позволяет только читать элементы, а MutableList расширяет его и добавляет методы изменения. Такие же пары есть для множества (Set и MutableSet) и мапы (Map и MutableMap). Поэтому теперь, если метод принимает в качестве параметра read-only List, я могу быть уверен, что внутри он ее точно не изменит.

fun main() {

    val digits = mutableListOf(1, 2, 3) // MutableList<Int>

    processDigits(digits)

}

// метод своей сигнатурой декларирует,

// что не меняет состояние исходного списка

fun processDigits(digits: List<Int>) {

}

Уход от extends и implements

Java в свое время сделала правильно, что ушла от множественного наследования в стиле C++. В качестве альтернативы она предложила явное разделение на интерфейс и класс на уровне ключевых слов. Kotlin в этом смысле полностью копирует Java.

Однако когда в Java мы хотим унаследовать базовый класс или реализовать интерфейс, необходимо произвести некоторую когнитивную нагрузку. Если класс наследуется от класса, то он его «расширяет» (extends). Если класс реализует интерфейс, то он его имплементирует (implements). Но при этом если мы наследуем интерфейс от интерфейса, то также его «расширяем»  (extends).

public class MyThread extends Thread {

    // ...

}

public class MyRunnable implements Runnable {

    // ...

}

public interface MyRunnable extends Runnable {

    // ...

}

К чему вся эта игра слов? Если вспомнить, что интерфейсы могут иметь реализацию методов по умолчанию, то по сути это превращает их в подобие обычных классов. И поскольку мы можем наследоваться от любого количества интерфейсов, то получаем множественное наследование. А если так, то я во всех трёх случаях «наследую» от нескольких источников одновременно. Немного чересчур, не правд ли?

Kotlin предлагает не заморачиваться и всегда обозначать такое «наследование» через двоеточие.

class MyThread: Thread() {

    // ...

}

class MyRunnable: Runnable {

    // ...

}

interface MyRunnable: Runnable {

    // ...

}

Операторы

В Kotlin меня радует, что я могу определить привычные операторы для собственных классов. Это повышает читаемость кода.

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

class Matrix {

    operator fun plus(right: Matrix): Matrix {

        // тут логика по складыванию двух матриц

        return resultMatrix

    }

}

Теперь при работе с экземплярами этого класса я могу использовать стандартные операторы:

fun main() {

    val a = Matrix()

    val b = Matrix()

    val c = a + b

}

В Java ничего подобного до сих пор нет. Хотя даже в C и C++ есть возможность определять пользовательские операторы.

Кстати, благодаря операторам в Kotlin можно сравнивать две строки по значению через двойное равно, в отличие от Java, где для этих целей нужно вызывать метод equals().

fun displayEquals(a: String, b: String) {

    // здесь вызывается equals() - сравниваем по значению

    println(if (a == b) "одинаковые" else "разные")

}

Также можно сравнивать два BigDecimal, используя обычные неравенства, как с примитивными типами.

fun isLeftGreaterThanRight(left: BigDecimal, right: BigDecimal): Boolean {

    return left > right // вызывается compareTo()

}

Все исключения — unchecked

В Java все исключения делятся на checked (проверяемые) и unchecked (непроверяемые). Checked наследуются от Exception, а unchecked от RuntimeException. Все checked-исключения, которые могут возникнуть внутри метода, должны быть перечислены в его сигнатуре. Казалось бы, видеть список возможных ошибок — это удобно.

private String readFile(String filename) throws FileNotFoundException, EOFException {

    // читаем файл

}

Однако это очень быстро приводит к загромождению сигнатуры метода, и разработчики часто заменяют несколько исключений на их общего предка в иерархии наследования.

private String readFile(String filename) throws IOException {

    // бизнес-логика не меняется

}

В сигнатуре мы теперь объявляем, что метод может возвращать вообще любые исключения, связанные с вводом-выводом (IOException). Однако, учитывая, что саму логику метода мы не меняем, это не совсем верно. На практике checked-исключения скорее мешают, чем помогают. Зачем тогда тратить время на постоянное уточнение списка исключений в сигнатуре метода?

Поэтому в Kotlin приняли волевое решение и вообще отказались от checked exceptions. Все исключения являются unchecked. Тот же метод на Kotlin выглядит так:

fun readFile(filename: String): String {

    // чтение файла

}

Методы расширения

Одна из удобных фичей Kotlin — это написание методов расширения, позволяющих легко дополнять набор имеющегося API. Это особенно полезно, когда у нас нет возможности физически добавить метод в исходный класс. Такой метод может быть определен в любом месте вашего приложения.

Например, мы можем в BigDecimal добавить метод, отображающий десятичную дробь в виде процентов.

fun main() {

    val a = BigDecimal("0.10")

    println(a.toPercentString()) // "10 %"

}

fun BigDecimal.toPercentString(): String {

    // здесь this - ссылка на объект BigDecimal

    return "${this.movePointRight(2).toInt()} %"

}

Однако эту фичу нужно использовать с осторожностью. Злоупотребления методами расширения, особенно в общих библиотечных компонентах, приводят к замусориванию скоупа во всех проектах, где будет использоваться ваша библиотека.

Нейтральные фичи

Теперь рассмотрим фичи, которые не дают заметного преимущества перед Java. Либо аналогичные возможности уже появились в Java, либо эти фичи изначально были спорными.

data и record

Еще во время учебы в вузе, лет 15 назад, я успел познакомиться с C#, и уже тогда у него были «свойства», к которым автоматически генерились get- и set-методы. Потом мне пришлось перейти на Java, и каково же было мое разочарование, когда я понял, что в Java ничего такого не было, и все приходилось писать вручную (благо IntelliJ IDEA позволяла делать это в два клика).

Например, вот так раньше выглядели все классы для хранения данных:

public class Person {

    private final String name;

    private final int age;

    public Person(String name, int age) {

        this.name = name;

        this.age = age;

    }

    public String getName() {

        return name;

    }

    public int getAge() {

        return age;

    }

    

    // а также equals(), hashCode(), toString()...

}

Характерной чертой таких классов является их иммутабельность, то есть значения устанавливаются только на этапе создания. Это снижает количество возможных багов и даже позволяет использовать такие сложные объекты в качестве ключей в мапе, например.

В Kotlin же изначально были предусмотрены специальные data class, в которых достаточно определить лишь сами поля, а все геттеры, equals(), hashCode() и toString() — компилятор создавал автоматически.

data class Person(

    val name: String,

    val age: Int,

)

Согласитесь, ничего лишнего?

В Java также активно используется Lombok, который решает эти проблемы.

@Getter

@AllArgsConstructor

public class Person {

    private final String name;

    private final int age;

}

Но Lombok все же не часть языка, поэтому оставим его за скобками.

И вот, видимо, глядя на Kotlin, разработчики Java решили, что data-классы — это хорошо и сделали практически то же самое, только назвали record.

public record Person(

        String name,

        int age

) {

}

На мой взгляд, это одна из киллер-фичей Java 16. Поэтому тут с Kotlin полный паритет.

Тип переменной после двоеточия

Сначала я считал, что писать тип переменной после имени разумно. Как известно, одна из самых больших проблем в программировании — придумывание имен переменных. Поэтому мне казалось логичным, что сначала нужно придумать имя. Поэтому мне казалось логичным, что сначала нужно придумать имя. А когда попытаюсь описать в имени суть переменной — выбор типа уже станет механической работой.

В теории выглядит логично, но на практике не очень удобно, каждый раз приходится писать двоеточие, когда мы хотим указать тип. Учитывая, что компилятор умеет выводить типы автоматически, чаще всего тип мы не указываем. Но если я сохраняю в переменной результат работы метода и при этом хочу типизировать переменную более базовым типом, мне нужно его указать явно, вставив двоеточие и тип перед присваиванием.

fun getResult(): ArrayList<String> {

    return arrayListOf()

}

fun main() {

    // list1 имеет тип ArrayList<String>

    val list1 = getResult()

    // list2 имеет тип List<String>

    val list2: List<String> = getResult()

}

В Java тоже добавили автоматический вывод типов (правда, через var). Но там мы бы просто заменили var на явный тип в начале строки и не добавляли двоеточие.

Уж если Kotlin разрабатывался как совместимый с Java, не стоило менять местами имя и тип переменной. В тех проектах, где пишут одновременно на Java и Kotlin — это вообще сплошная путаница, так как приходится постоянно перестраиваться с одной схемы объявления переменных на другую.

Недостатки Kotlin

Чтобы не выглядеть абсолютным фанатом Kotlin, приведу примеры фичей, которые я считаю скорее его недостатками.

Тип возвращаемого значения

Я склоняюсь к мысли, что излишняя вариативность синтаксиса — это скорее минус, чем плюс. Чем больше в языке вариантов сделать одно и то же, тем больше усилий нужно потратить на то, чтобы выработать в команде разработки единый стиль.

В Kotlin тип возвращаемого значения указывается в конце сигнатуры метода. Но его можно и не указывать явно. Тогда компилятор выведет тип на основе возвращаемого значения. Также можно использовать return, а можно не использовать.

fun getMagicNumber() = 42

// то же самое

fun getMagicNumber(): Int = 42

// то же самое

fun getMagicNumber(): Int {

    return 42

}

В итоге у нас есть 3 абсолютно эквивалентных записи одного и того же метода. Вроде бы, с одной стороны, удобно писать маленькие функции. Однако по мере усложнения логики мы, скорее всего, придем от первого варианта к третьему. Поэтому лучше сразу договориться в команде, чтобы везде использовать только третий вариант.

Тернарный оператор

В Java есть тернарный оператор, представляющий собой однострочный вариант конструкции if-else.

private static void displayOddEven(int n) {

    System.out.println(n % 2 == 0 ? "чёт" : "нечет");

}

В Kotlin его нет и не предвидится. Отчасти потому, что есть элвис-оператор. Но иногда тернарного оператора все-таки не хватает. Приходится использовать обычный if-else, записывая его в одну строку, что лично мне не нравится.

fun displayOddEven(n: Int) {

    println(if (n % 2 == 0) "чёт" else "нечет")

}

Мы рассмотрели основные недостатки Kotlin, и осталось понять, насколько они критичны в реальной практике и как соотносятся с преимуществами языка.

Заключение

Несмотря на то что одни фичи Kotlin уже перестали быть конкурентными, а другие являются делом вкуса, большая их часть до сих пор актуальна. А из-за необходимости сохранения обратной совместимости они вряд ли появятся в Java в обозримом будущем.

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

Теги:
Хабы:
+4
Комментарии24

Публикации

Информация

Сайт
www.raiffeisen.ru
Дата регистрации
Дата основания
1996
Численность
5 001–10 000 человек
Местоположение
Россия