Когда сработает полиморфизм a a new b
Перейти к содержимому

Когда сработает полиморфизм a a new b

  • автор:

7: Полиморфизм

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

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

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

Повторение приведения к базовому типу

В главе 6, Вы могли видеть, как можно использовать объект как своего собственного типа или в качестве базового типа. Получение ссылки на объект и привидение ее к типу базового класса называется «приведение к базовому типу», поскольку путь деревьев наследования растет сверху от базового класса.

Вы так же видели возникшую проблему истекающую из следующего:

//: c07:music:Music.java // Наследование и приведение к базовому типу. class Note < private int value; private Note(int val) < value = val; >public static final Note MIDDLE_C = new Note(0), C_SHARP = new Note(1), B_FLAT = new Note(2); > // И т.д. class Instrument < public void play(Note n) < System.out.println("Instrument.play()"); > > // Объект Wind так же и instruments // поскольку у них общий интерфейс: class Wind extends Instrument < // Переопределение метода: public void play(Note n) < System.out.println("Wind.play()"); > > public class Music < public static void tune(Instrument i) < // . i.play(Note.MIDDLE_C); > public static void main(String[] args) < Wind flute = new Wind(); tune(flute); // Приведение к базовому типу > > ///:~

Метод Music.tune( ) принимает ссылки на Instrument, а так же на все, что произошло от Instrument. В main( ), Вы можете увидеть как это происходит, ссылка на Wind передается tune( ), без нужного преобразования типов. Интерфейс Instrument при этом должен существовать в Wind, поскольку Wind произошел от Instrument. Преобразование типа из Wind к Instrument может уменьшить интерфейс, но при этом он не будет меньше, чем весь интерфейс Instrument.

Забывание типа объекта

Это выражение может показаться странным для Вас. Почему кто-то должен намеренно забыть тип объекта? А это происходит, когда, Вы производите приведение к базовому типу, и выглядит это более прямо если бы tune( ) просто брала ссылку на Wind в качестве аргумента. Тем самым приносится еще одна неотъемлемая часть полиморфизма: Если бы Вы сделали так, как написано выше, то Вам было бы необходимо писать новый метод tune( ) для каждого типа Instrument в вашей системе. Допустим, мы последовали этой технике и добавили инструменты Stringed и Brass:

//: c07:music2:Music2.java // Перегрузка, вместо приведедния к базовому типу. class Note < private int value; private Note(int val) < value = val; >public static final Note MIDDLE_C = new Note(0), C_SHARP = new Note(1), B_FLAT = new Note(2); > // И т.д. class Instrument < public void play(Note n) < System.out.println("Instrument.play()"); > > class Wind extends Instrument < public void play(Note n) < System.out.println("Wind.play()"); > > class Stringed extends Instrument < public void play(Note n) < System.out.println("Stringed.play()"); > > class Brass extends Instrument < public void play(Note n) < System.out.println("Brass.play()"); > > public class Music2 < public static void tune(Wind i) < i.play(Note.MIDDLE_C); >public static void tune(Stringed i) < i.play(Note.MIDDLE_C); >public static void tune(Brass i) < i.play(Note.MIDDLE_C); >public static void main(String[] args) < Wind flute = new Wind(); Stringed violin = new Stringed(); Brass frenchHorn = new Brass(); tune(flute); // Не приведение к базовому типу tune(violin); tune(frenchHorn); > > ///:~

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

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

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

Скручивание

Сложности с Music.java можно видеть при запуске этой программы. Вывод в Wind.play( ). Причем это почти желаемый вывод, но здесь не должно играть роли, как это будет проигрываться. Посмотрите на метод tune( ):

public static void tune(Instrument i) < // . i.play(Note.MIDDLE_C); >

Метод воспринимает ссылку на Instrument. А как компилятору узнать, что в действительности эта ссылка на Instrument указывает на Wind в этом случае и не указывает на Brass или Stringed? Компилятор не может. Для того, что бы поглубже разобраться в этом затруднении неплохо было бы разобраться и в самой сущности связывания.

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

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

В замешательство предыдущей программы находится вокруг раннего связывания, поскольку компилятор не знает правильный метод для вызова, если есть только ссылка на Instrument.

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

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

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

Выработка правильного поведения

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

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

В примере шейпе имеется класс Shape и множество дочерних типов: Circle, Square, Triangle и т.д. Причина этого примера проста, так же, как просто сказать «круг это всего лишь разновидность шейпа (геометрической фигуры)» и такое заявление легко понять. Диаграмма наследования показывает связи объектов:

Приведение к базовому типу происходит в выражении:

Shape s = new Circle();

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

Предположим, что Вы вызываете метод базового класса (который был переопределен в дочернем классе):

s.draw();

И снова, Вы можете ожидать, что вызовется метод из Shape draw( ), поскольку это он и есть и как компилятору узнать, что это не он? А в самом деле вызовется Circle.draw( ), поскольку используется позднее связывание(полиморфизм).

Следующий пример поместит его несколько другим путем:

//: c07:Shapes.java // Полиморфизм в Java. class Shape < void draw() <> void erase() <> > class Circle extends Shape < void draw() < System.out.println("Circle.draw()"); > void erase() < System.out.println("Circle.erase()"); > > class Square extends Shape < void draw() < System.out.println("Square.draw()"); > void erase() < System.out.println("Square.erase()"); > > class Triangle extends Shape < void draw() < System.out.println("Triangle.draw()"); > void erase() < System.out.println("Triangle.erase()"); > > public class Shapes < public static Shape randShape() < switch((int)(Math.random() * 3)) < default: case 0: return new Circle(); case 1: return new Square(); case 2: return new Triangle(); > > public static void main(String[] args) < Shape[] s = new Shape[9]; // Заполним массив шейпами: for(int i = 0; i < s.length; i++) s[i] = randShape(); // Сделаем вызов полиморфного метода: for(int i = 0; i < s.length; i++) s[i].draw(); >> ///:~

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

Главный класс Shapes содержит static метод — randShape( ), который возвращает ссылку на случайно выбранный объект Shape каждый раз, когда Вы вызываете его. Заметьте, что приведение к базовому типу происходит каждый раз при return-е, который ссылается на Circle, Square или Triangle и посылает их из метода, как возвращаемый параметр. Так что, когда Вы вызываете этот метод Вы не можете узнать, какого типа возвращается параметр, поскольку всегда возвращается базовый тип Shape.

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

Circle.draw() Triangle.draw() Circle.draw() Circle.draw() Circle.draw() Square.draw() Triangle.draw() Square.draw() Square.draw()

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

Расширяемость

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

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

Все эти новые классы работают нормально со старым, неизмененным методом tune( ). Даже если tune( ) в другом файле и новые методы добавлены в интерфейс Instrument, tune( ) работает без ошибок даже без перекомпиляции. Ниже приведена реализация вышерасположенной диаграммы:

//: c07:music3:Music3.java // Расширяемая программа. import java.util.*; class Instrument < public void play() < System.out.println("Instrument.play()"); > public String what() < return "Instrument"; > public void adjust() <> > class Wind extends Instrument < public void play() < System.out.println("Wind.play()"); > public String what() < return "Wind"; > public void adjust() <> > class Percussion extends Instrument < public void play() < System.out.println("Percussion.play()"); > public String what() < return "Percussion"; > public void adjust() <> > class Stringed extends Instrument < public void play() < System.out.println("Stringed.play()"); > public String what() < return "Stringed"; > public void adjust() <> > class Brass extends Wind < public void play() < System.out.println("Brass.play()"); > public void adjust() < System.out.println("Brass.adjust()"); > > class Woodwind extends Wind < public void play() < System.out.println("Woodwind.play()"); > public String what() < return "Woodwind"; > > public class Music3 < // Не беспокойтесь о новых типах, // поскольку добавленные продолжают работать правильно: static void tune(Instrument i) < // . i.play(); > static void tuneAll(Instrument[] e) < for(int i = 0; i < e.length; i++) tune(e[i]); >public static void main(String[] args) < Instrument[] orchestra = new Instrument[5]; int i = 0; // Приведение к базовому типу во время добавления в массив: orchestra[i++] = new Wind(); orchestra[i++] = new Percussion(); orchestra[i++] = new Stringed(); orchestra[i++] = new Brass(); orchestra[i++] = new Woodwind(); tuneAll(orchestra); > > ///:~

Новые методы what( ), который возвращает String ссылку с описанием класса, и adjust( ), который предоставляет некоторый путь для настройки каждого инструмента.

В main( ), когда Вы помещаете что-то внутрь массива Instrument Вы автоматически производите операцию приведения к базовому типу к Instrument.

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

Переопределение против перегрузки

