Pull to refresh

Почему Kotlin отстой

Reading time 12 min
Views 74K

Эта статья родилась по мотивам вот этой статьи в виде полу-шутки. В той статье большая часть "проблем" является либо синтетическими и крайне редко используемыми, либо притянутыми за уши из-за ожидания соответствия языка теоретической парадигме которой, по мнению автора, язык должен соответствовать. С другой стороны не упомянуты вещи, которые мне лично действительно усложняют жизнь.


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


Убогий for


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


inline fun <T> For(it : Iterator<T>, cb : (T) -> Unit) {
  while (it.hasNext()) cb(it.next())
}

fun main(a : Array<String>) {
  val list = listOf(1, 3, 4, 12)
  println("for");   for (it in list) println(it)
  println("FOR");   For(list.iterator()) { println(it) }

  val arr = arrayOf(1, 3, 4, 12)
  println("a-for"); for (it in arr) println(it)
  println("a-FOR"); For(arr.iterator()) { println(it) }

  println("r-for"); for (it in 0..10) println(it)
  println("r-FOR"); For((0..10).iterator()) { println(it) }
}

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


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


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


Истерично-бессмысленная война с null-абле


Может быть из-за того, что я стар, а может быть из-за того, что уже лет 25 успешно пишу на С, где (sic!) есть такая вещь как void*, я не испытываю никакого экстаза от повторения вслух шаблонных: "стрельба в ногу" и "ошибка на миллион". В результате, я просто не понимаю с чем воюют. Какая разница, когда хлопнется программа, на проверке аргументов или на их использовании?


В чем соль декларирования null-safety Kotlin-ом, если он ее даже теоретически обеспечить не может? Значение null есть в самом языке, оно есть в Java, без инфраструктуры которой Kotlin, скажем прямо, не представляет никакого интереса. Как можно защититься от того, что используется за пределами языка и никак им не контролируется? Да никак. Это не более чем модная тенденция, уродование исходных текстов и регулярный геморой.


Впрочем, я далек от того чтобы учить других как именно им жить. Если кому-то хочется какого-то самоуспокоения, то кто я такой чтобы им в этом мешать? И я бы абсолютно спокойно игнорировал ажиотаж вокруг null, если бы не регулярный геморой с ним. Вернее с той абстракцией, которой Kotlin старательно усложняем мне жизнь.


var value : Int? = null

fun F() : Int {
  if ( value != null ) return 0
  return value // Ошибка
}

Ошибка Smart cast to 'Int' is impossible, because 'value' is a mutable property that could have been changed by this time просто задалбывает. Хочется кого-то убить или что-то сломать.


Где, как и кем эта проперть может быть модифицирована между двумя строчками??!!! Соседним тредом? Откуда взялась эта абсолютно бредовая уверенность компилятора в том, что каждая буква моей программы — это элемент многопоточной конкуренции? Даже в случае написания жестокого многопоточного кода пересечения тридов случаются на очень малом объеме текста программы, но из-за репрессивной заботы компилятора о такой возможности я имею гиморрой с клинописью постоянно.


Кто придумал два восклицательных знака? Неуд! Два еще недостаточно взрывают мозг. Надо было пять. Или десять. И с обоих сторон. Так уж точно было бы понятно, где тут самый не кошерный и "небезопасный" код.


var value : Int? = null

fun F() : Int {
  if (value == null) return 0
  return when (Random().nextInt()) {
    3    -> value!! + 2
    12   -> value!! + 1
    5    -> value!! * 4
    else -> 0
  }
}

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


Смешно же во всей этой чепухе вокруг null то, что это не работает. Я уже почти смирился с писаниной бездарной клинописи в своем коде с надеждой, что "зато когда-нибудь это спасет".


Ага, щаз.


Java


public class jHelper {
  public static jHelper jF() { return null; }
  public void M() {}
}

Kotlin


fun F() {
  val a = jHelper.jF()
  a.M()  //Упс!
}

Это замечательно компилируется без каких-либо ошибок или предупреждений, запускается и с грохотом схлопытвается cо стандартным NullPointerException т.к. тут Kotlin не проверяет ничего и нигде. И где обеща..., тьфу, декларируемая безопасность?


В общем, в сухом остатке я имею следующее:


  • регулярный гиморой с преодолением надуманных проблем в моем коде;
  • постоянные приседания с !! при работе с nullable типами в моем коде;
  • постоянный оверхед, генерируемый компилятором на проверках всех параметров функций и при установке любых значений в моем коде;
  • нулевую безопасность для любых данных пришедших извне;

