Абстрактные классы и интерфейсы в Java: погружение в продвинутую теорию

Java позволяет реализовывать полиморфизм двумя ключевыми механизмами: абстрактными классами и интерфейсами. Несмотря на очень похожие концепции, они имеют важные различия, которые важно понимать для разработки эффективных приложений и успешного прохождения технических собеседований. В этой статье мы рассмотрим основные сценарии использования и теоретические аспекты различий между абстрактными классами и интерфейсами, а также рассмотрим примеры реализации задач с их помощью.

Для кого предназначена эта статья:

  • Вы используете язык Java в своей работе или только учитесь на нем программировать.
  • Вы хотите детально разобраться в различиях абстрактных классов и интерфейсов в Java, включая такие новейшие изменения как Sealed Classes представленные в JEP 409 в 17-й версии Java.

Несмотря на то что мы будем уделять внимание детальному, иногда дотошному, разбору базовых вопросов, которые будут полезны начинающим свой путь в разработке, опытные инженеры-программисты также найдут для себя много полезного в этом материале.

Определения

Для начала нам нужно дать определения абстрактному классу и интерфейсу в Java. Давайте рассмотрим этот вопрос на аналогии из реальной жизни:

Представим, что у нас есть два человека: мужчина и женщина. После рождения они будут иметь базовые данные вроде имени, фамилии, роста и веса. Также они будут уметь дышать и употреблять пищу. Со временем, они будут учиться и получать новые навыки вроде проведения интегральных вычислений, съемки видео для TikTok или создания презентаций в PowerPoint.

В этом примере:

  • Человек — это базовый абстрактный класс, определяющий состояние объекта вроде имени, фамилии, возраста и базовое поведение в виде навыков дыхания и употребления пищи.
  • Мужчина и женщина — это реализации абстрактного класса, определяющие поведение абстрактных методов и добавляющие свое состояние.
  • Навык создания презентаций в PowerPoint — это интерфейс, который могут реализовывать разные люди и который не является для них обязательным.

UML-диаграмма:

UML-диграмма с абстрактным классом Человек, двумя конкретными классами Мужчина и Женщина, наследующими класс Человек и реализующими два интерфейса: PowerPointService и TikTokService
UML-диграмма с абстрактным классом Человек, двумя конкретными классами Мужчина и Женщина, наследующими класс Человек и реализующими два интерфейса: PowerPointService и TikTokService

Стоит упомянуть, что это лишь аналогия, и она не может полностью отобразить сложность и разнообразие реального мира. В реальности люди не могут быть легко классифицированы или ограничены определенными атрибутами или способностями. Это также относится и к программированию: абстрактные классы, интерфейсы и их реализации часто могут быть гораздо сложнее и разнообразнее, чем может показаться на первый взгляд.

Формальное определение абстрактного класса будет звучать следующим образом:

Абстрактный класс — это класс, объявленый как abstract, который имеет возможность включать методы. Абстрактные классы не могут быть созданы (инстанциированы), но они могут быть расширены (наследованы) другими классами.

Формальное определение интерфейса:

Интерфейс — это ссылочный тип, который может содержать константы, сигнатуры методов, методы по-умолчанию, статические методы и вложенные типы, при этом тела методов могут иметь только методы по-умолчанию и статические методы. Интерфейсы не могут быть созданы (инстанциированы), но могут быть реализованы классами или расширены (наследованы) другими интерфейсами.

Абстрактный класс предоставляет структуру с базовым поведением и состоянием, присущим всем его наследникам. Интерфейс, в свою очередь, можно сравнить с набором навыков, которые могут иметь работники в конкретной области. Конкретные классы работников могут реализовывать этот интерфейс и добавлять свои собственные методы, связанные с особенностями их профессии.

Таким образом, абстрактный класс и интерфейс можно использовать вместе для создания гибких и расширяемых приложений, которые могут адаптироваться к изменяющимся условиям и потребностям в различных областях.

Отличия абстрактных классов и интерфейсов

Для начала давайте рассмотрим классическое определение различий между абстрактными классами и интерфейсами, которые можно чаще всего услышать в большинстве технический статей и дискуссий. В следующих разделах мы рассмотрим продвинутые нюансы, которые обычно упускаются при поверхностном изучении этого вопроса.

