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

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

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

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

Методы по умолчанию

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

Синтаксис

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

Следующий пример — модифицированная версия Comparator.thenComparing(Comparator) (ссылка) из JDK 8.:

default Comparator<T> thenComparing(Comparator<? super T> other) {
	return (o1, o2) -> {
		int res = this.compare(o1, o2);
		return (res != 0) ? res : other.compare(o1, o2);
	};
}

Это выглядит точно так же, как объявление «обычного» метода, за исключением ключевого слова default. Это необходимо для добавления такого метода в интерфейс без ошибок компиляции и намеков на стратегию разрешения вызова метода.

Каждый класс, реализующий Comparator, теперь будет содержать открытый метод thenComparing(Компаратор) без необходимости его самостоятельной реализации — он, так сказать, предоставляется бесплатно.

Явные вызовы методов по умолчанию

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

class StringComparator implements Comparator<String> {

	// ...

	@Override
	public Comparator<String> thenComparing(
			Comparator<? super String> other) {
		log("Call to 'thenComparing'.");
		return Comparator.super.thenComparing(other);
	}
}

Обратите внимание, как имя интерфейса используется для указания следующего super, которое в противном случае ссылалось бы на суперкласс (в данном случае Object). Синтаксически это аналогично тому, как ссылка на внешний класс может быть доступна из вложенного класса.

Невозможно вызвать метод из интерфейса, который не упомянут в implements. Если, например, наш StringComparator implements ObjectComparator<T>, extends Comparator<T>, вызов Comparator.super.thenComparing приведет к ошибке компиляции. При реализации двух интерфейсов, один из которых расширяет другой, Comparator.super приводит к другой ошибке компиляции. В совокупности это означает, что невозможно явно вызвать переопределенные или реабстрагированные методы по умолчанию.

Стратегия разрешения

Итак, давайте рассмотрим экземпляр типа, который реализует интерфейс с методами по умолчанию. Что произойдет, если будет вызван метод, для которого существует реализация по умолчанию? (Обратите внимание, что метод идентифицируется по его сигнатуре, которая состоит из имени и типов параметров)

Правило №1: Классы побеждают интерфейсы.

Если у класса в цепочке суперклассов есть объявление для метода (конкретного или абстрактного), все готово, и значения по умолчанию не имеют значения.

Правило № 2: Более специфичные интерфейсы выигрывают у менее специфичных (где специфичность означает «подтипирование»).

Метод «по умолчанию» из List выигрывает у метода «по умолчанию» из Collection, независимо от того, где, как и сколько раз список и коллекция попадали в дерево наследования.

Правило № 3: Нет никакого Правила № 3

Если в соответствии с вышеуказанными правилами не будет определен единственный победитель, конкретный класс должен быть указан вручную.

Брайан Гетц — 3 марта 2013 (форматирование мое):

Прежде всего, это объясняет, почему эти методы называются методами по умолчанию и почему они должны начинаться с ключевого слова default:

Такая реализация является резервной на случай, если класс и ни один из его суперклассов даже не рассматривают этот метод, т.е. не предоставляют никакой реализации и не объявляют его абстрактным (см. правило №1). Аналогично, метод интерфейса X по умолчанию используется только в том случае, если класс также не реализует интерфейс Y, который расширяет X и объявляет тот же метод (либо как стандартный, либо абстрактный; см. Правило № 2).

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

Разрешение конфликтов

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

Это также означает, что добавление реализаций по умолчанию к интерфейсу может привести к ошибкам компиляции. Если класс A реализует несвязанные интерфейсы X и Y и в Y добавляется метод по умолчанию, который уже присутствует в X, класс A больше не будет компилироваться.

Что произойдет, если A, X и Y не будут скомпилированы вместе и JVM столкнется с такой ситуацией? Интересный вопрос, ответ на который кажется несколько неясным. Похоже, что JVM выдаст ошибку IncompatibleClassChangeError.

Повторное абстрагирование методов

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

Этот метод используется во всем JDK, например, в ConcurrentMap (ссылка), который повторно абстрагирует ряд методов, для которых Map (ссылка).

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

Переопределение методов в ‘Object’

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

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

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

Суть в том, что методы Object, такие как toString, equals и hashCode, связаны с состоянием объекта.

Но интерфейсы не имеют состояния, а классы имеют состояние. Эти методы принадлежат коду, которому принадлежит состояние объекта, — классу.

Модификаторы

Обратите внимание, что существует множество модификаторов, которые вы не можете использовать в методах по умолчанию:

  1. видимость привязана к public (как и в других методах интерфейса)
  2. ключевое слово synchronized запрещено (как и в абстрактных методах)
  3. ключевое слово final запрещено (как и в абстрактных методах).

Конечно, эти функции были запрошены, и существуют исчерпывающие объяснения их отсутствия (например, для final и synchronized). Аргументы всегда схожи: это не то, для чего были предназначены методы по умолчанию, и внедрение этих функций приведет к более сложным и подверженным ошибкам языковым правилам и/или коду.

Однако вы можете использовать static, что уменьшит необходимость в служебных классах.

Немного контекста

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

Эволюция интерфейса

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

