Недавно я проходил собеседование и одним из вопросов, стал такой загадочный экземпляр:
"А какое главное преимущество системы типов Kotlin перед Java"?

Честно говоря, выделить какое преимущество считалось главным, оказалось неразрешимой для меня задачей: Nothing, отсутсвие Wildcard и First-Class Functions вместо Java-костыля с Functional Interface (имеется ввиду 8я версия Java) не заняли первых мест в личном топе интервьюера, который мне предложили угадать.

Оказалось что главное в Kotlin - возможность обьявить Nullable Type и Null Safety подход (Замечу, что по моему опыту собственные или библиотечные Optional или Maybe решают эту проблему, и пишутся за 10 минут на Java 7. А еще есть аннотации Nullable, позволяющие проверять поля в сompile-time. Короче, есть много способов заставить делать Null проверки в plain Java. Ну да ладно).

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

Как?

Long story short: ClassLoader ведет себя интересным образом при попытке загрузить статические поля классов рекурсивно ссылающиеся на классы друг-друга.

Под катом примеры кода и подробное объяснение того, как он обманывает проверки на Nullable. Я искренне надеюсь что специфические знания Java/Kotlin для статьи не нужны - я объясню все детали на ходу, и уложу расследование в 10 минут.

Начнем.


Не буду тянуть кота за хвост. Вот код:

class ClassToLoad1() {
    val classToLoad2 = ClassToLoad1.classToLoad2

    //Creating single instance of object
    companion object {
        val classToLoad2 = ClassToLoad2()
    }
}

class ClassToLoad2() {
    val classToLoad1 = ClassToLoad2.classToLoad1

    companion object {
        val classToLoad1 = ClassToLoad1()
    }
}


fun main() {
    val check = ClassToLoad1()
    val classToLoadRecurciveRef = check.classToLoad2.classToLoad1.classToLoad2
    println(classToLoadRecurciveRef) //null

    classToLoadRecurciveRef.classToLoad1 //Throws NPE
}

Ни одной строчки кода на Java, ни одного предупреждения и NPE в результате выполнения.

Почему так?

Роковое стечение нескольких обстоятельств:
Рекурсивная ссылка, статическая инициализация, и не самый явный контракт поведения ClassLoader в JVM в таких случаяx.


//Рекурсивные ссылки

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

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

Зачем вообще так делать?

Самый распространенный кейс, где вы такую структуру встречали - это Linked List (который, кстати, тоже любят на собеседованиях).

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

class SelfClassLoad(val s: SelfClassLoad) // warning: Constructor has non-null self reference parameter 

Ого, у нас есть предупреждение!
И оно есть по вполне понятной причине - вы не сможете использовать этот класс:

class SelfClassLoad(val s: SelfClassLoad) // warning: Constructor has non-null self reference parameter

fun main() {
    SelfClassLoad(
        SelfClassLoad(
            SelfClassLoad(
                SelfClassLoad(
                    SelfClassLoad(
                    //No value passed for parameter 's'
                    )
                )
            )
        )
    )
}

Интересно будет так-же немного изменить пример:

class SelfClassLoad(){
    val s = SelfClassLoad()
}

fun main() {
    SelfClassLoad() //Stackoverflow error
}

По понятным причинам, мы просто получим StackOverflow - бесконечная инициализация себя к добру не приводит.

Но будет ли у нас предупреждение, если мы попробуем создать рекурсивную связку из двух классов?

class SelfClassLoad1(val s2: SelfClassLoad2)
class SelfClassLoad2(val s1: SelfClassLoad1) //No warnings

Такой кейс warning уже не выдает.

Issue на эту тему уже создан:

И это первая ступенька на пути к нашему NPE.


//Статическая инициализация


Что происходит в Kotlin когда вы создаете Companion Object?

Если вы пытались вызвать подобный код из Java - то вы знаете о том, что в вашем классе будет находиться статическая ссылка на объект-компаньон.

Если попробовать транслировать Kotlin в Java, получится что-то подобное:

public final class ClassToLoad1 {
    @NotNull
    private static final ClassToLoad2 classToLoad2;
    @NotNull
    public static final Companion Companion; //Here, it's "static"

    @NotNull
    public final ClassToLoad2 getClassToLoad2() {
        return this.classToLoad2$1;
    }

    static {
        Companion = new Companion(null);
        classToLoad2 = new ClassToLoad2(); 
    }