Итак, абстрактный класс — это класс, который может иметь абстрактные методы, не может быть создан (инстанциирован), но можем иметь конструктор и состояние. Абстрактные методы — это методы, имеющие сигнатуру (название метода, принимаемые параметры и возвращаемый тип), но не имеющие реализации (тела метода). Абстрактный класс также может иметь неабстрактные методы, которые имеют реализацию (тело метода). Все конкретные классы, наследующиеся от абстрактного класса, должны определять реализацию (тело метода) для всех абстрактных методов. Состояние класса можно определить как переменные экземпляра и неабстрактные методы, которые могут получать доступ к этим переменным и изменять их.

Интерфейс, в свою очередь, — это набор абстрактных методов, которые должны быть реализованы классом. Один класс может реализовать множество интерфейсов, но наследоваться только от одного класса. Это дает возможность использовать интерфейсы для, своего рода, реализации множественного наследования, которое не поддерживается Java-классами. Когда класс реализует интерфейс, он должен предоставить реализацию для всех методов, объявленных в интерфейсе — аналогично абстрактным методам в абстрактном классе.

Главное различие между абстрактным классом и интерфейсом заключается в том, что абстрактный класс может иметь состояние, тогда как интерфейс нет. Отсюда вытекает тот факт, что абстрактный класс может иметь конструктор, тогда как интерфейс нет. Когда создается подкласс, конструктор его супер-класса (включая любой абстрактный класс) вызывается автоматически. Интерфейс не может иметь конструктор, потому что он не может быть создан.

Здесь сразу возникает очевидный вопрос — почему мы не можем создать (инстанциировать) абстрактный класс, если у него есть конструктор? Несмотря на наличие конструктора, абстрактный класс не может быть создан (инстанциирован) напрямую потому что он не является полноценным классом из-за отсутствия деталей имплементации — абстрактных методов. Если бы мы давали возможность напрямую создавать абстрактные классы, то у нас бы возникали исключительные ситуации при попытке вызвать его абстрактные методы из-за отсутствия у них реализации (тела метода), поэтому такое решение просто не имеет смысла.

Когда мы создаем объект класса наследника, конструктор абстрактного класса вызывается неявно для инициализации полей абстрактного класса (его состояния). Следовательно, можно считать, что конструктор абстрактного класса вызывается косвенно, а не напрямую. Это же объясняет и отсутствия конструктора у интерфейса в Java — так как у интерфейса нет состояния в виде переменных экземпляра и методов имеющих к ним доступ, то нет необходимости и в конструкторе, который их инициализирует.

Рассмотрим несколько базовых примеров кода для иллюстрации различий между абстрактным классом и интерфейсом в Java.

UML-диаграмма:

UML-диаграмма: интерфейса Drawable с двумя классами-реализациями: Circle и Rectangle
UML-диаграмма: интерфейса Drawable с двумя классами-реализациями: Circle и Rectangle

Пример интерфейса:

public interface Drawable { void draw(); } public class Circle implements Drawable { @Override public void draw() { System.out.println("Drawing a circle"); } } public class Rectangle implements Drawable { @Override public void draw() { System.out.println("Drawing a rectangle"); } }

В этом примере Drawable является интерфейсом, который определяет единственный абстрактный метод draw(). Классы Circle и Rectangle реализуют интерфейс Drawable и предоставляют реализации метода draw(). Интерфейс Drawable не имеет переменных экземпляра (состояния) или конструкторов.

Пример реализации абстрактного класса:

public abstract class Animal { protected String name; public Animal(String name) { this.name = name; } public abstract void makeSound(); public void sleep() { System.out.println(name + " is sleeping"); } } public class Dog extends Animal { public Dog(String name) { super(name); } @Override public void makeSound() { System.out.println(name + " says woof!"); } } public class Cat extends Animal { public Cat(String name) { super(name); } @Override public void makeSound() { System.out.println(name + " says meow!"); } }

UML-диаграмма:

UML-диаграмма: абстрактного класса Animal с двумя классами-наследниками: Dog и Cat
UML-диаграмма: абстрактного класса Animal с двумя классами-наследниками: Dog и Cat

В этом примере Animal — это абстрактный класс, который имеет конструктор, абстрактный метод makeSound() и неабстрактный метод sleep(). Классы Dog и Cat расширяют (наследуют) класс Animal и предоставляют реализации для метода makeSound(), где они получают доступ к переменной экземпляра name, определенной в классе Animal.

Можем ли мы не реализовывать метод интерфейса

Это довольно частый случай в рабочей практике. У нас может возникнуть ситуация, когда используя какой-либо интерфейс, мы не можем или не хотим реализовывать некоторые его методы. Такая ситуация может возникнуть по самым разным причинам, но чаще всего это вызвана не соблюдением принципа разделения интерфейса или Interface segregation principle (ISP), который является одним из пяти принципов SOLID и звучит следующим образом:

Принцип разделения интерфейсов говорит о том, что слишком «толстые» интерфейсы необходимо разделять на более маленькие и специфические, чтобы программные сущности маленьких интерфейсов знали только о методах, которые необходимы им в работе. В итоге, при изменении метода интерфейса не должны меняться программные сущности, которые этот метод не используют.

Общая практика в таком случае гласит, что когда класс реализует интерфейс, он должен предоставить реализацию для всех методов интерфейса. Если класс не предоставляет реализацию для какого-либо метода из интерфейсе, то такой класс должен быть объявлен абстрактным. Это даст нам возможность реализовать те методы интерфейса, которые мы хотим реализовать и объявить абстрактными остальные методы, не определяя их реализации.

Давайте рассмотрим пример такого интерфейса:

public interface InterfaceNotFollowingSegregationPrinciple { void goodMethod(); void unnecessaryMethod(); }

Если мы хотим реализовать интерфейс InterfaceNotFollowingSegregationPrinciple, но не хотим предоставлять реализацию для unnecessaryMethod(), мы можем объявить свой класс абстрактным:

public abstract class MyClass implements InterfaceNotFollowingSegregationPrinciple { @Override public void goodMethod() { // Implementation of a good method } // unnecessaryMethod() not implemented }

UML-диаграма:

UML-диграмма интерфейса InterfaceNotFollowingSegregationPrinciple с реализующим его абстрактным классом MyClass
UML-диграмма интерфейса InterfaceNotFollowingSegregationPrinciple с реализующим его абстрактным классом MyClass

В этом случае MyClass предоставляет реализацию для goodMethod(), но не предоставляет реализацию для unnecessaryMethod(). Однако, так как абстрактный класс не может быть создан (инстанциирован), то в какой-то момент нам все равно придется создать класс-наследник, реализующий все абстрактные методы.

Использование дефолтных и статических методов в интерфейсах

Что если нам не подходит решение в виде объявления класса абстрактным? В Java 8 и более поздних версиях можно использовать ключевое слово default для решения этой задачи.

Методы по-умолчанию были представлены в восьмой версии Java чтобы позволить интерфейсам определять дефолтную реализацию и предоставлять обратную совместимость при добавлении новых методов в существующий интерфейс. Метод по-умолчанию — это метод, объявленный в интерфейсе, который имеет ключевое слово default в свой сигнатуре и тело метода с реализацией по-умолчанию, которую могут использовать классы, реализующие интерфейс. Класс реализующий интерфейс, может использовать дефолтную реализацию или переопределить ее и предоставить свою собственную.

Пример интерфейса с методом по-умолчанию:

public interface MyInterface { void method1(); default void method2() { System.out.println("Default implementation of the second method"); } }

В этом примере у MyInterface есть дефолтная реализация для method2() . Любой класс, который реализует MyInterface, может выбрать между переопределением method2() или использованием реализации по-умолчанию.

Вот пример класса, который реализует MyInterface и переопределяет method1(), но не method2() :

public class MyClass implements MyInterface { @Override public void method1() { System.out.println("Custom implementation of the first method"); } }

UML-диаграмма:

UML-диаграмма интерфейса, содержащего метод по-умолчанию, MyInterface и реализующего его класса MyClass
UML-диаграмма интерфейса, содержащего метод по-умолчанию, MyInterface и реализующего его класса MyClass

В этом примере MyClass реализует метод method1(), но не предоставляет реализацию для метода method2(). Так как метод method2() имеет реализацию по-умолчанию в интерфейсе MyInterface, MyClass может использовать эту дефолтную реализацию.

Также стоит упомянуть, что кроме дефолтных методов в Java 8 были введено использование статических (static) методов в интерфейсе. Это методы которые также имеют тело метода с реализацией, но которые не могут быть переопределены классами реализующими интерфейс, что обусловленно самой спецификой статических методов.

UML-диаграмма:

UML-диаграмма интерфейса, содержащего статический метод, MyInterface и реализующего его класса MyClass
UML-диаграмма интерфейса, содержащего статический метод, MyInterface и реализующего его класса MyClass

Пример кода с использованием статического методы в интерфейсе:

public interface MyInterface { void nonStaticMethod(); // static method in the interface static void staticMethod() { System.out.println("This is a static method in an interface"); } } public class MyClass implements MyInterface { public void nonStaticMethod() { System.out.println("Implementation of the non-static method"); } } public class Main { public static void main(String[] args) { MyClass obj = new MyClass(); obj.nonStaticMethod(); // call to the static method of our interface MyInterface.staticMethod(); } }

Возвращаясь к нашему вопросу о том, что делать в случае если мы не хотим определять реализацию метода интерфейса в каком-либо классе. Здесь можно использовать еще один довольно экзотический вариант, если по какой-то причине мы не можем использовать абстрактный класс или дефолтный метод — реализовать класс-обертку, который реализует интерфейс и предоставляет реализацию по-умолчанию для всех методов в интерфейсе. Затем мы сможем расширить этот класс-обертку и переопределить только те методы, которые мы хотим реализовать в своем классе.

UML-диаграмма:

UML-диаграмма интерфейса MyInterface и двух реализующих его классов: MyWrapperClass и MyClass
UML-диаграмма интерфейса MyInterface и двух реализующих его классов: MyWrapperClass и MyClass

Пример класса-обертки:

public class MyWrapperClass implements MyInterface { @Override public void method1() { // Default implementation of the first method } @Override public void method2() { // Default implementation of the second method } } public class MyClass extends MyWrapperClass { @Override public void method1() { // Custom implementation of the first method } }

В этом примере MyWrapperClass реализует интерфейс MyInterface и предоставляет реализацию по-умолчанию для обеих методов method1() и method2() . MyClass расширяет MyWrapperClass и переопределяет только method1() . Поскольку method2() имеет реализацию по умолчанию в MyWrapperClass, MyClass может использовать реализацию по-умолчанию для method2() . Если MyClass предоставит свою реализацию для method2() , это переопределит дефолтную реализацию в MyWrapperClass.

Этот подход может быть полезен, если мы хотим предоставить дефолтные реализации для нескольких методов в интерфейсе, не используя дефолтные методы или абстрактные классы. Однако это является сложной и не интуитивной реализацией, которую стоит избегать в современных версиях языка.

Лучшей практикой является использование абстрактных классов для случаев когда реализация метода может быть определена в классах-наследниках. В случаях когда нам нужно предоставить обратную совместимость, лучше всего подойдет использование методов по-умолчанию в интерфейсах. Если же по какой-то причине нам необходимо реализовать какой-то интерфейс без определения тела одного из его методов и явно указать, что это метод не поддерживается нашим интерфейсом, то мы можем использовать следующий подход:

public interface MyInterface { void supportedMethod(); void unsupportedMethod(); } public class MyClass implements MyInterface { @Override public void supportedMethod() { System.out.println("Implementation of the supported method"); } @Override public void unsupportedMethod() { throw new UnsupportedOperationException("Not supported method"); } } public class Main { public static void main(String[] args) { MyClass obj = new MyClass(); // this method is supported by our interface and will be successful obj.supportedMethod(); // this method call will result in an exception obj.unsupportedMethod(); } }

UML-диагрмма:

UML-диаграмма интерфейса MyInterface, реализующего его класса MyClass и класса Main с главным методом программы
UML-диаграмма интерфейса MyInterface, реализующего его класса MyClass и класса Main с главным методом программы

В этом примере MyClass реализует MyInterface и оба его метода. В теле unsupportedMethod() мы выбрасываем исключение UnsupportedOperationException с сообщением «Not supported method». При вызове метода unsupportedMethod() в классе Main, пользователь получит исключение UnsupportedOperationException, что явно укажет ему на то, что данный метод не поддерживается.

Изменения абстрактных классов и интерфейсов в версиях Java 8-17

Мы уже рассмотрели часть довольно фундаментальных изменений представленных в восьмой версии Java — добавление статических методов и методов по-умолчанию в интерфейсы. Кроме этого, в Java 8 было представлено еще одно мощное изменение — функциональные интерфейсы, которое мы рассмотрим далее в данной статье.

В девятой версии Java возможности интерфейсов расширились еще больше — в этой версии была представлена возможность создавать приватные переменные и классы внутри интерфейсов, что позволило им иметь свое внутреннее состояние. Это значительное изменение, которое частично влияет на главное различие между абстрактными классами и интерфейсами — наличие своего состояния и методов способных его изменять.

Пятнадцатая версия Java добавила еще одно важное изменение — возможность создавать новые типы запечатанных (sealed) классов и интерфейсов, что было окончательно закреплено в семнадцатой версии языка (JEP 409). Это позволило ограничивать типы, которые могут реализовывать или наследовать класс или интерфейс.

Давайте по порядку рассмотрим все эти изменения чтобы полноценно понимать различия между абстрактными классами и интерфейсами, которые значительно изменились в новейших версиях Java.

Функциональные интерфейсы

Функциональные интерфейсы в Java – это интерфейсы, которые имеют только один абстрактный метод. С появлением Java 8 и введением лямбда-выражений, функциональные интерфейсы стали основой для этих новых функций. Они позволяют использовать лаконичные и читаемые лямбда-выражения вместо анонимных классов.

Вот простой пример функционального интерфейса:

@FunctionalInterface public interface SimpleFunction { int apply(int value); }

В этом примере интерфейс SimpleFunction имеет только один абстрактный метод apply. Аннотация @FunctionalInterface необязательна, но она помогает явно указать, что интерфейс должен быть функциональным, и компилятор Java будет проверять, что интерфейс удовлетворяет требованиям функционального интерфейса.

Пример использования этого интерфейса с лямбда-выражением:

SimpleFunction triple = x -> x * 3; int result = triple.apply(5); // result will be equal to 15

В этом примере triple – это экземпляр функционального интерфейса SimpleFunction, созданный с использованием лямбда-выражения. Это позволяет создавать гибкие и мощные абстракции с минимальным синтаксисом.

Java 8 также предоставляет набор встроенных функциональных интерфейсов в пакете java.util.function, таких как Function<T,R>, Predicate<T>, Consumer<T>, Supplier<T> и др., что позволяет использовать лямбда-выражения и ссылки на методы еще более широко в нашем коде.

Внутреннее состояние интерфейсов

С версии Java 9, интерфейсы получили возможность определять приватные методы. Это позволяет разработчикам писать код, который может быть общим для двух и более методов по-умолчанию или статических методов в интерфейсе, и скрывать этот общий код в приватном методе.

Помимо этого, в Java 9 была добавлена возможность определять приватные статические методы, что позволяет разрабатывать более структурированный и модульный код.

Важно отметить, что оба типа методов не могут быть переопределены в классе, который реализует интерфейс. Такие методы могут быть вызваны только из других методов в том же интерфейсе. Они предназначены для внутреннего использования интерфейсом и не являются частью его общедоступного API.

Пример использования приватного и статического методов в интерфейсе:

public interface MyInterface { String COMMON_MESSAGE = "Common behavior goes here"; String STATIC_MESSAGE = "Common static behavior goes here"; void nonDefaultMethod(); default void defaultMethod1() { commonPrivateMethod(); commonPrivateStaticMethod(); } default void defaultMethod2() { commonPrivateMethod(); commonPrivateStaticMethod(); } private void commonPrivateMethod() { System.out.println(COMMON_MESSAGE); } private static void commonPrivateStaticMethod() { System.out.println(STATIC_MESSAGE); } }

В этом примере commonPrivateMethod() — это приватный метод, который содержит общий код для defaultMethod1() и defaultMethod2() — это методы по-умолчанию, которые имеют реализацию прямо в интерфейсе. Эти методы могут быть переопределены в классе, который реализует интерфейс, но если они не переопределены, то будет использоваться реализация по-умолчанию.

Приватный метод commonPrivateMethod() используется для определения поведения, которое является общим для defaultMethod1() и defaultMethod2(), что позволяет избежать дублирования кода в методах по-умолчанию. Метод nonDefaultMethod() — это абстрактный метод, который не имеет реализации в интерфейсе, и, соответственно, не имеет доступа к приватным и статическим методам интерфейса.

Интерфейсы в Java могут содержать статические переменные. Эти переменные являются публичными, статическими и финальными по-умолчанию, и их можно использовать для определения констант, которые связаны с интерфейсом. В этом примере добавлены две статические переменные: COMMON_MESSAGE и STATIC_MESSAGE. Они используются в методах commonPrivateMethod() и commonPrivateStaticMethod(), соответственно, для вывода сообщений.

Вот пример класса, который реализует MyInterface:

public class MyClass implements MyInterface { @Override public void nonDefaultMethod() { System.out.println("Implementation of non-default method."); } // commonPrivateMethod() and commonPrivateStaticMethod() aren't available here public static void main(String[] args) { MyClass myClass = new MyClass(); myClass.nonDefaultMethod(); // will return "Implementation of non-default method." myClass.defaultMethod1(); // will return "Common behavior goes here" myClass.defaultMethod2(); // will return "Common behavior goes here" } }

UML-диаграмма:

UML-диаграмма интерфейса MyInterface, c приватными и статическими методами, и реализующего его класса MyClass с главным методом программы
UML-диаграмма интерфейса MyInterface, c приватными и статическими методами, и реализующего его класса MyClass с главным методом программы

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

Sealed классы и интерфейсы

Sealed классы и интерфейсы являются одной из новых функций в Java, впервые появившихся в Java 15 в виде предварительного предложения (preview feature) и ставших стандартом с Java 17 (JEP 409).

Sealed классы и интерфейсы позволяют разработчикам ограничивать наследование. В обычной Java любой класс или интерфейс может быть расширен или реализован, если он не является финальным. Однако с помощью sealed классов и интерфейсов разработчики могут указать, какие другие классы или интерфейсы могут наследоваться от них.

Это делается с помощью ключевого слова sealed, а также с помощью permits в объявлении класса или интерфейса. Ключевое слово permits используется для указания списка классов, которые могут наследовать или реализовать sealed класс или интерфейс.

Пример абстрактного sealed класса:

public abstract sealed class Shape permits Circle, Rectangle { private final String name; public Shape(String name) { this.name = name; } public String getName() { return this.name; } public abstract double area(); }

В этом примере Shape является абстрактным sealed классом, который содержит одно поле name и один метод area(). Метод area() абстрактный, поэтому каждый подкласс должен предоставить свою реализацию этого метода.

Теперь давайте определим классы Circle и Rectangle:

public final class Circle extends Shape { private final double radius; public Circle(String name, double radius) { super(name); this.radius = radius; } public double getRadius() { return this.radius; } @Override public double area() { return Math.PI * Math.pow(this.radius, 2); } } public final class Rectangle extends Shape { private final double width; private final double height; public Rectangle(String name, double width, double height) { super(name); this.width = width; this.height = height; } public double getWidth() { return this.width; } public double getHeight() { return this.height; } @Override public double area() { return this.width * this.height; } }

UML-диаграмма:

UML-диаграмма абстрактного sealed класса Shape с двумя финальными классами-наследниками: Circle и Rectangle
UML-диаграмма абстрактного sealed класса Shape с двумя финальными классами-наследниками: Circle и Rectangle

В этом примере классы Circle и Rectangle расширяют класс Shape и предоставляют свою реализацию метода area(). Класс Circle содержит дополнительное поле radius, а класс Rectangle — поля width и height. Оба этих класса объявлены как final, так что они не могут быть дальше расширены.

Классы, которые расширяют или реализуют sealed классы или интерфейсы, должны быть объявлены как final, sealed или non-sealed. Ключевое слово final означает, что класс не может быть дальше расширен. Sealed означает, что класс сам по себе является sealed и должен указать, какие классы могут его расширять. Non-sealed — что класс может быть расширен любыми другими классами.

Sealed классы и интерфейсы улучшают модель объектно-ориентированного программирования в Java, поскольку они предоставляют больше контроля над наследованием — это удобно, если вы хотите ограничить наследование только некоторыми подклассами, чтобы обеспечить определенные гарантии поведения или инварианты. Также это приводит к созданию ограниченных иерархий, где вы знаете все возможные подклассы во время компиляции, что удобно для моделирования алгебраических типов данных или ограниченных доменных моделей.

Sealed классы особенно полезны в сочетании с pattern matching (шаблонное сопоставление). Поскольку компилятор знает все подклассы sealed класса, он может проверить, что ваш код обрабатывает все возможные случаи, и предупредить вас, если вы пропустили какой-то. Если же вы хотите разрешить наследование в некоторых случаях, но сохранить большую часть иерархии классов закрытой, вы можете использовать non-sealed классы. Это удобно, когда вы хотите предоставить разработчикам возможность расширять часть вашей иерархии классов, но ограничить их в других местах.

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

Сценарии использования

Учитывая изменения в современных версиях Java, мы можем логично обратить внимания на то факт, что абстрактные классы и интерфейсы стали крайне близкими по функционалу и поведению. В пользу какой концепции стоит же делать выбор при проектированию иерархии объектов в ходе программирования? Давайте рассмотрим ключевые сценарии использования интерфейсов и абстрактных классов.

Интерфейсы в Java служат для определения "контрактов" поведения, которые классы обязуются выполнять. В контексте программирования, контракт представляет собой набор методов (сигнатур методов), которые класс должен реализовать, если он реализует интерфейс.

Давайте рассмотрим пример из реального мира. Предположим, у вас есть приложение для электронной коммерции, и вам необходимо обрабатывать различные типы платежей, такие как кредитные карты, PayPal, банковские переводы и т.д. В этом случае, вы могли бы определить интерфейс PaymentProcessor следующим образом:

public interface PaymentProcessor { void processPayment(PaymentData payment); }

Здесь PaymentData — это класс, который содержит всю информацию о платеже. Теперь каждый класс, который обрабатывает конкретный тип платежа (например, CreditCardProcessor, PaypalProcessor, BankTransferProcessor и т.д.), должен реализовывать этот интерфейс и обеспечивать свою собственную реализацию метода processPayment().

С введением методов по-умолчанию (default methods) в Java 8, мы получили возможность определять стандартное поведение в интерфейсе, которое может быть переопределено в классах, реализующих интерфейс. Это может быть полезно, например, когда вы хотите расширить интерфейс без нарушения существующих классов, которые его реализуют. Это одна из лучших практики использования методов по-умолчанию, которая помогает не нарушать существующие контракты в коде.

Например, вы можете добавить в интерфейс PaymentProcessor метод по умолчанию supportsRecurringPayments(), который возвращает false:

public interface PaymentProcessor { void processPayment(PaymentData payment); default boolean supportsRecurringPayments() { return false; } }

Теперь каждый процессор платежей по умолчанию не поддерживает повторяющиеся платежи, но процессоры, которые поддерживают такие платежи, могут переопределить этот метод и возвращать true.

Таким образом, с помощью интерфейсов, Java предоставляет мощные инструменты для организации и структурирования кода, упрощения тестирования (поскольку интерфейсы облегчают создание мок-объектов) и обеспечения гибкости и расширяемости приложений.

Абстрактные классы в Java используются для создания классов, которые содержат общую реализацию для нескольких подклассов, но которые не могут быть использованы для создания объектов напрямую. С помощью абстрактных классов можно определять общую структуру и иерархию объектов связанных типов. Подклассы, расширяющие супер-класс, будут иметь возможность предоставить свою реализацию абстрактных методов, дополнить состояние и расширить поведение таких классов.

Представим, что вы разрабатываете коммуникационный сервис и вам нужно обрабатывать "возвраты" сообщений отправленных по электронной почте, к примеру, из-за некорректно указанного email-адреса. В случае если мы используем разных почтовых провайдеров, у каждого из них будет свой формат ответа на такие некорректные отправки (bounce). В этом случае мы можем создать абстрактный класс AbstractBounceProperties определив общие свойства (состояние) таких объектов следующим образом:

public abstract class AbstractBounceProperties { private String serverAddress; private BounceType bounceType; private BouncedRecipient bouncedRecipient; private String prefixText; private String postfixText; public enum BounceType { UNDETERMINED, PERMANENT, TRANSIENT; } public static class BouncedRecipient { private String emailAddress; private String action; private String status; private String diagnosticCode; } }

Здесь AbstractBounceProperties — это абстрактная модель свойств для управления обработкой возвращаемых электронных писем (bounce messages). Он содержит свойства, являющиеся базовыми для всех почтовых провадеров:

  • serverAddress — адрес сервера, с которого отправляются письма;
  • BounceType — тип возврата, который может быть неопределенным, постоянным, или временным;
  • bouncedRecipient — вложенный класс, который содержит информацию о получателе, которому не удалось доставить письмо;
  • prefixText и postfixText — эти два поля представляют собой текст, который будет добавлен в начало и конец возвращаемого сообщения соответственно.

Теперь вы сможете создать конкретные классы-наследники для каждого почтового провайдера (например, GmailBounceProperties и AmazomSesBounceProperties). В этих классах вы сможете определить свойства и методы, которые специфичны для каждого провайдера.

Этот подход помогает упростить код и обеспечивает большую гибкость. Однако стоит помнить, что, как всегда при использовании наследования, стоит быть осторожными и использовать его только тогда, когда оно действительно имеет смысл с точки зрения дизайна вашего приложения, поскольку оно может привести к жесткой связанности кода. Во многих случаях, композиция может быть более гибкой и мощной альтернативой.

Композиция позволяет создавать более модульные и управляемые структуры, объединяя объекты в более сложные структуры, что способствует повторному использованию кода и изолированию обязанностей. Это также облегчает тестирование, поскольку каждый компонент может быть протестирован независимо.

Возвращаясь к нашему примеру с обработкой возвратов email-сообщений, можно создать отдельные компоненты, такие как BounceHandler, RecipientHandler, MessageTypeHandler и т.д., каждый из которых имеет свои обязанности и манипулирует своими конкретными аспектами процесса обработки возвращаемых писем. Это бы обеспечило хорошую модульность, упростило бы тестирование и сделало код более понятным и управляемым.

Однако, как уже было упомянуто, наследование имеет свое место и может быть очень полезным при правильном использовании. Важно понимать требования вашего конкретного проекта и применять наиболее подходящие паттерны и подходы, чтобы обеспечить чистоту и эффективность вашего кода.

Заключение

В этой статье мы рассмотрели ключевые концепции, лежащие в основе использования интерфейсов и абстрактных классов в Java, и то, как они эволюционировали с учётом последних обновлений языка.

Мы увидели, что использование абстрактных классов и интерфейсов позволяет увеличить модульность и повторное использование кода, делая его более гибким и легко адаптируемым к изменяющимся требованиям. Однако, мы также обсудили потенциальные проблемы, которые могут возникнуть при неправильном использовании этих концепций, включая жесткую связанность кода и вызовы связанные с тестированием.

Важно отметить, что в то время как интерфейсы и абстрактные классы являются важными инструментами в арсенале Java-программистов, они не являются единственными. Язык продолжает активно развиваться, и в последних версиях было внесено множество улучшений, которые предоставляют разработчикам больше возможностей для написания чистого, эффективного и легко-читаемого кода.

Например, долгожданное введение записей (records) в Java 16 упрощает создание классов, которые просто "держат" данные. Введение сопоставления типов (pattern matching), которое в настоящее время находится в процессе стандартизации, позволяет создавать более чистый и понятный код при работе с объектами разных типов. Новые функции, такие как Sealed Classes (запечатанные классы) и Pattern Matching, предоставленные в Java 17 и Java 18, позволяют разработчикам писать более безопасный и выразительный код.

Правильное использование рассмотренных нами функций и концепций, включая интерфейсы и абстрактные классы, поможет вам писать качественный код и проектировать расширяемые системы, которые могут эффективно адаптироваться и развиваться по мере изменения требований и условий.

Всегда стоит помнить, что выбор подхода зависит от конкретной задачи, и нет единого "правильного" решения для всех сценариев. Экспериментируйте с разными подходами, используйте лучшие практики, но помните, что конечная цель — это создание решений, которые работают эффективно и отвечают требованиям вашего конкретного проекта.

1414
16 комментариев

Неплохо. Хабр торт

2

А в чем смысл использования sealed классов, если при наследовании подкласс можно объявить non-sealed и дальше от него уже свободно наследоваться? Смысл теряется получается

1

Хороший вопрос! С одной стороны да, если задуматься по логике, то в чем смысл sealed класса или интерфейса если все можно легко обойти? Но если посмотреть в стандарт JEP 409, то можно заметить, что создатели языка не преследовали цели предоставить новый модификатор доступа:
1. «It is not a goal to provide new forms of access control such as "friends"»
2. «It is not a goal to change final in any way.»

Sealed класс/интерфейс дает более гибкий контроль над наследованием. Он может быть наследован только ограниченным заранее определенным набором других классов. Это позволяет нам контролировать наследование в иерархии классов, но при этом давая возможность подклассам самим решать вопрос дальнейших ограничений. Главная цель здесь — это предотвратить непреднамеренное или некорректное наследование, что делает более ясной структуру нашей программы. Это также может помочь компилятору Java делать некоторые оптимизации, поскольку он может быть уверен в иерархии классов.

Если нам нужно гарантировать, что поведение класса не будет изменено через наследование, то нужно использовать модификатор final для класса, что полностью заблокирует дальнейшее наследование. В этой статье, классы Circle и Rectangle, наследующие от sealed класса Shape, как раз и объявлены финальными.

2

А зачем вообще объявлять наследников non-sealed, а не final?

Спасибо за полезную статью!
Хотелось бы более подробно узнать про сценарии использования
Может есть какие-то четкие примеры, когда нужно использовать абстрактные классы, а когда - только интерфейсы?

1

Постараюсь ответить кратко, потому что про сценарии использования можно написать целую отдельную статью :)

В этой статье мы говорили про пример с определением контракта для класса и что с этим лучше всего справляется интерфейс. Это один из однозначных сценариев когда нужно использовать именно интерфейс. Другими словами это еще могут называть API класса. Казалось бы абстрактный класс тоже отлично справиться с заданием контракта и это действительно так, но из-за того что Java не поддерживает множественное наследование, мы всегда будем ограничены только одним базовым классом. А так как в реальной практике нам очень часто нужно использовать несколько разных контрактов, то абстрактные классы будут неподходящим инструментом для этого случая и лучшей практикой считается использование интерфейсов.

Другой пример, когда однозначно нужно использовать интерфейс — это реализация так называемых признаков и маркеров. Например, если вам нужно сравнивать объекты, вы можете реализовать интерфейс "Comparable" чтобы быть уверенным, что класс поддерживает сравнение. В свою очередь интерфейс "Cloneable", который является маркером, будет просто означать что ваш тип поддерживает клонирование.

1