Преуведомление
Вся нотация, используемая в этой статье, не является общепринятой для представления математических выражений. Возможно, вы ранее изучали эту тему либо продолжаете изучать, поэтому заранее прошу прощения, если допустил какие-либо фактические ошибки или некорректно использовал термины.
Выпуск Java 21 состоялся 19 сентября 2023 года. В этой версии поддерживаются паттерны записи в switch-блоках и выражениях. Такой синтаксис выглядит монументально (как минимум, по меркам Java). Это водораздел, после которого мы вправе говорить, что в Java полноценно поддерживаются паттерны функционального программирования, подобно тому, как это сделано в Kotlin, Rust или C#. Вот и первый пункт, который пробуждает во мне зависть (я Kotlin-разработчик).
Краткая история свежих версий Java
По состоянию на 2023 год язык Java ускоренно развивается на протяжении вот уже 10 лет. Релиз Java 9 был последним из «медленных», так как все последующие релизы выпускались с разницей в 6 месяцев. В следующей таблице показано, как язык Java обновлялся в течение последних 10 лет, подчёркнуты основные синтаксические изменения и дополнения, сделанные в каждой из версий (большинство изменений я опустил, чтобы не отвлекаться от темы).
Некоторые из вышеперечисленных релизов стоит рассмотреть отдельно.
В Java 14 были стабилизированы switch‑выражения, в Java 16 — записи и сопоставление с образцом для instanceof, в Java 17 — запечатанные классы. Теперь же, в Java 21, стабилизируются паттерны записей и сопоставление switch‑выражения с образцом.
Благодаря данному набору изменений в Java появляется возможность выражать такие базовые феномены функционального программирования, к которым ранее в Java было просто не подступиться. В частности, я имею в виду алгебраические типы данных, а также идиоматические способы их использования.
Алгебраические типы данных — это концепция, родившаяся в рамках теории типов. Это ответвление теории множеств; теория типов сосредоточена на разрешении таких вопросов как «фрукт ли яблоко?» и прочих причудливых связок, которые учителя математики любят подбрасывать ничего не подозревающим студентам, чтобы у тех голова пошла кругом.
Предельно краткое знакомство с некоторыми терминами, встречающимися в теории типов
В теории типов достаточно много «мяса», но большая часть этого материала неважна в контексте данной статьи.
Поэтому не буду объяснять всю теорию типов целиком, а затрону некоторые конкретные разновидности типов, разобраться в которых будет полезно.
Низший или пустой тип (⊥)
Данный тип описывает множество всех значений, являющихся невычислимыми (в смысле полноты по Тьюрингу). В любом нормальном языке программирования это множество обычно является пустым (Ø).
Ни один объект нельзя привести к низшему типу, поскольку это пустое множество.
Пример такого типа — это Nothing
, применяемый в Kotlin. Если в программе существует хотя бы один экземпляр Nothing
, это считается ошибкой. В Kotlin, чтобы предотвратить создание экземпляров Nothing
, соответствующий конструктор нужно сделать приватным.
Аналогичный тип в Java называется Void, это класс-обёртка для примитивного типа void. Опять же, создать экземпляр Void
невозможно, поскольку его конструктор является приватным. Но Void
нельзя считать подлинно низшим типом, так как в переменной Void
всё-таки может содержаться null
. То есть, чисто технически это единичный тип (подробнее об этом ниже). В таком смысле примитив void
подойдёт нам лучше. Нет абсолютно никакой возможности использовать этот тип в качестве переменного, так что у вас даже не может быть пустой переменной void
.
Высший тип (⊤)
Данный тип представляет любое значение любого типа — универсальное множество значений U
. В Kotlin такой тип называется Any
. Причём, может быть соблазнительно счесть высшим тип Object
(в Java он аналогичен Any
), если, конечно, не учитывать примитивов. Примитивы совершенно обособлены от объектной модели Java и взаимодействуют с объектами довольно нестандартными способами. Именно поэтому в Java, строго говоря, нет такого высшего типа, какой есть в некоторых других языках.
Тем временем, C себе в этом не изменяет и просто перегружает void
: при помощи void
*
представляет указатель, который может быть направлен на значение любого типа. Как смешно!
Любой объект может быть приведён к высшему, поскольку в U
содержатся все существующие значения.
Более о высшем типе почти нечего добавить, разве что в переменной этого типа может содержаться что угодно. В том числе значение низшего типа. Однако, если возьмётесь искать это значение — что ж, удачи вам.
Единичный тип (())
Этот тип имеет всего одно значение. Существует всего один экземпляр с этим значением, и создать второй такой экземпляр невозможно.
Технически, именно так устроен примитив void в Java. Когда метод возвращает void, можете считать, что под капотом он неявно возвращает единственный экземпляр типа void (но в виртуальной машине Java тип void обрабатывается не так). Здесь Java отклоняется от нормы, закреплённой в теории, так как void ни при каких условиях не может быть передан в метод как параметр.
Примечание
Фактически, Java просто скрещивает низший и единичный тип — так и получается void
.
Если потребуется, вы можете сымитировать в Java единичный тип. Для этого объявите новый финальный класс, в котором нет никаких полей сверх одного статического значения экземпляра. В таком случае вы сможете обращаться с этим экземпляром как с единичным типом, имеющим единственное значение.
На самом деле, именно так в Kotlin определяется предусмотренный там тип Unit
. Если вы заглянете в определение этого Unit, то увидите, насколько просто он трактуется; это просто object!
Kotlin, в отличие от Java, позволяет использовать Unit
где угодно, в том числе, передавать методу в качестве параметра. Следовательно, вот так делать можно:
fun identity(param1: Unit): Unit = param1
val result = identity(param1 = Unit) // просто вновь возвращает экземпляр Unit
Логический тип
Мы снова перебрались на знакомую территорию.
У логического (булева) типа есть два допустимых значения, true и false (или вы можете назвать их как-то иначе, как вам удобнее). На самом деле, этот тип можно представить, даже не прибегая к нативному логическому типу того языка, с которым вы работаете. С тем же успехом можно справиться, воспользовавшись таким экземпляром единичного типа, который может принимать null. Если переменная не равна null, то она true, а если равна null, то она false. Естественно, всё это пустая трата времени, и заниматься такими вещами могут только те, кто старается обфусцировать свой исходный код.
Итак, мы рассмотрели простейшие примеры тех «правил», по которым определяются типы в соответствии с теорией типов. Теперь давайте перейдём к сути и обсудим тип-суммы и тип-произведения, а также обсудим, как в Java 21 можно представлять их при помощи записей и запечатанных классов.
Тип-произведения
Тип-произведения — это составные конструкции, состоящие из двух или более типов. Вообще, тип‑произведение — это список из двух или более типов, сгруппированных воедино. Арность или степень тип‑произведения — это количество типов, входящих в состав этого произведения.
Если вы хотели бы посмотреть красивый конкретный пример тип-произведения, то вот вам самая обычная структура из C (struct):
struct some_type {
int val1; // тип 1
char *val2; // тип 2
double val3; // тип 3
int val4; // тип 4
};
В вышеприведённой структуре some_type
— это тип‑произведение, в состав которого входят четыре разных типа: int
, char *
, double
и опять int
. Обратите внимание: здесь у нас повторяется int
. Как нам определить, какой int в каком случае используется, если мы выполняем операции над некоторым some_type
? Это может показаться очевидным, но с математической точки зрения это проблема, поскольку приходится самостоятельно с нуля собрать все кирпичики и концепции, которые вы собираетесь использовать!
В данном случае у нас уже есть инструментарий, при помощи которого можно всё это запустить в работу. Каждый тип мы ассоциируем с именем, присвоенным структуре (уф). С математической точки зрения тип‑произведение — это не просто список типов, а список упорядоченных пар, где каждая из этих пар состоит из типа и имени, ассоциированного с этим типом.
Например, первое значение some_type можно представить как упорядоченную пару (int
, "val1"
). Таким образом, невозможно перепутать два компонента int
; у них же разные имена!
А что насчёт кортежей, таких, как применяются, например, в Python или Rust?
Их также можно расценивать как тип-произведения, где «именем» служит индекс данного типа-компонента в составе кортежа.
some_tuple = (1, '2', True, 5)
int_1 = some_tuple[0] # (int, 0)
str_2 = some_tuple[1] # (str, 1)
...
Почему же мы всё равно называем их тип-произведениями?
В теории множеств под «произведением» обычно понимается декартово произведение двух множеств.
Примечание
Декартово произведение двух множеств — это множество упорядоченных пар, представляющих собой все возможные комбинации из всех элементов, входящих в оба множества.
Если изложить эту проблему в простых математических терминах, имеем: количество элементов в декартовом произведении C двух множеств A и B — это произведение количества элементов в A и количества элементов в B.
Можно воспользоваться нотацией из теории множеств, чтобы выразить произведение двух типов A и B как C = A × B. Данная операция произведения некоммутативна; A × B не идентично B × A. Призадумайтесь об этом ненадолго, и поймёте, почему: ведь здесь приходится менять порядок декларируемых компонентов! В том примере, который я разбирал выше, в качестве компонентов используются всего два типа: A и B. Как нам, например, представить some_type
? Для этого нужно сцепить вместе несколько операций произведения, вот так:
some_type = int × char* × double × int
Множество значений, входящих в состав тип-произведения, можно выразить следующим образом (своё возмущение тем, как я (зло)употребляю математические символы, изложите пожалуйста в комментариях):
C = A × B = {(a, b) | a ∈ A, b ∈ B}
Можно вот так представить множество всех значений, относящихся к some_type
:
some_type = { (val1, val2, val3, val4) | val1 ∈ int, val2 ∈ char*, val3 ∈ double, val4 ∈ int }
Отлично, вот и разобрались, что представляют собой типы-произведения. Как всё это связано с Java?
Когда вышла версия Java 16, была стабилизирована фича «записи». Классы записей — отличный пример типов‑произведений. Все поля в этих классах финальные, наследовать от них также нельзя. Состояние записи целиком устанавливается в момент её создания и, после того как запись создана, так она и будет выглядеть весь оставшийся период своего существования — примерно 200 миллисекунд.
Этим они резко отличаются от нормальных классов, встречающихся в Java повсюду. Работая с ними, можно иметь публичное и приватное состояние, а из-за наследования может возникать и скрытое состояние. О котором вы даже не подозреваете, пока оно не выскочит, как одержимый чёртик из табакерки, чтобы напугать вас странными багами. У вас при этом могут быть изменяемые поля, статические поля, а также всевозможные прочие раздражающие вещи (ну, вы меня поняли).
С обычными типами Java возникает такая проблема: невозможно сделать обобщение, под которое подходили бы все компоненты типов. А если вы хотите эффективно обрабатывать данные, то иметь такое обобщение очень важно. Приходится пробраться сквозь лабиринт потенциально нестандартных геттеров, чтобы хотя бы получить ваши данные, не говоря уж о том, чтобы их переработать.
Так сложилось, что в Java никогда не поддерживалась деструктуризация — в отличие от JavaScript или Rust. Но, даже если бы в Java она поддерживалась, в спецификации допускалось бы применять эту возможность только с записями. Давайте попробуем ответить на несколько вопросов, чтобы лучше понять, почему так.
Примечание
Деструктуризация — это возможность, присутствующая в некоторых языках (самый известный из них — JavaScript), позволяющая вам принять сложное значение, а затем, позвольте мне такой PHP‑изм, EXPLODE его на компоненты, чтобы в итоге получился список совершенно независимых переменных. Подробнее об этой возможности можно почитать здесь.
Как же вообще можно деструктурировать обычный класс Java?
Во внутреннем состоянии класса Java содержатся все его поля, как публичные, так и приватные. Но кажется, что разрешить извлечение приватных полей при помощи деструктуризации — не лучшая идея. Все мы знаем, как дядюшку Боба бесит нарушение инкапсуляции. Что ж, хорошо, давайте не распространять деструктуризацию на приватное состояние.
Что же насчёт публичного состояния?
Давайте сначала вот о чём задумаемся: как объекты Java предоставляют публичное состояние? Естественно, поле можно определить как публичное, а, если хочется добиться, чтобы его как попало не меняли — сделайте это поле финальным. Но для этого есть и другой крайне распространённый подход. В большинстве объектов Java все поля делаются приватными, а доступ ко всем полям (как для чтения, так и для записи) разрешается только через специальные методы доступа.
К сожалению, в языке нет обязывающих соглашений, которые требовалось бы соблюдать при определении методов доступа. Например, геттер для foo вполне можно назвать getBar
, а он всё равно будет работать нормально — разве что запутает любого, кому понадобится обратиться к bar
, а не к `foo'
.
Разумеется, можно воспользоваться фреймворком наподобие Lombok и устранить подобную сложность и неопределённость, всего лишь расставив несколько аннотаций по вашим классам POJO. Но всё равно, как правило, об обычных классах Java очень сложно судить в статике из‑за того, как много «переменных» участвует в определении состояния класса.
Подозреваю, в этом и заключается одна из причин, по которым авторы спецификации Java сразу не добавили ко всем классам сопоставление с образцом (В JEP-441 это нововведение упомянуто среди планов на будущее).
Чтобы справиться с этой проблемой, в язык были добавлены записи — совершенно иная иерархия классов. Подобный прецедент уже был ранее, когда в Java 5 ввели перечисления, наследующие от java.lang.Enum
. Аналогично, все записи наследуют от java.lang.Record
.
Итак, что же такое можно сделать при помощи записей, что не делается при помощи обычных классов?
При работе с записями эта проблема решается так: вводятся ограничения на определения записей, а вдобавок жёстко задаётся тот набор свойств, которые они могут иметь.
А именно:
-
Записи неявно являются финальными классами, и от них нельзя наследовать.
Больше никаких недопустимых дочерних классов, которые могли бы быть прижиты от случайной связи со сторонней библиотекой.
-
Сами записи не могут наследовать ни один класс кроме
java.lang.Record
.Так устраняются подводные камни, которые могли бы возникнуть, если унаследованное состояние загрязнит код записи.
У компонентов записи не может быть никаких модификаторов видимости.
-
Любые компоненты записей всегда финальные и неизменяемые.
При этом правило неизменяемости не распространяется на любой компонент записи.
Только ссылки из компонента записи должны быть неизменяемыми.
-
Когда вы объявляете запись и не определяете для неё геттеров, методы-геттеры в дальнейшем будут определяться при помощи очень специфичного синтаксиса.
Этот синтаксис очень строг; в качестве имени геттера Java использует только имя поля.
Геттер для поля
a
будетa()
.Java воспользуется вашим определением, если вы вручную определите геттер, соответствующий соглашениям об именовании. В противном случае Java автоматически определит геттер, который полностью соответствует всем соглашениям. Нестандартный геттер большой разницы не сделает.
Поля, соответствующие компонентам записи, всегда неявно приватные, доступ к ним возможен только через геттеры.
(Есть ещё некоторые детали, но, пожалуй, остановлюсь на уже сказанном.)
Благодаря тому, что записи обладают этими свойствами, гарантируется: любая новая фича Java, использующая записи (например, сопоставление с образцом) непременно будет работать. Ведь в самой спецификации языка гарантируются и все варианты поведения, и структура записей.
Красота. Расскажите, как я смогу с этим работать.
Сопоставление с образцом.
Продолжайте…
Довольно обременительно писать глубоко вложенный код, когда приходится учитывать множество условий, зависящих от типов ваших данных. Эта проблема станет просматриваться особенно явно, когда ниже в статье мы поговорим о тип-суммах. Сопоставление с образцом — это способ статически (то есть во время компиляции, по мере написания кода) убедиться, что определённые паттерны действительно присутствуют в обрабатываемых вами данных.
Рассмотрим нижеприведённый пример. Обратите внимание, что в A
содержится экземпляр Record
, и вообще там может быть любой тип записи. Сначала попробуем вывести в консоль содержимое r
, воспользовавшись обычными для Java if
-операторами, а потом попытаемся добиться того же, сопоставляя switch
-выражения с образцом.
record A(Record inner) {}
record B(char b) {}
record SomeOtherRecord() {}
Record eitherAorB() {
boolean cond1 = ((int)(Math.random() * 100) % 2 == 0);
boolean cond2 = ((int)(Math.random() * 100) % 2 == 0);
return cond1 ? new A(cond2 ? new A(null) : new B('e')) : new B('f'); // возвращает A или B.
}
void main() {
var r = eitherAorB();
String oldJavaResult = "";
if (r instanceof A) {
var inner = ((A)r).inner(); // мы должны привести его к...
if (inner instanceof B) {
oldJavaResult = String.valueOf(((B)inner).b());
} else if (inner instanceof SomeOtherRecord) {
oldJavaResult = null;
}
} else if (r instanceof B) {
oldJavaResult = String.valueOf((B)r.b());
} else {
oldJavaResult = "r does not match any pattern";
}
System.out.println("With the old method: \"" + oldJavaResult + "\"");
// Тип в данном случае — Record.
var result = switch (r) {
case A(B(char a)) -> String.valueOf(a); // Деструктуризация!
case A(SomeOtherRecord(/* ... */)) -> {
// теперь обработаем.
yield null;
}
case B(char b) -> String.valueOf(b);
default -> "r does not match any pattern";
};
System.out.println(result.toString());
}
Блок switch
структурно явно более удобен, чем приведённая выше лесенка if-else
. Switch-паттерны обладают большим потенциалом, когда требуется быстро и легко извлечь глубоко вложенные данные, не возясь с проверками instanceof
и трудоёмкими приведениями типов. Если вам когда-либо доведётся поработать с Java 21 (да повезёт вам с начальством, которое не чурается новых версий Java) — уверен, вы по достоинству оцените эту фичу.
Если вы хотели бы попробовать эти возможности самостоятельно, то для начала установите Java 21. Для этого в AUR есть удобный пакет jdk21-jetbrains-bin, если хотите управиться максимально быстро. Я, к слову, пользуюсь Arch. Скопируйте этот код в main.java и выполните его командой:
java --enable-preview --source 21 main.java
Здесь также во всей красе предстаёт ещё одна новая фича, которая сейчас в состоянии превью: безымянные main-методы.
В предыдущем примере нам удалось при помощи сопоставления с образцом переключаться между разными типами записей. Теперь давайте ненадолго отложим эту тему и поговорим о том, как в Java управлять операциями выбора.
Мы выбираем…
Что же делать, если вам требуется выбирать из ограниченного набора альтернативных вариантов? Например, если работаете с перечислениями в Java: они представляют собой группы статических вариантов, и вы не можете менять данные, содержащиеся внутри них.
public enum Color {
RED(255, 0, 0),
GREEN(0, 255, 0),
BLUE(0, 0, 255);
public final int red;
public final int green;
public final int blue;
Color(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
}
В вышеприведённом перечислении определяется три цвета: красный, зелёный и синий, причём устанавливаются значения для разных полей. Цветовые значения в них изменить невозможно, если вы не хотите учинить беспорядок буквально в каждой точке, где используется это перечисление (в рассмотренном коде значения финальные, а представьте, что бы было, не будь они финальными).
Теперь переформулируем задачу. Вам нужны различные представления цветов — например, в системах RGB, HSL и CMYK. Может быть, просто сделать перечисление для этой цели?
public enum ColorRepresentation {
RGB,
HSL,
YUV,
CMYK
}
Так у нас получается красивое ограниченное множество значений, из которых мы можем выбирать. Но работать c ним неудобно: если вам требуется иметь по несколько цветовых значений для разных представлений, то придётся отдельно хранить данные о конкретных цветах, а также держать под рукой значение перечисления ColorRepresentation
, чтобы понимать, что именно сейчас происходит…
class Color {
public final ColourRepresentation repr;
public final Number val1;
public final Number val2;
public final Number val3;
}
Очевидно, что любой, кто разбирается в Java, НЕ СТАЛ бы так проектировать класс Color
. Гораздо лучше было бы реализовать множественные представления цвета при помощи полиморфизма — не жертвуя удобочитаемостью!
public abstract class Color {}
public class RGB extends Color {
private int red;
private int green;
private int blue;
public RGB(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
@Override
public String toString() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
public class CMYK extends Color {
private double cyan;
private double magenta;
private double yellow;
private double black;
public CMYK(double cyan, double magenta, double yellow, double black) {
this.cyan = cyan;
this.magenta = magenta;
this.yellow = yellow;
this.black = black;
}
@Override
public String toString() {
return "CMYK Color: (" + cyan + "%, " + magenta + "%, " + yellow + "%, " + black + "%)";
}
}
public class YUV extends Color {
private int y;
private int u;
private int v;
public YUV(int y, int u, int v) {
this.y = y;
this.u = u;
this.v = v;
}
@Override
public String toString() {
return "YUV Color: (Y=" + y + ", U=" + u + ", V=" + v + ")";
}
}
public class HSL extends Color {
private double hue;
private double saturation;
private double lightness;
public HSL(double hue, double saturation, double lightness) {
this.hue = hue;
this.saturation = saturation;
this.lightness = lightness;
}
@Override
public String toString() {
return "HSL Color: (H=" + hue + ", S=" + saturation + "%, L=" + lightness + "%)";
}
}
Теперь, если у вас есть экземпляр Color, вам всего лишь нужно проверить, является ли он instanceof того представления цвета, которое вам требуется. Всё, после этого вы сможете обращаться за данными из этого представления. Но в этой реализации есть изъян. Как мы ограничим место цвета в иерархии наших классов. Любой пользователь нашей библиотеки мог бы создать новый класс RYB, наследующий от Color или, например, от RGB. Здесь возникает проблема, если ваша библиотека не рассчитана на существование каких-либо новых вариантов Color или если в ней не предусмотрено, что поведение конкретных вариантов Color может измениться. Если только API сразу не проектировался с расчётом на расширяемость, то при создании новых представлений вы в лучшем случае отделаетесь отказами (тогда у вас хотя бы будет шанс узнать, что пошло не так). В худшем случае вы получите малозаметные баги, которые затронут код очень далеко от фактического источника проблемы.
Чтобы исправить ситуацию, можно сделать следующие вещи:
-
Сделать все варианты
final
.Притом, что это поможет, такой ход не исключает создания новых вариантов непосредственно от
Color
, ведь самColor
невозможно сделать финальным.
-
Предусмотреть для всей внутренней логики обязательный применяемый по умолчанию при возникновении нераспознанных вариантов.
Так расширяемость библиотеки ограничится, но, если такая расширяемость не наша главная цель, то данный ход будет полезен.
Но такой подход также гораздо сильнее чреват ошибками. Если хотя бы в одном элементе логики не будет учтён данный неблагоприятный случай, то возникнут проблемы.
Либо можно было бы воспользоваться запечатанными классами и убить сразу двух зайцев.
Тип-суммы
Появившиеся в Java 17 запечатанные классы позволяют применять паттерны проектирования, основанные на тип-суммах. Тогда как диапазон значений у тип-произведения получается путём перемножения значений тех типов, из которых оно состоит, диапазон значений тип-суммы получается путём суммирования. Что ж, из названия это очевидно… но что понимается под суммированием диапазона значений?
В тип-суммах заложено, что тип может быть любой из составляющих тип-суммы в каждый момент времени. Такие типы также называются мечеными объединениями, так как в теории типов обычно считается, что диапазон значений такого типа представляет собой объединённое множество его компонентов, причём, каждый тип-компонент в таком множестве «помечен».
Вот как можно выразить тип-сумму, воспользовавшись моей нотацией, которая якобы соответствует теории типов:
T = A + B + C
Множество значений, содержащихся в T
, можно выразить таким логическим предикатом:
T = { x | x ∈ A ⋃ B ⋃ C }
Возможно, всё это напоминает вам объединения из C.
union MyUnion {
int intValue;
double doubleValue;
char charValue;
};
MyUnion
состоит из трёх типов-компонентов, а C позволяет трактовать значение MyUnion
как контейнер для любого из трёх этих типов:
union MyUnion myUnion;
myUnion.intValue = 42;
printf("Integer value: %d\n", myUnion.intValue);
myUnion.doubleValue = 3.14159;
printf("Double value: %lf\n", myUnion.doubleValue);
Обратите внимание: значение объединения затирается при втором присваивании doubleValue
. Если бы вы хотели вывести myUnion.intValue
после второго присваивания, то увидели бы просто тарабарщину. На самом деле, это просто байты doubleValue
, разрезанного пополам и интерпретированного как целое число.
Здесь мы сталкиваемся с самым серьёзным изъяном, присущим объединениям C; отсутствует встроенный механизм, который позволил бы (не обладая дополнительной информацией) узнать, каково именно значение объединения. Следовательно, чтобы это определить, нам требуется внешний дискриминант. Предусмотренный в Java полиморфизм позволяет решить эту задачу при помощи instanceof
. Но иерархия классов Java слишком открыта; не существует способа как-либо ограничить количество вариантов в «объединении» Java.
Нам в данном случае нужны меченые объединения, и именно их удобно представлять при помощи запечатанных типов. Модификатор sealed
нужен для того, чтобы ясно указать: невозможно расширить запечатанный класс какими-либо иными классами сверх тех, которым разрешено от него наследовать. Этот механизм позволяет разработчику контролировать, как именно пользователь взаимодействует с API его библиотеки.
public sealed class Color permits RGB, CMYK, YUV, HSL {
// Общие свойства или методы для всех цветовых представлений
}
final class RGB extends Color {
private final int red;
private final int green;
private final int blue;
public RGB(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
// Дополнительные методы или свойства, специфичные для RGB
}
final class CMYK extends Color {
private final double cyan;
private final double magenta;
private final double yellow;
private final double black;
public CMYK(double cyan, double magenta, double yellow, double black) {
this.cyan = cyan;
this.magenta = magenta;
this.yellow = yellow;
this.black = black;
}
// Дополнительные методы или свойства, специфичные для CMYK
}
final class YUV extends Color {
private final int y;
private final int u;
private final int v;
public YUV(int y, int u, int v) {
this.y = y;
this.u = u;
this.v = v;
}
// Дополнительные методы или свойства, специфичные для YUV
}
final class HSL extends Color {
private final double hue;
private final double saturation;
private final double lightness;
public HSL(double hue, double saturation, double lightness) {
this.hue = hue;
this.saturation = saturation;
this.lightness = lightness;
}
// Дополнительные методы или свойства, специфичные для HSL
}
Обратите внимание на синтаксис запечатанного класса Color
. Здесь есть модификатор sealed
и условие permits, затрагивающее имена всех подклассов Color
. При помощи permits
мы указываем, какие классы могут наследовать от конкретного класса. Так мы предотвращаем любое нежелательное наследование. Также обратите внимание, что каждая из реализаций Color
помечена как final, так что у вас в распоряжении будут только те четыре цветовых представления, которые показаны здесь. Собственного цветового представления вы сделать не сможете.
Таким образом, это закрытая иерархия классов. Никакие новые классы не могут наследовать от Color
, независимо от того, находятся ли они в одном с ним пакете или нет.
Если ваша иерархия начинается с запечатанного класса, то все наследники (прямые или косвенные) вы должны пометить как sealed
, non-sealed
или final
. Если у наследующего класса нет этих модификаторов, то возникнет ошибка компиляции.
Вот что означает каждый из этих модификаторов:
sealed
— от этого класса нельзя наследовать, если только имя наследника не указано послеpermits
.non-sealed
— от этого класса можно наследовать как обычно. Это удобно, если нужно контролировать область действия специализированных поведений.final
— этот класс — «лист» в дереве наследования; более наращивать его нельзя.
Эти модификаторы не подходят для точного контроля, как именно могут себя вести классы некоторого API, и как они используются. Так удаётся избежать ситуаций, в которых единственной страховкой, не позволяющей всей системе взорваться, были бы обязательные правила — и договорённость между разработчиками соблюдать эти правила во имя добра.
Предположим, мы пишем код, который обрабатывал бы цвета в зависимости от их формата. Мы уже выявили, как можно красиво ограничить и обойтись без сюрпризов. Но как нам структурировать код, который обрабатывал бы экземпляры запечатанных классов?
В ранних версиях Java у нас практически бы не было выбора: лучшее, что мы могли бы соорудить — это лесенка из if-else
:
Color color = new RGB(255, 0, 0);
if (color instanceof RGB) {
RGB rgb = (RGB) color;
// ...
} else if (color instanceof CMYK) {
CMYK cmyk = (CMYK) color;
// ...
} else if (color instanceof YUV) {
YUV yuv = (YUV) color;
// ...
} else if (color instanceof HSL) {
HSL hsl = (HSL) color;
// ...
} else {
System.out.println("Unknown color type");
}
Можно было бы управиться лучше, работая с Java 16+ и применяя сопоставление с образцом if
:
if (color instanceof RGB rgb) {
// ...
} else if (color instanceof CMYK cmyk) {
// ...
} else if (color instanceof YUV yuv) {
// ...
} else if (color instanceof HSL hsl) {
// ...
} else {
System.out.println("Unknown color type");
}
Но как бы было здорово просто переключаться на цветовой переменной, одновременно с этим извлекая её содержимое — так, как это делается в Rust?
Вот, например, как такая ситуация могла бы решаться в Rust:
let color = Color::RGB(255, 0, 0);
match color {
Color::RGB(red, green, blue) => {
// ...
}
Color::CMYK(cyan, magenta, yellow, black) => {
// ...
}
Color::YUV(y, u, v) => {
// ...
}
Color::HSL(hue, saturation, lightness) => {
// ...
}
}
Обратите внимание: эти цветовые значения деструктурируются выше в блоке match
. Как можно было бы сделать то же самое при помощи запечатанных классов? Сопоставление switch-выражения с образцом и одновременное применение деструктуризации работает только с записями, а записи не могут наследовать ни от одного класса кроме Record
…
Решение — просто воспользоваться запечатанными интерфейсами. Функционально они аналогичны запечатанным классам, но с той оговоркой, что реализовывать их могут даже записи и перечисления.
public sealed interface Color permits RGB, CMYK, YUV, HSL {
// Общие свойства или методы для всех цветовых представлений
String getDescription();
}
record RGB(int red, int green, int blue) implements Color {
public String getDescription() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
record CMYK(double cyan, double magenta, double yellow, double black) implements Color {
public String getDescription() {
return "CMYK Color: (" + cyan + "%, " + magenta + "%, " + yellow + "%, " + black + "%)";
}
}
record YUV(int y, int u, int v) implements Color {
public String getDescription() {
return "YUV Color: (Y=" + y + ", U=" + u + ", V=" + v + ")";
}
}
record HSL(double hue, double saturation, double lightness) implements Color {
public String getDescription() {
return "HSL Color: (H=" + hue + ", S=" + saturation + "%, L=" + lightness + "%)";
}
Вот здесь можно красиво применить сопоставление с образцом и извлечь данные из экземпляра Color
— и это хорошо, если всё, что вам требуется — вынуть данные из класса, не вызывая в нём каких-либо методов:
Color color = new RGB(255, 0, 0);
switch (color) {
case RGB(int red, int green, int blue) -> {
// здесь в области видимости находятся красный, зелёный и синий цвет.
}
case CMYK(double cyan, double magenta, double yellow, double black) -> {
// ...
}
case YUV(int y, int u, int v) -> {
// ...
}
case HSL hsl -> {
// также это значение можно оставить нетронутым и прямо использовать значение, полученное при сопоставлении с образцом
System.out.println(hsl.getDescription());
}
case null -> {
System.out.println("How did color become null?!");
}
}
Примечание
В Java 21 теперь есть возможность отловить случай null
внутри блоков switch
и выражений, поэтому не требуется предварительно проверять на null перед тем, как перейти к переключателю.
Как вы также можете заметить, здесь не используется случай, заданный по умолчанию. Как правило, Java в таком случае выбросил бы ошибку, указывающую, что не все возможные случаи учтены. Но, поскольку Color
— это запечатанный класс, Java может считать, что все случаи обработаны.
Стража! Стража!
А я уже упомянул о граничных операторах (guard clauses)? Граничные операторы! С их помощью можно подключать дополнительные условия к switch
-веткам, и, чтобы ветка была выполнена, эти условия должны выполняться. При помощи граничных операторов можно лаконично постулировать более сложные условия, заложенные внутри switch-инструкций и выражений. Больше никаких глубоко вложенных if
-условий в ваших переключателях.
Рассмотрим ситуацию, в которой обрабатываются специальные случаи RGB-цветов, где red > 200
является true
. Поскольку нам приходится вставить это условие в if
-оператор в теле соответствующего случая:
switch (color) {
case RGB(int red, int green, int blue) -> {
if (red > 200) {
System.out.println("Very red.");
} else {
System.out.println("Not that red...");
}
}
// ...
}
Это не самый уродливый случай, но он всё равно означает, что в долгосрочной перспективе глубина вложенности кода постепенно растёт. Как правило, проще парсить код, ветвящийся по вертикали, а не по горизонтали, поскольку в первом случае приходится отслеживать меньше областей видимости.
В Java 21 можно встроить это условие в метку альтернативы (case label); это делается при помощи ключевого слова when
.
switch (color) {
// Граничный оператор для деструктуризации.
case RGB(int red, int green, int blue) when red > 200 -> {
System.out.println("Very red.");
}
// Граничные операторы также можно непосредственно добавлять к сопоставлению с образцом.
case RGB rgb when rgb.green > 100 -> {
System.out.println("Sort of green...");
// ...
}
// этот вариант нужен для сохранения исчерпывающего покрытия.
case RGB rgb -> {
System.out.println("Not that red...");
// ...
}
// ...
}
Примечание
Java так или иначе будет сначала жадно проверять, какие случаи результируют в true, чтобы обязательно первым делом обработать более конкретные случаи (ограниченные или нет), вслед за которыми идут менее специфичные.
Уф, исключения (с примером из JEP 441)
Теперь нам приходится иметь дело с новым классом исключений. А именно java.lang.MatchException.
Что происходит, если сопоставление с образцом не пройдёт? Рассмотрим случай, когда плохо реализован геттер записи:
record R(int i) {
public int i() { // плохой (но допустимый) метод доступа для i
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i); // метод доступа к i всегда выбрасывает исключение!
}
}
Вышеприведённый switch
-блок выбросит исключение MatchException
, так как при вызове геттера i
выдаётся исключение ArithmeticException
.
Как указано в JEP 441:
(Метод доступа к записи, который всегда выбрасывает исключение, очень необычен¸ равно как и исчерпывающий переключатель образцов, выбрасывающий исключение MatchException
.)
Исчерпывающие switch
-блоки будут выбрасывать исключение, если не один из указанных вариантов не совпадёт с селектором. Об этом в JEP сказано:
(Исчерпывающий переключатель для некоторого перечисления не найдёт совпадения только в том случае, если класс перечисления изменился уже после того, как переключатель скомпилировался, а это крайне необычно.)
Исключение MatchException
будет выброшено, если необходимость в нём возникает при выполнении граничного оператора.
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out. println("It's an R!");
default: break;
}
}
Приведённый здесь пример очень характерен: даже статически здесь сразу понятно, что происходит ошибка, связанная с делением на ноль. Но ситуация становится гораздо более мутной, если делитель подбирается динамически, и его значение может составить 0.
Заключение
В этой статье были рассмотрены некоторые новые вещи, которые можно сделать в Java 21 (некоторые вопросы я не затронул, например, мы не обсудили, как дженерики взаимодействуют со switch-паттернами).
Комментарии (8)
domix32
23.09.2023 10:11+2Интересно почему он решил примеры типов делать на основе сишных структур. Как минимум такие типы невыразимы в Си в нормальном виде, не говоря уже про некорректность сумм-типов представленных как union.
semenyakinVS
23.09.2023 10:11-1Сопоставление с образцом... Не сразу понял по заголовку о чём речь в статье будет)
novusnota
23.09.2023 10:11+1Я, к слову, использую Arch
Ну, наверное, нельзя переводить «i use arch, btw», прикол как-то теряется :)
Сопоставление с образцом
В качестве перевода «pattern matching» пойдёт, возьму на заметку. Но тоже, люди как-то привыкли видеть этот термин именно по-английски, так что я бы в скобках оставлял оригинал.
Спасибо за перевод!
Beholder
23.09.2023 10:11Ну ладно, фича, конечно, полезная и приятная. Но вот скажите, во многих ли программах сопоставление с образцом играет значительную роль, такую, что без неё прямо никак нельзя? Ну на личном примере этого Рагува - да, а у остальных?
Тем более, чтобы Kotlin из-за этого бросать. Там и так
when
достаточно удобный в сочетании со smart casting, пусть хоть и до вышеописанной конструкции в чём-то не дотягивает. В планах pattern matching и там есть.
Naf2000
23.09.2023 10:11Всё хотел узнать. В Java есть Expression типа таких как в шарпе? Чтобы их можно было интерпретировать
auddu_k
Спасибо за перевод!