Давайте теперь взглянем на первый пример этой главы, но под другим углом зрения. В следующей программе интерфейс метода play( ) изменен в процессе переопределения, что означает, что Вы не переопределили этот метод, а вместо этого перегрузили его. Компилятор позволяет вам перегрузить метод, поэтому и не было никаких жалоб с его стороны. Но поведение метода вероятно отличается от того, что бы Вы хотели. Пример:

//: c07:WindError.java // Случайное изменение интерфейса. class NoteX < public static final int MIDDLE_C = 0, C_SHARP = 1, C_FLAT = 2; > class InstrumentX < public void play(int NoteX) < System.out.println("InstrumentX.play()"); > > class WindX extends InstrumentX < // Упс! Изменился интерфейс метода: public void play(NoteX n) < System.out.println("WindX.play(NoteX n)"); > > public class WindError < public static void tune(InstrumentX i) < // . i.play(NoteX.MIDDLE_C); > public static void main(String[] args) < WindX flute = new WindX(); tune(flute); // Не желаемое поведедение! > > ///:~

Здесь есть еще одна запутывающая сторона применения полиморфизма. В InstrumentX метод play( ) принимает int, который имеет идентификатор NoteX. Так что, даже если NoteX это имя класса, то оно так же может быть использовано и в качестве переменной, без возражений со стороны компилятора. Но в WindX, play( ) берет ссылку NoteX, которая имеет идентификатор n. (Хотя Вы никогда не сможете осуществить play(NoteX NoteX) без сообщения об ошибке.) Поэтому кажется, что программист собирался переопределить play( ), но немного опечатался. Компилятор же в свою очередь понял, что это перегрузка (overload), а не переопределение (override). Заметьте, что если Вы следуете соглашению об именах в Java, то тогда идентификатор был бы noteX (в нижнем регистре «n»), что отделило бы его от имени класса.

В tune, InstrumentX i посылает сообщение методу play( ), с одним из членов NoteX (MIDDLE_C) в качестве аргумента. Поскольку NoteX содержит определение int, то это означает, что будет вызвана int версия перегруженного метода play( ) и в силу того, что он не был переопределен, то будет использована версия базового класса.

InstrumentX.play()

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

Абстрактные методы и классы

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

Единственная причина для создания этого общего интерфейса , то, что он должен быть реализован по разному для каждого отдельного подтипа. Он создает основную форму, так что Вы можете сказать, что общего во всех дочерних классах. Другой путь сказать то же самое, это вызов Instrument абстрактного базового класса (или просто абстрактного класса). Вы создаете абстрактный класс, когда Вы хотите управлять набором классов, через этот общий интерфейс. Все методы дочерних классов, совпадающие с объявлением сигнатуры базового класса, используют динамическое связывание. (Однако как было написано в предыдущей секции, если имя метода совпадает с именем метода базового класса, а аргументы различны, то это означает перегрузку, что обычно не то, что требуется.)

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

Java предоставляет механизм для этого, называемый вызов абстрактного метода[37]. Такой метод является не законченным; он имеет только объявление и не имеет тела метода. Ниже приведен синтаксис объявления абстрактного метода:

abstract void f();

Класс, содержащий абстрактные методы, называется абстрактным классом. Если класс содержит один или больше абстрактных методов, этот класс должен быть определен как abstract. (В противном случае компилятор выдаст сообщение об ошибке.)

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

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

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

Класс Instrument может быть с легкостью превращен в abstract класс. Только некоторые из методов будут abstract, поскольку создание абстрактного метода не требует от вас определение всех методов abstract. Здесь показано, на что это похоже:

Ниже пример с оркестром, модифицированный для использования abstract классов и методов:

//: c07:music4:Music4.java // Абстрактные методы и классы. import java.util.*; abstract class Instrument < int i; // хранилище зарезервировано для всех public abstract void play(); public String what() < return "Instrument"; > public abstract void adjust(); > class Wind extends Instrument < public void play() < System.out.println("Wind.play()"); > public String what() < return "Wind"; > public void adjust() <> > class Percussion extends Instrument < public void play() < System.out.println("Percussion.play()"); > public String what() < return "Percussion"; > public void adjust() <> > class Stringed extends Instrument < public void play() < System.out.println("Stringed.play()"); > public String what() < return "Stringed"; > public void adjust() <> > class Brass extends Wind < public void play() < System.out.println("Brass.play()"); > public void adjust() < System.out.println("Brass.adjust()"); > > class Woodwind extends Wind < public void play() < System.out.println("Woodwind.play()"); > public String what() < return "Woodwind"; > > public class Music4 < // Не беспокойтесь от типах, поскольку новые типы добавляемые // в систему, не мешают ей работать правильно: static void tune(Instrument i) < // . i.play(); > static void tuneAll(Instrument[] e) < for(int i = 0; i < e.length; i++) tune(e[i]); >public static void main(String[] args) < Instrument[] orchestra = new Instrument[5]; int i = 0; // Приведение к базовому типу во время добавления в массив: orchestra[i++] = new Wind(); orchestra[i++] = new Percussion(); orchestra[i++] = new Stringed(); orchestra[i++] = new Brass(); orchestra[i++] = new Woodwind(); tuneAll(orchestra); > > ///:~

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

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

Конструкторы и полиморфизм

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

Порядок вызова конструкторов

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

Конструктор для базового класса всегда вызывается в конструкторе дочернего класса, и так по всей цепочке наследования, пока не будут вызваны конструкторы всех базовых классов. Такой порядок имеет значение, поскольку конструктор выполняет специальную работу: что бы убедится, что объект был создан правильно. Дочерний класс имеет доступ только к его собственным членам и ни к одному из базового класса (чьи элементы обычно private). Только конструктор базового класса имеет необходимую информацию и доступ к элементам базового класса. Следовательно, естественно, что вызываются все конструкторы, с другой стороны объект целиком не создается. Вот поэтому компилятор и вызывает конструкторы в конструкторах дочерних классов. Он просто тихо вызывает конструктор по умолчанию, если Вы этого сами явно не сделали в теле конструктора. Если же у базового класса нет конструктора по умолчанию, то компилятор по этому поводу возразит. (В случае, если класс не имеет конструкторов компилятор автоматически создает конструктор по умолчанию.)

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

//: c07:Sandwich.java // Порядок вызова конструкторов. class Meal < Meal() < System.out.println("Meal()"); > > class Bread < Bread() < System.out.println("Bread()"); > > class Cheese < Cheese() < System.out.println("Cheese()"); > > class Lettuce < Lettuce() < System.out.println("Lettuce()"); > > class Lunch extends Meal < Lunch() < System.out.println("Lunch()");> > class PortableLunch extends Lunch < PortableLunch() < System.out.println("PortableLunch()"); > > class Sandwich extends PortableLunch < Bread b = new Bread(); Cheese c = new Cheese(); Lettuce l = new Lettuce(); Sandwich() < System.out.println("Sandwich()"); > public static void main(String[] args) < new Sandwich(); > > ///:~

Этот пример создает составной класс из других классов и каждый из классов имеет конструктор, который извещает о себе. Важный класс Sandwich отражает три уровня наследования (четыре, если считать наследование от Object) и три объекта элемента. Когда объект Sandwich уже создан, вывод программы таков:

Meal() Lunch() PortableLunch() Bread() Cheese() Lettuce() Sandwich()

Это означает, что существует следующий вызов конструкторов для сложного объекта:

  1. Вызван конструктор базового объекта. Этот шаг был повторен пока вызов не добрался до корня иерархии, следуя вниз, до того, как будут обработаны все дочерние классы.
  2. Участники инициализации вызваны по порядку их декларации.
  3. Вызвано тело дочернего класса.

Порядок вызова конструкторов чрезвычайно важен. Когда Вы наследуете, Вы знаете все о базовом классе и можете получить доступ к любому public и protected его участнику. Это означает, что вам необходимо быть уверенным в том, что все члены класса приемлемы и допустимы на момент наследования. В нормальном методе, создание объекта уже завершено, поэтому все члены этого класса соответственно созданы. Внутри конструктора, однако, Вы должны быть уверены в том, что все участники класса созданы нормально. Существует только один путь, гарантирующий это — вызов конструктора базового класса в самую первую очередь. Затем, когда управление уже передается в конструктор дочернего класса, все участники базового класса будут проинициализированы и созданы должным образом. Знание того, что все члены класса приемлемы уже в конструкторе хорошая причина для того, что бы где только возможно инициализировать объекты на стадии их определения. Если Вы будете следовать этой практике, то Вы будете уверены, что все члены классов и члены объектов были правильно проинициализированы. Но, к сожалению, часто это не играет никакой роли, но об этом читайте в следующей секции.

Наследование и finalize( )