    public static final class Companion {
        @NotNull
        public final ClassToLoad2 getClassToLoad2() {
            return classToLoad2;
        }

        private Companion() {
        }

        public Companion(DefaultConstructorMarker $constructor_marker) {
            this();
        }
    }
}

Опытные Java разработчики уже поняли к чему все идет, (если конечно не поняли с самого начала), а для широкой аудитории оставлю пояснение:

Ключевое слово static, делает ровно то, что оно гласит - объект становится статичным. А в мире JVM это значит, что этот объект находится в специальной области памяти - Permanent Generation Space.

Это означает 2 вещи:

1. Объект в static будет жить пока жива его JVM.
2. Он будет проинициализирован (положен в PermGen) 1 раз. (Да, да есть кейсы когда это не правда, это на данный момент Out of Scope)

Для тех, кто вспомнил про синглтоны:

Возможно вы сейчас подумали что-то вроде "А зачем тогда нужен сложный синглтон с Syncronized если static'a достаточно?".

Ответ: синхронизация, и прочие техники нужны когда вам нужно инициализировать синглтон отложенно.

class Singleton1{ 
    public final static Singleton1 singleton = new Singleton1() 
} //Warranty to be single time initialized, but will init on class init.
class Singleton2{
    private static Singleton2 singleton;
    
    public static void getSingleton(){
				if(singleton == null)
        		singleton = new Singleton2()
        return singleton;
    }
} //Late init but no warranty of single init in concurrent case.

Если у вас небольшой объект, который можно инициализировать сразу - то можно написать Singleton1.

Singleton2 можно написать если вы гарантируете вызов его метода get из одного потока всегда. В противном случае - он неверен, но это выходит за рамки обсуждения. Но вот неплохой пост на эту тему: https://habr.com/ru/post/27108/

Нас интересует второй нюанс. Инициализация объекта идет 1 раз, а мы тут создали 2 объекта с перекрестными ссылками друг на друга.

Как поведет себя JVM в таком случае?


//Classloader

Итак, мы пришли к нижнему уровню - загрузке класса. Когда мы первый раз пытаемся использовать класс - JVM загружает информацию о классе (в том числе статическую) в PermGen, а затем использует ее для создания экземпляров класса.

Что происходит в случае рекурсивной статической ссылки?

Примерно такая цепочка:

TryLoadClass1 -> LoadingClass1 -> FoundClass2InStatic -> TryLoadClass2 -> FoundClass1InStatic -> TryLoadClass1 -> AlreadyLoading, Null

И в результате мы получим Class1, в котором есть Class2, в котором находится ссылка на Null.

Почему оно так себя ведет, а не падает с Runtime Exception?
Мое предположение, и это только предположение - из-за обратной совместимости. Если мы исправим ClassLoader и дадим ему упасть в этом кейсе - у нас может повалиться огромное количество серверов просто из-за смены версии Java. Поэтому контракт на выдачу Null сохраняется на протяжении всех версий и останется там навсегда.

//Sidenote: Заранее думайте о контрактах, если хотите написать что-то обратно совместимое.


//Big Picture

Итак, вся цепочка событий:

  • Мы пишем код на Kotlin с циклической/рекурсивной ссылкой двух и и более объектов самих на себя в блоке с инициализацией. Анализатор - молчит.

  • Kotlin компилируется в java bytecode и переходит в чудесный мир JVM и Null.

  • Благодаря статической инициализации классы будут загружены 1 раз, и мы не получим StackOverflow в Runtime, как только до них дойдем.

  • ClassLoader попробует загрузить класс и благодаря старому, неявному контракту на возврат Null поставит null-refference в экземпляр Class2 при попытке обратно сослаться на Class1, который будет заблокирован собственной инициализацией, в контексте которой как раз и происходит инициализация Class2. Схемка:
    loading Class1 -> loading Class2 -> try load Class1 - result null

  • Бинго, мы провели анализатор и получили Null в контексте, где Kotlin его совершенно не ждет.

  • Наслаждаемся нашим NPE


//Критикуешь - предлагай

Ну, действительно. Критиковать все могут, а решение то какое?

Давайте для начала, посмотрим на пример решения такой проблемы в другом языке.
Я - мобильный разработчик, поэтому мне близок Swift:

//Won't compile
enum RelationDirect{//Error about recursive type declaration shown
    case Blocks(RelationBT)
}
enum RelationBT{
    case Reffers(RelationRevese)
}
enum RelationRevese{
    case BlockedBy(RelationDirect)
}

