Skip to content

Latest commit

 

History

History
366 lines (260 loc) · 27.1 KB

inheritance.md

File metadata and controls

366 lines (260 loc) · 27.1 KB

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

Введение

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

Сперва давайте постараемся ответить на вопрос: Зачем вообще придумали Наследование?

Давайте объявим некоторый класс.

Тут нам на помощь снова придет извечный бродяга примеров - класс Person.

class Person {
  private int age;
  private String name;

  // some remaining code
}

Теперь нам понадобилось создать класс Employee - работника. Работник имеет те же черты, что и Person, но вдобавок к этому он устроен на работу. Т.е это тот же Person, но с еще одним дополнительным полем - работа.

В мире, где механизм наследования отсутствует - мы бы написали такой класс примерно так:

class Employee {
  private int age;
  private String name;
  private String work;

  // some remaining code
}

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

Вдобавок к этому есть и еще один неприятный момент!

Для нас очевидно, что эти классы связаны логически, т.е Employee - является еще и Person, но для Java эти классы никак не связаны, с точки зрения Java это просто два разных, никак не связанных и не имеющих ничего общего класса. Но мы-то знаем, что это не так!

Возникает резонное желание - а что если делегировать ответственность за это копирование кода и поддержание его в консистентном состоянии на Java? Под консистентном состоянии я имею в виду, что при добавлении нового поля в Person это поле появится и у Employee.

Тут нам и поможет механизм наследования!

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

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

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

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

class Person {
  protected int age;
  protected String name;

  public Person(int age, String name) {
    this.age = age;
    this.name = name;
  }

  // some remaining code
}

class Employee extends Person {
    private String address;

    public Employee(int age, String name, String address) {
        super(age, name);
        this.address = address
    }
}

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

Для демонстрации этого мы просто создадим метод, который ждет на вход экземпляры класса Person. Допустим, у нас реализована некая телефонная книга:

class PhoneBook {
    public static void find(Person p) {
        System.out.println(p.name);
    }
}

Запустите следующий код в обоих случаях, про которые мы говорили.

public static void main(String[] args) {
    Person p = new Person(27, "Aleksandr");
    Employee e = new Employee(27, "Aleksandr", "Sberbank");

    PhoneBook.find(p);
    PhoneBook.find(e);
}

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

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

Однако, стоит понимать, что наследование - это не просто инструмент для избавления от дублирования кода. Помните, что наследование - это приобретение и состояния, и поведения класса-родителя.

Наследование - это приобретение и состояния, и поведения класса-родителя.

Ключевые слова this и super

Внимательный читатель сразу обратил внимание на еще одно новое ключевое слово в теле конструктора класса Employee - super. А если вы присмотритесь еще внимательней, то вы увидите и ключевое слово this.

Так вот, this - это ссылка на объект, текущего класса, а super - ссылка на объект его родительского класса. Само слово super пошло из теории множеств, где используется термин супермножество.

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

public Person(int age, String name) {
  this.age = age;
  this.name = name;
}

С помощью ссылки мы получили поле age у текущего объекта и присвоили туда значение, переданное в конструктор. Аналогичная ситуация с полем name.

Ключевые слова this и super также могут быть использованы для вызова конструктора текущего или родительского класса соответственно:

class Person {
  protected int age;
  protected String name;

  public Person(int age, String name) {
    this.age = age;
    this.name = name;
  }

  public Person(String name) {
    this(20, name);
  }

  // some remaining code
}

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

Второй конструктор по сути равносилен следующей записи:

public Person(String name) {
  this.age = 20;
  this.name = name;
}

То же самое справедливо и для super(), что мы видели в примере выше:

class Employee extends Person {
    private String address;

    public Employee(int age, String name, String address) {
        super(age, name);
        this.address = address
    }
}

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

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

И this, и super — это нестатические переменные, соответственно их нельзя использовать в статическом контексте. Переменной this нельзя присвоить новое значение, потому что она объявлена как final, что тоже очень даже логично.

Теперь стоит сказать о том, что в Java все классы неявно наследники java.lang.Object. А значит при создании любого класса, происходит вызов родительского конструктора через super().

При этом, даже если вы не создали ни одного конструктора класса, то Java все равно создаст конструктор пустой по умолчанию и вызовет там super(). Ведь мы создаем объект класса, родительский класс которого - java.lang.Object.

Вообще, если вам потребовалось внутри класса вызвать конструктор, например, более расширенный, то делать это надо именно через this:

class Employee extends Person {
    protected String address;

    public Employee(int age, String name, String address) {
        super(age, name);
        this.address = address;
    }

    public Employee(int age, String name) {
        this(age, name, "EMPTY");
    }
}

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