Когда Вы используете композицию для создания нового класса, Вы никогда не беспокоитесь о завершении объекта этого класса. Каждый участник — независимый объект и поэтому сборщик мусора не обращают внимание на то, что это член вашего класса. С наследованием Вы должны переопределять finalize( ) в дочерних классах, если у вас есть какие либо специальные очистки, которые должны произойти как часть сбора мусора. Когда Вы переопределяете finalize( ) в наследуемом классе, то важно не забыть вызвать finalize( ) базового класса, поскольку в противном случае финализация базового класса не произойдет. Следующий пример доказывает это утверждение:

//: c07:Frog.java // Проверка завершения с наследованием. class DoBaseFinalization < public static boolean flag = false; > class Characteristic < String s; Characteristic(String c) < s = c; System.out.println( "Creating Characteristic " + s); > protected void finalize() < System.out.println( "finalizing Characteristic " + s); > > class LivingCreature < Characteristic p = new Characteristic("is alive"); LivingCreature() < System.out.println("LivingCreature()"); > protected void finalize() throws Throwable < System.out.println( "LivingCreature finalize"); // Вызов версии базового класса! if(DoBaseFinalization.flag) super.finalize(); > > class Animal extends LivingCreature < Characteristic p = new Characteristic("has heart"); Animal() < System.out.println("Animal()"); > protected void finalize() throws Throwable < System.out.println("Animal finalize"); if(DoBaseFinalization.flag) super.finalize(); > > class Amphibian extends Animal < Characteristic p = new Characteristic("can live in water"); Amphibian() < System.out.println("Amphibian()"); > protected void finalize() throws Throwable < System.out.println("Amphibian finalize"); if(DoBaseFinalization.flag) super.finalize(); > > public class Frog extends Amphibian < Frog() < System.out.println("Frog()"); > protected void finalize() throws Throwable < System.out.println("Frog finalize"); if(DoBaseFinalization.flag) super.finalize(); > public static void main(String[] args) < if(args.length != 0 && args[0].equals("finalize")) DoBaseFinalization.flag = true; else System.out.println("Not finalizing bases"); new Frog(); // Тотчас становится мусором System.out.println("Bye!"); // Принудительный вызов завершения и очистки: System.gc(); > > ///:~

Класс DoBaseFinalization просто содержит флаг, который показывает для каждого класса в иерархии вызывать ли super.finalize( ). Этот флаг устанавливается как аргумент командной строки, так что Вы можете посмотреть поведение с и без вызовов завершения базового класса.

Каждый класс в иерархии так же содержит объект класса Characteristic. Вы увидите, что не обращая внимание на вызов завершителя базового класса объект Characteristic всегда завершается.

Каждое переопределение finalize( ) должно иметь доступ к protected членам класса, поскольку метод finalize( ) в классе Object является protected и компилятор не позволит вам уменьшить права доступа во время наследования. (» Friendly» менее «достижимы» чем protected.)

В Frog.main( ), флаг DoBaseFinalization настраивается и создается единственный объект Frog. Помните, что сборщик мусора и индивидуальное завершение, могут не произойти для отдельного объекта, поэтому, что бы вызвать их насильно вызывается System.gc( ) и оттуда уже завершение. Без завершения базовых классов вывод такой:

Not finalizing bases Creating Characteristic is alive LivingCreature() Creating Characteristic has heart Animal() Creating Characteristic can live in water Amphibian() Frog() Bye! Frog finalize finalizing Characteristic is alive finalizing Characteristic has heart finalizing Characteristic can live in water

Вы можете видеть, что не были вызваны завершители для базовых классов Frog (объекты класса были завершены, как Вы и ожидали). Но если Вы добавите аргумент «finalize» в командную строку, Вы получите:

Creating Characteristic is alive LivingCreature() Creating Characteristic has heart Animal() Creating Characteristic can live in water Amphibian() Frog() bye! Frog finalize Amphibian finalize Animal finalize LivingCreature finalize finalizing Characteristic is alive finalizing Characteristic has heart finalizing Characteristic can live in water

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

Поведение полиморфных методов внутри конструкторов

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

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

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

Вы можете разглядеть эту проблему в следующем примере:

//: c07:PolyConstructors.java // Конструткоры и полиморфизм // не производите то, что вы не можете ожидать. abstract class Glyph < abstract void draw(); Glyph() < System.out.println("Glyph() before draw()"); draw(); System.out.println("Glyph() after draw()"); > > class RoundGlyph extends Glyph < int radius = 1; RoundGlyph(int r) < radius = r; System.out.println( "RoundGlyph.RoundGlyph(), radius RoundGlyph.draw(), radius #0000ff" size="+1">class PolyConstructors < public static void main(String[] args) < new RoundGlyph(5); > > ///:~

В Glyph, метод draw( )abstract, так что он спроектирован для переопределения. В замен этого Вы принудительного переопределяете его в RoundGlyph. Но конструктор Glyph вызывает этот метод и этот вызов заканчивается в RoundGlyph.draw( ), что в общем-то выглядит как то, что было нужно. Но посмотрите на вывод:

Glyph() before draw() RoundGlyph.draw(), radius = 0 Glyph() after draw() RoundGlyph.RoundGlyph(), radius = 5

Когда конструктор Glyph-а вызывает draw( ), значение radius еще не приняло значение по умолчанию 1. Оно еще равно 0. Это означает, что не будет нарисована точка на экране, Вы будете пытаться нарисовать эту фигуру на экране и пытаться сообразить, почему программа не работает.

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

  1. Место отведенное под объекты инициализировано в ноль, до того, как что-то произойдет.
  2. Вызывается конструктор базового класса (как и было описано ранее). В этот момент вызывается переопределенный метод draw( )(да, до того, как будет вызван конструткор RoundGlyph), который открывает, что значение radius равно нулю, как и было описано в шаге 1.
  3. Инициализация элементов вызывается в порядке их определения.
  4. Вызывается тело конструткора базового класса.

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

С другой стороны, Вы должны быть устрашены результатами работы этой программы. Вы совершили совершенно логичную штуку и сейчас поведение программы непостижимо неправильно, и при этом без возражений со стороны компилятора. (C++ проявляет более рациональное поведение в таких ситуациях.) Ошибки на подобии этой могут быть с легкостью совершены, но в последствии потребуют много времени на их обнаружение.

В качестве результата, хорошие руководящие принципы для конструктора «Делайте в конструкторе настолько меньше, насколько можете и если это возможно, то не вызывайте никаких методов». Существует только один тип методов, которые безопасно вызывать из конструктора, это final методы из базового класса. (Это так же применимо и к private методам, которые так же являются final.) Они не могут быть переопределены и поэтому не могут преподнести своего рода сюрприз.

Проектировка с наследованием

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

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

//: c07:Transmogrify.java // Динамическое изменение поведения // при композиции объекта. abstract class Actor < abstract void act(); > class HappyActor extends Actor < public void act() < System.out.println("HappyActor"); > > class SadActor extends Actor < public void act() < System.out.println("SadActor"); > > class Stage < Actor a = new HappyActor(); void change() < a = new SadActor(); > void go() < a.act(); >> public class Transmogrify < public static void main(String[] args) < Stage s = new Stage(); s.go(); // Выводит "HappyActor" s.change(); s.go(); // Выводит "SadActor" > > ///:~

Объект Stage содержит ссылку на Actor, которая проинициализирована на объект HappyActor. Это означает, что go( ) предоставляет специфическое поведение. Но поскольку ссылка может быть перенаправлена на другой объект во время выполнения, то ссылка на объект SadActor может быть подставлена в a а затем посредством go( ) может быть изменена линия поведения. Так Вы наживаетесь на динамическом изменении во время работы программы. (Это так же называется статический шаблон (State Pattern). Смотрите для подробностей » Thinking in Patterns with Java«, доступный с www.BruceEckel.com.) В противоположность, Вы не можете решить использовать наследование с различными типами в режиме выполнения, типы должны быть полностью определены на стадии компиляции.

Основная линия поведения при этом может быть выражена фразой «Используй наследование для выражения различия в поведении и поля для выражения различий в значениях». В предыдущем примере использовались оба принципа из высказывания: два различных класса были наследованы для получения различий в методе act( ), а Stage использует композицию для изменения своего значения. В этом случае, такое изменение означает и изменение в поведении метода.

Чистое наследование против расширения

Во время изучения наследования, всегда преподается наиболее понятный, чистый путь для создания диаграммы наследования, т.н. «чистое» наследование. При этом существуют только методы в интерфейсе базового класса, которые переопределяются в дочерних классах, как и показано на диаграмме:

Это так называемая чистая » is-a» связь, поскольку интерфейс класса определяет, что же это есть на самом деле. Наследование гарантирует, что любой дочерний класс будет иметь тот же интерфейс (т.е. не меньше его) как и у базового класса и ничего более. Если Вы последуете представленной диаграмме, то можете увидеть, что дочерние классы так же имеют интерфейс не больший, чем у базового.