Т.е. весь гиморрой только в той части, которую я знаю и контролирую, а все наружнее молча посыпется при первой же возможности. Зашибись, зачет!


Зато все красиво и по феншую.


Почему присваивание — это не выражение?


Даже if это убогое, но выражение, а присваиванию эту возможность отрезали. Почему я не могу написать так?


var value = 10

fun F() : Int {
  return value = 0 // Ошибка
}

или так:


var v1 = 1
var v2 = 1
var v3 = 1

fun F() {
  v1 = v2 = v3 = 0 // Ошибка
}

Что в этом коде криминального? Хотя, я наверное, догадаюсь. Защищаем пользователя от if (v=20)?.. Но, врядли, т.к. это просто не соберется без автоматического приведения типов, которого у Kotlin, опять-же, нет. Сдаюсь. Кто знает ответ?


Чем не угодил оператор "?:"?


За что ампутировали оператор "?:"?


Что усмотрели вредного в таких конструкциях?


value != 0 ? "Y" : "N"

С if все замечательно:


if (value != 0) "Y" else "N"

кроме полной альтернативности (где такое еще есть?) и того, что часто побочная писанина if () else места занимает больше, чем само выражение.


За что убили автоматическое приведение типов?


Да, тотальное приведение типов друг к другу — это чистое и незамутненное зло. Я обоими руками против того, чтобы плодить ребусы, к которым приводит взаимное преобразование чисел и строк. В принципе, я даже за то, чтобы различать целочисленное и плавучку. Но зачем было совсем все-то вырезать?! Почему нельзя использовать стандартные и общепринятые правила приведения типов, которые существуют в подавляющем большинстве языков?


Ну ладно, пусть даже отрезали. Привет Pascal. Но зачем в документации-то врать про «there are no implicit widening conversions for numbers»? Где оно "are no", если такое замечательно собирается?


val i = 10
val l = 12L
val f = 12.1

val l1 = i+100/l-f

Где ожидаемый хардкор?!


val l1 = i.toDouble() + 100.toDouble() / l.toDouble() - f

Т.е. авто-приведения типов нет… хотя… оно как-бы есть… но только для выражений… и еще для констант. А вот если передать в качестве параметра надо переменную или там присвоить в переменную без вычислений — тут уже ручная гребля в санях. Ведь это так принципиально, и нужно акцентировать все внимание на том, что вот из этого Int получается именно Long, а из этого Float именно Double.


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


Хотел бы я еще заикнуться про крайне желательное:


val c : SomeClass? = null

if ( c ) "not-null"
if ( !c ) "is-null"

но не буду т.к. опасаюсь за свою жизнь.


Недо-typedef


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


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


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


  2. Создание нового типа без создания нового класса. Эту задачу существующие псевдонимы решить не способны вообще т.к. они не являются самостоятельным типом. Различить два псевдонима, отличающихся только именем невозможно.


  3. Уменьшение писанины при использовании шаблонных типов. Эта задача самая полезная и часто используемая. Существующие псевдонимы могут решить только описательную ее часть (см п.1), т.е. их можно использовать для описания типа переменных, параметров, возвращаемого значения и создать объект такого (базового) типа. Певдоним для шаблонного типа нельзя использовать для приведения или проверки типа объекта.

На практике мы имеем следующее:


typealias aI = SuperPuperClassA
typealias pSI = Pair<String,Int>
typealias pIS = Pair<Int,String>
typealias pOTHER = Pair<String,Int>
typealias aS = List<String>

class SuperPuperClassA {
  fun F() = pSI("",10)
}

fun main(a : Array<String>) {
  val a = aI()
  val i1 = a.F()
  val i2 : Pair<*,*> = a.F()
  val i3 : Any = a.F()

  //Этот код собирается и условие выполняется
  if ( i1 is pSI ) println("ok")
  if ( i1 is pOTHER ) println("ok")

  //Этот код НЕ собирается
  if ( i1 is pIS ) println("not compile")
  if ( i2 is pSI ) println("not compile")
  if ( i2 is pIS ) println("not compile")
  if ( i3 is pSI ) println("not compile")
  if ( i3 is pIS ) println("not compile")
}

Обратите внимание на то, что в обоих строках где код собирается условие выполнится. Т.к. псевдоним не является полноценным типом, то различить их невозможно. Собственно, Kotlin мог бы их различать хотя бы в случаях, как в этом примере (весь код с явными и известными типами), но, видимо, нет желания.


Код, которые не собирается, имеет одну и ту же проблему: "Cannot check for instance of erased type". Проблема в недоразвитости (попросту отсутствии) шаблонов в рантайме JVM.


