Скорее всего, из разработчиков, пользующихся Java, и в особенности Android-разработчиков многие уже знают про Kotlin. Если нет, то никогда не поздно узнать. Особенно если Java не устраивает вас чем-то как язык — что наверняка так — или если вы владеете Scala, но и этот язык вам не подходит, что тоже не исключено.

Если кратко, то Kotlin — это статически типизированный язык, ориентирующийся на JVM, Android (компилируется в байт-код Java) и веб (компилируется в JavaScript). JetBrains, разработчик языка, ставили своей целью лаконичный и понятный синтаксис, быструю компиляцию кода и типобезопасность. Язык пока находится в предрелизном состоянии, но всё стремительно движется к релизу.

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

Ещё одной целью разработчиков языка была возможность его гибкого использования, в том числе для создания библиотек, внешне похожих на DSL, и собственных конструкций (хороший пример типобезопасного builder'а для HTML; статья про реализацию yield). У Kotlin есть несколько фич, которые позволят решать эти задачи эффективно и красиво. Давайте с ними познакомимся.

Расширения (Extensions)


В Kotlin есть возможность дополнять функционал произвольного класса, не наследуясь от него, функциями (и свойствами) расширения. Такая же возможность есть, например, в C#. Стоит отметить, что поведение функций расширения отличается от member functions: вызовы функций расширения разрешаются статически, по объявленному типу, а не виртуально.

Пример:

fun String.words(): List<String> {
    return this.split("\\W".toRegex())
}

//сокращённая запись функции, состоящей только из return statement
fun <T> List<T>.rotate(n: Int): List<T> = drop(n) + take(n)

val str = "a quick brown fox jumps over the lazy dog"
val words = s.words() 

val yoda = words.rotate(5)
println(yoda.joinToString(" ") // over the lazy dog a quick brown fox jumps

toRegex(), drop(n), take(n) и joinToString(" ") в примере — это тоже функции расширения.

Альтернативный синтаксис для вызова функций


1. Функцию экземпляра или функцию расширения, имеющую только один аргумент, можно вызывать в инфиксной форме:

val squares = (1..100) map { i -> i * i } 
//эквивалентно (1..100).map({i -> i * i })

val multOfThree = squares filter { it % 3 == 0 } 
//it можно использовать в лямбда-выражении с одним аргументом для его обозначения

2. Если последний аргумент функции имеет функциональный тип, то при вызове функции можно соответствующее лямбда-выражение писать за круглыми скобками, просто в фигурных:

val list = arrayListOf(1, 2, 3) 
with(list) {
    add(4)
    add(5)
    add(6)
    removeIf { it % 2 == 0 }
}
//эквивалентно with(list, { ... })

Встраиваемые (inline) функции


В Kotlin есть аннотация @inline, которой можно пометить функцию. После этого при компиляции код этой функции, и её функциональных аргументов будет подставляться в места вызова. С одной стороны, это даёт некоторые новые возможности (non-local return, reified generics), с другой — есть ограничение, что функциональные аргументы inline-функции в её теле можно только вызывать или передавать в другие inline-функции. Основной же действие @inline — на производительность: происходит меньше вызовов функций и, что важно, не создаются анонимные классы и их объекты для каждого лямбда-выражения.

Большая часть функций расширения из стандартной библиотеки вроде тех же map и filter.

Небольшой пример:

@inline fun <T> Iterable<T>.withEach(action: T.() -> Unit) = forEach { it.action() }

//в теле метода:
var i = 0
val lists = (0..5) map { ArrayList<Int>() }
lists.withEach { add(++i) }

Несмотря на то, что этот код пестрит лямбда-выражениями, ни одного анонимного класса для них создано не будет, и i даже не попадёт в closure. Просто праздник!

Попробуем?


Посмотрим, что мы можем сделать со всем этим арсеналом — предположим, что мы хотим сделать такую довольно бесполезную конструкцию:

val a = someInt()
val b = someList()

val c = (a % b.size()) butIf (it < 0) { it + b.size() }
//аналогично (a % b.size()) let { if (it < 0) it + b.size() else it }

Прямо так, к сожалению, не получится, но постараемся сделать что-то похожее.

Первая попытка: функция с двумя функциональными аргументами


fun <T> T.butIf(condition: (T) -> Boolean, thenFunction: (T) -> T): T {
    if (condition(this)) {
        return thenFunction(this)
    }
    return this
}

Вот так её можно использовать:

val c = (a % b.size()).butIf({it < 0}) {it + b.size()}

Если сюда добавить inline, должно получиться достаточно эффективно. Позже посмотрим, насколько, а пока попробуем добиться более красивого синтаксиса для этой конструкции.

Вторая попытка: красивый синтаксис


abstract class _ButIfPrefix<T>
constructor(var originalValue: T) {
    abstract fun then(thenFunction: (T) -> T): T

    object trueBranch : _ButIfPrefix<Any?>(null) {
        override final inline fun then(thenFunction: (Any?) -> Any?) = thenFunction(originalValue)
    }

    object falseBranch : _ButIfPrefix<Any?>(null) {
        override final inline fun then(thenFunction: (Any?) -> Any?) = originalValue
    }
}

fun <T> T.butIf(condition: (T) -> Boolean): _ButIfPrefix<T> {
    val result = (if (condition(this))
        _ButIfPrefix.trueBranch else
        _ButIfPrefix.falseBranch) as _ButIfPrefix<T>
    result.originalValue = this
    return result
}

Этот вариант не рассчитан на многопоточность! Для использования его в нескольких потоках нужно будет завернуть экземпляры в ThreadLocal, что ещё немного ухудшит производительность.

Здесь будет цепочка из двух инфиксных вызовов, первый — функция расширения на самом объекте, второй — функция экземпляра _ButIfPrefix. Пример использования:

val c = (a % b.size()) butIf { it < 0 } then { it + b.size() }

Третья попытка: каррирование


Попробуем так:

fun <T> T.butIf0(condition: (T) -> Boolean): ((T) -> T) -> T {
    return inner@ { thenFunction ->
        return@inner if (condition(this)) thenFunction(this) else this
    }
}

Использование:

val c = (a % b.size()).butIf { it < 0 } ({ it + b.size() })

По сравнению с первой попыткой изменилось расположение скобок в вызове. :)
Учитывая inline, мы можем ожидать, что работать этот вариант будет так же, как первый.
Это можно проверить, взглянув на байт-код: у IntelliJ IDEA есть утилита, показывающая байт-код, в который скомпилируется код на Kotlin, на лету, и даже можно посмотреть, как будет отличаться байт-код с @inline и без.

Производительность


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

Тестировать будем на таком примере:

val range = -20000000..20000000
val list = ArrayList<Int>()
//warm-up
for (i in range) { 
    list add i % 2
}
list.clear()

val timeBefore = System.currentTimeMillis()
for (i in range) {
    val z = (i % 2) butIf { it < 0 } then { it + 2 } //и аналоги
    list add z
}
println("${System.currentTimeMillis() - timeBefore} ms")

Задно добавим к сравнению такой код, который будет эталоном производительности:

...
val d = it % 2
val z = if (d < 0) d + 2 else d
...

По итогам пятидесятикратного запуска с последующим усреднением времени получилась следующая таблица:

Реализация Без inline C inline
Эталон 319 ms
I попытка 406 ms 348 ms
II попытка 610 ms 520 ms
II попытка с ThreadLocal 920 ms 876 ms
III попытка 413 ms 399 ms

Как видно, производительность более простых первого и третьего вариантов достаточно близка к эталону, в некоторых случаях читаемость кода можно «купить» за такое увеличение времени работы. Вариант с более красивым синтаксисом устроен сложнее и работает, соответственно, дольше, но если хочется конструкций, совсем похожих на DSL, то и он вполне применим.

Итого


Kotlin предоставляет действительно гибкие возможности для «кастомизации» языка, но за них иногда будет нужно платить производительностью. Аннотация @inline может помочь улучшить ситуацию, если в вашем коде есть функции первого порядка. В любом случае, думаю, у вас найдутся хорошие сценарии применения для всего этого.

Удачи!

Комментарии (31)


  1. sborisov
    14.09.2015 16:17
    +14

    Когда JetBrains введёт платную подписку на Kotlin? :)


    1. Artem_zin
      14.09.2015 19:06

      Лол, хоть я и не работаю в JetBrains, но очевидно, что, сам язык будет всегда бесплатным.

      А вот тулинг (в чём JetBrains мастера), вполне может быть платным и это нормально.


      1. vintage
        14.09.2015 23:04
        +3

        Без бесплатных инструментов язык сейчас не продвинуть, если ты не Apple, конечно :-)


        1. Artem_zin
          14.09.2015 23:14

          На данный момент всё бесплатно (Kotlin Plugin для IntelliJ IDEA). Компилятор и интеграции к системам сборки, естественно, тоже + Open Source как язык, так и всё вокруг него.


          1. vintage
            14.09.2015 23:22
            +1

            А в чём подвох тогда? Не верится как-то что корпорация делает это просто из любви к программированию, без экономического обоснования :-)


            1. Artem_zin
              15.09.2015 00:25
              +2

              А в чём подвох бесплатности JRE и JDK? В обслуживании и тулзах. Здесь так же, и это прекрасно, для нас — пользователей.

              В чём по вашему подвох Swift?


            1. burjui
              15.09.2015 04:45
              +3

              Им тоже надо на чём-то писать новые продукты — тот же CLion — и плагины. Устали ребята от бесконечного boilerplate, общей многословности Java и тупых компиляторов, которые заставляют писать

              Dummy dumb = new Dummy();
              

              несмотря на то, что прекрасно знают, какой тип имеет выражение new Dummy(). Как Java лямбдами и всякими totallylazy не обмазывай, всё равно проступают угловатые контуры 90x.


    1. burjui
      15.09.2015 04:56

      Не раньше, чем плагин Kotlin для Gradle научится перекомпилировать изменившиеся .kt файлы. Шутки-шутками, но я бы даже из своего кармана заплатил, лишь бы на работе можно было перестать беспокоиться насиловать жабу и начать жить писать на нормальном ЯП. Но не волнуйтесь, пока всё слишком сыро.


  1. vintage
    14.09.2015 17:01

    Посмотрите на реализацию mixin и mixin template в D.


    1. burjui
      14.09.2015 19:04
      +1

      Пожалуй, на первое смотреть не стоит, потому что метапрограммирование через конкатенацию строк ничем принципиально не лучше сишных макросов. Надеюсь, в D когда-нибудь впилят нормальные AST macros вместо этого костыля, всё-таки 2015 год на дворе, а тот же Scheme это уже 40 лет как умеет (понятно, что в Lisp'ах это реализуется проще, но всё же). Я иногда использую template mixin, но строковый mixin я старательно избегаю, т.к. код генерации строк для mixin обычно выглядит, как синтаксическая блевотина, а попробуешь использовать std.string.format() для генерации строк, так получишь новую проблему — не забыть экранировать %, если генерируемый код использует форматирование или деление по модулю (x % 2). И даже не надейтесь на помощь IDE, т.к. для корректного парсинга сгенерированного кода в IDE нужно засунуть компилятор D. В общем, mixin лучше использовать только в крайнем случае, т.к. отлаживать сгенерённый фарш просто невозможно, а ошибки его компиляции могут посоревноваться в бесполезности с оными для шаблонов C++.


      1. vintage
        14.09.2015 23:03

        Разумеется по возможности стоит использовать mixin-template. А mixin — в тандеме с token-strings. Конкретно mixin хорош тем, что можно на лету транслировать например HTML-шаблон в код на D и закешировать на диске, а потом просто подключать этот код из файла. При этом всегда есть возможность глянуть что там нагенерилось в нормальном виде (на D), а не в AST. А то, что LISP код — это практически AST — это, конечно, круто, только это явно не Kotlin :-)


  1. Stiver
    15.09.2015 07:57

    Со своей колокольни: никогда не чувствовал потребности в подобных красивостях — там скобочки сэкономить, здесь стрелочку дописать… один черт. Что по-настоящему нужно в Java — это возможность возвращать из функции несколько значений, например как Matlab это разрешает. Вот что было бы здорово.


    1. h0tkey
      15.09.2015 09:57

      Про скобочки, пожалуй, правда ваша, в тех разумных пределах, пока синтаксический шум не мешает читать код.
      А насчёт возврата нескольких значений — Kotlin так умеет:

      val (x, y, z) = points3d[0]
      или даже
      for ((k, v) in hashMap) { /* ... */ }
      Настоящих tuples, правда, нет (мотивация — не нужны), и примеры выше работают за счёт реализации методов component1..N.


      1. Stiver
        15.09.2015 14:17

        >> А насчёт возврата нескольких значений — Kotlin так умеет:

        Насколько я понял, все равно придется определять класс (да еще с определенными методами) и запихивать возвращаемые значения в него. Пример use case описал ниже, именно от этой работы хочется избавиться.


        1. h0tkey
          15.09.2015 15:56

          Придётся, но, как в ветке ниже верно отметили, эта работа сведётся к одной строчке кода прямо перед объявлением метода. Для класса, помеченного аннотацией data, компилятор сам сгенерирует equals, hashCode, copy и component1..N. Ну, ещё есть Pair и Triple в стандартной библиотеке, если семантику полей не жалко. :)


    1. burjui
      15.09.2015 10:27
      +3

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

      Посудите сами, зачем бы вам были нужны кортежи, будь в Java короткий и ясный синтаксис Kotlin:


      1. grossws
        15.09.2015 18:04

        В случае scala в простых случаях достаточно кортежей (а они там до 42-арных), а во всех остальных удобно использовать case class'ы, которые являются immutable, имеют простой синтаксис использования, удобное создание и декомпозицию (для pattern matching), реализуют разумные equals/hashCode/toString и т. д.


        1. burjui
          15.09.2015 18:34

          а они там до 42-арных

          А вдруг не хватит?


          1. grossws
            15.09.2015 18:49

            Это скорее к java 8, где решили, что Function/Consumer/Predicate и BiFunction/BiConsumer/BiPredicate хватит всем.


    1. Borz
      15.09.2015 10:42

      есть пакет tuple или можно обычные коллекции возвращать


      1. Stiver
        15.09.2015 14:11

        Речь о самом языке, дополнительные библиотеки не всегда хочется тащить. А возвращать коллекции типа Object тоже дурная работа.


        1. Borz
          15.09.2015 14:13

          тогда заворачивайте в Data-класс результат


    1. burjui
      15.09.2015 11:04
      +3

      Ой, рука случайно дёрнулась и отправила комментарий раньше времени.
      Так вот:

      data class Point2D(val x: Int, val y: Int)
      

      Если у вас синтаксис лаконичен, как в Kotlin, вам не составит труда написать ещё один data-класс в одну строчку, придумать для него осмысленное название и лишний раз поразмыслить, а действительно ли вам нужно возвращать несколько значений и законно ли это вообще — иметь у функции несколько выходных параметров, замаскированных под возвращаемое значение? Что такое вы собираетесь оттуда возвращать, что вам нужно делить это на части? Если возвращаемы значения независимы, не слишком ли много обязанностей на себя берёт функция? Если же они связаны, то ведь классы для этого и предназначены: связывать данные в отдельную монолитную сущность — как, например, Point2D. Кортежи позволяют делать такое:

      y, x = getCursorPosition(); // упс, баг
      

      Баг здесь появился только потому, что кто-то решил, что x и y независимы, но это не так: они часть единой сущности — точки в двумерном пространстве, и не имеют смысла по отдельности. В следующем коде так же не нужны кортежи:

      file, err := os.Open("file.go")
      

      Goты утверждают, что это якобы заставляет больше думать об обработке ошибок. Они серьёзно? Первым же делом после предъявления этого дутого аргумента они сами показывают, как доказать его несостоятельность:

      file, _ := os.Open("file.go") // обработай это, чудила! ghetto style
      

      А выкидывай они исключения вместо кодов ошибок, и не думать об обработке ошибок уже бы не вышло, при этом бизнес-логика была бы отделена от обработки ошибок, и можно было бы обрабатывать ошибки на выбранном уровне абстракции, а не делать это в каждой функции. В общем, спорная фича эти кортежи и прочие multiple return values.


      1. Stiver
        15.09.2015 14:01

        >>>>а действительно ли вам нужно возвращать несколько значений и законно ли это вообще — иметь у функции несколько выходных параметров, замаскированных под возвращаемое значение? Что такое вы собираетесь оттуда возвращать, что вам нужно делить это на части? Если возвращаемы значения независимы, не слишком ли много обязанностей на себя берёт функция?
        <<<<

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

        Теоретически можно придумать какой-то класс, объединить их в структуру и т.д. Но зачем? Ради одной-единственной передачи из функции? А если подобных функций у вас десятки? Что для кода с некоторой математикой/алгоритмикой скорее правило, чем исключение. Matlab без этой фичи вовсе не выжил бы, а в Яве приходится какими-нибудь Object[]{, } извращаться.


        1. burjui
          15.09.2015 15:14

          В Java, пожалуй, это имело бы смысл. Но в Kotlin это всего одна лишняя строка:

          data class Region(val entrance: Node, val exit: Node) // вот эта
          fun Graph.findSomeRegion(): Region = ...
          ...
          val region = graph.findSomeRegion()
          

          и вам не надо помнить, что идёт первым — entrance или exit, даже если лень писать тесты. В этом конкретном случае очевидно, что entrance должен идти первым, но только для нас с вами, а вот компилятор скромно промолчит, если вы их перепутаете, т.к. оба значения имеют одинаковый тип.

          Впрочем, возможно, что я перегибаю палку. Просто обычно я стараюсь переложить максимум работы на компилятор, потому что даже со 100% покрытием кода тестами нельзя гарантировать отсутствие багов, и лучше баги находить до того, как их найдут клиенты. Но находить баги при сборке для меня недостаточно, я стараюсь по возможности писать код так, чтобы исключать целые классы багов ещё до сборки (используя Option вместо null, например). Грань между программерской ленью и перфекционизмом довольно тонка :)


        1. vintage
          15.09.2015 16:03

          А если используется только один из них, а второй можно было бы и не вычислять? Как реализовать ленивое вычисление значений только если оно действительно нужно? Что делать, когда кроме входного узла и выходного нужно будет возвращать ещё пачку дополнительных данных? Пихать всё это в огромный кортеж? Подход яваскрипта тут лучше смотрится:

          function findSomeRegion() {
              ...
              return { entrance : nodeEn , exit : nodeEx }
          }
          ...
          var region = findSomeRegion() // get all data as object
          var { nodeEn , nodeEx } = findSomeRegion() // destruct object to variables
          


          1. burjui
            15.09.2015 17:33

            Кстати, о лени: totallylazy. Имеются Sequence + генераторы, Option, а также filter, map/flatMap, fold/reduce и ещё кучка сверху. Без этой библиотеки и Retrolambda я бы уже повесился.


  1. Stiver
    15.09.2015 13:54

    .


    1. bigfatbrowncat
      18.09.2015 18:22

      У Kotlin, насколько мне удалось понять из документации, есть один очень серьезный дизайновый изъян (тот же, что и в C#). Там в архитектуру не заложены исключения, которые «трудно проигнорировать». Я говорю, разумеется, про checked exceptions. При этом объяснение в духе «глупые обезьяны всё равно выкрутятся, написав catch (Exception e) { }» совершенно несостоятельно. Мне нравится, что компилятор не дает мне «прозевать» IOException. А если совсем «приспичит», я этот IOException заверну в RuntimeException. Но 9 из 10 случаев, когда исключение не ловится — это когда оно тупо «прозёвывается».

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

      Ой… нечаянно написал ответ.


      1. h0tkey
        18.09.2015 18:50

        С проверяемыми исключениями далеко не всё однозначно, и в Java такой подход был скорее экспериментальным. В документации по поводу отсутствия проверяемых исключений в Kotlin ссылаются cюда, сюда и сюда.


        1. bigfatbrowncat
          19.09.2015 18:40

          Да-да. «один умный дядя сказал». Я читал эти статьи.

          У меня критерий проверки — собственная шкура. И аргумент простой. Если кто-то говорит «это — ненужная фича — выпилить», а мне она помогает, я шлю этого «эксперта» вдаль, несмотря на его авторитет. Потому что эксперимент для меня ценнее любой теории.

          К сожалению, запретить «проглатывать» исключения нельзя. Но то, что можно (и нужно) писать код, где каждый Exception отлавливается всегда, когда есть опасность, что он случится (хотя бы минимальная), и что я сам — ленивая сволочь, которая не напишет обработчик, если ее не пнуть, и что моё собственное честолюбие не позволит мне это исключение «проглотить», означает, что лично я с checked exceptions пишу более качественный код. И никто в обратном меня не убедит. А еще я верю, что я не один такой.

          Я перед Java пару лет посвятил C#. Язык в большинстве возможностей мощнее и лучше, чем Java, но когда я увидел checked exceptions, то тут же пожалел, что там их не было. Кучу проблем в моем старом коде это бы решило еще на этапе его набора.