Это разновидность так называемой чистой замены, поскольку объекты дочернего класса могут быть чудным образом заменены для базового класса и вам не нужно знать никакой дополнительной информации о подклассах, когда Вы их будете использовать:

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

Когда Вы видите такой путь, то это означает, что используются чистые связи «is-a», при этом такой подход является единственным и любой другой дизайн сигнализирует о запутанном обдумывании и по определению кривому восприятию кода. Вот и попались Вы в ловушку. Как только Вы начали думать в этом направлении, развернитесь и откройте для себя расширение интерфейса (которое к несчастью подстрекается ключевым словом extends) являющегося лучшим решением частной проблемы. Такой подход называется «is-like-a» (это похоже на то) связью, поскольку дочерний класс похож на базовый класс, из-за того, что они имеют один и тот же фундаментальный интерфейс, но они имеют различные особенности, которые требуют дополнительных методов для своей реализации:

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

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

Приведение к дочернему типу и идентификация типов во время работы

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

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

В некоторых языках (типа C++) Вы должны осуществлять специальную операцию в получении типо-безопасного приведения к дочернему типу, но в Java любое приведение к типу проверяется! И как бы это не выглядело странно, Вы просто выполняете ординарное родительское приведение, во время работы, это приведение проверяется, для того, что бы убедиться, что это на самом деле то, что нужно. Если что-то не так, то Вы получите ClassCastException. Этот акт проверки типов во время работы называется идентификация типов во время работы (run-time type identification (RTTI)). Следующий пример демонстрирует поведение RTTI:

//: c07:RTTI.java // Приведение к дочернему типу и RTTI. import java.util.*; class Useful < public void f() <> public void g() <> > class MoreUseful extends Useful < public void f() <> public void g() <> public void u() <> public void v() <> public void w() <> > public class RTTI < public static void main(String[] args) < Useful[] x = < new Useful(), new MoreUseful() >; x[0].f(); x[1].g(); // Время компиляции: метод не найден в Useful: //! x[1].u(); ((MoreUseful)x[1]).u(); // Приведение к дочернему типу RTTI ((MoreUseful)x[0]).u(); // Обработка исключения > > ///:~

Как и на диаграмме MoreUseful расширяет интерфейс Useful. Но поскольку он наследованный, он так же может быть приведен к базовому типу, к Useful. Как Вы можете видеть это происходит в момент инициализации массива x в main( ). Поскольку оба объекта в массиве есть типы от класса Useful, то Вы можете послать методы f( ) и g( ) обоим, а если Вы попытаетесь вызвать u( ) (который существует только в MoreUseful), то Вы получите ошибку времени компиляции.

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

У RTTI есть больше применений, чем простое приведение. К примеру, существует возможность увидеть с каким типом Вы работаете, до того, как Вы попытаетесь привести к дочернему типу. Вся глава 12 посвящена изучению различных аспектов применения идентификации типов во время работы в Java

Резюме

Полиморфизм означает «различные формы». В ООП у вас есть одно и то же лицо (общий интерфейс в базовом классе) и различные формы использующие это лицо: различные версии динамически компонуемых методов.

В этой главе Вы увидели, что невозможно понять, а часто и создать пример полиморфизма без использования абстракций данных и наследования. Полиморфизм это такое свойство, которое не может быть рассмотрено в изоляции (а оператор switch, например можно), и при этом полиморфизм работает только в сочетании, как часть большой картины связей классов. Люди часто находятся в затруднении относительно других, не объектно-ориентированных свойств Java, таких как перезагрузка, которые иногда преподносились как объектно-ориентированные. Не сглупите: если оно не с поздним связыванием, то оно и не с полиморфизмом.

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

Упражнения

Решения к выбранным упражнениям могут быть найдены в электронном документе The Thinking in Java Annotated Solution Guide, доступном с www.BruceEckel.com.

  1. Добавьте новый метод в базовый класс Shapes.java, который печатает сообщение, но не переопределяйте его в дочерних классах. Объясните, что происходит. Теперь переопределите его в одном из дочерних классов, но не в остальных, и посмотрите, что произошло. В конце переопределите его во всех классах.
  2. Добавьте новый тип Shape в Shapes.java и проверьте в main( ), что полиморфизм работает для ваших новых типов, как если бы он были старых типов.
  3. Измените Music3.java, так что бы what( ) стал корневым методом объекта Object метода toString( ). Попробуйте напечатать объект Instrument используя System.out.println( ) (без любых приведений).
  4. Добавьте новый тип Instrument к Music3.java и проверьте, что полиморфизм работает для вашего нового типа.
  5. Измените Music3.java, так, что бы он случайным образом создавал объекты Instrument так же, как это делает Shapes.java.
  6. Создайте иерархию наследования Rodent: Mouse, Gerbil, Hamster, и т.д. В базовом классе, создайте метод общий для всех Rodent и переопределите их в дочерних классах для осуществления различного поведения в зависимости от типа Rodent. Создайте массив из Rodent, заполните его различными типами Rodent и вызовите ваш метод базового класса, что бы посмотреть, что случилось.
  7. Измените упражнение 6, так, что бы Rodent стал abstract классом. Сделайте методы Rodent абстрактными, где только возможно.
  8. Создайте класс как abstract без включения любых abstract методов и проверьте, что Вы не можете создать ни одного экземпляра этого класса.
  9. Добавьте класс Pickle к Sandwich.java.
  10. Измените упражнение 6, так что бы оно демонстрировало порядок инициализации базовых и дочерних классов. Теперь добавьте участников объектов в оба, в базовый и в дочерний классы и покажите порядок в каком происходит инициализация при создании объекта.
  11. Создайте трех уровневую иерархию наследования. Каждый из классов должен иметь метод finalize( ) и он должен правильно вызывать версию finalize( ) из базового класса. Покажите, что ваша иерархия работает правильно.
  12. Создайте базовый класс с двумя методами. В первом методе, вызовите второй метод. Наследуйте класс и переопределите второй метод. Создайте объект дочернего класса и приведите его к базовому типу, затем вызовите первый метод. Объясните, что произошло.
  13. Создайте базовый класс с методом abstractprint( ), который переопределяется в дочернем классе. Переопределенная версия метода печатает значение переменной int, определенной в дочернем классе. В точке определения этой переменной, присвойте ей не нулевое значение. В конструкторе базового класса вызовите этот метод. В main( ), создайте объект дочернего типа и затем вызовите его print( ). Объясните результат.
  14. Следуйте примеру в Transmogrify.java, создайте класс Starship содержащий ссылку AlertStatus, которая может отображать три различных состояния. Включите в класс методы изменяющие это состояние.
  15. Создайте abstract класс без методов. Наследуйте класс и добавьте метод. Создайте static метод, который получает ссылку на базовый класс, приведите ее к дочернему типу и вызовите этот метод. В main( ), покажите, что это работает. Теперь поместите abstract объявление для метода в базовый класс, это уничтожит потребность в приведении к дочернему типу.

[37] Для программистов C++, это аналог C++ pure virtual function.

Не работает полиморфизм или я что-то не так делаю?

и mB должен быть ссылкой на объект С , но в реальности он содержит только методы В. Что не так я сделал?

Отслеживать
1,552 2 2 золотых знака 10 10 серебряных знаков 17 17 бронзовых знаков
задан 12 сен 2018 в 9:33
45 6 6 бронзовых знаков

2 ответа 2

Сортировка: Сброс на вариант по умолчанию

Доступность членов класса через ссылку ограничивается типом ссылки — в данном случае B . Чтобы обратиться к методам C нужно явно привести его к своему типу:

C c = (C) mB; 

Переменная c будет указывать на тот же самый объект, но через неё нам будут доступны методы C .

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

Так mB не может быть приведён к типу C , если он был создан как

B mB = new B(); 
B mB = new D(); // где D тоже потомок B, но не потомок C 

Как ответить на вопрос по поводу полиморфизма в Java?

Здравствуйте!
Задание звучит так:
Create a base class with two methods. In the first method, call the second method. Inherit a class and override the second method. Create an object of the derived class, upcast it to the base type, and call the first method. Explain what happens.

import static net.mindview.util.Print.*; class A < void a() < b(); >void b() < print("A.b()"); >> class B extends A < @Override void b() < print("B.b()"); >> public class Program < static public void main(String[] args) < A aa = new B(); aa.a(); >>

Как объяснить, что в итоге произошло? Произошло восходящее преобразование к базовому классу, вызов метода a() базового класса, и, внутри этого метода, переопределенного метода b() — я прав?

  • Вопрос задан более трёх лет назад
  • 673 просмотра

Комментировать
Решения вопроса 1

leahch

Я мастер на все руки, я козлик Элек Мэк 🙂