Итого.


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


А, да, я говорил что псевдонимы можно описывать только глобальные, вне любого класса?


Nested and local type aliases are not supported


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


Убогие шаблоны


Шаблоны (generics) в Java вообще и в Kotlin в частности убоги и причина абсолютно одна и та же: JVM ничего не знает о шаблонах и все эти треугольные скобки в языке не более чем навесное синтаксическое украшательство.


Бог с ней с Java т.к. меня лично ее проблемы не волнуют. Меня волнует ущербность шаблонов конкретно в Kotlin, который позиционируется как другой язык, а не препроцессор для Java и, в результате, кивать на ее недостатки по меньшей мере бессмысленно.


То что шаблонные типы нельзя (бессмысленно) использовать для проверки типа или его приведения еще как-то можно пережить т.к. об этом компилятор хотя бы ошибку выдаст, но это не все проблемы.


Как Вам такой ребус:


/*00*/ class C<T>(val value : Any) {
/*01*/   fun F() : T {
/*02*/     try {
/*03*/       val v = value as T //Предупреждение компилятора "Unchecked cast: Any to T"
/*04*/       return v
/*05*/     } catch(ex : RuntimeException) {
/*06*/       println("Incompatible")
/*07*/       // Хак для иллюстрации того, что эксепшин будет съеден и не пойдет дальше
/*08*/       return 0 as T
/*09*/     }
/*10*/   }
/*11*/ }
/*12*/ 
/*13*/ fun fTest() {
/*14*/   val a = C<Int>( 12.789 )
/*15*/   println( "rc: ${a.F()}" )
/*16*/ 
/*17*/   val b = C<Int>( "12.123" )
/*18*/   println( "rc: ${b.F()}" )
/*19*/ }

В этом коде, в классе "С" делается попытка проверить совместим ли тип объекта с типом шаблона.


Внимание, вопрос: как отработает этот код?


Варианты ответов:


  1. Не соберется вообще
  2. Соберется, выполнится и напечатает "12", "12"
  3. Соберется, выполнится и напечатает "12", "Incompatible"
  4. Соберется, выполнится и напечатает "12.789", "12.123"
  5. Хлопнется при запуске внутри функции "C::F" (на какой строке?)
  6. Хлопнется при запуске внутри функции "fTest" (на какой строке?)

Правильный ответ

Правильный ответ: хлопнется при запуске внутри функции "fTest" на строке 18


rc: 12

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number
    at jm.test.ktest.KMainKt.fT(kMain.kt:18)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)

Следующий конкурс: кто может объяснить почему это происходит?


  1. Почему не упало на первом вызове, где передается Double вместо Int?
  2. Почему не отработал блок try/catch?
  3. Как ошибка кастинга с ПРАВИЛЬНЫМИ типами смогла вообще доехать до кода используещего функцию "C::F"?

Под капотом

Кратеньно, выводы делайте сами.


Вот код, который генерирует Kotlin для проверки типа внутри "C::F":


// val v = value as T

GETFIELD jm/test/ktest/C.value : Ljava/lang/Object;
CHECKCAST java/lang/Object
ASTORE 1

Если очень сильно подумать (или заранее знать что оно неработоспособно), объяснить почему именно CHECKCAST Object можно. Сложнее объяснить зачем вообще этот код генерировать т.к. он абсолютная пустышка всегда, но это вопрос уже совсем к другой части компилятора.


А вот код, который генерируется при вызове функции "C::F":


LINENUMBER 18 L6
ALOAD 1
INVOKEVIRTUAL jm/test/ktest/C.F ()Ljava/lang/Object;
CHECKCAST java/lang/Number

Опять-же, если очень сильно думать (или знать заранее), то можно объяснить наличие правильных типов в этом месте, но лично для меня сам факт наличия проверки типов после вызова функции был неожиданностью. И да: Kotlin, оказывается, генерирует проверку типа снаружи при каждом использовании шаблонного результата для любого класса.


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


Я все понимаю: шаблонов в Java нет и все подобное. Этот пункт, скорее всего, не появился бы вообще, если бы нормальную работу с шаблонами нельзя было бы реализовать в принципе никогда и нигде… Но вот у меня перед глазами яркий пример — VCL. Фирма Borland в богом забытом году умудрилась прикрутить не к чему-нибудь, а к С и Pascal настолько мощное RTTI, что альтернатив ему не существует до сих пор. А тут не машинный код, тут Java и обеспечить в ней полнофункциональное использование шаблонов в своем, Kotlin-овском коде можно. Но его нет. В результате, язык вроде бы и другой, а ситуация, из-за синтаксического разнообразия, еще хуже чем в самой Java.


