Заведём жабу? Часть 10. Полиморфизм.

Полиморфизм

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

Рассмотрим на примере. Открываем Netbeans, файл MyFirstProgram. Для начала внесём изменения. В файле Computer.java изменим модификаторы доступа переменных с public на protected, чтобы получать к ним доступ из класса Notebook.

Изменим первый конструктор в классе Computer следующим образом:

public Computer(String name) {

this.name = name;

}

Класс Notebook переделаем следующим образом:

package testobject;

public class Notebook extends Computer{

public Notebook(String name) {

super(name);
}

public Notebook (String name, int ram, int hdd, double weight) {

super(name, ram, hdd, weight);
}

@Override
public void on(){

print("Notebook. Я включился. Моя модель " + getName());

}

@Override
public void load() {

}
}


Ключевое слово super говорит нам о том, что мы будем использовать переменные из родительского класса Computer. В общем наши файлы могут выглядеть примерно так:

MyFirstProgram.java

package myfirstprogram;
import testobject.Computer;
import testobject.Notebook;

public class MyFirstProgram {

public static void main(String[] args) {

// Computer comp = new Computer("IBM", 2048, 320, 2);

// comp.setName("IBM");
//
// comp.setRam(2048);
//
// comp.setHdd(320);

// comp.on();
//
// comp.load();
//
// comp.off();

Notebook notebook = new Notebook("IBM");

notebook.on();
//notebook.load();
//notebook.off();
}
}

Computer.java

package testobject;

public class Computer {

protected String name;

protected int ram;

protected int hdd;

protected double weight;

public Computer(String name) {

this.name = name;

}

public Computer (String name, int ram, int hdd, double weight) {

this.name = name;

this.ram = ram;

this.hdd = hdd;

this.weight = weight;
}

public String getName(){
return name;
}

public void setName(String newName){

name = newName;

}

public int getRam(){

return ram;

}

public void setRam(int newRam){
if(newRam>0){

ram = newRam;

} else {

print("Переданное значение " +newRam+ " не может быть отрицательным!");

}
}

public void setHdd(int newHdd){
if(newHdd>0){

hdd = newHdd;

} else {

print("Переданное значение " +newHdd+ " не может быть отрицательным!");

}
}

public void setWeight(int newWeight){
if(newWeight>0){

weight = newWeight;

} else {

print("Переданное значение " +newWeight+ "не может быть отрицательным!");

}
}

public void on(){

int time;

print("Я включился. Моя модель " + name);

}

public void off(){

print("Я выключился");

}

public void load(){

print("Я загружаюсь. Мой объём жесткого диска равен " + hdd + " ГБ");

}

protected void print (String str){

System.out.println(str);
}

}

Notebook.java


package testobject;

public class Notebook extends Computer{
         
public Notebook(String name) {
        
        super(name);
    }

 public Notebook (String name, int ram, int hdd, double weight) {
        
       super(name, ram, hdd, weight);
    }
        
@Override
  public void on(){

       print("Notebook. Я включился. Моя модель " + getName());  
        
    }
  
    @Override
    public void load() {
       
    }
}

Давайте запустим нашу программу. Дожно появиться что-то вроде:

run:
Notebook. Я включился. Моя модель IBM
СБОРКА УСПЕШНО ЗАВЕРШЕНА (общее время: 0 секунд)

Переменную Notebook также можно объявить следующим образом:


Computer notebook = new Notebook("IBM");

Так как Computer.java находится в другом пакете, следует выполнить импорт, Netbeans предупреждает об этом и предлагает согласиться с импортом.

 import testobject.Computer; 

Ещё раз запустим программу, и увидим, что она выполнилась без изменений.

run:
Notebook. Я включился. Моя модель IBM
СБОРКА УСПЕШНО ЗАВЕРШЕНА (общее время: 0 секунд)

Что же произошло? Вместо типа переменной объекта Notebook мы указали тип переменной вышестоящего объекта, или родительского объекта, Computer. Таким образом мы говорим, что создаём переменную Notebook типа Computer, но присваиваем ей созданный объект Notebook. Причём Notebook является дочерним объектом от Computer. Здесь происходит так называемое динамическое связывание, или позднее связывание, когда конкретный объект уже определяется на этапе выполнения программы. Не на этапе компиляции, а на этапе выполнения.

Казалось бы переменная Notebook имеет тип Computer, и должен был бы выполниться метод on() объекта Computer, но по ходу выполнения программы компилятор видит, что к этой переменной мы присваиваем объект Notebook и выполняем метод именно Ноутбука, а не Компьютера. Также следует помнить, что наоборот мы написать не можем. Допустим вот так написать нельзя: Notebook notebook = new Computer(«IBM»); Компилятор выдаст что-то вроде «неподходящие типу.»

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

