Лекция 5
Тема: Принцип полиморфизма. Статическое и динамическое связывание. Переопределение методов. Перегрузка методов. Upcasting и downcasting. Ключевое слово instanceof.
Полиморфизм является второй важной особенностью объектно-ориентированных языков.
Он представляет еще одну степень отделения интерфейса от реализации, разъединения что от как. Полиморфизм улучшает организацию кода и его читаемость, а также способствует созданию расширяемых программ, который могут "расти" не только в процессе начальной разработки проекта, но и при добавлении новых возможностей.
Преобразование типов в Java
Чтобы начать рассматривать принцип полиморфизма, рассмотрим механизм преобразования типов в Java.
Преобразование примитивных типов
Преобразование типов позволяет преобразовывать данные одного типа в другой тип. Можно выделить следующие типы преобразования:
расширяющее преобразование (widening) - значение одного типа преобразовывается в более широкий тип, с большим диапазоном допустимых значений, то есть не происходит потери данных.
сужающее преобразование (narrowing) - значение одного типа преобразовывается в более узкий тип, с меньшим диапазоном допустимых значений, в этом случае возможна потеря данных.
упаковка\распаковка данных (boxing\unboxing) - позволяет преобразовывать данные примитивных типов в соответствующие им объекты типов-оболочек.
Про упаковку и распаковку данных мы будем говорить позже, а пока рассмотрим примеры расширяющие и сужающего преобразования.
Результат работы программы будет следующим
Как мы видим из приведенного фрагмента кода, расширяющее преобразование происходит неявно, автоматически. Конечно, нам никто не запрещает указать явное преобразование
но Java этого не требует и такая форма записи при расширяющие преобразовании не используется.
Сужающее преобразование можно привести к потере данных и поэтому Java требует явного преобразования типов, запись типа
вызовет ошибку компиляции.
Преобразование ссылочных типов
Наряду с преобразованием данных примитивных типов, 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
. Даже если вы будете использовать явное преобразование, то следующий код
приведет к ошибке компиляции
2. Объект может быть преобразован в тип суперкласса. Это расширяющее преобразование, поэтому Java не требует явного преобразования. Например, ссылка на объект типа String
может быть присвоена переменной типа Object
, так как String
наследуется от класса Object
, напрямую или опосредовано. Ссылка на объект типа Point
также может быть присвоена переменной типа Object
. Хотя класс Point
напрямую наследуется от Point2D
, класс Object
все равно является суперклассом для Point
, хотя и опосредовано. К примеру, следующий код вполне корректен и не вызовет ошибок
Важнейший момент - при преобразовании ссылочных типов, сам физический объект ни во что не преобразовывается. Просто теперь мы можем работать с ним как будто он объект типа Object.
Это замечание - важнейший момент, который вам необходимо усвоить. В примере выше переменные o1
и o2
по факту будут ссылаться на объекты типа String
и Point
соответственно. При любых преобразованиях типов, сам объект никак не модифицируется, просто Java теперь считает его объектом другого типа. К чему это приводит - рассмотрим позже.
3. Объект может быть преобразован в тип подкласса. Это является сужающим преобразованием и требует явного приведения типов.
Опасность сужающего преобразования ссылочных типов заключается в том, что компилятор Java не может определить корректность преобразования во время компиляции, эта проверка происходит во время выполнения (в runtime, в рантайме) приложения. Это может привести к ошибке во время выполнения и закрытию приложения, что недопустимо при разработке качественного ПО.
Сужающее преобразование будет корректным только в одном случае - если объект по факту является объектом этого класса!
Нисходящее преобразование является корректным только когда это операция, обратная восходящему преобразованию.
Например, данный код
приведет к ошибке во время выполнения
Ошибка возникает потому что ссылочная переменная person
ссылается на объект, который имеет фактический тип Person
. При попытке преобразовать его в объект типа Student
произойдет ошибка преобразования типов.
Но следующий код
будет работать корректно и преобразование будет корректным.
В примере выше мы сначала сделали восходящее преобразование
и переменная person
ссылается на объект, который имеет фактический тип Student
. Реализуя сужающее преобразование
мы ставим все на свои места - ссылочная переменная типа Student
ссылается на объект фактического типа Student
.
Еще раз повторим, нисходящее преобразование используется, чтобы восстановить тип объекта после восходящего преобразования.
4. Все типы массивов являются несовместимыми типами, поэтому массив одного типа не может быть преобразован в массив другого типа, даже если элементы массивов могут быть преобразованы. Например, следующий код
приведет к ошибке во время выполнения, хотя отдельные элементы типа int
можно преобразовать в double
.
5. Массивы не имеют иерархию типов, но все массивы считаются экземплярами типа Object, поэтому любой массив может быть преобразован в тип Object
с помощью расширяющего преобразования и обратно с помощью сужающего преобразования.
Восходящее преобразование (upcasting)
Для начала, вернемся к наследованию. Самая важная особенность наследования заключается в том, что наследование выражает отношение между новым и базовым классом. Это отношение можно выразить как "Новый класс является разновидностью базового класса", это же справедливо и для объектов этих классов. Например, класс Student
является разновидностью Person
, класс Bear
является разновидностью Animal
и так далее.
Данное отношение поддерживается языком программирования. Например, рассмотрим базовый класс Instrument
, который представляет музыкальный инструмент, и класс Guitar
, который представляет гитару.
Так как наследование означает, что все методы базового класса также доступны в производном классе, любое сообщение, которое мы можем отправить базовому классу, можно отправить и производному классу. Если в классе Instrument
есть метод play()
, то он будет присутствовать и в классе Guitar
. Рассмотрим следующий код
В результате получим следующие сообщения в консоли
Обратите внимание, что на вход методу tune()
можно подать как объект типа Instrument
, так и объект типа Guitar
.
Таким образом, метод tune()
можно применять для объектов типа Instrument
и объектов любых классов, производных от Instrument
.
Как уже было сказано выше, мы имеет дело с расширяющим преобразованием ссылочных типов (преобразование ссылки на объект Guitar
в ссылку на объект Instrument
). Расширяющее преобразование ссылочных типов называется восходящим преобразованием типов (upcasting).
Рассмотрим еще раз пример с классом Instrument
Метод tune()
получает ссылку на объект класса Instrument
, но мы можем передать объект любого класса, производного от Instrument. В методe main()
ссылка на объект класса Guitar
передается методу tune()
без явных преобразований. Это нормально - интерфейс класса Instrument должен существовать и в классе Guitar
, так как класс Guitar
был унаследован от класса Instrument
.
Казалось бы, зачем нам указывать, что метод tune()
принимает ссылку на объект класса Instrument
? Если мы хотим, чтобы метод tune()
принимал ссылку на объект класса Guitar
, почему просто не написать
В таком случае, для каждого класса, производного от Instrument
придется писать свой метод tune()
. Предположим, что в новой версии программы мы добавили классы Saxophone
и Violin
. В таком случае у нас получился бы следующий код
Программа будет работать корректно, но у нее есть огромный недостаток - для каждого нового подкласса Instrument
необходимо будет писать свой новый метод tune()
, который будет принимать объект этого производного класса.
Если же указать в методe tune()
ссылку на базовый класс, тогда необходимо будет написать всего один единственный метод tune()
.
Таким образом, код метода tune()
будет, своего рода, обобщенным кодом, который будет выполняться не только для объектов базового класса, но и для объектов производного класса. В этом и заключается главное преимущество полиморфизма.
Важно понять, что в зависимости от разных объектов разных производных классов, метод tune()
может работать по-разному, так как в каждом подклассе мы переопределяем метод play()
.
В данном случае, методы play()
различных подклассов возвращают различную строку, поэтому, хотя и вызывается один и тот же метод tune()
, но результат его работы каждый раз разный, в зависимости от того - объект какого подкласса мы ему передали.
Перегрузка методов
В Java разрешается в одном и том же классе определять два или более метода с одинаковым именем, если только объявления их параметров отличаются. В этом случае методы называются перегружаемыми, а сам процесс – перегрузкой метода (method overloading).
Если у методов одинаковые имена, как Java узнает, какой именно из них вызывается? Ответ прост: перегружаемые методы должны отличаться по типу и/или количеству входных параметров. Даже разного порядка аргументов достаточно для того, чтобы методы считались разными (хотя это не рекомендуется).
Перегрузка по возвращаемым значениям
Логично спросить, почему при перегрузке используются только имена классов и списки аргументов? Почему не идентифицировать методы по их возвращаемым значениям?
Идентифицировать их нельзя, потому что Java в этом случае не может определить, какая версия метода должна выполняться.
При вызове перегружаемого метода для определения нужного варианта в Java используется тип и\или количество аргументов метода. Следовательно, перегружаемые методы должны отличаться по типу и\или количеству их параметров. Возвращаемые типы перегружаемых методов могут отличаться, но самого возвращаемого метода недостаточно, чтобы отличить два разных варианта метода. Когда в исполняющей среде Java встречается вызов перегружаемого метода, в ней просто выполняется тот вариант, параметры которого соответствуют аргументам, указанным в вызове.
Перегрузка методов позволяет поддерживать принцип «один интерфейс, несколько методов».
В языках программирования без перегрузки методов, каждому методу должно быть присвоено однозначное имя. Но зачастую требуется реализовать, по существу, один и тот же метод для разных типов данных.
В таком случае, в языках программирования без перегрузки реализуют несколько методов, которые немного отличаются названиями.
Перегрузка методов ценна тем, что позволяет обращаться к похожим методам по общему имени. Следовательно, имя представляет общее действие, которое должно выполняться. Выбор подходящего варианта метода для конкретной ситуации входит в обязанности компилятора.
Ничто не запрещает вам реализовать несколько перегруженных методов, каждый из которых будет работать совершенно по-разному. Но на практике крайне рекомендуется, чтобы перегруженные методы реализовывали одну и ту же общую операцию.
Перегрузка конструкторов
Наряду с перегрузкой обычных методов можно также выполнять перегрузку конструкторов. Перегружаемые конструкторы – это норма и часто используемый прием.
Соответствующий перегружаемый конструктор вызывается в зависимости от параметров, указываемых при выполнении оператора new.
Если вы пишете для класса несколько конструкторов, иногда бывает удобно вызвать один конструктор из другого, чтобы избежать дублирования кода. Такая операция проводится с использованием ключевого слова this.
Связывание "метод-вызов"
Давайте еще раз взглянем на метод tune()
Мы уже разобрались, что в зависимости от ссылки на объект того или иного подкласса, Java вызовет тот или иной переопределенный метод play()
. Но откуда компилятор знает - какой из методов play()
необходимо будет вызвать, ведь в качестве входного аргумента у нас указана ссылка на объект класса Instrument
? Ответ заключается в том, что компилятор этого не знает.
Присоединение вызова метода к телу метода называется связыванием. Если связывание производится перед запуском программы (на этапе компиляции или компоновки), оно называется ранним (early) или статическим (static) связыванием (binding).
Неоднозначность в работе метода tune()
связана именно с ранним связыванием: компилятор не может знать заранее, какой вариант метода play()
нужно будет вызвать, когда у него есть только ссылка на объект класса Instrument
.
Данная проблема решается благодаря позднему связыванию, то есть связыванию, проводимому во время выполнения программы, в зависимости от типа объекта. Позднее (late) связывание (binding) также называют динамическим (dynamic) или связыванием на этапе выполнения программы (runtime binding).
В реализации Java существует механизм для фактического определения типа объекта во время работы программы для вызова подходящего метода. Иначе говоря, компилятор не знает тип объекта, но механизм вызова метода определяет фактический тип объекта и вызывает соответствующее тело метода.
Для всех методов Java используется механизм позднего связывания, если только метод не был объявлен как final
(приватные методы являются final
по умолчанию).
Итак, подведем итоги:
статическое связывание в Java происходит на этапе компиляции, тогда как динамическое связывание происходит во время выполнения программы (в runtime);
для
private
,
иfinalstatic
методов, а также для полей используется статическое связывание, тогда как для остальных методов (такие методы в некоторых языках программированию называются виртуальными (virtual
)) используется динамическое связывание;в статическом связывании используется тип ссылки, тогда как в динамическом связывании используется фактический тип объекта;
перегруженные методы используют статическое связывание, тогда как переопределенные методы используют динамическое связывание.
Пример.
Рассмотрим популярный пример с геометрическими фигурами. Создадим базовый класс Shape
и различные производные классы: Circle
, Square
и Triangle
.
Создадим объект класса Circle
используя принцип восходящего преобразования
В данном коде создается объект типа Circle
, после чего ссылка на объект присваивается переменной типа Shape
. На первый взгляд это ошибка, мы не можем присвоить ссылочной переменной одного типа ссылку на объект другого типа. Но на самом деле, все правильно, потому что тип Circle
является типом Shape
посредством наследования.
Если мы вызовем у объекта метод draw()
то можно подумать, что вызовется метод draw()
из класса Shape
, так как ссылочная переменная имеет тип Shape
. Но на самом деле будет вызван правильный метод Circle.draw()
, так как в программе используется позднее связывание (полиморфизм).
Создадим объекты других типов
и посмотрим на результат
Базовый класс Shape
устанавливает для всех классов, производных от Shape
, общий интерфейс - набор публичных методов (действий, которые может совершить внешний код над объектом). То есть любую фигуру можно нарисовать (draw()
) и стереть (erase()
). Производные классы переопределяют этот набор методов, чтобы реализовать свое уникальное поведение для этой фигуры.
Благодаря полиморфизму, вы можете добавлять сколько угодно новых типов, внося в программу минимальные изменения или не внося изменений вовсе. В хорошо спроектированной программе, большая часть методов (или даже все методы) переопределяют интерфейс базового класса. Такая программа будет являться расширяемой, поскольку в нее можно добавлять дополнительную функциональность, создавая новые типы данных из общего базового класса.
Last updated
Was this helpful?