Напишем аналог шаблона из ребуса на Java.


public class jTest<T> {
  Object value;

  jTest( Object v ) { value = v; }

  public T F() { return (T)value; } //Предупреждение компилятора "Unchecked cast"

  public static void Test() {
    jTest<Integer> a = new jTest<Integer>( 12.123 );
    System.out.print( "rcA: " );
    System.out.print( a.F() );

    jTest<Integer> b = new jTest<Integer>( "12.789" );
    System.out.print( "\nrcB: " );
    System.out.print( b.F() );

    System.out.print( "\n" );
  }
}

И попробуем его вызвать из Kotlin и Java.


fun fTJ_1() {
  val a = jTest<Int>( 12.123 )
  println( "rc: ${a.F()}" )

  val b = jTest<Int>( "12.789" )
  println( "rc: ${b.F()}" )
}

fun fTJ_2() {
  jTest.Test()
}

Я не буду утомлять разнообразием ребусов для всех возможных вариантов и сведу его к простейшему: как поведет себя программа, в которой:


  1. и шаблон и его использование реализовано на Kotlin;
  2. шаблон на Java, а его использование на Kotlin;
  3. и шаблон и реализацая на Java;

и какие будут результаты выполнения программы в каждом случае?


Варианты:


  • Все примеры отработают одинаково.
  • Все примеры отработают по разному.
  • Все примеры, где реализация написана на Kotlin отработают одинаково, а с Java по другому.

Правильный ответ

Правильный ответ: все три варианта поведут себя по разному


  1. Kotlin:


    rc: 12
    Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

  2. Kotlin->Java:


    Exception in thread "main" java.lang.ClassCastException: java.lang.Double cannot be cast to java.lang.Integer

  3. Java:
    rcA: 12.123
    rcB: 12.789

Почему так а не иначе? А это будет домашнее задание.


Нет синтаксиса для описания структур


Если взять абсолютно любой сравнымый язык (хоть саму Java, хоть Scala, Groovy и множество прочих от Lua до, даже, С++) то в них во всех сделано так, чтобы было удобно описывать структуры данных в коде программы.


Kotlin — это единственный известный мне язык, где синтаксиса для описания структур данных нет вообще. Есть (грубо говоря) всего три функции: listOf, mapOf и arrayOf.


Если с массивами и спискамми синтаксис громоздок, но как-то структурируется зрительно:


  val iArr1 = arrayOf(1, 2, 3)
  val iArr2 = arrayOf( arrayOf(1, 2, 3), arrayOf(1, 2, 3), arrayOf(1, 2, 3) )
  val iArr3 = arrayOf(
    arrayOf(arrayOf(1, 2, 3), arrayOf(1, 2, 3), arrayOf(1, 2, 3)),
    arrayOf(arrayOf(1, 2, 3), arrayOf(1, 2, 3), arrayOf(1, 2, 3)),
    arrayOf(arrayOf(1, 2, 3), arrayOf(1, 2, 3), arrayOf(1, 2, 3))
    )

то с картами все значительно печальнее:


  val tree = mapOf(
    Pair("dir1", mapOf(Pair("file1", 0), Pair("file2", 1))),
    Pair("dir2", mapOf(
      Pair("dir21", mapOf(Pair("file1", 0), Pair("file2", 1))),
      Pair("dir22", mapOf(Pair("file1", 0), Pair("file2", 1))))) )

Я не знаю как именно другие пользователи описывают константные данные, но лично я испытываю жестокий дискомфорт при попытке использовать что-то сложнее одномерного списка.


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


  1. Засовывать каждый десяток строк в отдельный файл только для того, чтобы иметь возможность ими наглядно манипулировать — это как-то избыточно (хотя именно так и приходится делать).
  2. Усилия по написанию структуры кода прямо в программе и во внешнем файле, с последующим их чтением, просто несопоставимы.
  3. В случае изменения структуры данных приходится, помимо кода, править гору совершенно лишнего текста по обслуживанию его загрузки.

В общем, концепция минимализма — это круто, но аццки неудобно.


ПС: В виде отдельного гвоздя в голову я пожелаю кому-нибудь написать библиотеку для работы с матрицами. Зато научитесь понимать отличать Array<Array<Array<Array<Double>>>> и Array<Array<Array<Double>>> с первого взгляда и с любого расстояния.

Tags:
Hubs:
+51
Comments 154
Comments Comments 154

Articles