Если мы напишем так:


Object notebook = new Notebook("IBM");

Но тогда метод on класса ноутбук (notebook.on();) не будет работать, поскольку нужно знать следующую особенность: у переменной через точку доступны те методы, которые ТИПУ этой переменной, то есть в данном случае Object. А как мы знаем у переменной Object не может быть такого метода on(), поскольку мы его сами придумали в объекте Computer.

Наш объект Notebook никакие методы не добавлял, он использовал методы класса Computer (например on(); load();) и переопределил их.

Вернём


Computer notebook = new Notebook("IBM");

в наш класс.

Повторюсь, через точку нам доступны именно те методы, которые являются методами типа переменной Notebook в данном случае Computer (если мы говорим о Computer notebook = new Notebook(«IBM»);), а не типа объекта, который мы присваиваем в эту переменную (new Notebook(«IBM»);) в нашем случае Notebook.

Если мы в классе Notebook добавим новый метод, например заряжаться:


public void charge(){
 System.out.println("Notebokk charging...");
}

и вернёмся в класс MyFirstProgram, после чего напишем notebook. (вот так с точкой, чтобы Netbean показал нам все доступные методы), то мы увидим, что через точку метод charge() нам недоступен, потому что наша переменная имеет тип Computer а не Notebook. Помните, мы писали строку Computer notebook = new Notebook(«IBM»); ?

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

Объявляя переменную типа Computer, мы также можем присвоить ей любой дочерний объект Компьютера (к примеру Asus, Toshiba, другие конкретные модели). Для чего это нужно?

Попробуем на примере. Добавим в класс MyFirstProgram следующий метод:

public static void doSmth(Computer comp){
    
   comp.on();
    
}

В качестве параметров метод doSmth будет принимать объект Computer (Computer comp). Так как метод on(); у нас есть в объекте Computer, попробуем его вызвать. Компьютер выводит «Я включился. Моя модель…,» а ноутбук выводит «Notebook. Я включился. Модель…»

Давайте посмотрим какой же из методов будет выполняться, когда мы передадим или computer или notebook

Закомментируем строку: //notebook.on(); вызовем метод doSmth и передадим в него переменную notebook.


doSmth(notebook);

Сам файл MyFirstProgram.java у нас выглядит примерно так:

package myfirstprogram;
import testobject.Computer;
import testobject.Notebook;
 
public class MyFirstProgram {
 
    public static void main(String[] args) {
       
//      Computer comp = new Computer("IBM", 2048, 320, 2);
       
//     comp.setName("IBM");
//       
//      comp.setRam(2048);
//      
//      comp.setHdd(320);
       
//      comp.on();
//       
//      comp.load();
//       
//      comp.off();

Computer notebook = new Notebook("IBM");

//notebook.on();
//notebook.load();
//notebook.off();

doSmth(notebook);
       
    }  
    
public static void doSmth(Computer comp){
   comp.on(); 
}
    
}

В данном случае переменная notebook имеет тип Computer (Computer notebook = new Notebook(IBM);), но по ходу работы программы ей присваивается значение Notebook, а не Computer (Computer notebook = new Notebook(IBM);) Запустим нашу программу.

Появится что-то вроде:

run:
Notebook. Я включился. Моя модель IBM
СБОРКА УСПЕШНО ЗАВЕРШЕНА (общее время: 0 секунд)

Выполнился метод on() именно ноутбука, потому что в переменную notebook мы присвоили созданный объект Notebook (Computer notebook = new Notebook(IBM);) и при передачи его в метод doSmth java автоматически определила, что это объект Notebook, а не Computer и выполнила именно его метод, а не метод Компьютера.

А если мы создадим ещё одну переменную типа Computer и тоже передадим в метод doSmth(comp);, например


Computer comp = new Computer("Comp");

doSmth(comp);

и запустим программу, то получим что-то вроде:

run:
Notebook. Я включился. Моя модель IBM
Я включился. Моя модель Comp
СБОРКА УСПЕШНО ЗАВЕРШЕНА (общее время: 0 секунд)

То есть во втором случае (Я включился. Моя модель Comp) программка отображает метод on() объекта Computer. Метод doSmth является очень гибким, мы в него можем передавать как объекты Computer, так и все дочерние объекты и если в этих дочерних объектах переопределён метод on(), то будет выполняться метод именно этого объекта, а не объекта Computer, который передаётся в этом параметре: public static void doSmth(Computer comp)

Это как раз одно из преимуществ использования полиморфизма. Если бы у нас не было полиморфизма, нам нужно было бы сделать для двух объектов Computer и Notebook следующие методы, например:

public static void doSmth2(Notebook notebook){

       notebook.on();

} 

