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

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

Основы ООП в Java: объекты vs классы

Сначала я хочу разграничить два понятия – объект и класс. Эти понятия постоянно путают. Между тем, они являются центральными в ООП. И знать различия между ними, на мой взгляд, необходимо.

Итак, объект. По сути, это что угодно. Вот кубик лежит. Деревянный, синий. Длина ребра 5 см. Это объект. А вон пирамидка. Пластмассовая, красная. 10 см ребро. Это тоже объект. Что между ними общего? Разные размеры. Разная форма. Разный материал.

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

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

Итак, определение.

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

Пример класса и объектов в Java

Java
// Класс - описатель общих свойств
public class Shape {
    protected String color;
    protected double size;
    
    public Shape(String color, double size) {
        this.color = color;
        this.size = size;
    }
    
    public void draw() {
        System.out.println("Рисую фигуру " + color + " цвета");
    }
    
    public void rotate() {
        System.out.println("Вращаю фигуру");
    }
}

// Создание объектов
Shape cube = new Shape("синий", 5.0);
Shape pyramid = new Shape("красный", 10.0);

В этом примере Shape - это класс (описатель), а cube и pyramid - это объекты (конкретные экземпляры).

Замечание. Слово "всех" (описатель всех свойств) произнесено не было. Что означает, что любой объект может принадлежать к нескольким разным классам.

Наследование как явление - 1

Что такое наследование в Java: синтаксис и примеры

Ключевое слово extends

В Java наследование реализуется с помощью ключевого слова extends. Дочерний класс наследует все public и protected поля и методы родительского класса.

Java
public class Cube extends Shape {
    private int faces = 6;
    
    public Cube(String color, double size) {
        super(color, size); // Вызов конструктора родителя
    }
    
    @Override
    public void draw() {
        System.out.println("Рисую куб " + color + " цвета с размером " + size);
    }
    
    public int getFaces() {
        return faces;
    }
}

Ключевое слово super

  • super() - вызывает конструктор родительского класса
  • super.methodName() - вызывает метод родительского класса
  • super.fieldName - обращается к полю родительского класса
Java
public class Tetrahedron extends Shape {
    private int faces = 4;
    
    public Tetrahedron(String color, double size) {
        super(color, size);
    }
    
    @Override
    public void draw() {
        super.draw(); // Вызов метода родителя
        System.out.println("Это тетраэдр с " + faces + " гранями");
    }
}

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

Дальше. Мы можем конкретизировать описание. Допустим, мы хотим нарисовать этот многогранник. Введем такое понятие как отрисовываемый правильный многогранник. Что нам нужно для рисования? Описание общего способа отрисовки, не зависящего от конкретных координат вершин. Возможно, цвет объекта.

Теперь введем классы Куб и Тетраэдр. Объекты, принадлежащие к этим классам, безусловно являются правильными многогранниками. Единственное отличие – числа вершин, ребер и граней уже жестко фиксированы для каждого из новых классов. Далее, зная вид конкретной фигуры, мы можем дать описание способа отрисовки. А значит, любой объект классов Куб или Тетраэдр также является и объектом класса отрисовываемый правильный многогранник. Налицо иерархия классов.

В этой иерархии мы спускаемся от самого общего описания к наиболее конкретизированному. Заметьте, что объект любого класса также подходит под описание любого более общего класса по иерархии. Такое отношение классов и называется наследованием. Каждый дочерний класс наследует все свойства родительского, более общего, и (возможно) добавляет к этим свойствам какие-то свои. Либо переопределяет какие-то свойства родительского класса.

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

Наследование в Java тесно связано с полиморфизмом. Объекты дочерних классов могут использоваться везде, где ожидается объект родительского класса:

Java
public class GeometryDemo {
    public static void main(String[] args) {
        Shape[] shapes = {
            new Cube("синий", 5.0),
            new Tetrahedron("красный", 3.0),
            new Shape("зеленый", 2.0)
        };
        
        // Полиморфизм: вызывается соответствующий метод draw()
        for (Shape shape : shapes) {
            shape.draw(); // Вызов переопределенного метода
            shape.rotate(); // Вызов унаследованного метода
        }
    }
}

