JEP draft: Null-Restricted and Nullable Types (Preview)
Типы Null-Restricted и Nullable (версия Preview (предварительная версия))
| Ответственный | Dan Smith |
| Тип | Feature |
| Область | SE |
| Статус | Draft |
| Компонент | tools / javac |
| Обсуждение | valhalla dash dev at openjdk dot org |
| Трудоёмкость | L |
| Длительность | M |
| Создан | 2023/02/23 01:23 |
| Обновлён | 2025/07/24 04:18 |
| Задача | 8303099 |
Аннотация
Поддержка маркеров nullness для типов Java, указывающих, что тип отвергает или намеренно допускает null. Это возможность языка в статусе Preview.
Цели
-
Расширить ссылочные типы Java, чтобы программисты могли указывать, ожидаются ли ссылки
nullкак значения типа -
Поддерживать преобразования между типами с разными свойствами nullness и сопровождать их предупреждениями о значениях
null, которые, возможно, обрабатываются неправильно -
Обеспечить совместимое взаимодействие с традиционным кодом на Java, который ничего не утверждает о совместимости своих типов с
null, и поддержать постепенное внедрение этих новых возможностей без несовместимостей на уровне исходного кода или двоичного кода -
Гарантировать, что переменные, типы которых отвергают
null, инициализируются до первого чтения -
Обеспечить соблюдение типов, отвергающих
null, во время выполнения, даже если классы компилируются раздельно -
Предоставить метаданные и гарантии целостности, необходимые для того, чтобы оптимизации во время выполнения (например, Flattening (плоское размещение) для Value Objects (объекты-значения)) могли полагаться на типы, которые заявляют об исключении
null
Что не является целью
-
Цель не в том, чтобы автоматически переосмыслить существующий код: использование этих возможностей должно быть необязательным и включаться явно (в дальнейшей работе будут исследованы механизмы массового включения без необходимости изменять отдельные типы)
-
Цель не в том, чтобы требовать от программ явно учитывать все значения
null, которые могут встретиться: неучтённые значенияnullмогут вызывать предупреждения при компиляции, но не ошибки компиляции -
Цель не в том, чтобы как-либо менять примитивные типы, например добавлять поддержку nullable-типа
int -
Цель (на данный момент) не в том, чтобы применить эти улучшения языка к стандартным библиотекам
Мотивация
В программе на Java переменная типа String может хранить либо ссылку на объект String, либо специальное значение null. В одних случаях автор подразумевает, что переменная всегда будет хранить ссылку на объект String, в других автор ожидает null как осмысленное значение. К сожалению, в языке нет способа формально выразить, какой из этих вариантов подразумевается, что приводит к путанице и ошибкам.
Например, программы часто исходят из общего допущения, что значений null не будет. Но чтобы изложить это ожидание в спецификациях Javadoc и надёжно обеспечить его соблюдение в коде реализации, требуются дополнительные усилия. Если их не приложить и кто-то не будет следовать предполагаемому протоколу, значения null могут беспрепятственно распространяться по коду реализации и в итоге вызвать исключение в месте, далёком от ошибки.
Ситуацию можно значительно улучшить, если дать разработчикам средства утверждать в составе типа, что либо (1) значения null не поддерживаются и будут отвергнуты, либо (2) значения null ожидаются и должны корректно учитываться.
По умолчанию язык не может обоснованно предполагать ни одну из этих интерпретаций. Разработчики должны явно указывать своё намерение.
Имея чётко выраженное намерение, язык мог бы ввести как обратную связь во время компиляции, так и проверки во время выполнения, чтобы помочь разработчикам раньше обнаруживать неожиданные null.
В проекте Valhalla переменную, тип которой — value-класс, можно оптимизировать с помощью плоского представления её значений. Но такому плоскому представлению могут понадобиться дополнительные биты для кодирования null, что увеличивает расход памяти, а иногда делает оптимизацию хранения вовсе невозможной. Если бы разработчик мог исключить null из области значений переменной, можно было бы получить более эффективное кодирование.
В проекте Amber свойство nullness проверяемого значения при Pattern Matching (сопоставление с образцом) может влиять на то, следует ли считать switch исчерпывающим, а nullness шаблона типа может влиять на то, совпадает ли шаблон с null. Было бы полезно, чтобы разработчики могли управлять этим поведением.
Описание
Описанные ниже возможности являются Preview-возможностями и включаются флагами --enable-preview при компиляции и во время выполнения.
Свойства и маркеры nullness
Ссылочный тип может при необходимости выражать nullness — то, предполагается ли включение null в множество значений типа.
В синтаксисе Java для указания этого свойства используется маркер nullness.
Тип Foo! является null-restricted: множество значений исключает null. (Null-Restricted Types (типы, запрещающие null) — типы, запрещающие null.)
Тип Foo? является nullable: множество значений намеренно включает null.
По умолчанию nullness типа Foo не указан: null может встретиться, но мы не знаем, намеренно ли.
Nullness типа является неотъемлемой частью типа, иными словами, Foo? и Foo — разные типы, поскольку у них разный nullness. Однако, как описано далее, большинство правил языка определены так, что они либо игнорируют nullness, либо помогают адаптироваться (возможно, с предупреждением) между типами с разным nullness.
Маркеры nullness могут быть как у типов массивов, так и у типов их компонентов. Foo?[]! — null-restricted-тип массива, компоненты которого имеют nullable-тип Foo. Маркеры null для многомерных массивов могут стоять после каждой пары скобок и по соглашению интерпретируются от внешнего к внутреннему, слева направо.
Аналогично маркеры nullness могут быть как у параметризованных типов, так и у их аргументов типа. Predicate!<Foo?> — null-restricted Predicate с nullable-типом Foo в качестве аргумента типа. Интерпретация аргументов типа подробнее описана далее.
В этом JEP маркеры nullness явные: чтобы выразить null-restricted- или nullable-тип, в исходном коде должен присутствовать символ ! или ?. В будущем может оказаться полезным, например, чтобы программист мог каким-либо образом указать, что каждый тип в классе или единице компиляции следует интерпретировать как null-restricted, если не указан символ ?. Детали такой возможности будут исследованы отдельно.
Инициализация полей и массивов
Большинство переменных в Java должны быть инициализированы значением типа переменной, прежде чем их можно использовать. Локальные переменные проверяются на определённое присваивание (definite assignment), прежде чем на них можно сослаться; параметры методов получают начальные значения из выражения вызова метода; переменные шаблонов связываются в процессе сопоставления с шаблоном и т. д.
Традиционно поля и компоненты массивов обрабатываются особым образом: поскольку к ним могут обращаться несколько компонентов программы сразу после создания класса или объекта, они автоматически инициализируются «при рождении» значением по умолчанию, которое программисты обычно перезаписывают в ходе выполнения программы.
Значение по умолчанию для ссылочного типа — null. Но это неподходящее начальное значение для null-restricted-поля или компонента массива: если кто-то прочитает переменную до того, как в неё записали значение, он увидит значение, не относящееся к типу переменной.
Поэтому поля и массивы с null-restricted-типами ведут себя иначе, чем другие поля и массивы: программа должна всегда инициализировать их, прежде чем их можно прочитать. Это обеспечивается следующим образом:
-
Null-restricted-поле экземпляра без инициализатора должно быть определённо присвоено до (явного или неявного) вызова
super(...)в каждом конструкторе класса. JEP Flexible Constructor Bodies (Flexible Constructor Bodies — гибкие тела конструкторов) позволяет писать необходимый код инициализации в начале конструктора. В этом контексте ранней конструкции логике инициализации не разрешено ссылаться наthisили допускать какие-либо попытки прочитать неинициализированное поле.class Person { private String! name; public Person(String name) { this.name = name; super(); } } -
Если у null-restricted-поля экземпляра есть инициализатор, он выполняется в начале каждого конструктора, до вызова
super(...). (Конструкторы, вызывающиеthis(...), — особый случай и, как обычно, вообще не выполняют инициализаторы.) Это снова означает, что логика инициализации поля выполняется в контексте ранней конструкции и не может ссылаться наthisили допускать какие-либо чтения неинициализированного поля. -
Null-restricted-статическое поле должно быть определённо присвоено к концу всех статических инициализаторов и блоков инициализации класса. Однако обратите внимание, что это правило не мешает какому-либо другому классу попытаться прочитать поле в процессе инициализации класса; в этом случае проверка во время выполнения обнаруживает попытку раннего чтения и выбрасывает исключение.
В следующем примере код инициализации поля
Foo.sсодержит циклическую зависимость. Традиционно циклическая ссылка наFoo.sдала бы значениеsпо умолчанию,null; с null-restricted-типом поля это невозможно, поэтому выбрасывается исключение, сообщающее разработчику об ошибке.class Foo { public static String! s = Bar.getString(); } class Bar { static String! getString() { return Foo.s; // may throw an exception } } -
Массив с null-restricted-типом компонентов должен предоставлять инициализатор для каждого компонента в выражении создания массива. Этого можно достичь, явно перечислив каждое начальное значение в инициализаторе массива или используя новую сокращённую форму (синтаксис пока не определён).
String![] labels; labels = new String![]{ "x", "y", "z" }; labels = new String![100]{ "" }; // strawman syntax labels = new String![100]{ i -> "x"+i }; // strawman syntax
Nullness выражений и преобразования
В рамках проверки типов компилятор Java отвечает за определение nullness каждого выражения.
-
Nullness ссылки на переменную определяется объявлением этой переменной (но пока не определено, будет ли компилятор Java дополнительно учитывать, что благодаря предыдущим использованиям переменной известно об отсутствии
null). -
Nullness вызова метода определяется типом возвращаемого значения вызываемого метода.
-
Тип выражения приведения явно указан в приведении (но, опять же, пока не определено, учитывает ли компилятор Java другую информацию).
-
Литерал
nullявляется nullable (разумеется). -
Большинство других выражений ссылочного типа являются null-restricted. К ним относятся литералы, конкатенации строк,
this, создание экземпляров классов и массивов, ссылки на методы и лямбда-выражения.
Преобразование nullness позволяет рассматривать выражение с одним видом nullness как имеющее другой nullness. Преобразования nullness разрешены во всех контекстах присваивания, вызова и приведения.
Расширяющие преобразования nullness:
Foo!вFoo?Foo!вFooс неуказанным nullnessFoo?вFooс неуказанным nullnessFooс неуказанным nullness вFoo?
А это сужающие преобразования nullness:
Foo?вFoo!Fooс неуказанным nullness вFoo!
Сужающие преобразования nullness аналогичны преобразованиям распаковки: компилятор выполняет их автоматически, а во время выполнения они влекут динамическую проверку, которая может привести к NullPointerException.
Более того, теперь можно упростить преобразования упаковки и распаковки: упаковка преобразует int в Integer!, за чем может последовать расширяющее преобразование nullness в Integer; распаковка преобразует Integer! в int, чему может предшествовать сужающее преобразование nullness из Integer в Integer!.
Попытка напрямую преобразовать литерал null в null-restricted-тип является ошибкой компиляции.
Проверка null во время выполнения
Если во время выполнения значение null подвергается сужающему преобразованию nullness в null-restricted-тип, выбрасывается NullPointerException.
String? id(String! arg) { return arg; }
String s = null;
Object! o1 = s; // NPE
Object o2 = id(s); // NPE
Object o3 = (String!) s; // NPE
Некоторые сужающие преобразования nullness не видны в исходном коде, но происходят неявно в ходе выполнения. К ним относятся:
-
Массиву, созданному с null-restricted-типом компонентов, в исходном коде может быть присвоен менее конкретный тип, но он всё равно будет отвергать значения
nullпри обычной проверке записи в массив. Неудачное преобразование приведёт кArrayStoreException. -
Аналогично поле, которое при компиляции не было null-restricted, но позже при раздельной компиляции стало null-restricted, будет отвергать значения
nullпри новой проверке записи в поле. Неудачное преобразование приведёт кFieldStoreException. -
Когда один метод переопределяет другой, аргумент вызова метода суперкласса подвергается преобразованию в тип параметра при вызове, а затем — преобразованию в тип параметра переопределяющего метода. Как описано ниже, nullness этих двух типов параметров может различаться.
-
Аналогично возвращаемое значение метода подвергается преобразованию в объявленный тип возвращаемого значения метода, а затем — преобразованию в тип возвращаемого значения, ожидаемый при вызове.
Nullness переменных типа
Как и другие типы, тип переменной типа (то есть использование переменной типа) может выражать nullness. T! — null-restricted-тип, а T? — nullable-тип.
Null-restricted- и nullable-типы переменных типа (T! и T?) задают определённую nullness внутри обобщённого кода. Это может быть уместно, если обобщённый код напрямую взаимодействует с null.
class Box<T> {
boolean set;
T? val; // nullable field
public Box() { set = false; }
public void set(T val) { this.val = val; set = true; }
public T? getOrNull() { // nullable result
return set ? val : null;
}
public T! getNonNull(T! alt) { // null-restricted result
return (set && val != null) ? (T!) val : alt;
}
}
Типы, используемые как аргументы типа, могут выражать nullness; маркеры null на типах переменных типа переопределяют nullness, заданную аргументом типа, какой бы она ни была.
Box<String!> b1 = new Box<String!>();
b1.getOrNull(); // nullable result
Box<String?> b2 = new Box<String?>();
b2.set(null);
b2.getNonNull(""); // null-restricted result
Конечно, ограничения на null нельзя обеспечить внутри стёртой реализации обобщённого API. Но обычные неявные приведения, происходящие на границах обобщённых API, будут обеспечивать соблюдение null-restricted-аргументов типа во время выполнения.
Аргументы типа и границы
Как показано выше, аргументы типа могут выражать nullness, которая влияет на подставленную nullness API везде, где встречаются параметрические переменные типа.
Ради совместимости nullness в аргументах типа строго не обеспечивается, а непроверяемые преобразования nullness позволяют изменять nullness аргументов типа. Например, Predicate<String!> можно преобразовать в Predicate<String> или Predicate<String?>. Такие преобразования могут вызывать предупреждения (см. «Предупреждения компилятора» ниже).
Аналогично непроверяемые преобразования nullness позволяют изменять nullness типов компонентов массивов. Ещё предстоит определить, при каких условиях эти преобразования проверяются во время выполнения.
Объявление переменной типа или подстановочный тип (wildcard) может содержать маркеры nullness на своих границах. Однако тип может удовлетворять границам через преобразование nullness, поэтому и эти маркеры nullness строго не обеспечиваются, но могут вызывать предупреждения.
Переопределение методов и выведение аргументов типа
При определении того, имеют ли два метода одинаковую сигнатуру, nullness игнорируется. Один метод может переопределять другой, даже если nullness их параметров и возвращаемых значений не совпадает.
class A {
String? lookup(String! arg) { ... }
}
class B extends A {
String lookup(String arg) { ... }
}
Такие несоответствия будут частыми, поскольку разные API вводят маркеры nullness независимо друг от друга.
Формально два метода считаются имеющими одинаковую сигнатуру, если каждый тип параметра и каждая граница параметра типа могут быть преобразованы в соответствующие у другого метода с помощью преобразований nullness и непроверяемых преобразований.
Аналогично тип возвращаемого значения переопределяющего метода должен преобразовываться в тип возвращаемого значения переопределяемого метода с помощью расширяющего ссылочного преобразования, за которым, возможно, следуют преобразования nullness и непроверяемые преобразования.
Если метод обобщённый, любые параметрические использования его параметров типа в сигнатуре метода будут влиять на выводимую nullness аргументов типа. Nullness не влияет на применимость метода и не может привести к неудаче выведения аргументов типа, но может влиять на nullness, выводимую для типа возвращаемого значения метода. (Детали алгоритма выведения ещё предстоит определить.)
Предупреждения компилятора
Как описано выше, если сделать тип null-restricted, могут появиться новые ошибки компиляции, если поле или массив этого типа остаются неинициализированными или если делается попытка преобразовать литерал null в этот тип. Сравнение литерала null с выражением null-restricted-типа также может быть ошибкой компиляции.
В остальных ситуациях анализ nullness является вспомогательным и не приводит к ошибкам компиляции. Однако javac будет выдавать предупреждения, чтобы помочь программистам избежать ошибок во время выполнения. IDE и другим инструментам анализа рекомендуется делать то же самое. Возможные источники предупреждений:
-
Сужающие преобразования nullness, особенно из типов
? -
Выражения типа
?, используемые при обращении к членам или в других операциях, несовместимых с null -
Аргументы типа, nullness которых не согласуется с их границами
-
Параметры или возвращаемые значения методов с nullness, не совпадающей с переопределяемым методом
-
Непроверяемые преобразования, меняющие nullness типа
Компиляция и представление в class-файле
Большинство использований маркеров null стираются в файлах class, а сопутствующие преобразования во время выполнения выражаются непосредственно в байт-коде.
Грамматика атрибутов Signature обновлена, чтобы при необходимости допускать ! и ? в типах. Nullness не кодируется в дескрипторах методов и полей.
Однако, чтобы предотвратить загрязнение полей, новый атрибут NullRestricted позволяет полю указать, что оно не допускает значений null. Это приводит к следующему:
-
Поле также должно быть помечено
ACC_STRICT, что означает, что оно должно быть «строго инициализировано». Верификатор проверяет, что всем строго инициализируемым полям экземпляра присвоены значения к моменту, когда конструктор выполняет вызовsuper(...). -
Каждая попытка записи в поле проверяет, не записывается ли значение
null, и если да, выбрасываетFieldStoreException.
Инструкция anewarray не поддерживает создание null-restricted-массивов: его нужно выполнять вызовом API рефлексии (см. ниже). Все попытки записи в компонент null-restricted-массива отвергают значения null при обычной проверке записи в массив.
Базовая рефлексия
Литералов Foo!.class или Foo?.class не существует, как и соответствующих экземпляров java.lang.Class. Эти типы выводятся из объявления класса, но не представляют отдельные классы. (Сравните List<String> и List<Integer>.)
Однако новый API RuntimeType описывает множество типов, соблюдение которых обеспечивается проверками записи в массивы и поля во время выполнения, включая null-restricted-вариант каждого типа класса и интерфейса. (Это надмножество типов компоновки, которые могут встречаться в дескрипторах и представлены API Class.)
API Field поддерживает запрос RuntimeType поля, который может не совпадать с результатом getType.
API Array поддерживает варианты newInstance, позволяющие выразить тип компонента с помощью RuntimeType. Эти варианты также позволяют вызывающему коду задать начальные значения компонентов массива и будут отвергать попытки создать null-restricted-массивы без начальных значений. Ещё один новый метод отражает RuntimeType компонентов массива.
Сопутствующие изменения
Традиционная десериализация несовместима с null-restricted-полями и массивами. Отдельный JEP предоставит новый механизм поддержки сериализации, не раскрывающий неинициализированные null-restricted-поля и массивы.
Документация, генерируемая javadoc, будет включать маркеры nullness.
API java.lang.reflect.Type и javax.lang.model будут кодировать nullness в своём представлении типов.
Альтернативы
Ряд инструментов разработки в экосистеме Java реализовали собственное отслеживание null во время компиляции. Эти инструменты не меняют язык Java и поэтому, естественно, имеют некоторые ограничения, особенно в используемом синтаксисе (аннотации) и в поведении, на которое они могут влиять (проверки во время компиляции).
Другие языки программирования отслеживают nullness в своих системах типов. Во многих из них типы по умолчанию null-restricted. Многие также считают ошибкой присваивание значению null-restricted-типа без явной проверки на null. В случае Java важно, чтобы эта возможность была необязательной и чтобы программисты могли начинать её использовать постепенно, без монолитной миграции.
Обеспечение nullness во время выполнения можно реализовать явными проверками или вызовами стандартного API Objects.requireNonNull. Но последовательно применять эти проверки утомительно, это требует дополнительной работы над документацией и затрудняет чтение программ. Применить такие проверки непосредственно к хранилищу переменных, особенно к полям и массивам, невозможно.
Зависимости
Предварительные требования:
- Flexible Constructor Bodies (Second Preview) позволяет конструкторам выполнять операторы до вызова
super(...)и разрешает в этом контексте присваивания полям экземпляра. Эти изменения упрощают выполнение требований к инициализации null-restricted-полей.
Дальнейшая работа:
-
Null-Restricted Value Class Types (Preview) оптимизирует кодирование null-restricted-полей и массивов с типами Value Classes (классы-значения) и может позволить некоторым value-классам объявлять собственные значения по умолчанию.
-
JEP 402: Enhanced Primitive Boxing (Preview) будет отслеживать nullness, расширяя использование неявных преобразований упаковки в языке.
-
Специализация классов и методов в JVM (JEP 218, с доработками) позволит обобщённым классам и методам овеществлять nullness (по крайней мере некоторых) своих аргументов типа и обеспечивать её соблюдение.
Другие возможные будущие улучшения на основе этого JEP могут включать:
-
Применение маркеров nullness к определённым частям стандартных API.
-
Доработку JVM, чтобы она предоставляла краткий способ с минимальными затратами выразить проверку на null в байт-коде.
-
Доработку JVM, чтобы она обеспечивала более строгое низкоуровневое соблюдение null-restricted-параметров методов.
-
Введение в язык механизма, позволяющего указать, что все типы в определённом контексте неявно null-restricted, не требуя от программиста явно использовать символы
!.