В одном и том же null часто прячут разные смыслы: “нет значения”, “неизвестно”, “неинициализировано”. Потом это всплывает в NPE и в кривой логике исполнения.
В Ceylon эту проблему решают через типовую систему, через Union типы. Подробнее, в переводе от Spring АйО.
Комментарий от Михаила Поливаха
Статья достаточно старая, написанная Лукасом Едером (автор Jooq) про реализацию nullability Ceylon.
Конечно, Ceylon довольно старый язык, мёртвый на данный момент. Его дизайнером кстати когда-то выступал сам Gavin King (тот самый авто�� Hibernate).
Тем не менее, это единственный относительно известный на моей памяти язык поверх JVM, который поддерживал Union типы (не считая Scala 3), через которые и была сделана реализация nullable типов в Ceylon.
Статья больше призвана расширить Ваш кругозор и призвать Вас к осмысленной дискуссии. Приятного чтения.
Ну вот, опять. ТА САМАЯ ТЕМА. Но подождите. Подход, о котором здесь пойдёт речь (и который реализован в языке Ceylon), — не то, что встретишь каждый день. При этом он одновременно очень хитроумный.
Null встроены в язык
… или так может показаться. Действительно, в Ceylon, как и в Kotlin (и, возможно, во многих других языках), есть особая «аннотация» типа, которую можно дописывать после любого ссылочного типа, чтобы сделать его “допускающим null”. Например:
String firstName = "Homer"; String? middleName = "J"; String lastName = "Simpson";
В примере выше и firstName, и lastName — обязательные значения, которые никогда не могут быть null, тогда как middleName — значение опциональное. Большинство языков, поддерживающих подобный подход, затем поставляются со специальными операторами для доступа к опциональному значению, например ?. в Ceylon, а также в Kotlin (null-safe call).
// Ещё одно опциональное значение: Integer? length = middleName?.length; // Неопциональное значение: Integer length = middleName?.length else 0;
Так в чём же особенность Ceylon, благодаря которой всё работает настолько гладко?
То, что Ceylon сделал действительно правильно, — это то, что всё перечисленное выше на самом деле всего лишь синтаксический сахар, который:
Легко использовать
Хорошо соответствует нашему образу мышления, в котором
nullпо-прежнему существуетМожет взаимодействовать с Java
Не создаёт когнитивного трения
Для нас, людей из мира Java, это означает, что мы по-прежнему можем делать вид, будто null — это более-менее допустимая, трудноустранимая вещь (как мы уже утверждали раньше в этом блоге). Но что такое null на самом деле? Это отсутствующее значение? Неизвестное значение? Неинициализированное значение? В Java есть только один «null-объект», и его (зло-)употребляют для обозначения всего перечисленного — и многого другого, — хотя теоретически это лишь неинициализированное значение и не более того.
Комментарий от Михаила Поливаха
Речь про null как про literal. По��робнее в JLS:
https://docs.oracle.com/javase/specs/jls/se9/html/jls-3.html#jls-3.10.7
С другой стороны, при работе с JDBC (а значит, и с SQL в Java) оно неявно означает «неизвестное значение» (со всеми вытекающими оговорками). В Ceylon же “Null” — это специальный тип в типовой системе, похожий на Void в Java. Единственное значение, которое можно присвоить типу Null, — это null:
// Ceylon Null x = null; // Java Void x = null;
Но принципиальная разница в том, что null нельзя присвоить никакому другому типу! Постойте. Разве мы не можем присвоить null переменной типа String?…? Конечно, в Ceylon возможно следующее:
String? x = null;
Но почему это возможно? Потому что String? — это всего лишь синтаксический сахар для String|Null, то есть объединённого типа (union type), который представляет собой либо тип String, либо тип Null.
Хм, а что такое объединённые типы (union types)?
Давайте рассмотрим это внимательнее. Когда в API jOOQ вы хотите работать с SQL-функциями и выражениями, почти всегда есть отличный набор перегрузок, которые дают вам стандартный вариант и «удобный» вариант, где можно передать bind-переменную. Возьмём, к примеру, оператор равенства:
interface Field { Condition eq(Field field); Condition eq(T value); }
Комментарий от Павла Кислова
Это не логическое "или" как мы привыкли к || в джава условиях. Это именно типовое "или", которое позволяет переменной принимать значения нескольких разных типов.
Это специфичная для цейлона штука судя по всему. | здесь как объединение типов. Оно говорит компилятору: «Переменная может быть любого из этих типов». Это не вычисление выражения, а описание допустимых типов.
В Java есть либо битовое "или" без короткого замыкания, либо обычное логическое "или" с коротким замыканием
Эти перегрузки позволяют писать примерно так, не задумываясь о различии между SQL-выражением и Java bind-переменной (которая в конечном счёте тоже является SQL-выражением):
// Сравнение столбца с bind-переменной .where(BOOK.ID.eq(1)) // Сравнение столбца с выражением другого столбца .and(BOOK.AUTHOR_ID.eq(AUTHOR.ID))
На самом деле перегрузок ещё больше, потому что правая часть операции сравнения может быть и другими выражениями, например:
interface Field { Condition eq(Field field); Condition eq(T value); Condition eq(Select<? extends Record1> query); Condition eq(QuantifiedSelect<? extends Record1> query); }
Теперь тот же набор перегрузок приходится повторять для «не равно», «больше», «больше или равно» и т. д. Разве не было бы здорово выразить эту «штуку справа» одним-единственным переиспользуемым типом? То есть объединённым типом, который включает все перечисленные типы?
interface Field { Condition eq( Field | T | Select<? extends Record1> | QuantifiedSelect<? extends Record1> thingy ); }
Или даже так:
// Это называется псевдоним типа (type alias). Ещё одна классная // фича языка Ceylon (псевдосинтаксис) alias Thingy => Field | T | Select<? extends Record1> | QuantifiedSelect<? extends Record1>; interface Field { Condition eq(Thingy thingy); }
В конце концов, именно так определён и язык SQL. Да что там — так любая нотация BNF определяет синтаксические элементы. Например:
<predicate> ::= <comparison predicate> | <between predicate> | <in predicate> | <like predicate> | <null predicate> | <quantified comparison predicate> | <exists predicate> | <unique predicate> | <match predicate> | <overlaps predicate>
Ладно, формально синтаксический элемент — это не совсем то же самое, что тип, но интуитивное восприятие здесь одно и то же.
О, и у Java тоже есть union типы!
В короткой вспышке озарения экспертные группы Java 7 добавили поддержку объединённых типов в обработке исключений. Можно писать вот так:
try { ... } catch (IOException | SQLException e) { // e может быть любым из перечисленных типов! }
А ещё можно эмулировать объединённые типы с помощью дженериков, которые сами по себе не поддерживают union-типы, но поддерживают intersection-типы в Java.
Возвращаясь к Ceylon и NULL
Ceylon действительно правильно подошёл к null. Потому что исторически nullable-тип — это тип, который может быть либо «настоящим» типом, либо значением «null». Нам именно это и нужно. Мы, разработчики на Java, этого жаждем. Мы не можем жить без успокаивающей возможности такого рода «опциональности». Но прекрасное в этом подходе — его расширяемость. А что, если мне действительно нужно различать «неизвестно», «неинициализировано», «не определено», «42»? Могу. С помощью типов. Вот String, который может моделировать все перечисленные выше «специальные значения»:
String|Unknown|Uninitialised|Undefined|FortyTwo
А если это слишком многословно, я просто даю этому имя:
interface TheStringToRuleThemAll => String|Unknown|Uninitialised|Undefined|FortyTwo;
Но! Это не может быть Null!. Потому что я не хочу, чтобы оно было тем значением, которое является всем и ничем одновременно. Вы убеждены? Уверен, что да. С этого момента:
Не доверяйте ни одному языку, который делает вид, будто монада Option(al) — приличный способ моделировать null. Это не так.
Моя цитата. Только что придумал.
Почему? Сейчас покажу. Синтаксический сахар в стиле Kotlin/Ceylon/Groovy с оператором Элвиса (вне зависимости от семантики null под капотом):
String name = bob?.department?.head?.name
То же самое с монадами Optional:
Optional name = bob .flatMap(Person::getDepartment) .map(Department::getHead) .flatMap(Person::getName);
И ГОРЕ ВАМ, ЕСЛИ ВЫ ХОТЬ РАЗ ПЕРЕПУТАЕТЕ map() С flatMap()!!
«
@EmrgencyKittens: кот в коробке, в коробке. pic.twitter.com/ta976gqiQs» И мне кажется, этоflatMap
— 𝗖𝗵𝗮𝗻𝗻𝗶𝗻𝗴 𝗪𝗮𝗹𝘁𝗼𝗻
Некоторые утверждают:
Использовать union-типы это как ездить на новеньком Ferrari, когда на пассажирском сиденье у вас тёща.
Возможно. Но я утверждаю другое: браво, Ceylon. Будем надеяться, что union-типы появятся и в Java — и не только в catch-блоках!
Комментарий от Михаила Поливаха
На деле, опять же, опустив личную любовь Лукаса к Union Types, стоит отметить, что про Union типы Java Architects Board спрашивали давно, в том числе на Devoxx, на JavaOne и на других конференцях.
Брайен дал четкий ответ, который можно увидеть вот в этом видео, но если коротко: Корень проблем в том, что union типы потребуют довольного сильных изменений в JLS, т.к, например, по JLS базово метод может иметь лишь одно возвращаемое значение, к тому же там возникают сложные ситуации с тем, чтобы соблюсти backward compatibility. В общем, этот вопрос он давний, но его активно сейчас не прорабатывают.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
