Наконец-то нашел время дописать статью в блоге, которая давно уже в черновиках лежит…. Итак, про запечатанные классы и немного про алгебраические типы данных.
Запечатанные классы определяются с помощью ключевого слова sealed. Это нужно, чтобы ограничить в наследовании. Потенциальных наследников в таком случае нужно указать явно, через запятую после permits.
Например так:
public abstract sealed class Shape
permits Circle, Rectangle, Square { ... }
На самом деле, основной вопрос который мне задают, не как ограничить наследование, а зачем вообще запрещать или ограничивать наследование? Ведь наследование – один из столпов ООП, такое же как и инкапсуляция и полиморфизм…
Если посмотреть официальные цели в JEP, то в них написано:
1. Allow the author of a class or interface to control which code is responsible for implementing it.
2. Provide a more declarative way than access modifiers to restrict the use of a superclass.
3. Support future directions in pattern matching by providing a foundation for the exhaustive analysis of patterns.
JEP 409: Sealed Classes
Цели №1 и №2 под “контролировать” и “… более декларативный способ для ограничения … ” означает “мы даем вам возможность запрещать”, не раскрывая зачем это нужно. Цель №3 интереснее, в ней виден прикладной аспект, но о нем позднее.
Начнем с того, что прикладному программисту, который пользуется готовыми библиотеками, особой пользы в sealed нет или эта польза не особо видна. Этот “инструмент для запечатывания” нужен архитектору или ведущему программисту, который пытается построить дизайн/архитектуру проекта.
Например, если совсем никак не ограничивать наследование, то иерархия классов начинает сильно разрастаться и в ней становиться тяжело ориентироваться. Возьмем популярный фреймворк Spring. В нем наследование AnnotationConfigWebApplicationContext выглядит так:
java.lang.Object
↑ org.springframework.core.io.DefaultResourceLoader
↑ ...context.support.AbstractApplicationContext
↑ ...support.AbstractRefreshableApplicationContext
↑ ...AbstractRefreshableConfigApplicationContext
↑ ...AbstractRefreshableWebApplicationContext
↑ AnnotationConfigWebApplicationContext
В случае Spring-а это сделано умышлено, это всё росло годами и хорошо показывает всю гибкость этого огромного фреймворка.
На самом деле, большинство компаний не пишут “Spring”. Часто многим компаниям не нужен еще один фреймворк, а нужно спроектировать конкретной решение. Например, когда я разрабатываю API или архитектуру системы, часто хочется сделать такое ограничение, чтобы мой класс не наследовали “до седьмого колена”. Раньше это было сделать сложно.
Теперь возможно.
Если смотреть на вопрос глобально, то ограничения в программирование, это очень полезная вещь. Например некоторые радикально настроенные программисты вообще считают, что ограничение – двигатель прогресса.
Очень ёмко эту идею изложил Роберт Мартин (также всем известный как дядюшка Боб или “Uncle Bob”) в книге Идеальная архитектура.
Здесь речь идет, не об sealed классов, а в целом о роли различных запретов и ограничениях в программировании.
Например структурное программирование – борется с таким злом как оператор “goto”. ООП вводит ограничение на косвенную передачу управления. Функциональное программирование накладывает ограничение на присваивание.
Конечно это слишком смелая идея, полностью принять и согласиться с ней я не могу. Например, мне кажется, что крутость ООП не только в том, что борется с передачей управлением, а ещё в том, что борется засильем глобальных переменных, а также дает возможность программистам выразить в коде “вот эти методы и поля не смей трогать!”.
С другой стороны, в целом идея верная. Например если взять такой живой пример, как очень популярный сейчас язык программирования Python. В нем наложили запрет на плохо отформатированный код. Казалось бы люди должны страдать от того, что не могут ставить пробелы и/или таб так, как они хотят и где хотят. Тем не менее, за счет этого ограничения, Python стало особо привлекателен и выразителен.
Вернемся к sealed классам.
Использование запечатанных классов довольно простое.
Перед классом или интерфейсом указываем sealed, потом указываем после permits список классов-наследников. Классы должны быть final.
Например:
sealed class S permits A,B,C { }
Означает, что объект класса S
может быть объектом класса A, B или C.
S abc = new A(); // Объект abc может быть только A, B или C
abc = new B();
abc = new C();
Самое интересное во всей этой истории, как я писал ранее, это нововведение поможет нам начать движение в сторону алгебраических типов данных. С помощью sealed мы можем реализовать сложение классов.
В качестве грубой аналогии, это можно представить как сумму множеств:
S = тип A + тип B + тип C
или можно написать так:
S = A U B U C
Осторожно! Это всего лишь грубая аналогия. Мои выкладки здесь никак не связанны с теорией множеств и настоящей математикой. Кто учил матан и дискретку должны это понимать.
Теперь двигаемся дальше в сторону алгебраических типов данных. Берем произведение типов. Мы можем их сделать с помощью record.
Например:
record R(A a, B b) { }
Аналогия прямого произведения множества R = A x B, которое может содержать различные комбинации объектов из A и B.
Естественно объекты класса R также могут быть частью S.
sealed interface S permits A,B,C,R {}
record R(A a, B b) implements S {}
Что-то вроде S = A U B U C U R
, где R = A x B
Также в permits можно использовать enum.
sealed interface S permits A,B,C,R,E {}
record R(A a, B b) implements S {}
// Делаем enum для запечатанного S
enum E implements S { AZ, BUKI, VEDI, GLAGOL, DOBRO }
Важно понимать, что sealed это про объединение “типов”, а enum – объединение объектов одного типа. Разница как между множеством и элементами множества.
Архитекторы языка Java нам оставили специальное ключевое слово non-sealed. Это такой аварийный люк, который можно использовать, чтобы “распечатать” запечатанный класс.
sealed interface S permits A,B,C,R,E, Gin { }
record R(A a, B b) implements S { }
enum E implements S { AZ, BUKI, VEDI, GLAGOL, DOBRO; }
// От этого класса можно наследоваться, он не final
non-sealed class Gin { }
Ключевое слово non-sealed очень необычное. Это первый hyphenated keyword. Другими словами написанный через дефис, т.е. в kebab-case или шашлык-нотации. Такая нотация наиболее популярна в lisp-е и в частности в clojure, но в Java встречается впервые.
Что касается в целом последних изменений в языке и своего опыта. В последнее время активно использую record. По ощущениям, это “новшество” почти такое же крутое, как появление в 2004-ом году generic-ов в Java 5 или нормальных коллекций в 1998-ом, когда вместо Vector и Hashtable в Java 2 появились List, Map.. Постоянно думаешь, почему этого не было раньше.
Про sealed не могу сказать также. Жду сентября 2023 года, когда выйдет Java 21 LTS. Тогда можно будет использовать новый pattern matching в полную силу.
Про новый pattern matching в другой раз, а то итак статья очень большая получилась.
PS: Сделал телеграм канал @prgrmdr для анонсов