Может быть так проще будет?
Класс В наследует методы и переменные класса A. И когда мы вызываем метод объект В, то он может использовать методы и переменные объекта А, также можно представить объект В, как объект А, но поведение будет все равно, как у объекта В.
В моем примере произошел вызов переопределённого метода b класса B с последующим вызовом родительского оригинального метода класса A. Причем видно, что принудительная кастенация (приведение типов) класса В к классу А — ничего не меняет, как был объект класса В, так он и остался, с переопределённым методом.
И еще, объектом обычно называют экземпляр класса, то, что образовалось после new.

package jtests; import java.lang.System; public class MyTest < class A < String a () < return("from A:a"); >String b() < return("from A:b - " + a()); >> class B extends A < @Override String b() < return ("from B:b - " + super.b()); >> public static void main(String[] args) < MyTest m = new MyTest(); System.out.println("A"); A a = m.new A(); // Используем A System.out.println(a.getClass().getName() + " * " + a.b()); System.out.println("B"); B b = m.new B(); // Используем B System.out.println(b.getClass().getName() + " * " + b.b()); System.out.println("B ->A"); A ab = (A) m.new B(); // Используем B как A System.out.println(ab.getClass().getName() + " * " + ab.b()); > >
A jtests.MyTest$A * from A:b - from A:a B jtests.MyTest$B * from B:b - from A:b - from A:a B -> A jtests.MyTest$B * from B:b - from A:b - from A:a

Полиморфизм простыми словами

Скорее всего вы уже встречались с понятием «полиморфизм» и даже помните пример с наследованием кошек и собак от Animal или квадратов и кругов от Shape. Однако эти примеры показывают далеко не всё, что скрывается за полиморфизмом.

Как читать статью? Статья большая, пытайтесь читать ее частями, не забывайте обращать внимание на сопровождающие статью ссылки.

Благодарности

  • Александр Мышов, t.me/defront — редактирование статьи;
  • Даня Рогозин — консультация по теоретическим вопросам;
  • Виталий Брагилевский — консультация по теоретическим вопросам;
  • Дмитрий Свиридкин — примеры кода на C++.

Дисклеймер

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

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

Отсюда в статье не будет подобных формул:

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

ООП ∩ Полиморфизм = проблема

Спросите практически любого фронтенд разработчика: «Полиморфизм? Что это такое? Есть ли он в JavaScript?». Готов поспорить, что вы не получите точного и однозначного ответа.

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

Далее они приведут пример, связанный с наследованием, а после ответ вероятно будет подкреплен ссылками на реализацию полиморфизма в других языках программирования, потому что «JavaScript не такой, как все».

Безусловно один из принципов ООП — это полиморфизм, но полиморфизм это не только один из принципов ООП.

Также замечу, что эта постоянная отсылка к ООП создала еще одну проблему: попробуйте уточнить у разработчика понятие полиморфизма без использования или упоминания наследования, и чтобы никаких Cat, Dog, Animal! Ну как? Получилось?

Как подметил Даня Рогозин: на самом деле, есть более общая проблема, что люди очень редко умеют давать точные определения, а сразу идут к примерам по личным ассоциациям.

На YouTube в одном из популярных видео про объяснение принципов ООП нет ничего про полиморфизм, возможно лектору он кажется чем-то естественно вытекающим:

Большая часть статей, докладов или лекций на тему полиморфизма в других языках программирования, которые мне попадались, описывают исключительно частные виды полиморфизма и, черт возьми, не дают полной картины!

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

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

Πολύμορφος

Дословный перевод с греческого — много форм. И здесь на мой взгляд стоит акцентировать внимание на двух аспектах, которые, применяя к предметной области, позволяют определить наличие полиморфизма, где нечто:

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

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

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

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

А теперь к делу.

Полиморфизм

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

В начале статьи я сетовал на отсутствие полной картины. На самом деле, я вас обманул, имя этой картине — Теория типов, которая, «неожиданно», изучает типы, используя методы теории доказательств и аппарат λ-исчисления, но ими не ограничивается.

Понятие «полиморфизм» в программировании тесно связано с типизацией или, если быть более точным, c системой типов.

Все то, что во время компиляции или исполнения программы может содержать или обрабатывать значения различных типов — является полиморфным, например:

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

Но пожалуй, самое лаконичное определение полиморфизма, я нашел в книге Бенджамина Пирса Типы в языках программирования:

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

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

Полиморфизм. Первое приближение

В 1967 году в мире программирования было зафиксировано первое упоминание о полиморфизме в статье Fundamental Concepts in Programming Languages от Кристофера Стрейчи.

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

В этой статье Кристофер Стрейчи пишет, что в большинстве языков программирования мы используем типы, чтобы понимать, с какими объектами (или переменными) мы имеем дело. Он вводит понятие полиморфных операторов, которые имеют несколько версий (или форм), в зависимости от своих аргументов. Например, то, каким образом необходимо произвести вычисления для оператора +, будет зависеть исключительно от типа операндов.

По итогу Кристофер Стрейчи выделил два основных типа полиморфизма:

  • Параметрический полиморфизм, который позволяет описывать вычисления в общем виде, абстрагируясь от того, какие типы будут использованы. Проиллюстрирую на примере из статьи:
Пусть: 
L - список, где все элементы имеют тип α
f - функция, принимающая аргумент типа α, результат имеет тип β
map - функция, принимающая функцию-аргумент и список значений,
результатом является список значений, где к каждому элементу
исходного списка применяется функция-аргумент
Из описания следует, что типы f и L:
f: α ⇒ β
L: list a
Откуда получаем тип функции map:
map: (α ⇒ β, list α) ⇒ list β

Запись типа α ⇒ β означает некое преобразование — функция, которая отображает значения типа α в значение типа β. Запись типа f: a ⇒ β, дает имя f этой функции. Подобные записи можно объединять в более сложные выражения, как в примере выше (α ⇒ β, list α) ⇒ list β, такая запись формирует свой язык над типами.

  • Специальный полиморфизм. Согласно статье Кристофера Стрейчи в этом виде полиморфизма не существует единого и общего способа определения типа результата по типу аргументов. В нем происходит диспетчеризация (перенаправление) к одной или нескольким функциям для конкретного типа аргумента.

Проиллюстрирую на своем примере, используя воображаемый язык:

Пусть: 
typeof - функция, принимающая аргумент любого типа,
результат это значение типа String
multiply - функция, принимающая аргумент value неизвестного типа
и аргумент multiplier типа Number,
результатом является произведение аргументов
Из описания typeof следует ее тип:
typeof: α ⇒ String
Из описания multiply проблематично сделать общее описание
Но можем описать отдельно произведение для строк, чисел и массивов
multiplyString: (String, Number) ⇒ String
multiplyNumber: (Number, Number) ⇒ Number
multiplyArray: (Array, Number) ⇒ Array Тогда функция multiply будет выглядеть так:fn multiply(value, multiplier):
switch typeof(value):
case "string":
return multiplyString(value, multiplier)
case "number":
return multiplyNumber(value, multiplier)
case "array":
return multiplyArray(value, multiplier)
default:
raise TypeError("Bad type")

Выше будем считать что функции multiplyString, multiplyNumber, multiplyArray были реализованы отдельно от примера.

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

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

Полиморфизм. Второе приближение

Следующей важной статьей касательно полиморфизма является статья “On Understanding Types, Data Abstraction, and Polymorphism”, написанная в 1985 году Лука Карделли (Luca Cardelli) и Питером Вегнером (Peter Wegner), в которой обобщаются виды полиморфизма актуальные на то время:

В статье обозначено две основных категории полиморфизма: универсальный (universal) и специальный (ad-hoc). К первой категории относят уже знакомый параметрический (parametric) и добавляется полиморфизм включений (inclusion). Во второй категории находится перегрузка (overloading) и приведение типов (coercion).

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

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

Выдуманный язык SPL

Давайте придумаем с вами язык SPL (Static Polymorphic Language), который по мере продвижения по статье будет детализироваться, и каждый раз в него будет добавляться поддержка нового вида полиморфизма.

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

Пусть язык SPL является очень строгим и статически типизированным. Если по какой-то причине вам неизвестны, или вы забыли эти понятия, то рекомендую прочитать статью Ликбез по типизации в языках программирования или ознакомится с видео Типизация / Введение в программирование.

Итак, вот описание возможностей нашего языка:

// поддержка комментариев 42 // числовой тип данных - Number
"abc" // строковый тип данных - String
a: Number = 42 // или указывается явно
b = "abc" // или указывается неявно
// операции над числами4 + 4 // 8
4 * 4 // 16
8 / 2 // 4
8 - 4 // 4
// операции над строками"abc" + "xyz" // "abcxyz" - конкатенация строк
// возможность описывать функции с явным указанием типовfn sum(a: Number, b: Number) -> Number:
return a + b

