Дефолтные методы интерфейсов

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

Среди прочих рекомендаций и описаний возможных вопросов на интервью часто встречаются темы нововведений в различных версиях Java 5, 8, 11 и т.д. Да и в лекциях преподаватель периодически обращает внимание, что тот или иной класс или метод появились с такой-то версии. И здесь задаешься вопросом — к чему мне обращать внимание и запоминать что и когда было введено в язык, если учусь я по последней версии, в которую включены все возможные функции.

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

Но есть и второй момент. Следует понимать, что в базовом курсе мы учились именно основам Java. Разбирались в принципах ООП, синтаксиса и работе базовых инструментов языка. А часть популярных в обсуждениях нововведений зачастую предназначена для помощи программисту, упрощения и удобства работы. Например лямбда выражения и Stream, изучение которых предполагается в следующем курсе. И такая последовательность изучения верна, т.к. программист должен понимать, что кроется за упрощенным выражением.

На своем первом собеседовании я столкнулся с вопросом “для чего нужны дефолтные методы в интерфейсах?”. И ответ, как не сложно догадаться, можно построить из вопроса. Однако, в лекции об интерфейсах о такой возможности не упоминалось, относится она к нововведениям в Java 8, поэтому предлагаю рассмотреть эту тему подробнее.

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

Перейдем к примерам. Для наглядности воспользуемся фрагментами кода из лекции “Интерфейсы” (курс: Java Developer, level 1).

Возьмем три класса, описывающих геометрических фигуры, например, “Segment”, “Circle” и “Square”, и создадим интерфейс, который бы позволял сравнивать их по периметру.

public class Segment{
   double a; //Длина отрезка
   Segment(double a) {
       this.a = a;
   }
}

public class Circle{
   double radius;
   Circle(double radius) {
       this.radius = radius;
   }
}

public class Square {
   double a;	//Сторона квадрата
   public Square(double a){
       this.a = a;
   }
}

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

 public interface ComparePerimeter {
	//Метод для определения периметра
    double perimeter();
	//Метод для сравнения фигур по периметру
    int comparePerimeters(ComparePerimeter figure);
}

После включения интерфейса наши объекты будут выглядеть следующим образом.

public class Segment implements ComparePerimeter{
   double a; //Длина отрезка
   Segment(double a) {
       this.a = a;
   }
   @Override
   double perimeter() {
       return a;
   }
    @Override
   public int comparePerimeters(ComparePerimeter figure) {
       return Double.compare(perimeter(), figure.perimeter());
   }

}

public class Circle implements ComparePerimeter{
   double radius;
   Circle(double radius) {
       this.radius = radius;
   }
   @Override
   double perimeter() {
       return 2 * Math.PI * radius;
   }
    @Override
   public int comparePerimeters(ComparePerimeter figure) {
       return Double.compare(perimeter(), figure.perimeter());
   }
}

public class Square implements ComparePerimeter {
   double a;
   public Square(double a){
       this.a = a;
   }

   @Override
   public double perimeter() {
       return 4 * a;
   }   
  
   @Override
   public int comparePerimeters(ComparePerimeter figure) {
       return Double.compare(perimeter(), figure.perimeter());
   }
}

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

public static void main(String[] args) {
       Segment segment = new Segment(5);
       Circle circle = new Circle(0.5);
       Square square = new Square(5.0);
       System.out.println("segment.comparePerimeters(circle) = " + segment.comparePerimeters(circle));
       System.out.println("circle.comparePerimeters(square) = " + circle.comparePerimeters(square));
       System.out.println("square.comparePerimeters(segment) = " + square.comparePerimeters(segment));

}

Вывод на консоль:

segment.comparePerimeters(circle) = 1
circle.comparePerimeters(square) = -1
square.comparePerimeters(segment) = 1

Но что мы видим? Если содержимое метода perimeter() для каждого класса уникально, то код метода comparePerimeters(ComparePerimeter figure) в каждом классе повторяется. Вызвано это необходимостью переопределения метода.

И здесь нам на помощь приходит возможность создания дефолтных методов.

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

public interface ComparePerimeter {
   double perimeter();

