- Интерфейс
Как мы уже обсуждали во введении в ООП, класс - это совокупность поведения
и состояния
.
Состояние - это то, какую информацию, какие данные хранит этот класс. Поведение же - это то, что мы можем ожидать при работе с классом, как с ним взаимодействовать и т.д.
В ООП
понятию поведение
выделена настолько большая роль, что существует специальный термин для этого - интерфейс
.
Если приводить пример из жизни, то можно рассмотреть объект 'машина'. Состояние этого объекта - это материал, цвет, колеса, стекла и т.д Поведение же - это возможность открыть двери, багажник, возможность передвижения, включения фар, переключения передач и т.д
Что вы видите, когда садитесь за руль?
Скорее всего, по крайней мере так было в 2018 году, вы увидите руль, педали, рычаг коробки переключения передач и т.д. Другими словами, вы видите интерфейс взаимодействия с машиной.
Графическая составляющая программ не даром называется интерфейсом. Она определяет то, как вы будете взаимодействовать с программой, то как вы будете использовать функционал программы. То, как ведет себя программа. При этом вы можете абсолютно не знать деталей реализации интерфейса.
В случае с автомобилем вы можете абсолютно не иметь представления о том, какой мотор у вас используется, карбюратор у вас или инжектор, на каком языке написана программа, которой вы пользуетесь: вам, как обычному водителю, это не важно.
Интерфейс позволяет вам не знать деталей реализации, а просто взаимодействовать с объектом.
Точно тот же принцип используется и в программировании!
Если перенести эту мысль в область программирования, то можно сказать, что интерфейс определяет то, как мы можем использовать объект.
Другими словами:
Интерфейс
- это определение функциональности, в виде определения методов и свойств, без каких либо привязок к особенностям класса.
Теперь давайте продемонстрируем ту гибкость, которую нам предоставляют интерфейсы. Для этого мы снова заглянем в святая святых - JDK
.
Рассмотрим гибкость, которую дает нам применение интерфейсов на примере Java
коллекций.
Одна из самых известны и часто применяемых коллекций в программировании - это список, от английского list
.
Список — это абстрактный тип данных, представляющий собой упорядоченный набор значений, в котором некоторое значение может встречаться более одного раза.
Подробнее про списки можно прочесть в википедии
Существует несколько видов реализации списка.
В Java
наиболее популярны две реализации: связные списки - java.util.LinkedList
и списки, основанные на массивах, java.util.ArrayList
.
Обе структуры данных являются списками, они представляют собой упорядоченный набор значений, каждый элемент может встречаться более одного раза. Однако, то как они хранят эти значения - различно.
Но то, что они делают - одинаково, они хранят элементы в порядке добавления, мы можем достать оттуда элемент, удалить и т.д.
Наверное вы уже начали догадываться: и java.util.LinkedList
, и java.util.ArrayList
предоставляют нам один интерфейс взаимодействия.
Этим интерфейсом является java.util.List
.
Благодаря этому можно писать более гибкий и общий код. Например, вы хотите распечатать список строк. Вы можете написать код как построенный на использовании интерфейса, так и на использовании конкретной реализации.
void print1(List<String> lst) {
for(String s : lst) {
System.out.println(s);
}
}
void print2(ArrayList<String> lst) {
for(String s : lst) {
System.out.println(s);
}
}
И тот, и другой способ будут работать.
Однако, второй вариант написания ограничивает нас конкретной реализацией java.util.ArrayList
.
Благодаря указанию в качестве параметра метода интерфейса мы можем передать в print1
любой список строк: и java.util.ArrayList
, и java.util.LinkedList
, и собственную реализацию списка, реализующую интерфейс java.util.List
.
Чем это грозит?
Давайте опять проведем параллель с реальным миром и представим себе автомойку.
В первом случае, наша автомойка может работать со всеми автомобилями, в то время как во втором случае автомойка может работать только с автомобилем ВАЗ-2109
.
Пока вы ездите на ВАЗ-2109
или пользуетесь java.util.LinkedList
для вас разницы в использовании print1
и print2
нет, однако как только вы захотите использовать другую реализацию списка, то вам потребуется уже искать новую автомойку/переписывать print2
.
При использовании же интерфейса вам ничего делать не надо, начали ездить на Alfa Romeo
/использовать ArrayList
- никаких телодвижений не требуется, все уже готово.
Ведь вы ждете от объекта, с которым взаимодействуете, некоторое определенное поведение и если этот объект вам его гарантирует - вам не важно, что именно это за объект.
В этом заключается та универсальность и мощь применения интерфейсов. Если вы пользуетесь интерфейсами - вы абстрагируетесь от реализации, поэтому если у вас эта реализация изменится, то вам не потребуется переписывать ваш код.
Этот совет касается не только передаваемых в метод ссылок, но и объявления свойств в классе, переменных в методе и т.д.
В начале своего пути начинающие программисты часто пишут код вида:
class Books {
private ArrayList<String> bookNames;
// или
public static void main(String args[]) {
ArrayList<String> books = new ArrayList<>();
}
}
Т.е объявляя ссылку на объект используют конкретную реализацию.
Объявляя переменную помните, что вы объявляете поведение, которое ждете от объекта. Точно также, как и в случае, когда вы передаете объект.
Какое поведение мы хотим от bookNames
или books
? Мы просто хотим создать ссылку на список книг!
А значит нам надо использовать не ArrayList
в объявлении ссылки, а просто интерфейс списка:
class Books {
private List<String> bookNames;
// или
public static void main(String args[]) {
List<String> books = new ArrayList<>();
}
}
Поэтому везде, где это возможно, старайтесь пользоваться интерфейсами, передавать и возвращать их из методов, объявлять параметром класса и т.д.
И да, бывают ситуации, когда мы хотим использовать что-то более конкретное, например, в случае, когда нам нужен порядок добавления элементов в хеш-мапу, то необходимо работать с LinkedHashMap
. И интерфейс Map
в данном случае не подойдет.
Для создания интерфейса используется ключевое слово interface
, после которого идет название интерфейса, внутри которого происходит уже определение методов и свойств.
public interface Greeting {
// methods
void greeting();
}
Исходя из того, что интерфейс, это поведение
не имеющее состояния
, можно сделать вывод, что создается только описание метода.
Какие методы могут быть объявлены в интерфейсе:
- Абстрактные, без тела метода,
Java 7+
. - Статические,
Java 8+
. - С реализацией по умолчанию,
Java 8+
. - Приватные,
Java 9+
.
По умолчанию, все методы и свойства интерфейса имеют модификатор доступа public
.
Что довольно логично ожидать, учитывая что цель интерфейса - это определение функционала для его последующей реализации.
Что можно сказать об абстрактных методах в интерфейсе:
- Абстрактный метод в интерфейсе не имеет реализации.
- Модификатор доступа
public
. - Является стандартным механизмом объявления функционала.
Типичный пример:
public interface Greeting {
// methods
void greeting();
}
Каждый класс, реализующий интерфейс Greeting
добавляет себе абстрактный метод void greeting()
.
Класс не обязательно должен определять все абстрактные методы реализуемого интерфейса, но в таком случае он сам должен быть абстрактным классом.
public abstract class GreetingClass implements Greeting {
// body
}
Здесь тоже все логично: мы реализовываем интерфейс, тем самым добавляем себе новый метод, так как этот метод абстрактный, то мы либо должны его определить, либо быть абстрактным классом.
Подробнее про абстрактные классы
Начиная с Java 8
стало возможно описание статического метода в интерфейсе.
Тут все также как у обычного статического метода:
interface Printable {
static void print(){
System.out.println("Printable print!");
}
}
public class Test {
public static void main(String[] args) {
Printable.print();
}
}
Статические методы у интерфейса очень удобны для группирования utility
или factory
методов.
Если вы не знаете что такое
factory
метод, то советую посмотреть:
- Подробнее о паттерне фабрика
- Подробнее статических методах
Если раньше такие методы выносились в отдельный класс, то теперь можно логически сгруппировать это в одном месте, как сделано, например, в java.util.stream.Stream
.
Где сгруппированы методы на подобие public static<T> Stream<T> of(T t)
и прочее, необходимые для создания стримов.
Раньше бы потребовалось объявить интерфейс java.util.stream.Stream
, а подобные фабричные методы вынести в отдельный класс, т.е 'размазать' эту логику на два файла.
Сейчас можно удобно сгруппировать это в одном интерфейсе.
При этом, чтобы не было путаницы, в Java
статический метод, определенный в интерфейсе можно вызвать только явно через интерфейс его содержащий.
Другими словами:
interface Printable {
static void print() {
System.out.println("Hello there!");
}
}
class Test implements Printable {
public static void main(String[] args) {
print(); // error
Printable printableTest = new Test();
printableTest.print(); // error
Printable.print(); // ok
}
}
В Java 8
добавили возможность реализации метода по умолчанию, так называемой default
реализации.
Это достигается с помощью ключевого слова default
.
public interface Greeting {
// methods
default void greeting() {
System.out.println("Default greeting");
}
}
Реализация по умолчанию удобна, когда большинство классов, реализующих интерфейс, будут определять метод, содержащийся в этом интерфейсе, одинаково. И в таком случае, удобно, когда вы не дублируете один и тот же кусок реализации в каждый класс, а сделали реализацию по умолчанию.
В таком случае все классы, где реализация одинакова, будут использовать то, что объявлено по умолчанию, а те классы, которым такая реализация не подходит, просто переопределят ее.
Использование методов с реализацией по умолчанию позволит избежать дублирования кода, при этом не теряется ни гибкость, ни читабельность.
Начиная в Java 9
добавили возможность объявления private
-методов в интерфейсе.
Если коротко, то такие методы введены для внутреннего использования, чтобы убрать повторяемость кода, которая может возникнуть в некоторых случаях.
Такие методы могут быть статическими и нестатическими, но они не могут иметь реализации по умолчанию. Такие методы обязаны быть реализованы сразу и могут использоваться только внутри самого интерфейса, в котором они определены.
Сделано это для упрощения написания кода, когда вам в интерфейсе необходимо выполнить повторяющиеся действия:
public interface Logger {
default void logInfo(String message) {
log(message, "INFO");
}
default void logWarn(String message) {
log(message, "WARN");
}
default void logError(String message) {
log(message, "ERROR");
}
default void logFatal(String message) {
log(message, "FATAL");
}
private void log(String message, String msgPrefix) {
// Log Message with Prefix and styles etc.
}
// Any other abstract, static, default methods
}
Интерфейс может быть пустым, т.е не содержать никаких объявлений:
public interface Serializable {
}
Такие интерфейсы называются интерфейсы-маркеры
.
В качестве примера можно посмотреть такие интерфейсы как java.io.Serializable
, java.lang.Cloneable
и java.util.EventListener
.
Как следует из названия, задача интерфейса-маркера сообщить о наличии определённого поведения у объектов класса, помеченного таким интерфейсом.
Не стоит принижать значение интерфейсов-маркеров просто ввиду их 'пустоты'. Они способны оказывать серьезное влияние на производные классы, как например, java.lang.Cloneable
или java.io.Serializable
.
Классы, реализующие java.lang.Cloneable
, например, могут использовать метод clone
, в то время как при отсутствии этого маркера вызов clone
породит ошибку java.lang.CloneNotSupportedException
!
О клонировании объектов можно прочесть тут
В интерфейсах мы можем описывать не только методы, но и свойства.
Помимо методов в интерфейсах могут быть определены еще и константы.
По умолчанию поля в интерфейсах имеют модификаторы public static final
, и поэтому их значение доступно из любого места вашего кода.
С одной стороны, можно считать, что константа - это тоже часть интерфейса. Поэтому и сделали возможность их добавления в интерфейс.
public interface Stateable {
int OPEN = 1;
int CLOSED = 0;
}
Эту возможность разработчики, особенно раньше, часто используют для хранения констант в интерфейсах и использование их в классах, реализующих такие интерфейсы.
Этот подход имеет право на жизнь, до сих пор часто встречается, но я считаю это не совсем правильным подходом, так как мне кажется, что интерфейсы предназначены не для этого.
Более удобный способ хранения констант, как и более красивый, на мой взгляд - это использование классов для констант
Мы пока не сказали ничего о том как же использовать интерфейс в Java
. Давайте это исправим?
Как вы уже знаете, ключевое слово extends
используется для наследования классов.
Однако, интерфейс - это не совсем класс, это только поведение, поэтому интерфейс реализуют.
Для того, чтобы реализовать интерфейс используется ключевое слово implements
.
interface Logging {
void log();
}
class Test implements Logging {
@Override
public void log() {
System.out.println("Logging");
}
}
В отличии от наследования, класс может реализовывать несколько интерфейсов.
Для этого после ключевого слова implements
вы перечисляете через запятую все интерфейсы, которые реализует ваш класс:
class Test implements Serializable, Cloneable {
}
В Java
запрещено множественное наследование и очень часто можно встретить утверждение, что возможность реализовать несколько интерфейсов заменяет множественное наследование.
Так вот, я опять таки не соглашусь с этим утверждением.
Да, вы можете реализовать несколько интерфейсов и это частично поможет вам заменить множественное наследование. Но давайте помнить, что наследование - это приобретение и поведения, и состояния.
В то время как реализация интерфейса - это приобретение только поведения.
И то, что вы можете частично заменить множественное наследование с помощью интерфейсов никак не говорит о том, что интерфейсы нужны для этого. Гвоздь микроскопом вы тоже забить можете.
Подробнее про наследование прочитать можно здесь
Одна из проблем множественного наследования - это так называемая проблема ромбовидного наследования.
Если коротко описать эту проблему, то представьте, что у вас есть класс Button
, он может одновременно наследуется от класса Rectangle
и от класса Clickable
.
В свою очередь, Rectangle
и Clickable
наследуются от класса Object
.
Если вызвать метод equals
для объекта Button
, и в классе Button
не окажется такого метода, но в классе Object
будет присутствовать метод equals
по-своему переопределенный как в классе Rectangle
, так и в Clickable
, то какой из методов должен быть вызван?
Для ее решения в каждом из языков программирования, которые поддерживают множественное наследование, используется своя стратегия.
В C++
, например, используется виртуальное наследование, в Python
явно определен порядок поиска таких методов в родительских классов и т.д.
В Java
этой проблемы нет из-за отсутствия множественного наследования, но что если у вас есть два интерфейса, имеющих одно и то же описание метода?
Например:
interface One {
void hello();
}
interface Two {
void hello();
}
Что тогда? Не нарушает ли это нашу идиллию и равновесие?
Абсолютно нет! Ведь как мы уже говорили, интерфейс - это только поведение!
И если два интерфейса имеют одно и то же описание метода, то это всего лишь говорит о том, что реализующий эти интерфейсы объект должен уметь это делать. А как он будет это делать - это ответственность уже класса, реализующего интерфейсы.
Ну а что если мы с помощью default
методов попробуем воспроизвести проблему с множественным наследованием?
Определим интерфейс следующим образом:
interface One {
default void hello() {
System.out.println("Hello One!");
}
}
Создадим класс, реализующий интерфейс One
и переопределим метод hello
.
class OneImpl implements One {
@Override
public void hello() {
System.out.println("Hello OneImpl!");
}
}
Как вы думаете, что будет при выполнении следующего кода:
class Test {
public static void main(String[] args) {
new OneImpl().hello();
}
}
Другими словами, какая из реализаций имеет более высокий приоритет? Правильно, реализация у класса.
Соответственно, вывод будет:
Hello OneImpl!
Запомните:
Реализация у класса или у суперкласса всегда имеет более высокий приоритет, чем реализация по умолчанию в интерфейсе.
Теперь напишем еще один интерфейс Two
:
interface Two extends One {
default void hello() {
System.out.println("Hello Two!");
}
}
И напишем следующий класс:
class OneTwoImpl implements One, Two {
}
Еще раз обращу внимание на то, что интерфейсы Two
и One
связаны между собой.
Как вы думаете, какой результат выполнения кода будет:
class Test {
public static void main(String[] args) {
new OneTwoImpl().hello();
}
}
Результатом будет.. Барабанная дробь...
Hello Two!
Почему так будет понятно, если мы нарисуем как связаны наши интерфейсы и класс:
Берем реализацию из Two
- она наиболее явная, так как до нее ближе всего.
И это, на мой взгляд, довольно логично. Мы пытаемся вызвать метод и если не находим его реализации у текущего класса идем выше по иерархии.
Запомните:
Наибольшим приоритетом обладает наиболее явный метод, тот до которого ближе всего.
Ну и в тот момент, когда вы уже окончательно запутались и решили завязать с программированием, стоит прояснить последнюю возможную ситуацию.
Если мы добавим к прошлому примеру еще один интерфейс Three
, с дефолтной реализацией метода hello
:
interface Three extends One {
default void hello() {
System.out.println("Hello Three!");
}
}
Достроим ромб и по сути полностью воспроизведем проблему с ромбовидным наследованием.
class OneTwoThreeImpl implements One, Two, Three {
}
Диаграмма связи будет в виде:
Будет ли валиден такой код?
Нет, такой код будет не валиден и Java
вам явно скажет, что так нельзя, так как каждый из интерфейсов тянет за собой какую-то дефолтную реализацию методов.
А в таком случае возникает уже рассмотренная проблема ромбовидного наследования.
И тут, в отличии от предыдущего случая, явно нельзя сказать какую из найденных реализаций выбрать - и Three
, и Two
на одном уровне по приоритету.
Поэтому в Java
это просто запрещено и компилятор явно попросит вас реализовать этот метод в классе, реализующем интерфейсы.
Java
позволяет нам создать иерархию интерфейсов.
Если вы хотите отнаследовать один интерфейс от другого - вы можете использовать ключевое слово extends
.
Т.е один интерфейс расширяет другой, даже с точки зрения английского языка все выглядит очень логично!
interface Printable {
void print();
}
interface Logging extends Printable {
}
class Test implements Logging {
@Override
public void print() {
}
}
Таким образом, интерфейс Logging
расширяет интерфейс Printable
и приобретает его константы и методы, кроме статических.
К слову говоря, в Java
существует возможность создать анонимный класс, реализующий интерфейс.
Это бывает очень удобно тогда, когда объявлять именованный класс не совсем разумно, например, он будет использоваться только один раз.
И вместо того, чтобы объявлять класс, придумывать ему имя, реализовывать у него нужный интерфейс и создавать в месте использования объект этого класса можно просто написать так:
public class Test {
public static void main(String[] args) {
Namable helloName = new Namable() {
@Override
public String name() {
return "Hello";
}
};
}
}
interface Namable {
String name();
}
Удобно и коротко.
С приходом в Java
функционального программирования(ФП) стало необходимо как-то объявить функцию.
До этого, если вы не забыли, у нас были только методы, которые принадлежали классам.
В Java
сделали поддержку ФП максимально по ООП
канонам и ввели понятие функционального интерфейса.
Если у интерфейса только один абстрактный метод, то можем считать, что это функциональный интерфейс.
Его принято помечать аннотацией @FunctionalInterface
, которая указывает компилятору, что при обнаружении второго абстрактного метода в этом интерфейсе нужно сообщить об ошибке.
При этом, default
методов у интерфейса может быть несколько.
Для примера рассмотрим EventHandler
:
@FunctionalInterface
public interface EventHandler<T extends Event> extends EventListener {
void handle(T event);
}
Этот интерфейс имеет один абстрактный метод, помечен аннотацией и является функциональным интерфейсом. А это дает нам возможность использовать его в виде:
button.setOnAction(event -> // если происходит событие
System.out.println("Обрабатываем нажатие кнопки."));
Где setOnAction выглядит в виде: void setOnAction(EventHandler<ActionEvent> value)
.
Вот так вот в Java
ввели поддержку функций.
Интерфейсы определяют поведение объекта, при этом не выставляя никаких требований к состоянию. Это позволяет абстрагироваться от реализации и ориентироваться только на поведение объекта, на то, что от него можно ждать и как с ним взаимодействовать.
Интерфейсы реализуются классами, при этом используется ключевое слово implements
.
Класс может реализовывать более одного интерфейса, техническое ограничение на количество реализуемых интерфейсов составляет 65535
.
Если вам мешает это ограничение - вы делаете что-то не так.
Интерфейсы могут наследоваться друг от друга, один интерфейс может иметь несколько родительских интерфейсов.
Разумеется, интерфейс не может наследоваться от класса. Это логично, если вспомнить что класс - это и поведение, и состояние. В то время как интерфейс - это только поведение.
С некоторой точки зрения, интерфейсы позволяют обойти ограничение в множественном наследовании у классов. Помните, что реализация методов в классах имеет более высокий приоритет, чем реализация по умолчанию в интерфейсах.
Всегда держите в уме простое правило:
Использование интерфейса в качестве типа переменной или параметра метода позволяет писать более поддерживаемый, гибкий и понятный код.
Интерфейсы тесно связаны с понятием абстрактного класса, поэтому здесь мы разберем отличия абстрактного класса от интерфейса, а также когда что предпочтительнее использовать.
Также стоит познакомиться с SOLID