То есть для двух объектов — два разных метода. Для компьютера первый метод

 public static void doSmth(Computer comp){
   comp.on(); 
}

для ноутбука второй:

public static void doSmth2(Notebook notebook){

       notebook.on();

} 

Это не очень удобно, ведь если у нас будет 10-20 объектов, то пришлось бы на каждый объект создавать по отдельному методу. Плюс в будущем мы не знаем какие дочерние объекты могут появиться в нашем объекте Computer, поэтому мы не можем предусмотреть все методы для всех типов объектов. И здесь как раз вступает в силу полиморфизм, а именно мы можем использовать переменную типа Computer, которая является родительской для объекта Notebook и всех нижестоящих объектов (Asus, Toshiba, конкретные модели), но когда мы передаём в это метод уже конкретно объект, то выполняется метод on() уже этого конкретного объекта.

То есть Java сама разбирается по ходу работы программы какой метод нужно выполнить. Это и есть полиморфизм. Важный момент: если в последующем у нас добавятся какие-то новые дочерние объекты — они все будут подходить для типа Computer и наш метод останется без изменений. Он будет работать с любым из дочерних объектов, сможет вызывать метод on(); любого из дочерних объектов.

Но не всё так просто, если дочерний объект, например Notebook добавит новый метод (в нашем случае мы добавили метод charge(), то у нас не будет возможности вызвать этот метод в нашем новом созданном методу doSmth, так как типа Computer не может знать какие новые методы добавятся в новых дочерних объектах. И соответственно метод charge мы не можем вызвать в методе doSmth. Даже если мы посмотрим «через точку» — его там не окажется.

Почему так происходит? Рассмотрим схему.

Объект Notebook является более расширенной версией объекта Computer и поэтому при создании объекта Notebook внутри него как бы тоже можно сказать находится объект Computer, который является частью объекта Notebook и важно понимать, что методы on(); off(); и load(); доступны и Ноутбуку и Компьютеру, а метод charge() доступен только Ноутбуку — компьютеру он недоступен, компьютер об этом методе ничего не знает.

Таким образом метод doSmth может принимать как Computer, так и Notebook, но возможно вызова методов будет разной. Когда в метод doSmth мы передаём объект Computer, нам доступно только три его метода — без метода charge. А когда мы передаём объект Notebook — нам доступны все методы, включая charge.

Можно сказать, что метод doSmth ещё не знает какой точно в него будут передавать объект, может это будет объект Компьютер, может объект Ноутбук, а может ещё какой-то дочерний объект, о котором мы сейчас ничего не знаем. Но методы On(); off(); и load(); будут вызываться для нужного объекта и виртуальная машина сама «разберётся» из какого объекта нужно вызывать эти методы во время выполнения программы.

Это и есть полиморфизм, то есть способность определять переданный объект на этапе выполнения программы и вызывать метод уже конкретного объекта. Полиморфный — то есть имеющий разные формы. Объект может принимать разные формы и выполнять методы конкретной формы. Если у вас есть много наследуемых друг от друга классов, то Java не запутается и выберет метод именно того объекта, который нужно выполнить в данный момент.

Но если нам всё-таки нужно, чтобы метод doSmth() умел выполнять метод charge() — нам необходимо определённым образом дописать свой код. Изменим метод doSmth следующим образом:

public static void doSmth(Computer comp){
  
    if(comp instanceof Notebook){
        ((Notebook)comp).on();
        ((Notebook)comp).charge();
      } else if (comp instanceof Computer){
           comp.on();
    }  
  }

Запустим программу, получим что-то вроде:

run:
Notebook. Я включился. Моя модель IBM
Notebook charging…
Я включился. Моя модель Comp
СБОРКА УСПЕШНО ЗАВЕРШЕНА (общее время: 0 секунд)

При передаче методу doSmth объекта notebook (doSmth(notebook);) выполняется следующий блок кода

   
((Notebook)comp).on();
((Notebook)comp).charge();

Notebook. Я включился. Моя модель IBM
Notebook charging…

а при передачи объекта computer (doSmth(comp);) выполняется вот этот блок кода:

 comp.on(); 

Я включился. Моя модель Comp

Мы написали условие if, которое «говорит» — если переданный объект comp является экземпляром класса Notebook (if(comp instanceof Notebook)), то выполняется вот этот участок кода:

  ((Notebook)comp).on();
        ((Notebook)comp).charge(); 

иначе если переменная comp является экземпляром класса Computer (else if (comp instanceof Computer)), то выполнится вот этот участок кода comp.on(); просто выполнится метод on(); , у компьютера нет метода charge();

Здесь мы использовали ключевое слово instanceof, которое «говорит» является ли данная переменная определённым типом, например Notebook. То есть мы проверяем на этапе выполнения программы, что мы передали в этот метод, в переменную comp. Если объект comp в этот момент является экземпляром класса Notebook ( if(comp instanceof Notebook)), то мы делаем определённые вещи, иначе делаем другие вещи.

То есть мы ввели новое понятие или новые конструкции кода — instanceof. По другому можно сказать является ли comp инстанцией какого-либо типа объекта или инстанцией Ноутбука. И только после того,как мы точно определили, что переданный объект является экземпляром Ноутбук мы в этих строчках ( ((Notebook)comp).on(); ((Notebook)comp).charge(); ) при помощи вот такой конструкции сделали преобразование типа, либо по другому говорят привидение типа, то есть мы привели тип.

Ранее у нас переменная comp была типа Computer ( public static void doSmth(Computer comp) ) но мы здесь вручную привели её в тип Notebook ( ((Notebook)comp).charge(); ) для того, чтобы вызвать метод charge. Это важно помнить.

Есть такое понятие привидение типов. Его ещё называют кастинг. Говорят «закастить» — то есть привести к типу. Можно сказать, что привидение типов — это изменение типа переменной, но мы не можем привести из любого типа переменной в любой. Например из Computer в String или из Notebook в integer. В понятиях полимофризма привидение типов обозначает изменение типа в пределах объектов, которые связаны с текущим через наследование. Сейчас мы пока рассматриваем привидение типов для ссылочных или объектных переменных и примитивные типы пока не будем трогать — там немного другая ситуация.

Рассмотрим привидение типов на схеме.

Существует два типа приведения. Восходящее и нисходящее. Или по другому говорят восходящее — upcasting, нисходящее — downcasting.

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

Также мы видим, что Тошиба является как ноутбуком, так и компьютером на этой схеме — это очень важно понимать, потому что мы можем привести Тошиба сразу к объекту Компьютер, миную Ноутбук. Очень важно помнить, что при привидении типа из одного в другой не происходит замены объекта на другой. Происходит как бы пометка другим типом объекта, который либо урезает возможности объекта, либо расширяет, но сам объект остаётся без изменений.

Если мы, например, создали объект Тошиба и привели его к объекту Ноутбук, он так и остался объектом Тошиба, но мы как бы урезали те возможности, которые были у Тошиба и оставили те, которые есть у Ноутбука. Если мы обратно приведём этот объект от Ноутбука к Тошиба — урезанные функции появятся снова. То есть как будто мы накладываем на объект кальку того объекта, к которому хотим привести. Рассмотрим этот момент подробнее.

Допустим у нас есть созданный объект Ноутбук, у него есть добавленный метод charge. Остальные методы он либо наследует, либо переопределяет от объекта Computer. Если мы хотим привести его к типу Компьютер, то мы как бы обрежем ту часть, которая была доступна только Ноутбуку, и нас останется доступна только часть Computer, то есть методы on(), load() и off(), а метод charge() в этом случае нам не будет доступен.

Если же мы сделаем наоборот

то есть приведем от Компьютера к Ноутбуку, то нам вновь станет доступен метод charge(); В примере, который мы только что рассматривали в методе doSmth() как раз и использовался этот прием. Переменная comp у нас была типа Computer, но мы знаем, что мы передаём в этот метод объект типа Notebook, то есть мы знаем, что в переменной comp у нас хранится объект Notebook и перед использованием мы убедились в этом при помощи функции instanceof.

После приведения к типу Notebook мы смогли использовать метод charge(). Без приведения к типу Notebook мы бы не смогли использовать метод charge() и нам были бы доступны только те методы, которые доступны объекту Computer.

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

Computer notebook = new Notebook("IBM");

Также автоматическое восходящее приведение мы можем увидеть в методе doSmth(), которое принимает объект Computer.

А нисходящее приведение нам нужно делать вручную. Например

 notebook = (Notebook)comp; 

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

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

Ещё один важный момент. Нужно избегать ситуаций, когда мы пытаемся привести один тип объекта к другому, к которому нельзя приводить. Что значит нельзя приводить? Рассмотрим ещё раз схему. На этой схеме видно, что мы можем привести объект Toshiba к объекту Computer, либо к объекту Notebook. Тоже самое Asus. Мы можем привести его к объекту Notebook либо сразу к объекту Computer.

Но мы не можем привести Toshiba к Asus или наоборот. Потому что эти типы не приводимые друг к другу. Мы можем привести только к объектам связанным по цепочке наследования. А Тошиба и Асус у нас находятся в разных цепочках. Поэтому желательно при приведении типов друг к другу всегда использовать проверку instanceof. При неправильном приведении типов выходит ошибка ClassCastException

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

Поделиться ссылкой:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *