сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Java: ядро языка

Records, sealed и switch в Java

Современная Java: не застрял ли ты в восьмёрке

Замер, который показывает, зачем всё это. 20 000 задач, каждая ждёт 50 миллисекунд. На виртуальных потоках прогон занял 0,51 секунды. На пуле из 200 обычных потоков - 5,08 секунды, ровно вдесятеро дольше. Машина одна и та же, две ядра, код задачи идентичный.

Вопросы про версии с 14 по 21 проверяют одно: живёшь ли ты в актуальном языке или пишешь код восьмёрки в двадцать первой. Компании сидят на долгих версиях 17 и 21, и от кандидата ждут свободного владения record, sealed и выражениями switch.

// Формулировки: «чем record отличается от класса?», «зачем sealed?», «virtual threads - когда помогут, а когда нет?»

record: объект-значение без обвязки

record - неизменяемый носитель данных. Объявляешь компоненты и получаешь готовыми канонический конструктор, аксессоры, equals, hashCode и toString по значениям. Я напечатал: new Point(1, 2) выводится как Point[x=1, y=2], а две одинаковые точки равны и дают одинаковый хеш. Поля финальны, наследоваться от record нельзя.

Проверки кладут в компактный конструктор - это тело без списка параметров, которое выполняется до присваивания полей. Замер подтвердил: record Range с проверкой lo > hi ловит некорректный диапазон прямо в момент создания.

// И главный подвох: неизменяемость тут по ссылкам. Я создал record с обычным списком внутри, потом добавил элемент в исходный список снаружи - и содержимое record изменилось. Тот же record с List.copyOf в компактном конструкторе остался прежним. Хочешь честную неизменяемость - копируй коллекции.

record SafeBox(List<String> items) {
    SafeBox { items = List.copyOf(items); }  // копия, а не ссылка
}
компактный конструктор
тело без параметров, выполняется до присваивания
дырявая неизменяемость
record хранит ссылку, а её содержимое мутируется

sealed и switch: компилятор следит за полнотой

Модификатор sealed закрывает иерархию: список permits перечисляет всех наследников, других не будет. Компилятор знает полный набор вариантов, поэтому switch по такой иерархии не требует ветки default.

И проверяет полноту всерьёз. Я убрал одну ветку из такого switch и получил ошибку компиляции: the switch expression does not cover all possible input values. То есть добавили в домен новый вид платежа - код не соберётся, пока не обработаешь его везде. Это то самое, ради чего конструкция и придумана.

Рядом живут выражения switch со стрелками (возвращают значение, без проваливания между ветками) и сопоставление с образцом: case по типам, разбор record на компоненты, условия when. Связка record плюс sealed плюс switch - идиома моделирования доменных вариантов.

// Сюда же instanceof с привязкой переменной: if (o instanceof String s) проверяет и приводит одной конструкцией. И var - вывод типа локальной переменной. Именно локальной: в поле класса var не поставить, я проверил, компилятор говорит 'var' is not allowed here. Тип при этом остаётся статическим, никакой динамики.

sealed interface Payment permits Card, Sbp {}
return switch (p) {          // default не нужен
    case Card c -> c.pan();
    case Sbp  s -> s.phone();
};
sealed и permits
закрытый список наследников, известный компилятору
сопоставление с образцом
проверка типа и структуры с привязкой переменных

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

Вернёмся к замеру из начала. Виртуальные потоки - дешёвые потоки, которые виртуальная машина мультиплексирует поверх немногих потоков операционной системы, их называют несущими. Блокирующий вызов не занимает несущий поток: виртуальный отцепляется, несущий обслуживает других. Отсюда 0,51 секунды против 5,08 на пуле из двухсот - и арифметика сходится, 20 000 задач разделить на 200 потоков умножить на 50 миллисекунд как раз даёт 5 секунд.

Граница применимости жёсткая: это про пропускную способность ОЖИДАНИЯ. Задачам, которые считают, виртуальные потоки не дают ничего - ядер и несущих потоков столько же, вычисления быстрее не станут.