Для закрепления:

Ключевое слово Описание
this Ссылка на текущий объект
super Ссылка на родительский объект
this() Вызов конструктора без аргументов
super() Вызов конструктора без аргументов родительского класса

Множественное наследование

Снова давайте начнем с вопроса: Может ли у класса быть более одного предка?

java.lang.Object мы не берем в виду, так как он является родительским классом для всех.

В языках программирования типа C++, Python и т.д это возможно, такой механизм называется множественным наследованием. В Java так сделать нельзя.

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

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

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

Вы представляете весь ужас такого действия? И дело не в том, что почти наверняка оба класса оружия имеют у себя метод fire.

Дело в том, что наследование значит, что наш класс-наследник - это класс, который является - отношение is a - тем же, что и родитель. У него те же свойства и параметры, он только расширяет родителя, но никак не сужает его возможности.

И в итоге при таком подходе наш Танк - это и пулемет, и пушка. А это как раз таки в корне не верно. Танк содержит и пулемет, и пушку, т.е здесь уже отношение - has a.

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

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

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

Об этом мы еще поговорим подробнее.

Когда разумно наследоваться?

Выше мы разобрали пример не совсем разумного применения наследования и у вас должен возникнуть вопрос - когда можно использовать наследование, а когда нельзя?

И ответ на этот вопрос довольно прост. Если класс, который потенциальный потомок, удовлетворяет отношению is a к потенциальному родителю - т.е можно сказать, что он является тем же, что и родительский класс, то вы можете и это будет правильно использовать наследование.

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

Для примера возьмем два класса - Figure и Rectangle.

Является ли прямоуголник некоей абстрактной фигурой? Скорее всего да. Значит, логично сделать так, что Rectangle является наследником Figure.

Теперь поговорим о том, что делать в случае, если наше отношение - это has a

Отношение has a

Существует несколько видов взаимодействия объектов, объединенных под общим понятием "Has-A Relationship" или "Part Of Relationship". Это отношение означает, что один объект является составной частью другого объекта.

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

В обоих случаях мы буквально составляем наш класс по кирпичикам. Именно так, как какой-нибудь прибор состоит из деталей.

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

Отличия композиции от агрегации

  • Композиция

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

    Это как сердце и человек, сердце - это часть человека, но отдельно от него оно не может существовать, не может или его существоание не имеет смысла.

  • Агрегация

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

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

Композиция - это частный случай агрегации.

Минусы и плюсы

Давайте разберем минусы и плюсы подхода с наследованием и композциией/агрегацией.

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

Плюсы:

  • Повторное использование уже существующих и протестированных участков кода
  • Выстраиваемая иерархия наследников

Минусы:

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

    Пусть у нас есть своя какая-то MyHashMap и мы отнаследовали ее от HashMap, переопределили метод add(...), если разработчики HashMap введут addAll(..) - у нас будет в этом месте дыра, ведь мы переопределили уже add по-своему, но не addAll, который будет добавлять элементы 'по старому'.

  • Ошибка в неверной иерархии наследования - и у нас огромные проблемы с расширением нашего кода в дальнейшем.

  • Нарушение инкапсуляции.

  • Тянем все проблемы и ошибки наследованного кода.

  • Тяжело выстраивать правильные абстракции.

Композиция/Агрегация

Плюсы:

  • Ситуации на подобие той, что описана выше с MyHashMap исключены.
  • Возможность скрыть проблемы класса-родителя, создав обертку, в которой скроем недостатки API класс-родителя.
  • Легко применять и строить абстракции.

Минусы:

  • Иногда действительно удобно работать с наследованием и иерархией классов.
  • Если объектов-владельцев достаточно много, то создание и уничтожение вместо одного объекта двух или более может пагубно сказаться на производтельнсоти.

Наследование - это именно 'расширение' какого-то функционала, в то время как композиция - это включение(внедрение) функционала.

Существует даже правило: "Предпочитайте композицию наследованию".

Ситуация с Бонни - MyHashMap

Почему при композиции мы избегаем ситуации описанной в примере с MyHashMap?

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

Т.е при композиции мы максимально контролируем и знаем поведение нашего класса - ведь мы сами его и написали. Никакие "новые" методы не могут попасть в интерфейс нашего класса - пока мы сами их явно не вызовем и не напишем на них свои обертки.

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

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

Заключение

  • Не злоупотребляйте наследованием.

Помните про солдат, берущих в руки танки.

  • Всегда задавайтесь вопросом is a или has a и только после этого делайте выбор.
  • Старайтесь избегать дублирования кода применяя или композицию/агрегацию, или наследование.
  • Думайте над полученной иерархией классов.