Такой код в свифте просто не скомпилируется, вам нужно пометить его отдельным ключевым словом indirect:

//Compiles
indirect enum RelationDirect{ //No errors
    case Blocks(RelationBT)
}
...

Но есть одна проблема - если мы просто возьмем решение из Swift - мы сломаем Backward Compatibility - при обновлении часть проектов перестанет собираться.

Нельзя просто взять и все сломать - это не JVM Way.

Я подумал некоторое время об этой проблеме (целых 45 минут), и возможно придумал решение.

Мы не можем сломать старый код, но можем сделать так, чтобы в старом коде возникли Warnings (подробнее о том, как может работать warning - в тикете), которые посоветуют добавить keyword, который пометит для компилятора Kotlin этот класс, как требующий проверки. Это является дополнением контракта, а не его изменением, поэтому не ломает совместимость (по крайней мере, на первый взгляд).

Примерно таким образом в Java 1.1 были добавлены final, и таким же образом работает val в Kotlin: Мы не заставляем компилятор бросать exception, если мы модифицируем любое поле. Но мы можем пометить поле, и попросить компилятор бросить exception, тогда и только тогда когда оно помечено. На мой вкус, это даже более элегантное решение, чем то, что используется в Swift.

В коде это будет выглядеть так:

Warning без keyword:

//Compiles with warning
sealed class RelationDirect(
		val opposite: RelationReverseSealed
) { //Warning - add selfref key to enable checks for self-refference in initializers
    object Blocks : RelationDirectSealed(RelationReverseSealed.IsBlockedBy)
}

sealed class RelationReverse(
		val opposite: RelationDirectSealed
) { 
    object IsBlockedBy : RelationReverseSealed(RelationDirectSealed.Blocks)
}

Добавление keyword, теперь компилятор чекает иерархию и находит проблему:

//Won't compile
sealed selfref class RelationDirect(
        val opposite: RelationReverseSealed
) { //Error, initializer self reffrence detected
    object Blocks : RelationDirectSealed(RelationReverseSealed.IsBlockedBy)
}

sealed class RelationReverse(
        val opposite: RelationDirectSealed
) { 
    object IsBlockedBy : RelationReverseSealed(RelationDirectSealed.Blocks)
}


Но, я не претендую на идеальные знания Kotlin и Java, JVM и компиляторов и тут будет интересен комментарий от JetBrains. Было бы здорово, если бы вы дополнили эту статью. Или помогли поправить, если я где-то ошибся в объяснении.



Напоследок, я бы хотел сказать, что это не первый кейс в моей жизни, когда меня подводило излишнее доверие языку, тулингу и библиотекам. Singleton может быть не один, если вы забыли про один нюанс связанный с ClassLoader. ConcurrentHashMap.computeIfAbsent - может оказаться местом где заблокируются все треды вашего сервера, хоть он и считается потокобезопасным (в Java 8). И это реальные production кейсы, которые мне пришлось расследовать.

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