Переопределение методов (@Override)

Аннотация @Override помогает избежать ошибок при переопределении методов:

Java
public class Cube extends Shape {
    @Override
    public void draw() {
        // Переопределение метода родителя
        System.out.println("Рисую куб специальным способом");
    }
    
    // Ошибка компиляции, если метод не переопределяется
    // @Override
    // public void draww() { } // Опечатка в названии
}

Здесь я хочу привести цитату из классической уже книги Гради Буча по объектно-ориентированному дизайну:

Inheritance, therefore, defines an "is a" hierarchy among classes, in which subclass inherits from one or more superclasses. This is in fact the litmus test for inheritance. Given classes A and B, if A "is not a" kind of B, then A shouldn't be a subclass of B.

В переводе это звучит так:

Наследование, таким образом, определяет иерархию "является" между классами, в которой подкласс наследует от одного или более суперклассов. Это, фактически, определяющий тест (дословно – лакмусовый тест, прим. моё) для наследования. Если у нас есть классы А и В и если класс А "не является" разновидностью класса В, то А не должен быть подклассом В.

Принцип "is-a" vs "has-a" в Java

Java
// ПРАВИЛЬНО: Куб ЯВЛЯЕТСЯ фигурой (is-a)
public class Cube extends Shape {
    // ...
}

// НЕПРАВИЛЬНО: Танк НЕ ЯВЛЯЕТСЯ пулеметом (has-a)
// public class Tank extends Gun { } // Ошибка дизайна!

// ПРАВИЛЬНО: Танк СОДЕРЖИТ пулемет (has-a - композиция)
public class Tank {
    private Gun mainGun;      // Композиция
    private Gun[] machineguns; // Агрегация
    
    public Tank() {
        this.mainGun = new Gun("main");
        this.machineguns = new Gun[] { new Gun("mg1"), new Gun("mg2") };
    }
    
    public void fire() {
        mainGun.shoot();
    }
}

Дочитавшие до этого места, возможно, недоуменно покрутят пальцем у виска. Первая мысль – это же тривиально! Так и есть. Но если бы вы знали, сколько безумных иерархий наследования я видел! В той дискуссии, о которой я упомянул в самом начале, один из участников совершенно серьезно унаследовал танк от... пулемета!!! На том простом основании, что у танка ЕСТЬ пулемет. И это – самая распространенная ошибка. Наследование путают с агрегированием – включением одного объекта в состав другого. Танк не является пулеметом, он его содержит. И из-за этой ошибки чаще всего и возникает желание воспользоваться множественным наследованием.

Интерфейсы в Java: современный подход к множественному наследованию

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

Наследование как явление - 2

Синтаксис интерфейсов

Java
// Интерфейс определяет поведение
public interface Drawable {
    void draw(); // Абстрактный метод (по умолчанию public abstract)
    
    // Default методы (с Java 8)
    default void highlight() {
        System.out.println("Подсвечиваю объект");
    }
    
    // Статические методы (с Java 8)
    static void showVersion() {
        System.out.println("Drawable API version 1.0");
    }
    
    // Приватные методы (с Java 9)
    private void validateState() {
        // Вспомогательный метод
    }
}

// Реализация интерфейса
public class Circle implements Drawable {
    @Override
    public void draw() {
        System.out.println("Рисую окружность");
    }
    
    // highlight() унаследован как default метод
}

Множественная реализация интерфейсов

Java
public interface Serializable {
    void serialize();
}

public interface Comparable {
    int compareTo(T other);
}

// Класс может реализовывать несколько интерфейсов
public class Shape implements Drawable, Serializable, Comparable {
    private String name;
    private int priority;
    
    @Override
    public void draw() {
        System.out.println("Рисую фигуру: " + name);
    }
    
    @Override
    public void serialize() {
        System.out.println("Сериализую фигуру: " + name);
    }
    