Цель методов по умолчанию […] — обеспечить совместимую эволюцию интерфейсов после их первоначальной публикации.

Брайан Гетц — сентябрь 2013 г.

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

Но с появлением лямбда-выражений это стало невыносимым. Представьте себе коллективную боль от постоянного написания Stream.of(myList).forEach(…), потому что forEach нельзя было добавить в List.

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

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

Там, где группа сочла это возможным без ухудшения удобства использования этого основного варианта использования, они также включили использование методов по умолчанию для создания характеристик — или, скорее, чего-то близкого к ним. Тем не менее, они часто подвергались нападкам за то, что не прошли «весь путь» до микширования и трейтов, на что часто повторялся ответ: «Да, потому что это не является/не было нашей целью».

Вытеснение полезных классов

JDK и особенно распространенные вспомогательные библиотеки, такие как Guava и Apache Commons, полны служебных классов. Их название обычно является формой множественного числа интерфейса, для которого они предоставляют свои методы, например, Collections или Sets. Основная причина их существования заключается в том, что эти служебные методы не могли быть добавлены в исходный интерфейс после его выпуска. С помощью методов по умолчанию это становится возможным.

Все эти статические методы, которые принимают экземпляр интерфейса в качестве аргумента, теперь могут быть преобразованы в метод интерфейса по умолчанию. В качестве примера рассмотрим статический Collections.sort(List) (ссылка), который, начиная с Java 8, просто делегирует новый метод экземпляра по умолчанию List.sort(Comparator) (ссылка). Другой пример приведен в моем посте о том, как использовать методы по умолчанию для улучшения шаблона декоратора. Другие служебные методы, которые не принимают аргументов (обычно это конструкторы), теперь могут стать статическими методами по умолчанию в интерфейсе.

Хотя удаление всех связанных с интерфейсом служебных классов из базы кода возможно, это может быть нежелательно. Удобство использования и согласованность интерфейса должны оставаться главным приоритетом, а не включать в него все мыслимые функции. Я предполагаю, что имеет смысл перенести в интерфейс только самые общие из этих методов, в то время как более непонятные операции могут остаться в одном (или нескольких?) служебных классах. (Или полностью удалить их, если вам это нравится.)

Классификация

В своем аргументе в пользу новых тегов Javadoc Брайан Гетц слабо классифицирует методы по умолчанию, которые были введены в JDK до сих пор (форматирование моего):

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


default void remove() {
    throw new UnsupportedOperationException("remove");
}
Он придерживается своего контракта, поскольку контракт явно слаб, но любой класс, который заботится об удалении, определенно захочет его переопределить. 2. Методы с разумными значениями по умолчанию, но который может быть переопределен реализациями, которые заботятся достаточно: Например, опять же из итератора:

default void forEach(Consumer<? super E> consumer) {
    while (hasNext())
        consumer.accept(next());
}
Эта реализация идеально подходит для большинства реализаций, но у некоторых классов (например, ArrayList) может быть шанс добиться большего успеха, если их разработчики будут достаточно мотивированы для этого. Новые методы в Map (например, putIfAbsent) также находятся в этом разделе. 3. Методы, которые вряд ли кто-либо когда-либо переопределит: Например, этот метод из Predicate:

default Predicate<T> and(Predicate<? super T> p) {
    Objects.requireNonNull(p);
    return (T t) -> test(t) && p.test(t);
}

Брайан Гетц — 31 января 2013 г.

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

Документация

Обратите внимание, что методы по умолчанию были основной причиной введения новых (неофициальных) тегов Javadoc @apiNote, @implSpec и @implNote. В JDK они часто используются, поэтому важно понимать их значение. Хороший способ узнать о них — прочитать мой последний пост (гладкий, не так ли?), в котором они описаны во всех подробностях.

Наследование и создание классов

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

Множественное наследование — Кого?
При наследовании тип может принимать характеристики другого типа. Существует три вида характеристик:

1. тип — т.е. подтипом типа является другой тип

2. поведение — т. е. тип наследует методы и, следовательно, ведет себя так же, как и другой тип

3. состояние — т.е. тип наследует переменные, определяющие состояние другого типа

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

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

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

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

Методы по умолчанию против миксинов и трейтов

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

Миксины

Миксины позволяют наследовать их тип, поведение и состояние. Тип может наследоваться от нескольких миксинов, что обеспечивает множественное наследование всех трех характеристик. В зависимости от языка можно также добавлять миксины к отдельным экземплярам во время выполнения.

Трейты

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

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

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

Трейты, как правило, так или иначе решают эту проблему.

Трейты позволяют выполнять определенные операции, которые Java поддерживает не полностью.

Смотрите список ключевых слов после «выбор операций» в статье Википедии о трейтах.

В статье «Программирование, ориентированное на трейты, в Java 8» рассматривается стиль программирования, ориентированный на признаки, с использованием методов по умолчанию, и возникают некоторые проблемы.

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

Методы по умолчанию против абстрактных классов

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

Языковые различия

Давайте сначала рассмотрим некоторые различия на уровне языка:

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

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

Концептуальные различия

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

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

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

Еще ссылки