А если у вас есть интересные кейсы из собственного опыта, когда какая-нибудь функциональность встроенная в библиотеку/язык отказывала при специфических обстоятельствах - было бы интересно увидеть эту историю в комментариях.

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


  1. Doppo
    15.09.2021 15:17

    Странный пост. Все правильно ваш интервьюер сказал. Нал-сэйфти - зе бест.

    А про Optional в джава смешно конечно сравнивать с этим.

    И в пример приводить грязный хак с рефлексией - так себе пруф.

    В общем руки прочь от котлина! ????????


    1. LonelyDeveloper97 Автор
      15.09.2021 15:22

      И что такого дала вам null safety в котлин? Не, ну она бьет по рукам людей которые не пользовались другими контейнерами или аннотациями. И синтаксис чуть более лаконичен. (Соглашусь что бить по рукам это в целом неплохо, и сахарок приятный)

      Как вы используете null-safety что это прям лучшая фича в типах в Котлине?

      Кстати, где тут грязный хак? Я просто хотел 2 обьекта с кроссреференсом, а получил NPE)


      1. nerumb
        15.09.2021 16:17
        +1

        Контейнеры это полумера. В них также можно передать null. На моей практике бывали и null переданные в Optional, и даже Some в котором лежал null (правда это было в Scala, но все же).
        Nullable типы не про то, что вы никогда не поймаете NPE, это лишь более естественный механизм описания типов.
        И он практично реализован в Kotlin. В рамках Kotlin кода без рефлексии тяжело поймать NPE, а все проверяется на уровне компиляции. Для всего того что приходит из Java кода полагаемся на аннотации (часть которых также понимает компилятор), а в крайнем случае возвращается платформенный тип.


        1. LonelyDeveloper97 Автор
          15.09.2021 17:44
          +1

          Хм.

          Так в чем все же принципиальная разница?

          Я не могу так сходу придумать пример кода, который сильно выигрывает от наличия в системе типов Kotlin nullable типа. Чтобы при взгляде на него приходила мысль вроде: «на java даже с контейнерами и аннотациями я не добьюсь схожего поведения компилятора и безопасности своего кода».

          Будет здорово посмотреть пример и его обсудить.


          1. gBear
            15.09.2021 19:15

            Самое очевидное, это "согласование инвариантов" в чем-нибудь типа:

            fun <T> doSome(value: T, transform: (T) -> T) = transform(value)

            Смысл в том, что - какой-нибудь - doSome<String> уже закрывает кучу вариантов. Если есть таки варианты сделать такое аннотациями -- будет круто узнать про них.

            Или "чуть-более темное":

            fun <T, R> doSome(value: T, transform: (T) -> R) = transform(value)

            Что тут можно выправить "контейнерами и аннотациями" я вообще слабо себе представляю. Но если таки можно - буду рад узнать.


      1. gBear
        15.09.2021 18:27
        +5

        Вот буквально сегодня по производственной необходимости пересел с kotlin на java. Реально две самые "бесячие" вещи в "java после kotlin": а - отсутствие nullable/non-null типов, и б - наличие checked exceptions.

        И что такого дала вам null safety в котлин? Не, ну она бьет по рукам людей которые не пользовались другими контейнерами или аннотациями. И синтаксис чуть более лаконичен. (Соглашусь что бить по рукам это в целом неплохо, и сахарок приятный)

        Наличие nullable/non-null типов дает - внезапно - одну очень важную вещь: T и T? - это разные типы... со всеми вытекающими :-) Собственно, null safety - как таковая - это органичное следствие из.

        А то, что на каком-то конкретном бэке она - пока ещё - в каких-то случаях каким-то образом "пробивается" - дело десятое, на самом деле. "Полечат" и это... не в первый раз. В конце концов, и из вашего примера видно, что для этого - мягко говоря - надо "специально стараться" :-)

        Важно другое... вы можете выразить nullable/non-null инвариант на уровне типов. В java - пока, по крайней мере - что-то похожее возможно только для "примитивов". Отсутствие необходимости писать какие-нибудь "бесконечные" Objects.requireNonNull - это хоть и приятное, но таки лишь следствие.

        "Контейнеры" в java - это вообще мимо темы, как говорится. А аннотации - это паллиатив... т. е. какие-то конкретные аспекты они могут "закрыть", но целиком "область применения" они, к сожалению, не накрывают.

        Как вы используете null-safety что это прям лучшая фича в типах в Котлине?

        Я вообще не совсем понимаю, как можно использовать null-safety? Использовать можно non-null типы. И с этим - вроде как - проблем быть не должно. Нет?

        Кстати, где тут грязный хак? Я просто хотел 2 обьекта с кроссреференсом, а получил NPE)

        "Грязный хак" - это возможность через статическую инициализацию выразить cross-referense на non-null типах :-)


        1. Throwable
          25.09.2021 13:15

          > Как вы используете null-safety что это прям лучшая фича в типах в Котлине?

          Я вообще не совсем понимаю, как можно использовать null-safety? Использовать можно non-null типы. И с этим - вроде как - проблем быть не должно. Нет?

          Ну да. Для non-null типов Котлин дает гарантию отсутствие NPE при работе с ними (по крайней мере для котлиновского кода). Но все остальные типы Котлин обязует жестко декларировать как nullable и проверять при каждом обращении. Отсюда лезут проблемы с отложенной инициализацией, которая чуть менее, чем всегда используется в большинстве фреймворков. То есть вроде как бы исходя из контекста нула быть не должно, но поле все-равно требуется объявить nullable (либо присвоить значение) и каждый раз бестолково проверять. А внедрение lateinit было лишь неудачным костылем.

          А проблема в том, что nullability во большинстве случаяев невычислима в статике и является контекстно-зависимой. Поэтому лучшим решением было бы внедрить в Котлин послабление вроде Any!!, где null-safety полностью ложится на разработчика, к тому же такие типы существуют внутренне в компиляторе. Но разработчики посчитали не нужным "портить киллер-фичу".


          1. gBear
            26.09.2021 08:26

            Но все остальные типы Котлин обязует жестко декларировать как nullable и проверять при каждом обращении.

            А какие варианты?! То, что может быть null обязательно нужно проверять.

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

            "Проблема" отложенной инициализации она больше про мутабельность, чем про nullable/non-null типы. Т.е. проблема в том не в том, что "исходя из контекста нула быть не должно", но в том, что "исходя из контекста" должен бы быть val, а приходиться объявлять как var.

            Ну а про то, что nullable - безотносительно какой-либо инициализации - нужно проверять уже говорилось.

            А внедрение lateinit было лишь неудачным костылем.

            Ну он же "неудачный" не из-за обязательности non-null, а из-за var. Non-null тут никак не мешает. А вот отсутствие гарантии неизменяемости после инициализации - мешает.

            Но "вот это вот все" уже потихоньку вытанцовывается в полноценные контакты.

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

            "Замнем для ясности" пока про "невычислима в статике"... но зависимость от контекста в чем выражается?!

            Поэтому лучшим решением было бы внедрить в Котлин послабление вроде Any!!, где null-safety полностью ложится на разработчика, к тому же такие типы существуют внутренне в компиляторе.

            ?! В смысле, позволить выносить в run-time то, что только вот что перенесли в compile-time? Так это и сейчас можно... "забить" на type inferred from a platform call и перестать "бестолково проверять". Но типы свои придется таки описывать вне kotlin - это да... "проблема" :-)


            1. Throwable
              26.09.2021 17:16

              А какие варианты?! То, что может быть null обязательно нужно проверять.

              Не нужно, если вы, исходя из контекста выполнения (который в большинстве случаев не может быть вычислен компилятором статически), гарантируете, что здесь не null. И если оно все-таки по каким-то обстоятельствам null, то вы получите вполне нормальный NPE -- это ничем не хуже, нежели каждый раз делать дополнительную проверку и кидать исключение вручную.

              "Замнем для ясности" пока про "невычислима в статике"... но зависимость от контекста в чем выражается?!

              Форма пользовательского ввода. Nullability полей объекта формы уже может быть проверена выше по треду косвенно или сторонними средствами (валидаторы всякие), а компилятор об этом ничего не знает.

              data class MyForm {
                ...
                @NotNull @Valid // JSR-303 annotations
                // наплевать, что NotNull, все-равно ВЕЗДЕ будем вставлять ненужные проверки
                var address : Address?
                ...
              }
              ...
              // Типа Address уже проверен, что не null.
              // Зачем тут нужен "!!"? Чем он будет лучше простого NPE?
              System.out.println(myForm.address!!.city)

              ?! В смысле, позволить выносить в run-time то, что только вот что перенесли в compile-time?

              Именно. Не быть NPE-nazi и дать возможность пользователю выбирать между compile-time check и runtime check.


              1. gBear
                27.09.2021 09:32

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

                "Эти грабли, эти золотые грабли..." (с) :-) Проходили же это все. Единственный способ что-то такое гарантировать - использовать non-null типы.

                Форма пользовательского ввода. Nullability полей объекта формы уже может быть проверена выше по треду косвенно или сторонними средствами (валидаторы всякие), а компилятор об этом ничего не знает.

                Самый очевидный вопрос - если все так хорошо, зачем использовать nullable тип?! Может быть таки не "все так хорошо"? :-)

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

                Именно. Не быть NPE-nazi и дать возможность пользователю выбирать между compile-time check и runtime check.

                А в чем смысл ослабления compile-time проверок? Проблема же не в них, а только в том (по вашим же словам), что - на данный момент - по каким-то причинам (не суть) невозможно использовать non-null типы, в ситуациях когда из "контекста выполнения" следуют именно они. Ведь так же?

                Я к тому, что если таки ввести в язык notable platform types это же только замаскирует проблему, а не решит её. Разве нет?


  1. darksnake
    26.09.2021 18:43
    +1

    null-safety ломается гораздо проще через порядок инициализации (вызов из init блока функции, которая обращается к переменной, которая инициализируется позже). Это плата за интероп с джавой. Если бы можно было сохранить интероп с джавой и при этом иметь foolproof null-safety, то это бы, разумеется сделали. Аналогичная история с lateinit. Его сделали только для работы с API андроида.