Урок 17. Наследование и полиморфизм в Java
В этой статье из моего бесплатного курса Java я буду обсуждать наследование в Java. Подобно интерфейсам, наследование позволяет программисту обрабатывать группу похожих объектов единообразным способом, что сводит к минимуму дублирование кода. Однако, чтобы избежать ошибок в долгосрочной перспективе, используйте данный инструмент нужным образом и в нужном месте, а не везде.
Предупреждение
Наследование - одна из самых мощных функций объектно-ориентированных языков. Это звучит очень многообещающе и полезно. Однако я думаю, что это просто немного слишком мощно. При неправильном использовании оно может повредить вашу кодовую базу, что делает ее очень уязвимой для плохого дизайна, поэтому используйте разумно. Безусловно, есть случаи, когда использование наследования оправдано, но их не так много. Для правильного использования наследования требуется некоторый опыт со стороны программиста.
Что такое полиморфизм?
Полиморфизм - это понятие, глубоко связанное с интерфейсами и наследованием. Термин “полиморфизм” происходит от греческих корней “Поли морфы” – множество форм. Полиморфизм позволяет нам создавать различные объекты в правой части объявления переменной, но назначать их все единому типу объекта в левой части. Давайте взглянем на приведенный ниже пример кода:
import org.junit.Test; public class ZooTest { @Test public void shouldFeedAllAnimals() { Zoo zoo = new Zoo(); Animal[] animals = {new Dog(), new Gorilla(), new Tiger(), }; zoo.feed(animals); } }На самом деле мы здесь ничего не тестируем, это всего лишь демонстрация.
Давайте сосредоточимся на нашем массиве животных объектов. В нашем массиве есть собака, горилла и тигр. Использование здесь указывает на полиморфизм. Мы используем ссылочную переменную нашего супертайпа, или родительского класса, Animal в левой части нашего оператора, а в правой части экземплярами являются любые подклассы или подтипы животных: Dog, Gorilla и Tiger в нашем примере. Эти различные животные могут быть отнесены к индексам животных, потому что полиморфизм означает, что мы можем иметь различные реализации на правой стороне, которые обрабатываются равномерно слева.
Если вы посмотрите в нашем классе зоопарка, вы можете увидеть метод подачи ниже, который применяет один и тот же код к любому животному, независимо от того, является ли он собакой или тигром.
public class ZooTest { public void feed(Animal[] animals){ for (Animal animal : animals){ animal.eat(); animal.grow(); } } }Мы перебираем множество животных с произвольным примером, где каждое животное ест и растет. Все эти животные, вероятно, питаются по-разному, но, как мы видим, им приказано есть и расти, несмотря ни на что. Например, горилла будет есть листья и насекомых, в то время как тигр будет есть мясо. Идея состоит в том, чтобы у каждого подтипа животных была своя конкретная реализация, свой собственный способ питания, определенный его методом eat().
Чтобы подкрепить эту идею, у нас также есть метод grow(). Моя идея состоит в том, чтобы grow() был конкретным методом, в то время как eat() – абстрактным методом, причем оба метода находятся в классе Animal. Это означало бы, что все животные растут с одинаковой скоростью, питаясь по-разному.
Наследование
Давайте перейдем к нашей первой итерации класса Animal:
public class Animal { public void eat() { System.out.println(“Animal is eating”); } public void grow() { System.out.println(“Animal is growing”); } }Как вы можете видеть, наш класс животных имеет два конкретных метода: eat () и grow(). Позже мы сделаем grow() абстрактным. У нас оба методы выводят на консоль - чего нам обычно следует избегать – не делайте так в реальном коде. Как вы можете видеть, этот класс очень похож на любой обычный класс, который вы видели так много раз раньше.
А теперь давайте посмотрим на класс Dog. Скажем, мы хотим объявить Dog Animal. Если Animal представляли собой интерфейс, мы хотели написать реализует. Поскольку это класс, мы выражаем его связь подкласса с ключевым словом extends. Проще говоря, мы говорим, что Dog расширяет Animal, и вуаля, мы только что использовали наследование.
public class Dog extends Animal { }Теперь может показаться, что класс Dog пуст, но на самом деле мы уже можем использовать eat() и grow() с Dog. Мы также можем создать классы горилл и тигров таким же образом, но я не буду показывать их здесь. Вместо этого давайте продолжим и смешаем реализацию интерфейсов и расширение классов, реализовав два интерфейса: Loggable и Printable. Теперь встает вопрос, что стоит на первом месте: расширение или реализация? Существует синтаксическое правило, утверждающее, что extends должен быть первым, а затем implements из-за единой модели наследования Java. Мы можем расширить только один класс, но мы можем реализовать любое количество интерфейсов. Мы отражаем это в изменения в объявлении класса Dog:
public class Dog extends Animal implements Loggable, Printable{ }Обратите внимание на разделенный запятыми список интерфейсов, а именно Loggable и Printable, которые Dog объявляет своими интерфейсами. Эти интерфейсы расскажите, что в нашем классе собака может играть роль заготовки древесины. Например, мы можем распечатать и зарегистрировать наш объект Dog, поскольку он может играть роль Loggable. Вы можете узнать больше об этом в моей статье об интерфейсах в Java.
Мы пишем те же сигнатуры методов для print() и message(), что и в Loggable и Printable соответственно. Мы делаем это таким образом, потому что существует правило, утверждающее, что при реализации абстрактных методов интерфейса или класса сигнатуры методов и возвращаемый тип должны быть точно такими же. Мы не можем просто написать public void message(), например, поскольку Loggable явно требует строкового типа возврата.
public class Dog extends Animal implements Loggable, Printable{ public void print() { System.out.println(“printing...”); } public String message(){ return “something”; } }Сейчас, только наш класс Dog реализует заглушки реализаций message и print. Это означает, что он имеет базовую общую функциональность, которая удовлетворяет интерфейсам, однако, вероятно, это не тот способ, которым мы хотим оставить код. Если бы мы хотели, чтобы все Animal реализовывали Loggable и Printable, мы могли бы реализовать эти интерфейсы в классе Animal (и удалить их в Dog, чтобы избежать дублирования кода).
Множественное наследование
Хотя мы можем реализовать несколько интерфейсов, в Java мы можем расширить только до одного класса, поэтому мы не можем сказать что-то вроде Pig extends Herbivore, Carnivore. Обратите внимание, что это работает в C++ и может быть даже более неприятным, чем одиночное наследование. Использование множественного наследования может привести к тому, что называется смертельным алмазом смерти (Проблема ромба, проблема алмаза).
Абстрактный класс
Абстрактный класс подобен гибриду между интерфейсом и классом. Проще говоря, нам разрешено создавать как абстрактные, так и неабстрактные методы в абстрактном классе. Напомним, что для интерфейсов нам не нужно явно объявлять наши методы абстрактными, поскольку все методы абстрактны по умолчанию. Поскольку абстрактный класс может смешивать как абстрактные, так и не абстрактные методы, мы должны явно использовать модификатор abstract для абстрактных методов. Обратите внимание, что, как и в случае с интерфейсами, вы не можете создать экземпляр абстрактного класса.
Абстрактный класс может расширять другой абстрактный класс, что означает, что он может добавлять дополнительные методы (как абстрактные, так и неабстрактные), а также реализовывать или перезаписывать (абстрактные) методы своего суперкласса. Первый конкретный класс в иерархии классов, расширяющих друг друга, должен обеспечивать реализацию всех абстрактных методов иерархии.
Давайте сделаем eat() абстрактным в Animal. Поскольку мы знаем, что каждый из наших подклассов будет реализовывать этот метод по-разному, нет никакой причины предоставлять конкретную реализацию этого метода в классе Animal.
public abstract void eat();Однако объявление Animal абстрактным меняет его смысл и поведение. Поскольку абстрактные классы не могут быть созданы, компилятор больше не позволит нам создавать экземпляр объекта Animal с помощью new Animal(). Вот как сейчас выглядит наш класс Animal:
public abstract class Animal { public abstract void eat(); public void grow() { System.out.println(“Animal is growing”); } }Когда использовать abstract?
Во-первых, я хотел бы отметить, что нет никакого “золотого правила абстракции”. Все, что связано с наследованием, чрезвычайно индивидуально, и даже изменение вашей программы может изменить то, следует ли использовать абстракцию или нет.
Вы должны сделать родительский класс абстрактным, если вы не собираетесь его создавать. Что такое животное? В этом случае вы не собираетесь создавать объект животного происхождения; вы только собираетесь создавать осязаемых животных, таких как тигры, собаки и гориллы. Именно тогда вы должны сделать класс абстрактным. Когда вы только собираетесь использовать его наследников в своей программе.
Вы должны сделать свои методы абстрактными, когда вы хотите, чтобы каждый наследник имел различную реализацию. Если вы знаете, что для животного не существует "стандартного" пищевого поведения, нет никакой причины писать такое, которое будет переопределено каждым отдельным классом. Если некоторые или все ваши наследники реализуют метод таким же образом, вы можете написать этот метод и переопределить его при необходимости. В конце концов, именно поэтому наследование полезно в первую очередь.
Реализация абстрактного метода
Возвращаясь к нашим занятиям, у нас есть еще несколько неровностей, которые нужно сгладить. Теперь, когда метод eat() животного является абстрактным, мы вынуждены реализовать абстрактный метод eat для других подклассов. Давайте начнем с Тигра:
public class Tiger extends Animal{ public void eat() { System.out.println("Tiger is eating..."); } }Здесь мы имеем пример реализации подкласса eat(). Каждый подкласс животных будет иметь свою собственную специфическую логику для этого метода. Реальная реализация, вероятно, не будет использовать оператор print line для реализации eat (), однако он достаточно хорош для нашей демонстрации.
Вся логика, которая является общей для всех подклассов Animal, таких как grow(), принадлежит классу Animal. Но это обоюдоострый меч. С одной стороны, это очень удобно для повторного использования кода, но с другой стороны, это делает нашу программу более сложной для понимания. Например, кто-то, читающий код, не может точно сказать, стареет ли горилла и ест ли она с помощью Animal.grow() и Animal.eat() или у нее есть свои собственные переопределяющие методы Gorilla.grow() и Gorilla.eat(), подобные приведенным ниже.
@Override public void grow() { System.out.println("Gorilla is implementing the age by itself"); } @Override public void eat() { System.out.println("Gorilla is eating..."); }Это похоже на то, как вы можете просто определить любое произвольное исключение из некоторого правила. Вы можете сказать: “Ну, моя горилла не использует метод Animal.grow (), а вместо этого заменяет его своим собственным”. Но вы хотите убедиться, что используете этот инструмент эффективно. Если вы хотите изменить функциональность метода, написанного в Animal, вы можете переопределить его. При переопределении метода он должен иметь ту же сигнатуру метода, что и родительский метод. Для этого нельзя изменить имя метода или типы параметров, так как они являются частью сигнатуры метода. Обычно при переопределении метода вы должны использовать аннотацию @override, которая гарантирует, что все эти правила соблюдаются, давая предупреждение, когда вы на самом деле не переопределяете метод.
Если вы намереваетесь создать новый метод с тем же именем, но с другими параметрами, вы не переопределяете, а “перегружаете” метод. Например, у нас может быть наш метод grow(), который увеличивает размер животного на основе значения по умолчанию, и метод grow(int amount), который растет на сумму. Два метода с одинаковым именем могут существовать до тех пор, пока они имеют разные параметры.
Изменение видимости
Существует еще одно измерение наследования, и для этого мы должны ввести новый модификатор видимости. Защищенный модификатор дает видимость члена любому классу в том же пакете, а также любым подклассам. protected отличается от модификатора package private тем, что модификатор protected позволяет получить доступ к методу вне пакета через наследование. В этом случае давайте применим его к grow().
public abstract class Animal{ public abstract void eat(); protected void grow() { System.out.println(“Animal is growing”); } }В нашем примере модификатор protected подразумевает, что Animal.grow() виден любому объекту в пакете com.marcusbiel.java8course, а также любому подклассу Animal за пределами этого пакета. Оба свойства protected делают его использование сложным, настолько, что мы будем углубляться еще глубже, чтобы понять, почему мы должны избегать его использования. Среди модификаторов видимости в Java только public имеет больше прав доступа, чем protected.
Когда вы переопределяете метод в классе Java, вы можете изменить его видимость. Однако вы можете только увеличить видимость членов в дочерних классах. Это означает, что в данном случае единственными модификаторами, которые мы можем использовать для grow() в подклассах животных, являются protected и public. В любом подклассе Animal попытка сделать age() приватным приведет к ошибке компилятора, которая говорит: “попытка назначить более слабые права доступа”
В конце концов, наш абстрактный класс Animal определяет контракт или протокол, точно так же, как с интерфейсами. Фактически вы обещаете любому клиентскому классу во Вселенной Java, что клиент может фактически использовать любые экземпляры Animal со всеми общедоступными методами, которые они предоставляют. Только представьте себе, как было бы грязно, если бы это было необязательно для класса и его подклассов, чтобы выполнить контракт видимости. У вас была бы практически сломанная модель наследования!
Когда мы наследуем члены класса, мы наследуем как методы экземпляра, так и переменные экземпляра. Это означает, что если бы животное имело защищенную переменную двойного веса, все его подклассы унаследовали бы ее. Скажем, например, что когда наш Тигр растет, он прибавляет килограмм к своему весу. Это будет выглядеть примерно так, как наш пример ниже:
public class Tiger extends Animal{ public void grow() { super.grow(); weight += 1.0; } }Одна из проблем с protected заключается в том, что он имеет невероятно сложные правила, которые очень легко испортить во время программирования. Это также нарушает инкапсуляцию, потому что Animal должен быть единственным, кто имеет доступ к своим внутренним переменным. Даже если это возможно, я бы не стал использовать защищенный.
Теперь, когда мы закончили наши занятия, мы, наконец, в состоянии запустить наш Зоотест. Когда мы запускаем его, каждый подкласс Animal реализует свой собственный метод eat(), и если ему не хватает собственного поведения grow(), он использует тот, который указан в Animal.
Слабости в наследовании
Наследование может легко стать чрезвычайно запутанным и трудным в использовании. Например, предположим, что у вас есть пять классов, наследуемых друг от друга в иерархии. Чтобы понять поведение объекта, происходящего из этой иерархии классов, вы должны будете понять всю сеть классов, а также ее взаимосвязанные зависимости. Каждый отдельный метод может быть перезаписан в любом или во всех классах иерархии. Еще хуже то, что метод дочернего класса может вызывать все неприватизированные методы своего родительского класса, помещая предшествующее “супер” перед именем метода родительского класса. Это позволяет поведению (предположительно простого) метода превратиться в очень плотную липкую кашу и превратить базовое наследование в непрозрачную сеть классов, сильно зависящих друг от друга. Но что, если ваш клиент нуждается только в том, чтобы вы создали несколько классов? Ну, даже это может закончиться неудачей.
Позвольте мне кратко проиллюстрировать это: представьте, что у вас есть абстрактный класс Duck с несколькими вариантами реализации, такими как MallardDuck и RedHeadDuck. Оба являются утками, оба fly() и swim() одинаково, так что это может быть оправданной причиной для использования наследования. Мы предоставляем стандартную реализацию fly() и swim() в классе Duck как для MallardDuck, так и для RedHeadDuck, а также абстрактный метод quack(), который оба реализуют своим собственным уникальным способом.
Несколько месяцев спустя мы расширяем нашу систему, добавляя резиновый и подсадной утки. Абстрактный метод quack() должен быть реализован для обоих, однако вместо этого RubberDuck будет внутри squeak(). DecoyDuck не крякает и не пищит, поэтому мы предоставляем пустую реализацию quack() в DecoyDuck. Мы также не хотим, чтобы наш резиновый и подсадной утки летать. Все эти изменения реализуемы, но наш дизайн, который когда-то блистал красотой и гибкостью, начинает быть скорее болью, чем помощью.
Есть лучшее решение. Мы могли бы использовать более гибкий подход, известный как паттерн стратегии! Для начала вы заменяете абстрактный класс Duck на интерфейс Duck. Поскольку это интерфейс, он может содержать только абстрактные методы для fly(), swim() и quack(). Во-вторых, вы определяете конкретные классы для MallardDuck, RedHeadDuck, RubberDuck и DecoyDuck, каждый из которых реализует интерфейс Duck.
Теперь мы определяем еще три интерфейса: FlyingBehavior, SwimmingBehavior и QuackingBehavior – и предоставляем различные реализации для каждого из них. Например, полет с Крыльями, кряканье и писк и так далее. Поскольку RubberDuck и DecoyDuck оба не fly(), а DecoyDuck даже не крякае, мы также должны обеспечить реализацию not Flying и NotQuacking. Все еще не очень приятно, но гораздо приятнее, чем использовать наследование. Теперь у всех уток не будет реализации по умолчанию.
Используя паттерн стратегии, никогда не случится так, что утка вдруг полетит на добычу, когда этого делать не следует. Все гораздо яснее. Кроме того, этот дизайн гораздо более гибкий – вы можете обмениваться поведением во время выполнения.
if(summer) { flyBehavior = new FlyingWithFastWings(); } else { flyBehavior = new FlyingWithWings(); }В заключение следует отметить, что иногда бывают оправданные случаи наследования, но позже требования меняются, и использование наследования может быстро превратиться в кошмар обслуживания. С другой стороны, даже в тех случаях, когда рекомендуется использовать наследование, обычно не мешает все же принять решение против его использования. Я хотел проиллюстрировать вам, насколько проблематично наследование, хотя на первый взгляд это может показаться не так. Я также хотел показать, что существуют отличные альтернативы его использованию. Например, интерфейсы.
Автор этого материала - я - Пахолков Юрий. Я оказываю услуги по написанию программ на языках Java, C++, C# (а также консультирую по ним) и созданию сайтов. Работаю с сайтами на CMS OpenCart, WordPress, ModX и самописными. Кроме этого, работаю напрямую с JavaScript, PHP, CSS, HTML - то есть могу доработать ваш сайт или помочь с веб-программированием. Пишите сюда.
Отправляя сообщение я подтверждаю, что ознакомлен и согласен с политикой конфиденциальности данного сайта.