   default int comparePerimeters(ComparePerimeter figure){
       return Double.compare(perimeter(), figure.perimeter());
   };
}

После чего, уберем переопределение метода из классов и убедимся, что система не выдаст нам ошибок.

public class Segment implements ComparePerimeter{
   double a; //Длина отрезка
   Segment(double a) {
       this.a = a;
   }
   @Override
   double perimeter() {
       return a;
   }
}

public class Circle implements ComparePerimeter{
   double radius;
   Circle(double radius) {
       this.radius = radius;
   }
   @Override
   double perimeter() {
       return 2 * Math.PI * radius;
   }
}

public class Square implements ComparePerimeter {
   double a;
   public Square(double a){
       this.a = a;
   }

   @Override
   public double perimeter() {
       return 4 * a;
   }   
}

Повторим проверку, чтобы подтвердить то, что метод по-прежнему доступен для объектов.

public static void main(String[] args) {
   Segment segment = new Segment(5);
   Circle circle = new Circle(0.5);
   Square square = new Square(5.0);
   System.out.println("segment.comparePerimeters(circle) = " + segment.comparePerimeters(circle));
   System.out.println("circle.comparePerimeters(square) = " + circle.comparePerimeters(square));
   System.out.println("square.comparePerimeters(segment) = " + square.comparePerimeters(segment));
}

Вывод на консоль:

segment.comparePerimeters(circle) = 1
circle.comparePerimeters(square) = -1
square.comparePerimeters(segment) = 1

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

А теперь давайте представим, что нам понадобилось выводить на консоль значения периметров наших фигур. И при этом переопределять toString() у классов нет ни времени, ни сил.

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

public interface ComparePerimeter {
double perimeter();

default int comparePerimeters(ComparePerimeter figure){
 return Double.compare(perimeter(), figure.perimeter());
   };

   default void printPerimeter(){
       System.out.println("printPerimeter: " + perimeter());
   }
}

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

Проверим.

public static void main(String[] args) {
   Segment segment = new Segment(5);
   Circle circle = new Circle(0.5);
   Square square = new Square(5.0);
   System.out.println("segment.comparePerimeters(circle) = " + segment.comparePerimeters(circle));
   System.out.println("circle.comparePerimeters(square) = " + circle.comparePerimeters(square));
   System.out.println("square.comparePerimeters(segment) = " + square.comparePerimeters(segment));

   segment.printPerimeter();
   circle.printPerimeter();
   square.printPerimeter();
}

Вывод на консоль:

segment.comparePerimeters(circle) = 1
circle.comparePerimeters(square) = -1
square.comparePerimeters(segment) = 1
printPerimeter: 5.0
printPerimeter: 3.141592653589793
printPerimeter: 20.0

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

Для примера добавим в один из классов единицы измерений.

public class Circle implements ComparePerimeter {
   double radius;
   Circle(double radius) {
       this.radius = radius;
   }

   @Override
   public double perimeter() {
       return 2 * Math.PI * radius;
   }

   @Override
   public void printPerimeter() {
       System.out.println("printPerimeter: " + perimeter() + " mm");
   }
}

Вывод на консоль:

segment.comparePerimeters(circle) = 1
circle.comparePerimeters(square) = -1
square.comparePerimeters(segment) = 1
printPerimeter: 5.0
printPerimeter: 3.141592653589793 mm
printPerimeter: 20.0

И напоследок рассмотрим ситуацию, в которой два интерфейса содержат дефолтные методы с одинаковым именем.

Создадим второй интерфейс и включим в него дефолтный метод с аналогичным именем.

public interface PrintPerimeter {
   double perimeter();
  
   default void printPerimeter(){
       System.out.println("Perimeter: " + perimeter() + "мм");
   }
}

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

public class Square implements ComparePerimeter, PrintPerimeter {
   double a;
   public Square(double a){
       this.a = a;
   }

   @Override
   public void printPerimeter() {
      
   }

   @Override
   public double perimeter() {
       return 4 * a;
   }
}

Подведем итоги:

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

Минкин Михаил, после окончания базового курса, февраль 2021