Лекция 5

Тема: Принцип полиморфизма. Статическое и динамическое связывание. Переопределение методов. Перегрузка методов. Upcasting и downcasting. Ключевое слово instanceof.

Полиморфизм является второй важной особенностью объектно-ориентированных языков.

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

Преобразование типов в Java

Чтобы начать рассматривать принцип полиморфизма, рассмотрим механизм преобразования типов в Java.

Преобразование примитивных типов

Преобразование типов позволяет преобразовывать данные одного типа в другой тип. Можно выделить следующие типы преобразования:

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

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

  • упаковка\распаковка данных (boxing\unboxing) - позволяет преобразовывать данные примитивных типов в соответствующие им объекты типов-оболочек.

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

System.out.println("Расширяющее преобразование");
int x = 1000;
long y = x;

float f = 10000.0f;
double d = f;

System.out.println(x + " -> " + y);
System.out.println(f + " -> " + d);

System.out.println("\nСужающее преобразование");
long l = 10000000000L;
int i = (int) l;

double dbl = 1E44;
float flt = (float) dbl;

System.out.println(l + " -> " + i);
System.out.println(dbl + " -> " + flt);

Результат работы программы будет следующим

Расширяющее преобразование
1000 -> 1000
10000.0 -> 10000.0

Сужающее преобразование
10000000000 -> 1410065408
1.0E44 -> Infinity

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

int x = 1000;
long y = (long) x;

но Java этого не требует и такая форма записи при расширяющие преобразовании не используется.

Сужающее преобразование можно привести к потере данных и поэтому Java требует явного преобразования типов, запись типа

long ln = 10000000000L;
int i = ln;

вызовет ошибку компиляции.

Преобразование ссылочных типов

Наряду с преобразованием данных примитивных типов, Java поддерживает преобразование ссылочных типов.

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

Класс-родитель считается базовым типом, потому что все объекты класса-потомка входят во множество объектов базового класса. Как мы уже говорили раньше, все объекты Student являются объектами Person (все студенты являются людьми), все объекты Dog являются объектами Animal (все собаки являются животными).

Каждый класс Java расширяет (наследуется) другой класс, который называется суперклассом. Класс наследует поля и методы суперкласса и определяет свои дополнительные поля и методы. Существует специальный класс Object, который является корнем классовой иерархии. Он не наследуется от каких-либо классов, но все другие классы Java либо напрямую расширяют класс Object, либо наследуются от одного из потомков Object.

Рассмотрим классы String и Point. Мы можем сказать, что все объекты String также являются объектами Object. Также мы можем сказать, что все объекты Point являются объектами Object. Обратное утверждение неверно (не все объекты Object являются объектами Point или String).

Опишем правила преобразования ссылочных типов в Java:

1.Объект не может быть преобразован в несовместимый (unrelated) тип. Речь может идти только о классах с отношением "родитель" - "потомок". К примеру, Java не сможет преобразовать объект типа String в объект типа Point. Даже если вы будете использовать явное преобразование, то следующий код

String str = "строка";
Point point = str;
Point point2 = (Point) str;

приведет к ошибке компиляции

incompatible types: java.lang.String cannot be converted to java.awt.Point
incompatible types: java.lang.String cannot be converted to java.awt.Point

2. Объект может быть преобразован в тип суперкласса. Это расширяющее преобразование, поэтому Java не требует явного преобразования. Например, ссылка на объект типа String может быть присвоена переменной типа Object, так как String наследуется от класса Object, напрямую или опосредовано. Ссылка на объект типа Point также может быть присвоена переменной типа Object. Хотя класс Point напрямую наследуется от Point2D, класс Object все равно является суперклассом для Point, хотя и опосредовано. К примеру, следующий код вполне корректен и не вызовет ошибок

Object o1 = new String("строка");
Object o2 = new Point(100, 200);

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

Это замечание - важнейший момент, который вам необходимо усвоить. В примере выше переменные o1 и o2 по факту будут ссылаться на объекты типа String и Point соответственно. При любых преобразованиях типов, сам объект никак не модифицируется, просто Java теперь считает его объектом другого типа. К чему это приводит - рассмотрим позже.

3. Объект может быть преобразован в тип подкласса. Это является сужающим преобразованием и требует явного приведения типов.

Опасность сужающего преобразования ссылочных типов заключается в том, что компилятор Java не может определить корректность преобразования во время компиляции, эта проверка происходит во время выполнения (в runtime, в рантайме) приложения. Это может привести к ошибке во время выполнения и закрытию приложения, что недопустимо при разработке качественного ПО.

Сужающее преобразование будет корректным только в одном случае - если объект по факту является объектом этого класса!

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

Например, данный код

class Person {
    public void foo() {}
}

class Student extends Person {
    public void bar() {}
}

public class Main {
    public static void main(String[] args) {
        
        Person person = new Person();
        Student s = (Student) person;
    }
}

приведет к ошибке во время выполнения

Exception in thread "main" java.lang.ClassCastException: Person cannot be cast to Student

Ошибка возникает потому что ссылочная переменная person ссылается на объект, который имеет фактический тип Person. При попытке преобразовать его в объект типа Student произойдет ошибка преобразования типов.

Но следующий код

class Person {
    public void foo() {}
}

class Student extends Person {
    public void bar() {}
}

public class Main {
    public static void main(String[] args) {

        Person person = new Student(); // << --- ВНИМАНИЕ!
        Student s = (Student) person;
    }
}

будет работать корректно и преобразование будет корректным.

В примере выше мы сначала сделали восходящее преобразование

Person person = new Student();

и переменная person ссылается на объект, который имеет фактический тип Student. Реализуя сужающее преобразование

Student s = (Student) person;

мы ставим все на свои места - ссылочная переменная типа Student ссылается на объект фактического типа Student.

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

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

int[] array = {1,3,4,5,6};
double[] array2 = (double[]) array;

приведет к ошибке во время выполнения, хотя отдельные элементы типа int можно преобразовать в double.

5. Массивы не имеют иерархию типов, но все массивы считаются экземплярами типа Object, поэтому любой массив может быть преобразован в тип Object с помощью расширяющего преобразования и обратно с помощью сужающего преобразования.

Восходящее преобразование (upcasting)

Для начала, вернемся к наследованию. Самая важная особенность наследования заключается в том, что наследование выражает отношение между новым и базовым классом. Это отношение можно выразить как "Новый класс является разновидностью базового класса", это же справедливо и для объектов этих классов. Например, класс Student является разновидностью Person, класс Bear является разновидностью Animal и так далее.

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

Так как наследование означает, что все методы базового класса также доступны в производном классе, любое сообщение, которое мы можем отправить базовому классу, можно отправить и производному классу. Если в классе Instrument есть метод play(), то он будет присутствовать и в классе Guitar. Рассмотрим следующий код

public class Main {
    public static void main(String[] args) {

        Instrument instrument = new Instrument();
        Guitar guitar = new Guitar();

        tune(instrument);
        tune(guitar);
    }

    public static void tune(Instrument instrument) {
        instrument.play();
    }
}

class Instrument {
    public void play() {
        System.out.println("Играет инструмент");
    }
}

class Guitar extends Instrument {
    @Override
    public void play() {
        System.out.println("Играет гитара");
    }
}

В результате получим следующие сообщения в консоли

Играет инструмент
Играет гитара

Обратите внимание, что на вход методу tune() можно подать как объект типа Instrument, так и объект типа Guitar.

Таким образом, метод tune() можно применять для объектов типа Instrument и объектов любых классов, производных от Instrument.

Как уже было сказано выше, мы имеет дело с расширяющим преобразованием ссылочных типов (преобразование ссылки на объект Guitar в ссылку на объект Instrument). Расширяющее преобразование ссылочных типов называется восходящим преобразованием типов (upcasting).

Рассмотрим еще раз пример с классом Instrument

public class Main {
    public static void main(String[] args) {

        Instrument instrument = new Instrument();
        Guitar guitar = new Guitar();

        tune(instrument);
        tune(guitar);
    }

    public static void tune(Instrument instrument) {
        instrument.play();
    }
}

class Instrument {
    public void play() {
        System.out.println("Играет инструмент");
    }
}

class Guitar extends Instrument {
    @Override
    public void play() {
        System.out.println("Играет гитара");
    }
}

Метод tune() получает ссылку на объект класса Instrument, но мы можем передать объект любого класса, производного от Instrument. В методe main() ссылка на объект класса Guitar передается методу tune() без явных преобразований. Это нормально - интерфейс класса Instrument должен существовать и в классе Guitar, так как класс Guitar был унаследован от класса Instrument.

Казалось бы, зачем нам указывать, что метод tune() принимает ссылку на объект класса Instrument? Если мы хотим, чтобы метод tune() принимал ссылку на объект класса Guitar, почему просто не написать

public static void tune(Guitar guitar) {
    guitar.play();
}

В таком случае, для каждого класса, производного от Instrument придется писать свой метод tune(). Предположим, что в новой версии программы мы добавили классы Saxophone и Violin. В таком случае у нас получился бы следующий код

public class Main {
    public static void main(String[] args) {

        Instrument instrument = new Instrument();
        Guitar guitar = new Guitar();
        Violin violin = new Violin();
        Saxophone saxophone = new Saxophone();

        tune(instrument);
        tune(guitar);
        tune(violin);
        tune(saxophone);
    }

    public static void tune(Instrument instrument) {
        instrument.play();
    }

    public static void tune(Guitar guitar) {
        guitar.play();
    }

    public static void tune(Saxophone saxophone) {
        saxophone.play();
    }

    public static void tune(Violin violin) {
        violin.play();
    }
}

class Saxophone extends Instrument {
    @Override
    public void play() {
        System.out.println("Играет саксофон");
    }
}

class Violin extends Instrument {
    @Override
    public void play() {
        System.out.println("Играет скрипка");
    }
}

class Instrument {
    public void play() {
        System.out.println("Играет инструмент");
    }
}

class Guitar extends Instrument {
    @Override
    public void play() {
        System.out.println("Играет гитара");
    }
}

Программа будет работать корректно, но у нее есть огромный недостаток - для каждого нового подкласса Instrument необходимо будет писать свой новый метод tune(), который будет принимать объект этого производного класса.

Если же указать в методe tune() ссылку на базовый класс, тогда необходимо будет написать всего один единственный метод tune().

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

Важно понять, что в зависимости от разных объектов разных производных классов, метод tune() может работать по-разному, так как в каждом подклассе мы переопределяем метод play().

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

Перегрузка методов

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

class MyClass {
    
    public void foo() {
        // ... код
    }
    
    public void foo(String s) {
        // ... код
    }
}

Если у методов одинаковые имена, как Java узнает, какой именно из них вызывается? Ответ прост: перегружаемые методы должны отличаться по типу и/или количеству входных параметров. Даже разного порядка аргументов достаточно для того, чтобы методы считались разными (хотя это не рекомендуется).

Перегрузка по возвращаемым значениям

Логично спросить, почему при перегрузке используются только имена классов и списки аргументов? Почему не идентифицировать методы по их возвращаемым значениям?

// ДАННЫЙ КОД ВЫЗОВЕТ ОШИБКУ КОМПИЛЯЦИИ!

class MyClass {

    public int foo() {
        return 0;
    }

    public double foo() {
        return 0;
    }
}

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

При вызове перегружаемого метода для определения нужного варианта в Java используется тип и\или количество аргументов метода. Следовательно, перегружаемые методы должны отличаться по типу и\или количеству их параметров. Возвращаемые типы перегружаемых методов могут отличаться, но самого возвращаемого метода недостаточно, чтобы отличить два разных варианта метода. Когда в исполняющей среде Java встречается вызов перегружаемого метода, в ней просто выполняется тот вариант, параметры которого соответствуют аргументам, указанным в вызове.

Перегрузка методов позволяет поддерживать принцип «один интерфейс, несколько методов».

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

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

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

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

Перегрузка конструкторов

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

Соответствующий перегружаемый конструктор вызывается в зависимости от параметров, указываемых при выполнении оператора new.

class MyClass {
    
    public MyClass() {
        // какой-то код
    }
    
    public MyClass(int arg0) {
        // какой-то код
    }
    
    public MyClass (int arg0, String arg1) {
        // какой-то код
    }
}

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

class MyClass {

    public MyClass() {
        // Вызываем конструктор MyClass(int arg0)
        this(0);
    }

    public MyClass(int arg0) {
        // Вызываем конструктор MyClass (int arg0, String arg1)
        this(arg0, " ");
    }

    public MyClass(int arg0, String arg1) {
        // какой-то код
    }
}

Связывание "метод-вызов"

Давайте еще раз взглянем на метод tune()

public static void tune(Instrument i) {
    System.out.println(i.play());
}

Мы уже разобрались, что в зависимости от ссылки на объект того или иного подкласса, Java вызовет тот или иной переопределенный метод play(). Но откуда компилятор знает - какой из методов play() необходимо будет вызвать, ведь в качестве входного аргумента у нас указана ссылка на объект класса Instrument? Ответ заключается в том, что компилятор этого не знает.

Присоединение вызова метода к телу метода называется связыванием. Если связывание производится перед запуском программы (на этапе компиляции или компоновки), оно называется ранним (early) или статическим (static) связыванием (binding).

Неоднозначность в работе метода tune() связана именно с ранним связыванием: компилятор не может знать заранее, какой вариант метода play() нужно будет вызвать, когда у него есть только ссылка на объект класса Instrument.

Данная проблема решается благодаря позднему связыванию, то есть связыванию, проводимому во время выполнения программы, в зависимости от типа объекта. Позднее (late) связывание (binding) также называют динамическим (dynamic) или связыванием на этапе выполнения программы (runtime binding).

В реализации Java существует механизм для фактического определения типа объекта во время работы программы для вызова подходящего метода. Иначе говоря, компилятор не знает тип объекта, но механизм вызова метода определяет фактический тип объекта и вызывает соответствующее тело метода.

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

Итак, подведем итоги:

  1. статическое связывание в Java происходит на этапе компиляции, тогда как динамическое связывание происходит во время выполнения программы (в runtime);

  2. для private, final и static методов, а также для полей используется статическое связывание, тогда как для остальных методов (такие методы в некоторых языках программированию называются виртуальными (virtual)) используется динамическое связывание;

  3. в статическом связывании используется тип ссылки, тогда как в динамическом связывании используется фактический тип объекта;

  4. перегруженные методы используют статическое связывание, тогда как переопределенные методы используют динамическое связывание.

Пример.

Рассмотрим популярный пример с геометрическими фигурами. Создадим базовый класс Shape и различные производные классы: Circle, Square и Triangle.

    class Shape {
        public String draw() {
            return null;
        }

        public String erase() {
            return null;
        }
    }
    
    class Circle extends Shape {
        @Override
        public String draw() {
            return "Рисуем круг";
        }

        @Override
        public String erase() {
            return "Стираем круг";
        }
    }

    class Triangle extends Shape {
        @Override
        public String draw() {
            return "Рисуем треугольник";
        }

        @Override
        public String erase() {
            return "Стираем треугольник";
        }
    }

    class Square extends Shape {
        @Override
        public String draw() {
            return "Рисуем квадрат";
        }

        @Override
        public String erase() {
            return "Стираем квадрат";
        }
    }

Создадим объект класса Circle используя принцип восходящего преобразования

public static void main(String[] args) {
    Shape s = new Circle();
}

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

Если мы вызовем у объекта метод draw()

public static void main(String[] args) {
    Shape s = new Circle();
    System.out.println(s.draw());
}

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

Создадим объекты других типов

public static void main(String[] args) {
    Shape s = new Circle();
    System.out.println(s.draw());

    Shape s2 = new Triangle();
    System.out.println(s.draw());

    Shape s3 = new Square();
    System.out.println(s.draw());
}

и посмотрим на результат

Рисуем круг
Рисуем треугольник
Рисуем квадрат

Базовый класс Shape устанавливает для всех классов, производных от Shape, общий интерфейс - набор публичных методов (действий, которые может совершить внешний код над объектом). То есть любую фигуру можно нарисовать (draw()) и стереть (erase()). Производные классы переопределяют этот набор методов, чтобы реализовать свое уникальное поведение для этой фигуры.

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

Last updated