И вторая граница, про которую спрашивают отдельно. Блокировка внутри synchronized прибивает виртуальный поток к несущему. Замерил на 100 виртуальных потоках, каждый спит 50 миллисекунд со своей собственной блокировкой: под synchronized прогон занял 2,51 секунды, под ReentrantLock - 0,05 секунды. Разница в 50 раз, и она равна ровно числу ядер: под synchronized одновременно работали только два.

// Это поведение версии 21, в 24-й его починили. Но код обычно живёт на долгих версиях, поэтому правило пока такое: в местах, где виртуальный поток может заблокироваться, берём ReentrantLock, а не synchronized.

виртуальный поток
лёгкий поток для блокирующего ввода-вывода
прибивание к несущему
synchronized не даёт виртуальному потоку отцепиться

Как отвечать: «Чем record отличается от обычного класса и когда его берёшь?»

record это неизменяемый носитель данных: компоненты финальны, а канонический конструктор, аксессоры, equals, hashCode и toString генерируются по значениям, наследование запрещено. Беру его для объектов-значений: DTO (data transfer object), ключи мап, результаты методов, доменные значения вроде суммы денег. Валидацию кладу в компактный конструктор - тело без параметров, которое отрабатывает до присваивания полей. Про подвох помню и проверял его специально: неизменяемость у record по ссылкам, поэтому если компонент это коллекция, снаружи её можно изменить и содержимое record поедет следом. Лечится копией через List.copyOf прямо в компактном конструкторе. Не беру record там, где нужна изменяемая сущность с жизненным циклом - например, JPA (Java Persistence API) -сущности: у них контракт на мутабельность, пустой конструктор и прокси, record туда не годится по конструкции.

Сильный ответ: перечислена механика, названы конкретные места применения и показаны два подвоха - дырявая неизменяемость и несовместимость с сущностями. Это уровень «использовал», а не «слышал».

На чём валят

  • Считают record обычным POJO (plain old Java object). Сеттеров и наследования у него нет по замыслу, поля финальны.
  • Считают record с полем-списком неизменяемым. Список мутируется снаружи, спасает List.copyOf в компактном конструкторе.
  • Пишут default в switch по sealed «на всякий случай». Главный бонус потерян: компилятор перестаёт проверять полноту при добавлении наследника.
  • Пробуют var в поле или в сигнатуре. Компилятор отвечает 'var' is not allowed here - это только локальные переменные.
  • Ждут ускорения от виртуальных потоков на вычислениях. Их выигрыш только в ожидании, а synchronized и вовсе сводит его к числу ядер.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 20, остальные разбираются в тренажёре.

  1. #modern_features1 / 5
    Зачем нужны sealed-классы/интерфейсы (Java 17)?
    A)Чтобы запретить наследование класса — sealed делает его финальным, как ключевое слово final
    B)Чтобы автоматически сгенерировать equals/hashCode для всех наследников иерархии сразу
    C)Чтобы явно ограничить список наследников (permits) — тогда switch по типу можно проверить на полноту
    D)Чтобы разрешить множественное наследование классов, которое обычные классы в Java не позволяют
    показать ответ и разбор
    +C)Чтобы явно ограничить список наследников (permits) — тогда switch по типу можно проверить на полноту

    // разбор: sealed-тип явно перечисляет допустимых наследников через permits (а каждый наследник обязан быть final, sealed или non-sealed). Это даёт контролируемую, закрытую иерархию: компилятор знает ВСЕ варианты, поэтому switch по такому типу можно сделать исчерпывающим — если покрыты не все наследники, будет ошибка компиляции (а добавив новый наследник, вы сразу увидите, где switch пора дополнить). Отлично сочетается с record и pattern matching для моделирования «алгебраических» типов.

  2. #modern_features2 / 5
    Что даёт pattern matching для instanceof: if (o instanceof String s)?
    A)Проверку типа с одновременной привязкой и приведением к переменной s — без отдельного явного каста
    B)Гарантию, что переменная o не окажется равна null: при null-значении instanceof в этой новой форме сразу бросит исключение
    C)Сравнение o со строкой s по содержимому, заменяя собой вызов o.equals(s) в условии
    D)Автоматическое преобразование o в строку через toString(), даже если o другого типа
    показать ответ и разбор
    +A)Проверку типа с одновременной привязкой и приведением к переменной s — без отдельного явного каста

    // разбор: Раньше писали в два шага: if (o instanceof String) { String s = (String) o; ... }. Pattern matching совмещает проверку типа, приведение и объявление переменной: if (o instanceof String s) — внутри ветки s уже типа String, каст не нужен. Переменная связывается только там, где проверка истинна (можно использовать даже в условии: o instanceof String s && s.length() > 0). instanceof для null всегда даёт false (не бросает). Это убирает шумный каст и снижает риск ClassCast.

  3. #modern_features3 / 5
    Чем switch-выражение со стрелкой (case X -> ...) отличается от классического switch?
    A)Стрелочный switch работает только с enum, а классический — с типами, включая строки
    B)Это выражение: возвращает значение, стрелка не проваливается (без break), для enum/sealed требует полноты
    C)Стрелочный switch выполняет подряд все ветки ниже совпавшей, пока не встретит yield, — именно в этом и состоит его главное отличие
    D)Разница чисто синтаксическая: -> и: компилируются в один и тот же байткод с fall-through
    показать ответ и разбор
    +B)Это выражение: возвращает значение, стрелка не проваливается (без break), для enum/sealed требует полноты

    // разбор: switch-выражение (Java 14+) можно использовать как значение: var r = switch (day) { case MON -> 1; ... };. Стрелочная форма case X -> не проваливается на следующую ветку (fall-through нет, break не нужен), а для сложной логики есть блок с yield. Как ВЫРАЖЕНИЕ по enum/sealed оно должно быть исчерпывающим — покрыть все варианты или иметь default, иначе ошибка компиляции. Классический switch с : и break этих гарантий не даёт и легко ловит баги на забытом break.

  4. #modern_features4 / 5
    Что проверяет компилятор у switch с pattern matching (Java 21) по sealed-типу?
    A)Что все case-ветки возвращают один и тот же тип значения, иначе switch не скомпилируется
    B)Что покрыты все разрешённые наследники sealed-типа (иначе — ошибка компиляции о неполноте)
    C)Что каждая ветка помечена break, — при pattern matching fall-through обязателен и контролируется
    D)Ничего дополнительно: switch по типам компилятор проверяет так же, как обычный switch по int
    показать ответ и разбор
    +B)Что покрыты все разрешённые наследники sealed-типа (иначе — ошибка компиляции о неполноте)

    // разбор: switch по типам (case Circle c -> ...) в паре с sealed-иерархией даёт проверяемую полноту: раз компилятор знает весь список permits, он требует покрыть ВСЕ подтипы (или добавить default) — иначе ошибка компиляции о неисчерпывающем switch. Огромный плюс: добавили новый наследник в sealed — и все switch, где его забыли, перестают компилироваться, указывая, что дополнить. Плюс привязка переменной (c уже нужного типа) и вложенные record-паттерны. Это делает моделирование вариантных типов безопасным.

  5. #modern_features5 / 5
    Что делает var (Java 10) при объявлении локальной переменной?
    A)Делает переменную динамически типизированной: в неё можно потом положить значение типа
    B)Объявляет переменную-заготовку без типа, тип присваивается позже при первом использовании
    C)Просит компилятор вывести статический тип из инициализатора; тип фиксируется и не меняется
    D)Создаёт нетипизированную ссылку Object, к которой затем нужно применять явные приведения типов
    показать ответ и разбор
    +C)Просит компилятор вывести статический тип из инициализатора; тип фиксируется и не меняется

    // разбор: var — это вывод типа локальной переменной (local variable type inference). Компилятор определяет СТАТИЧЕСКИЙ тип из правой части (var list = new ArrayList<String>(); → тип ArrayList<String>) и жёстко его фиксирует — динамики, как в скриптовых языках, тут нет: положить потом другой тип нельзя. Требуется инициализатор (var x; запрещено), работает только для локальных переменных — не для полей, параметров, возвращаемых типов. Это сахар для читаемости, не изменение системы типов.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.