// встроенные функции в язык
str(100) // "100", преобразование чисел в строки
print("Hello") // вывод строк
print(str(100)) // вывод числа приведенного к строке

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

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

Сигнатура функции

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

Так, для функции sum:

fn sum(a: Number, b: Number) -> Number: 
return a + b

сигнатура функции в SPL будет следующей:

sum(a: Number, b: Number) -> Number

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

function sum(a: number, b: number) : number;

Если бы функция была написана на языках похожих на Haskell или Elm, то мы бы получили следующую сигнатуру функции:

sum: Int -> Int -> Int

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

Параметрический полиморфизм

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

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

Для демонстрации параметрического полиморфизма позаимствуем довольно простую функцию тождества, которая принимает аргумент и возвращает его — все просто. Однако функция должна работать со всеми типами.

На нашем языке SPL она будет выглядеть так:

fn identity(x: T) -> T: 
return x

Где выражение будет объявлением переменной типа. Выражение x: T показывает какого типа функция принимает аргументы, здесь мы используем переменную типа Т, выражение после стрелки показывает какого типа возвращается результат из функции.

Cигнатура функции identity в SPL:

identity: (x: T) -> T

Добавлю, что параметрически полиморфные функции еще также называются обобщенными (Generic), программирование вычислений с помощью таких функций называют обобщенным программированием (generic programming).

Лабораторный стол

Подготовим лабораторный стол с несколькими статически типизированными языками TypeScript, Elm, C++ и парой динамических языков JavaScript, Python, далее попытаемся отыскать в каждом из языков тот или иной вид полиморфизма.

Чтобы определить, есть ли в конкретном ЯП тот или иной вид полиморфизма, в идеале необходимо ознакомится с его системой типов. В некоторых из систем типов полиморфизм формализован.

Однако я буду использовать очень наивный подход: добавив поддержку нового вида полиморфизма в SPL и написав функцию, которая этот полиморфизм поддерживает, мы попробуем реализовать такую же функцию в другом ЯП. Если реализация такой функции невозможна, тогда покажем как конкретный вид полиморфизм может реализовываться по-другому, если он поддерживается в ЯП.

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

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

TypeScript

В нём есть поддержка дженериков, поэтому мы можем описать функцию identity, которая будет работать со всеми типами. Для этого воспользуемся переменными типов:

function identity (arg: T): T return arg;
>
console.log(identity("Hello !")); // "Hello !"
console.log(identity(42)); // 42

Elm

Язык Elm поддерживает ограниченный набор концепций из мира функционального программирования. В нем реализация функции identity будет выглядеть так:

module Main exposing (..)import Html exposing (text)identity : a -> a
identity a = a
strings =
[ identity "Hello! "
, String.fromInt (identity 42) ]
main =
text (String.concat strings)

Где a это переменная типа, а сигнатура функции identity в Elm:

identity : a -> a

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

Elm поставляет стандартную библиотеку, в которой есть набор параметрически полиморфных функций, в том числе и identity.

К слову, в Haskell реализация функции identity аналогична:

identity :: a -> a
identity a = a
main :: IO ()
main = do
print $ identity "hello"
print $ identity [42]
print $ identity 42

C++

C++ поддерживает обобщённое программирование благодаря шаблонам:

#include 
#include template T identity(T x) return x;
>
int main() std::cout std::cout std::cout >

JavaScript (и Python)

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

Функцию тождества можно реализовать в JavaScript так:

function identity(arg) return arg;
>

Благодаря динамической типизации все переменные в JavaScript/Python изначально полиморфны, или, другими словами, могут содержать в себе значения любых типов. Нам не нужно описывать типы аргументов функции или указывать, какой тип имеет переменная.

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

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

Параметрический полиморфизмсложная тема

Формально полиморфизм типов изучается в полиморфно типизированном лямбда-исчислении, называемом Системой F, к которой можно подходить только после изучения обычного лямбда-исчисления.

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

Стоило только приоткрыть ящик параметрического полиморфизма, как оттуда повалились: пренексный полиморфизм, let-полиморфизм, предикативный полиморфизм, импредикативный полиморфизм. Стало страшно, закрыл!

В чем польза разработчику?

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

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

Посмотрите, на эту сигнатуру функции из Elm:

(a -> Bool) -> List a -> List a

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

Перегрузка

Перегрузка — это разновидность специального полиморфизма. Такой вид полиморфизма позволяет объявлять функции с одним и тем же именем, но с разными типами аргументов и их количеством (арностью). Говоря другими словами, у функции может быть несколько сигнатур.

Или вот цитата, но несколько строже, из книги Бенджамина Пирса:

“Самый обычный пример специализированного полиморфизма — перегрузка (overloading), когда один и тот же символ функции соответствует различным реализациям; компилятор (или система времени выполнения, в зависимости от того, идет ли речь о статическом (static) или динамическом (dynamic) разрешении перегрузки) выбирает подходящую реализацию для каждого случая применения функции, исходя из типов ее аргументов.”

Добавим возможность перегрузки функций в SPL. Напишем функцию умножения, которая сможет умножать не только числа, но и строки. Умножение строк будет заключаться в повторении переданной строки указанное количество раз:

fn multiply(value: String, multiplier: Number) -> String: 
result = ""
for i to multiplier:
result += value
return result
fn multiply(value: Number, multiplier: Number) -> Number:
return value * multiplier
multiply("Hello! ", 3) // "Hello! Hello! Hello! "
multiply(42, 3) // 126

Откуда функция multiply имеет две сигнатуры:

multiply: (value: String, multiplier: Number) -> String
multiply: (value: Number, multiplier: Number) -> Number

Во время вызова функции multiply компилятор сам определит необходимую версию функции (диспетчеризация) на основе типов аргументов.

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

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

Лабораторный стол

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

TypeScript

Если попытаться описать перегрузку в TypeScript таким же образом, как для SPL, то ни к чему хорошему это не приведет:

function multiply(value: string, multiplier: number) : string return value.repeat(multiplier);
>
function multiply(value: number, multiplier: number) : number return value * multiplier;
>

После попытки скомпилировать код получим ошибку:

Duplicate function implementation.

Перегрузка в TypeScript допустима, но реализуется по-другому:

type Value = string | numberfunction multiply(value: string, multiplier: number) : string;
function multiply(value: number, multiplier: number) : number;
function multiply(value: Value, multiplier: number) : Value if (typeof value == "string") return value.repeat(multiplier)
>
return value * multiplier;
>

Функция multiply должна быть полностью совместима со всеми перегруженными сигнатурами, именно поэтому выше мы добавили union тип Value для аргумента функции valuе и возвращаемого значения.

Elm

При попытке скомпилировать текущий код:

module Main exposing (..)import Html exposing (text)multiply : String -> Int -> String
multiply x y = String.repeat x y
multiply : Int -> Int -> Int
multiply x y = x * y
main =
text (String.fromInt (multiply 5 5))

Мы получим следующую ошибку:

У каждой описываемой функции может быть только одна сигнатура. Если попытаться объявить функцию с таким же именем произойдет конфликт имен. Elm не поддерживает перегрузку.

C++

В C++ существует возможность перегружать функции:

#include 
#include
#include
#include std::string multiply(const std::string& value, size_t times ) std::string result = "";
result.reserve(value.length() * times);
for (size_t i = 0; i < times; ++i)result += value;
>
return result;
>
int multiply(int value, int multiplier) return value * multiplier;
>
int main() std::cout std::cout >

Также в C++ можно перегружать операторы, методы классов, и порой кажется, что в нем можно делать вообще все.

Python

По счастливой случайности в Python оператор умножения уже перегружен, то есть он может умножать числа и строки, причем так, как мы описали это выше. Поэтому писать вторую функцию multiply конкретно в данном случае не придется:

def multiply(a, b): 
return a * b
multiply("a", 5) # "aaaa"
multiply(5, 5) # 25

В Python существует возможность перегружать операторы классов. Например, вот так мы легко мы можем описать класс “капля воды”, где одна капля может присоединяться к другой и существует возможность сравнивать капли:

class WaterDrop: 
def __init__(self, size):
self.size = size

def __add__(self, other): # перегрузка оператора суммы
self.size += other.size
return self
def __str__(self):
return f'Size is '
def __eq__(self, other): # перегрузка оператора сравнения
return self.size == other.size
one_small_drop = WaterDrop(3)
other_small_drop = WaterDrop(3)
big_drop = WaterDrop(10)
print(big_drop + one_small_drop + other_small_drop) # Size is 16
print(one_small_drop == other_small_drop) # True

Но вернемся к нашей “лакмусовой бумажке”. Если написать несколько функций multiply, то каждая следующая перезапишет ранее объявленную функцию, и даже type hints тут нам не помогут:

def multiply(value: int, multiplier: int) -> int: 
return value * multiplier
def multiply(value: str, multiplier: int) -> str:
result = ""
for _ in range(multiplier):
result += value
return result
print(multiply("a", 5)) # "aaaaa"
print(multiply(5, 5)) # ?

Более того, при выполнении функции multiply(5, 5) возникнет ошибка:

JavaScript

JavaScript также не поддерживает перегрузку функций в том виде, как это было определено выше в SPL:

function multiply(value, multiplier) return value.repeat(multiplier);
>
function multiply(value, multiplier) return value * multiplier
>

При попытке повторно объявить функцию с таким же именем через Function Declaration, мы перезапишем ранее объявленную функцию.

Ручная диспетчеризация

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

function multiply(value, multiplier) if (typeof multiplier !== "number") throw new TypeError('Bad type of multiplier') 
>
if (typeof value === "number") return value * multiplier
>
if (typeof value === "string") return value.repeat(multiplier)
>
throw new TypeError('Bad type of value')
>

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

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

В чем польза от перегрузки для разработчика?

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

Расширим наш язык SPL:

true, false // тип данных Boolean[1, 2, 3] // списочный тип данных List 
['a', 'b', 'c']
['a', 'b', 'c']
if true: // выражение условия
print('condition') // тело условия
len([1, 2, 3]) // получение значения длины списка
toCode("a") // получение значение ASCII-кода символа

Теперь напишем алгоритм сортировки списка пузырьком на SPL:

fn isGreater(a: Char, b: Char) -> Boolean: 
return toCode(a) > toCode(b)
fn isGreater(a: Number, b: Number) -> Boolean:
return a > b
fn isGreater(a: Boolean, b: Boolean) -> Boolean:
return a
fn swap(list: List, i: number, j: Number) -> List:
tmp = list[j]
list[j] = list[i]
list[i] = tmp
return list
fn bubbleSort(list: List) -> List:
n = len(list)
for i = 0 to n-1:
for j = 0 to n-i-2:
if isGreater(list[j], list[j+1]):
list = swap(list, j, j + 1)
return list
bubbleSort([4, 3, 2]) // [2, 3, 4]
bubbleSort([true, false, false, true]) // [false, false, true]
bubbleSort(["c", "b", "a"]) // ["a", "b", "c"]

В реализации сортировки списка пузырьком выше, за счет параметрического полиморфизма мы описали функции swap и bubbleSort, а благодаря перегрузке функций реализовали функцию сравнения isGreater для типов Char, Number, Boolean.

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

Подтипирование

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

Существует два подхода к классификации того, на основе чего может быть реализована системы подтипизации:

  • реализация на включениях (inclusive), в которой любое значение типа A представляет такое же значение, но типа B, если А — подтип B. Например, в ОО языках подтипирование основано, как правило, на включениях (inclusive).
  • реализация на приведении (coercive), где любое значение типа A может автоматически быть сконвертировано в значение типа B. Отношения подтипов, которые связывают целые числа и числа с плавающей точкой, обычно основаны на приведениях (coercive).

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

Полиморфизм включений

Полиморфизм включений — это универсальный вид полиморфизма (согласно статье Лука Карделли и Питера Вегнера). В этом виде полиморфизма функции или операторы могут содержать один или множество аргументов, типы которых имеют подтипы.

В самом общем смысле, тип — это множество значений, поэтому классы в ООП можно рассматривать как тип, потому что они порождают множество значений.

Расширим наш язык SPL и превратим его в ОО язык, добавив поддержку классов, а именно возможность наследовать классы, и возможность переопределять методы.

class Animal: // объявление класса 
fn say(): // объявление метода say
print('..')
class Dog extends Animal: // расширение класса (наследование)
fn say():
print('wauf ')
class Cat extends Animal:
fn say():
print('meow ')
animal = Animal() // создание экземпляров класса
cat = Cat()
dog = Dog()
animals: List = [animal, cat, dog]

Это классический пример наследования, где мы создаем класс Animal, затем расширяем его классами Dog и Cat, в которых переопределяем метод say.

Напишем функцию, которая заставит список животных говорить:

fn sayAnimals(animals: List): 
print('animals say: ')
for animal in animals:
animal.say()
sayAnimals([ Dog(), Dog() ]) // animals say: wauf wauf
sayAnimals([ Cat(), Dog() ]) // animals say: meow wauf
sayAnimals([ Cat(), Cat() ]) // animals say: meow meow

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

Как правило все ОО языки, основанные на классах, например Java, C#, TypeScript, и прочие поддерживают данный тип полиморфизма.

Полиморфизм включений в объектно-ориентированных языках отражает принцип подстановки Барбары Лисков (вспомните принципы SOLID), о чем она подробно написала в 1987 году в своей статье Data Abstraction and Hierarchy. Или наоборот: принцип гарантирует полиморфизм включений.

Принцип Барбары Лисков звучит так:

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

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

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

Кстати, когда описывают полиморфизм в ООП, то говорят, что полиморфизм — это комбинация возможностей наследования классов и переопределения метода в дочернем классе.

Если бы вымышленный язык SPL поддерживал интерфейсы, то мы могли бы рассматривать не только классы как типы, но и интерфейсы как типы. Этот момент хочется прокомментировать цитатой из учебника Теория и практика языков программирования:

Очень интересной особенностью интерфейсов является то, что они обеспечивают некоторую разновидность полиморфизма. Суть в том, что интерфейсы могут трактоваться как типы. К примеру, некоторый формальный параметр метода определяется как интерфейс. Такому формальному параметру соответствует фактический параметр любого класса, который реализует интерфейс. Следовательно, метод становится полиморфным.

C++

Позволяется писать программы в ООП-стиле:

#include 
#include
#include
#include class Animal public:

// polymorhic objects should have virtual destructor to
// prevent UB on deletion through pointers to base-class

virtual ~Animal() = default;
virtual void say() std::cout >
>;
class Cat : public Animal public:

// 'override' is not required. It enforces compile-time check
void say() override std::cout >
>;
class Dog : public Animal public:
void say() override std::cout >
>;
using AnimalPtr = std::unique_ptr;void SayAnimals(const std::vector& animals) for (auto& animal : animals) animal->say();
std::cout >
>
int main() const auto animals = [] <
// vector of unique_ptrs can't be initialized
// from initializer_list
// due to unique_ptr has not copy-constructor

std::vector animals;
animals.emplace_back(std::make_unique());
animals.emplace_back(std::make_unique());
animals.emplace_back(std::make_unique());

return animals;
>();

SayAnimals(animals);
>
  • https://godbolt.org/z/JnpG8U
  • https://godbolt.org/z/R7U6W-

Elm

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

Полиморфизм или нет: TypeScript vs TypeScript

В отличие от общеизвестных ОО языков типа C++ или Java понятия подтипизации и наследования в других языках могут быть несвязанными концепциями. Это можно увидеть в OCaml или, например, в TypeScript:

class A prop: number 
constructor(prop: number) this.prop = prop
>
>
class AA extends A <>class B prop: number
constructor(prop: number) this.prop = prop
>
>
class BB extends B <>function output(objects: A[]) objects.forEach((object: A) => console.log(obj.prop)
>)
>
// 1const a:A = new A(1)
const b:B = new B(2)
output([a, a, a, a])
output([a, a, b, b])
// 2const aa:AA = new AA(1)
const bb:BB = new BB(2)
output([aa, aa, aa, aa])
output([aa, aa, bb, bb])

По определению функция output является полиморфной, поскольку способна обрабатывать значения типа A и всего подтипы, но будет ли этот полиморфизм проявляться в первом и во втором блоке кода? То есть будет ли функция output работать с подтипами типа A в каждом блоке кода?

Ответ на этот вопрос лежит в понятии структурной типизации, которая поддерживается в TypeScript и OCaml. При структурной типизации два типа данных считаются эквивалентными, если они имеют идентичную структуру, т.е. для каждого метода или атрибута одного типа существуют идентичные методы и атрибуты в другом типе, и наоборот.

Отсюда типы A и B считаются эквивалентными типами, поэтому в первом блоке кода функция output работает с одним и тем же типом. То есть никакой речи о подтипах в этом случае не идет.

Во втором блоке кода значения aa и bb являются подтипами типа A и B и используются в качестве аргументов для функции output. В этом случае, учитывая что тип B эквивалентен типу A, функция обрабатывает массив значений подтипов типа A.

Полиморфизм или нет: TypeScript vs JavaScript

Давайте перепишем наш пример с наследованием на TypeScript и JavaScript и сравним реализации.

TypeScript

Код ниже демонстрирует полиморфизм включений. Функция sayAnimals может работать с типом Animal и всеми его подтипами, где в аргументах функции sayAnimals это описывается явным образом.