    @Override
    public int compareTo(Shape other) {
        return Integer.compare(this.priority, other.priority);
    }
}

Так вот, язык позволяет унаследовать класс от другого класса, потенциально содержащего реализацию. НО ТОЛЬКО ОТ ОДНОГО! Поясню, зачем это сделано. Дело в том, что каждая реализация может иметь дело только со своей частью – с теми переменными и методами, о которых она знает. И если даже мы унаследуем класс С от А и В, то метод processA, унаследованный из класса А, может работать только с внутренней переменной а, ибо о b он ничего не знает, равно как ничего он не знает и о c, и о методе processC. Точно так же и метод processB может работать только с переменной b. Т.е., по сути, унаследованные части оказываются изолированными. Класс С, безусловно, может с ними работать, но точно так же он может работать с этими частями, если они будут просто включены в его состав, а не унаследованы.

Однако тут есть еще одна неприятность, заключающаяся в перекрытии имен. Если бы методы processA и processB назывались одинаково, допустим, process, то какой эффект дало бы обращение к методу process класса С? Какой из двух методов был бы вызван? Разумеется, в С++ есть средства управления в этой ситуации, однако стройности языку это не добавляет.

Проблема Diamond Problem

Классическая проблема множественного наследования - "Diamond Problem":

Java
// Проблема в C++ (в Java так сделать нельзя)
/*
class A {
    public void method() { }
}

class B extends A {
    public void method() { System.out.println("B"); }
}

class C extends A {
    public void method() { System.out.println("C"); }
}

// Это невозможно в Java!
class D extends B, C {
    // Какой метод method() будет вызван?
}
*/

// В Java решение через интерфейсы:
interface InterfaceB {
    default void method() { System.out.println("B"); }
}

interface InterfaceC {
    default void method() { System.out.println("C"); }
}

class D implements InterfaceB, InterfaceC {
    @Override
    public void method() {
        // Явное указание, какой метод вызвать
        InterfaceB.super.method();
        InterfaceC.super.method();
    }
}

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

Что представляет собой интерфейс? Набор методов. (Определение в интерфейсах констант мы сейчас не рассматриваем, подробнее об этом тут.) А что есть метод? А метод, по своей сути, определяет поведение объекта. Не случайно в названии практически каждого метода содержится действие – getXXX, drawXXX, countXXX, и т.д. А поскольку интерфейс – это совокупность методов, то интерфейс представляет собой, фактически, определитель поведения.

Современные возможности интерфейсов (Java 8+)

Java
public interface ModernInterface {
    // Константы (public static final по умолчанию)
    int VERSION = 1;
    String NAME = "Modern Interface";
    
    // Абстрактные методы
    void abstractMethod();
    
    // Default методы (с Java 8)
    default void defaultMethod() {
        System.out.println("Default реализация");
        helperMethod(); // Можно вызывать приватные методы
    }
    
    // Статические методы (с Java 8)
    static void staticMethod() {
        System.out.println("Статический метод интерфейса");
    }
    
    // Приватные методы (с Java 9)
    private void helperMethod() {
        System.out.println("Приватный вспомогательный метод");
    }
}

// Использование
public class Implementation implements ModernInterface {
    @Override
    public void abstractMethod() {
        System.out.println("Реализация абстрактного метода");
    }
    
    public static void main(String[] args) {
        Implementation impl = new Implementation();
        impl.abstractMethod(); // Реализованный метод
        impl.defaultMethod();  // Default метод
        ModernInterface.staticMethod(); // Статический метод
        
        System.out.println(ModernInterface.VERSION); // Константа
    }
}

Другой вариант применения интерфейса – определитель роли объекта. Наблюдатель, Слушатель и т.п. В этом случае метод фактически является воплощением реакции на какое-то внешнее событие. Т.е., опять-таки, поведением.

