☕ Разбираем на простых примерах: наследование в Java

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

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

Что такое наследование?

После пары вступительных слов перейдем непосредственно к теме статьи.

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

☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»

Пример наследования

Рассмотрим механизм наследования на примере языка программирования Java.

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

Начальная схема отношения классов
Начальная схема отношения классов

Исходя из схемы выше, реализуем 4 класса: Pixel, Snake, Food, Bonus.

        public class Pixel {

<...>

}

class Snake extends Pixel {

<...>

}

class Food extends Pixel {

<...>

}

class Bonus extends Food {

<...>

}
    

Здесь:

extends – ключевое слово, применяемое к классам для указания родительской сущности. В Java, в отличие от, например, C++ родитель может быть только один.

Таким образом, описав класс Pixel, были описаны и все остальные классы. То есть классы Snake, Food и Bonus унаследовали все поля и методы класса Pixel.

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

public abstract boolean canBeEaten();

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

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

Реализуем это в коде:

public abstract class Pixel

Теперь необходимо определить поведение метода canBeEaten() в дочерних классах:

        class Snake extends Pixel {
   @Override
   boolean canBeEaten() {
       return false;
   }
}
class Food extends Pixel {
<...>
   @Override
   boolean canBeEaten() {
       return true;
   }
}

    

Заметим, что у класса Bonus не переопределяется метод canBeEaten(). Он наследует свое поведение уже от своего родителя – класса Food.

🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»

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

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

Иллюстрация проблемы множественного наследования
Иллюстрация проблемы множественного наследования

Получаем дилемму: если объект класса Enemy вызывает метод canBeEaten(), определенный в классе Pixel и при этом не переопределенный в классе Enemy, а классы Snake и Food определили этот метод каждый по-своему. Возникает вопрос: от какого класса должен наследовать свое поведение экземпляр класса Enemy: Snake или Food? Такая конфигурация наследования называется «проблемой множественного наследования».

В Java для решения этой проблемы применяются интерфейсы.

Что такое интерфейс?

Интерфейс – это объект языка Java схожий по своей сути с абстрактным классом. Для определения интерфейса в языке есть отдельное ключевое слово interface.

Интерфейс не обязан иметь в себе сигнатуры методов. Пустые интерфейсы называются интерфейсами-маркерами. Классическим примером интерфейса-маркера можно считать Cloneable из пакета java.lang.

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

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

  1. Идеологическая. В парадигме ООП все классы – существительные. Абстрактный класс не исключение. Следовательно, он описывает общие свойства объектов. Интерфейс же — это контракт. Он описывает общие действия доступные всем классам, реализующим его. Интерфейс не располагает реализацией – он лишь гарантирует наличие действия у объекта. Именно поэтому в некоторых нотациях принято именовать интерфейсы как Noun-able.
  2. Практическая. Класс может расширять только один класс и имплементировать множество интерфейсов. С помощью интерфейса можно решить проблему множественного наследования.

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

Создадим интерфейс, определяющий возможность перемещаться, и имплементируем его:

        interface Movable {
   void move(String direction);
}
class Snake extends Pixel implements Movable {
<..>
   @Override
   public void move(String direction) {
       switch (direction) {
           case "up" -> y = y + 1;
           case "down" -> y = y - 1;
           case "left" -> x = x - 1;
           case "right" -> x = x + 1;
       }
   }
}

    

Вредные советы. Делаем из интерфейса абстрактный класс

Как уже говорилось выше, интерфейс и абстрактный класс очень похожи, и, вместе с этим, у интерфейса есть преимущество: класс может имплементировать множество интерфейсов. Так почему бы не сделать из интерфейса абстрактный класс? Данная функциональность хоть и не лишена смысла, однако, ее следует применять только в крайне ограниченном наборе ситуаций, например, при создании примесей (Mixins).

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

Реализуем это в коде:

        interface Messageble {
   default void infoMessage() {
       System.out.println("Змейка вырастет");
   }
}
class Food extends Pixel implements Messageble {
   protected boolean eat() {
       this.infoMessage();
       return true;
   }
}
    

Решение проблемы множественного наследования

Схема отношений классов без проблемы множественного наследования
Схема отношений классов без проблемы множественного наследования

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

        interface Eatable {
   boolean canBeEaten();
}
abstract class Pixel implements Eatable {
<...>
}
    

Добавим также классу Snake интерфейс-маркер, чтобы отличать «живые» объекты. Например, змеек и, возможно, других персонажей от еды.

        interface Alivable{}
class Snake extends Pixel implements Movable, Alivable {
  	<...>
}

    

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

​​Ссылки

https://gist.github.com/denkarz/InheirenceExample – пример кода

***

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

Подведем итог. В этой статье мы:

  1. Научились создавать интерфейсы, абстрактные классы и методы.
  2. Поняли, чем отличается абстрактный класс от интерфейса.
  3. Познакомились с проблемой множественного наследования, а также методами ее решения.

Материалы по теме

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Продуктовый аналитик в поддержку
по итогам собеседования
DevOps
Санкт-Петербург, от 150000 RUB до 400000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