class Animal say() console.log('..')
>
>
class Cat extends Animal say() console.log('meow')
>
>
class Dog extends Animal say() console.log('wouf')
>
>
const animals = [new Cat(), new Dog(), new Animal()]function sayAnimals(animals: Animal[]) animals.forEach(animal => animal.say())
>
sayAnimals(animals)

JavaScript

Если написать подобный пример кода на JavaScript, который отличается только объявлением типа animals в функции sayAnimal:

class Animal say() console.log('..') 
>
>
class Cat extends Animal say() console.log('meow')
>
>
class Dog extends Animal say() console.log('wouf')
>
>
const animals = [new Cat(), new Dog(), new Animal()]function sayAnimals(animals) animals.forEach(animal => animal.say())
>
sayAnimals(animals)

или даже если убрать расширение класса Animal:

class Animal say() console.log('..') 
>
>
class Cat say() console.log('meow')
>
>
class Dog say() console.log('wouf')
>
>
const animals = [new Cat(), new Dog(), new Animal()]function sayAnimals(animals) animals.forEach(animal => animal.say())
>
sayAnimals(animals)

то функция sayAnimals успешно обработает все входящие аргументы, не только предполагаемый нами «тип» Animal и всего его подтипы, но и вообще все значения любых типов в силу динамической природы языка.

Функция ожидает, что любой объект, который будет передан в качестве аргумента «умеет говорить», то есть имеет метод say, это, кстати, называется утиной типизацией. Утиная типизация не является полиморфизмом.

Почему утиная типизация не является полиморфизмом? Утиная типизация присуща динамически типизированным языкам. Это неявная типизация, в которой типы не определяются на уровне синтаксиса. С точки зрения утиной типизации, если два объекта имеют одинаковое поведение, то они относятся к одному типу, например:

const obj1 = < 
myMethod: () => console.log('hello'),
propA: 'value'
>
const obj2 = myMethod: () => console.log('hellohello')
propB: 'value'
>
const callMethod = obj => obj.myMethod()callMethod(obj1)
callMethod(obj2)

В коде выше для функции callMethod объекты obj1 и obj2 — это объекты относящиеся к одному типу, поскольку обладают похожим поведением, имеют метод myMethod. Когда мы говорим, что функция callMethod полиморфна, то имеем в виду, что функция способна обрабатывать значения разных типов, но в текущей ситуации тип всего один.

Вернемся к JavaScript.

Чтобы реализация функции sayAnimals на JavaScript начала попадать под определение полиморфизма включений, функция должна обрабатывать тип Animal и всего его подтипы, ни больше, ни меньше.

Теперь мы можем заявить, что функция sayAnimal является полиморфной с точки зрения полиморфизма включений:

function sayAnimals(animals) animals.forEach(animal => if (animal instanceof Animal) animal.say() 
>
>)
>

Как и в случае со специальным полиморфизмом в JavaScript, здесь мы занимаемся ручной проверкой типов, а именно: проверяем то, что элемент списка есть подтип или тип Animal.

Встроенные полиморфные функции и переменные

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

Другими словами, в языке могут присутствовать полиморфные функции или переменные.

В стандартной библиотеке Elm есть много разных полиморфных функций, например:

Или, например, переменная this в TypeScript является полиморфной внутри методов классов, поскольку this может ссылаться не только на текущий экземпляр класса, но и на экземпляр подкласса.

Приведение типов

Приведение типов — это специальный вид полиморфизма. Возьмем два динамически типизированных языка: JavaScript и Python. Попробуем сложить строку и число (значения разных типов).

Сначала попробуем это сделать в Python:

>>> "Answer is " + 42
Traceback (most recent call last):
File "", line 1, in
TypeError: can only concatenate str (not "int") to str

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

>>> "Answer is " + str(42)
'Answer is 42'

Операции над значениями различных типов в JavaScript происходят по принципу «Спасибо! Дальше я сам»:

> "answer is " + 42
'answer is 42'

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

Из примеров выше логично сделать вывод, что приведение типов может быть явным (casting) и неявным (coercion). К слову, в JavaScript можно приводить и явным способом значения к указанному типу:

> "answer is " + String(42)
'answer is 42'

Неявное приведение типов является подтипом специального полиморфизма.

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

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

Но могут быть исключения. Например, ReasonML или OCaml требуют явного привидения чисел к типу Float, а складывать значения типа Float необходимо, используя специальные арифметических операторы с точкой в конце.

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

Когда язык поддерживает неявное приведение типов аргументов при вызове функции, тогда функция, которая занимается приведением, считается полиморфной.

Почему неявное приведение типов это полиморфизм?

Добавим в наш вымышленный язык SPL неявное приведение типов. Для этого разделим тип Number (все числа) на два: тип Int (целочисленные значения) и тип Float (целочисленные значения + числа с плавающей точкой):

-2.0 -1.0, 0.0, 1.0, 2.0 // Float-2 -1, 0, 1, 2 // Int 

Добавим правило. Если в операциях над числами одновременно встречаются и Int, и Float, или если функция ожидает аргумент типа Float, а передается значение типа Int, то пусть значение типа Int будет неявно приведено к типу Float, то есть:

fn sum(a: Float, b: Float) -> Float: 
return a + b
sum(1, 2) // 3.0
sum(1.0, 2) // 3.0
sum(1, 2.0) // 3.0
sum(1.0, 2.0) // 3.0

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

Между неявным приведением и перегрузкой могут возникать неочевидные или даже конфликтные ситуации, например:

abs(5.5) == 5; // abs в C принимает intstd::abs(5.5) == 5.5; // abs в C++ abs нормально перегружен

Лабораторный стол

Давайте в качестве лакмусовой бумаги возьмём выражение “1.0 + 2”. Такой подход, как мы выяснили ранее, иногда даёт сбои, но иногда срабатывает. Пусть в выражении значение 2 имеет один тип, а значение 1.0 имеет тип шире. Результирующие значение пусть имеет такой же тип как и 1.0, то есть 2 будет неявно приведено к тому же типу, что и 1.0.

Elm

Результаты в elm repl:

C++

#include 
#include int main() float float_value = 1;
int int_value = 2;
// int_value приведется к типу float
std::cout >

Python

Результат в repl

TypeScript/JavaScript

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

Однако в этих языках существует неявное приведение типов:

console.log(argument) // argument приведется к типу String
if (expression) < /* */ >// expression приведется к типу Boolean
[1, 2, 3] + "abc" // массив приведется к типу String

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

Стоит дополнить, что в JavaScript существует возможность управлять тем, как именно будет приведен указанный объект к примитивному типу с помощью Symbol.toPrimitive.

Расширение и сужение типов

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

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

Расширение типов при неявном приведении может происходить внутри математических выражений, в таком случае типы расширяются в сторону «наибольшего»:

#include 
#include int main() double double_value = 1;
float float_value = 1;
long long_value = 1;
int int_value = 1;
// приведение к типу double
std::cout std::cout std::cout // приведение к типу float
std::cout std::cout // приведение к типу long
std::cout >

А что насчет сужения?

#include 
#include int main() double double_value = 1;
float float_value = 1 + double_value;
long long_value = 1 + float_value;
int int_value = 1 + long_value;
std::cout std::cout std::cout >

Лабораторные результаты и общие выводы

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

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

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

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

Язык может предлагать свои механизмы для реализации полиморфизма, например, шаблоны в C++ или дженерики в TypeScript. Или полиморфизм может быть реализован средствами языка, вспомните, как мы делали ручную диспетчеризацию в JavaScript в разделе про полиморфизм перегрузки.

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

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

Используемый материал

  • Luca Cardelli and Peter Wegner. On Understanding Types, Data Abstraction, and Polymorphism.
  • Oscar Nierstrasz. Лекция Types and Polymorphism в курсе Programming Languages
  • https://www.cs.princeton.edu/courses/archive/fall04/cos441/lectures/lect27.pdf
  • Сергей Орлов. Теория и практика языков программирования: Учебник для вузов.
  • Очаровательный Python. Множественная диспетчеризация. Девид (David) Мертц (Mertz), 2007,
  • https://westmont.edu/~iba/teaching/CS105/CS105-S04/lecturenotes/Ch08.pdf

Также в процессе написания статьи неожиданно выяснилось, что существует отличная статья в русской википедии про параметрический полиморфизм и полиморфизм в целом.

Дополнительно рекомендуемые материалы

  • Роман Душкин, Полиморфизм в языке Haskell
  • Sam Galson, Program like Proteus — a beginner’s guide to polymorphism in JavaScript
  • Вячеслав Шебанов — Системы типов в двух словах (доклад на HolyJS)
  • Неформальное введение в теорию типов, Максим Кольцов / PiterPy Meetup #21

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

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