Объект, безусловно, может иметь несколько различных поведений. Если ему нужно отрисоваться – он отрисовывается. Если ему нужно сохраниться – он сохраняется. Ну и т.д. Соответственно, возможность наследования от классов, определяющих поведение – весьма и весьма полезна. Точно так же объект может иметь несколько различных ролей. Однако реализация поведения – целиком и полностью на совести дочернего класса. Наследование от интерфейса (его реализация) говорит, что объект этого класса должен уметь делать то-то и то-то. А КАК он это делает – каждый реализующий интерфейс класс определяет самостоятельно.

Абстрактные классы в Java: когда использовать

Когда использовать абстрактные классы:

  1. Есть общая реализация - когда несколько классов имеют общий код
  2. Нужен конструктор - интерфейсы не могут иметь конструкторы
  3. Приватные/protected поля - для инкапсуляции состояния
  4. Смешанное наследование - часть методов реализована, часть абстрактна

Когда использовать интерфейсы:

  1. Множественное наследование - класс может реализовывать несколько интерфейсов
  2. Контракт без реализации - определяем только "что делать", а не "как"
  3. Слабая связанность - интерфейсы обеспечивают более гибкую архитектуру

Современные паттерны: композиция vs наследование

Принцип "Composition over Inheritance"

Java
// Плохой пример: наследование для переиспользования кода
// public class ArrayList extends Vector { } // Реальный антипаттерн!

// Хороший пример: композиция
public class SmartList {
    private List list; // Композиция вместо наследования
    private int accessCount = 0;
    
    public SmartList() {
        this.list = new ArrayList<>();
    }
    
    public void add(T item) {
        accessCount++;
        list.add(item);
    }
    
    public T get(int index) {
        accessCount++;
        return list.get(index);
    }
    
    public int getAccessCount() {
        return accessCount;
    }
}

Паттерн Decorator

Java
// Базовый интерфейс
interface Coffee {
    String getDescription();
    double getCost();
}

// Простая реализация
class SimpleCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "Простой кофе";
    }
    
    @Override
    public double getCost() {
        return 2.0;
    }
}

// Базовый декоратор
abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;
    
    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
    
    @Override
    public String getDescription() {
        return coffee.getDescription();
    }
    
    @Override
    public double getCost() {
        return coffee.getCost();
    }
}

// Конкретные декораторы
class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", молоко";
    }
    
    @Override
    public double getCost() {
        return coffee.getCost() + 0.5;
    }
}

class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return coffee.getDescription() + ", сахар";
    }
    
    @Override
    public double getCost() {
        return coffee.getCost() + 0.2;
    }
}

// Использование
public class CoffeeShop {
    public static void main(String[] args) {
        Coffee coffee = new SimpleCoffee();
        coffee = new MilkDecorator(coffee);
        coffee = new SugarDecorator(coffee);
        
        System.out.println(coffee.getDescription()); // Простой кофе, молоко, сахар
        System.out.println("Стоимость: $" + coffee.getCost()); // 2.7
    }
}

Паттерн Strategy

Java
// Стратегия сортировки
interface SortStrategy {
    void sort(int[] array);
}

class BubbleSort implements SortStrategy {
    @Override
    public void sort(int[] array) {
        System.out.println("Сортировка пузырьком");
        // Реализация сортировки пузырьком
    }
}

class QuickSort implements SortStrategy {
    @Override
    public void sort(int[] array) {
        System.out.println("Быстрая сортировка");
        // Реализация быстрой сортировки
    }
}

// Контекст
class SortContext {
    private SortStrategy strategy;
    
    public void setStrategy(SortStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void sort(int[] array) {
        strategy.sort(array);
    }
}

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

Типичные ошибки наследования в Java

Чаще всего совершается ошибка, о которой я уже упоминал – наследование путается с агрегированием. Иногда это происходит из-за неверных предпосылок. Т.е. берется, например, спидометр, утверждается, что измерить скорость можно только измерив расстояние и время, после чего спидометр благополучно наследуется от линейки и часов, становясь, таким образом, линейкой и часами, согласно определению наследования. (На мои просьбы измерить спидометром время обычно отвечали шутками. Или вообще не отвечали.) А в чем тут ошибка? В предпосылке. Дело в том, что спидометр не измеряет времени. И расстояния, кстати, тоже. Одометр, который есть в любом спидометре – это классический пример второго прибора в том же корпусе, т.е. агрегирования. Для измерения скорости он не нужен. Его можно вообще убрать – на измерение скорости никак это не повлияет. Иногда такие ошибки совершают осознанно. Это гораздо хуже. "Да, я знаю, что так неправильно, но мне так удобнее". Во что это может обернуться? А вот во что: унаследуем танк от пушки и пулемета. Удобнее так. В результате танк становится пушкой и пулеметом. Дальше мы оборудуем самолет двумя пулеметами и пушкой. Что получаем? Самолет с подвесным вооружением в виде трех танков! Потому что ОБЯЗАТЕЛЬНО найдется человек, который, не разобравшись, использует танк в качестве пулемета. Исключительно согласно иерархии наследования. И будет абсолютно прав, потому что ошибку сделал тот, кто такую иерархию спроектировал. Вообще, я не очень понимаю подход "мне так удобнее". Удобние писать как слышыца, а те, кто гаварит пра граматнасть – казлы. Я утрирую, конечно, но основная мысль остается – кроме сиюминутного удобства есть такое понятие как грамотность. Это понятие определено на основании очень большого опыта очень большого количества людей. Фактически, это то, что в английском называется "best practice" – наилучшее решение. И чаще всего решения, которые кажутся более простыми, приносят в дальнейшем немало проблем. Пример этот, конечно, сильно утрирован и потому абсурден. Однако встречаются не столь явные случаи, приводящие тем не менее к катастрофическим последствиям. Унаследовавшись от объекта, вместо того, чтобы его агрегировать, разработчик дает любому возможность использования функциональности родительского объекта напрямую. Со всеми вытекающими из этого последствиями. Представьте что у вас есть класс, работающий с базой данных, DBManager. Вы создаете еще один класс, который будет работать уже с вашими данными, используя DBManager, – DataManager. Этот класс будет осуществлять контроль данных, преобразования, дополнительные действия и т.п. В общем, прослойка между бизнес-уровнем и уровнем базы. Если унаследовать DataManager от DBManager, то всякий, использующий его, получит доступ к базе напрямую. И, следовательно, сможет выполнить любые действия в обход контроля, преобразований и пр. Ладно, предположим, что умышленного вреда никто наносить не хочет и прямые действия будут грамотными. Но! Допустим, что база изменилась. В смысле, изменились какие-то принципы контроля или преобразований. DataManager изменили. Но тот код, который раньше работал с базой напрямую – так и будет работать. Про него с большой вероятностью не вспомнят. В результате появится ошибка такого класса, что те, кто ее будут искать, поседеют. Никому ведь в голову не придет, что с базой работают в обход DataManager. Кстати, пример из реальной жизни. Ошибку искали ОЧЕНЬ долго. Напоследок повторю еще раз. Наследование необходимо применять ТОЛЬКО при наличии отношения "является". Потому как в этом заключается сама суть наследования – возможность использовать объекты дочернего класса как объекты базового. Если же отношения "является" между классами нет – наследования быть НЕ ДОЛЖНО!!! Никогда и ни при каких обстоятельствах. И тем более – только потому что так удобно.

Заключение

Наследование в Java — мощный инструмент, который при правильном использовании делает код более гибким, расширяемым и поддерживаемым. Основные принципы:
  • Используйте наследование только для отношений "is-a"
  • Предпочитайте композицию наследованию для отношений "has-a"
  • Используйте интерфейсы для определения контрактов и множественного наследования
  • Применяйте абстрактные классы когда нужна частичная реализация
  • Следуйте принципам SOLID для создания качественной архитектуры
Помните: хорошо спроектированная иерархия классов — это инвестиция в будущее вашего проекта. Плохо спроектированная — источник технического долга и головной боли для всей команды.