3.5. Полиморфизм
§ 42. Определение. В предыдущих разделах, обсуждая наследование, абстрактные и виртуальные методы и интерфейсы, мы уже неоднократно сталкивались с полиморфизмом, не называя его явно. Вспомним следующий пример.
Shape[] shapes = new Shape[];
shape[0] = new Triangle();
shape[1] = new Circle();
shape[2] = new Polygon();
for (int i = 0; i < shapes.Length; i++)
{
Shape shape = shapes[i];
shape.Scale(p, k);
}
Переменная shape типа Shape в первой итерации (i == 0) обозначает объект типа Triangle, во второй итерации (i == 1) объект типа Circle, в третьей (i == 2) – Polygon. Мы подробно рассматривали эту ситуацию в предыдущих главах. В общем случае метод Scale может быть: 1) не виртуальным методом базового класса Shape; 2) виртуальным или абстрактным методом, переопределенным в производном классе; 3) методом интерфейса Shape (если Shape – интерфейс), реализуемом в соответствующем классе, реализующем этот интерфейс (Triangle, Circle или Polygon). Обратим внимание, что конкретная реализация метода, которую нужно вызывать во втором и третьем случае, определяется на этапе выполнения программы и не может быть определена на этапе компиляции. Эта возможность объектно-ориентированных языков программирования – записи кода с использованием переменных базовых типов (классов или интерфейсов), откладывая до момента выполнения кода определение того, какую именно реализацию метода нужно выполнить, и называется полиморфизмом. Прежде всего полиморфизм позволяет существенно повысить уровень абстракции: мы оперируем ровно с тем минимально детализированным представлением объекта (методом Shape.Scale), которое нам нужно в данный момент, не отвлекаясь на особенности производных классов.
Полиморфизм (polymorphism) – возможность одной и той же переменной в различные моменты выполнения программы обозначать объекты различных типов (классов), относящихся к одному базовому типу (классу или интерфейсу).
Сам термин «полиморфизм» составлен из греческих слов πολύς, много, и μορφή, форма, и позаимствован из естественных наук.
По большому счету, полиморфизм – основной выигрыш от использования иерархических типов данных. Практическая значимость полиморфизма уже подробно анализировалась нами в предыдущих главах при рассмотрении механизмов наследования, приведения переменных производных классов к базовым, виртуальных и абстрактных методов, интерфейсов. Главное при этом – возможность писать более лаконичный и обобщенный код, опираясь на базовые типы, и, тем самым, повышать уровень абстракции и снижать сложность программы. Читатель может самостоятельно вернуться к материалу глав 3.1 – 3.4 и показать, где именно шла речь о полиморфизме и какие именно мы получали практические преимущества от его использования.
Остановимся особо на вопросе о том, что на этапе компиляции неизвестно, к какому именно конкретному типу будет относиться объект и, соответственно, какие именно реализации вызываемых методов следует использовать. Компилятор не может связать вызов метода с реализацией этого метода. Термин «связывание» здесь обозначает сопоставление имени метода в коде (в примере: shape.Scale) с конкретной реализацией этого метода (адресом кода метода в памяти). Если связывание выполняется на этапе компиляции, то говорят о статическом связывании, если на этапе выполнения – о динамическом связывании. Таким образом, полиморфизм возможен только при динамическом связывании54.
В заключение отметим, что в более широком значении, вне контекста ООП, выделяют три типа полиморфизма. Первый тип, который мы рассматриваем в настоящем параграфе, называют полиморфизмом подтипов, или «подтипизацией» (subtyping). Другой тип полиморфизма – параметрический полиморфизм, используется в рамках обобщённого программирования. Эта тема будет обзорно рассмотрена в следующей главе. И к третьему типу – ad hoc полиморфизму – относят перегрузку методов, исходя из логики, что одно и то же имя метода в зависимости от параметров может обозначать разные реализации.
§ 43. Принципы качественного проектирования иерархических типов. Объектно-ориентированный язык, как и любой другой язык программирования – инструмент в руках программиста, который можно применять лучше или хуже. В главе 2.6 мы говорили, что не всякое разбиение программы на части, в частности, на классы, будет удачным. Можно сказать (мы уже формулировали ранее эту идею), что основная цель, для достижения которой формулируются различные принципы (правила) качественного проектирования (design principles) заключается в том, чтобы любой фрагмент кода зависел от другого кода тогда и только тогда, когда это абсолютно необходимо (синтаксически и семантически) для решаемой задачи. Мы рассматривали несколько ключевых правил без учета специфики наследования классов. Рассмотрим теперь два важных правила, относящихся к наследованию.
Начнем с классической задачи, называемой проблемой квадрата-прямоугольника55. Положим, у нас есть классы квадрата Square и прямоугольника Rectangle. Логично считать, что квадрат – это разновидность прямоугольника, ведь именно так оно и есть с точки зрения геометрии. Соответственно, класс Square представляется логичным сделать производным от класса Rectangle. Однако рассмотрим следующую реализацию:
class Rectangle
{
public void SetWidth (float width) { /* ... */ }
public void SetHeight (float height) { /* ... */ }
}
class Square : Rectangle { }
// Использование:
Rectangle[] rect = new Rectangle[10];
/* ... */
// Растягиваем все прямоугольники по ширине и сжимаем по высоте.
float k = 2;
for (int i = 0; i < rect.Length; i++)
{
rect[i].SetWidth(rect[i].GetWidth() * k);
rect[i].SetHeght(rect[i].GetHeight() / k)
}
Этот код демонстрирует серьезную проблему: методы базового класса Rectangle оказываются неподходящим для производного Square. Дело в том, что квадрат, будучи разновидностью прямоугольника с точки зрения математики, не является разновидностью прямоугольника с точки зрения объектной модели в объектно-ориентированном программировании. Полиморфизм, позволяя работать с объектами базовых типов, не зная реального типа объекта, предполагает, что любой метод базового типа имеет одну и ту же семантику для любого из производных классов. В рассмотренном примере класс Square меняет семантику методов SetWidth и SetHeight. Например, реализуя их так, что вызов любого из них ведет к обновлению и ширины, и высоты. Это изменение приводит к невозможности безопасно использовать переменные базового класса без оглядки на то, к какому именно реальному типу относится объект. Таким образом, сформулируем следующий принцип (правило) объектно-ориентированного проектирования:
Производные классы не должны сужать возможности базовых классов или менять семантику состояния и поведения базовых классов; или, другая формулировка: в любой ситуации, где используется переменная базового типа, должно быть возможно безопасно, то есть без каких-либо изменений в работе программы, заменить ее или присвоить ей экземпляр любого производного класса.
Этот принцип называется принципом подстановки Лисков (Liskov substitution), по фамилии известного американского специалиста Барбары Лисков, которая сформулировала его в 1987 г56.
Приведенное решение с квадратом и прямоугольником нарушает этот принцип, так как код с переменной rectangle становится некорректным, если эта переменная имеет реальный тип Square, из-за того что этот тип сужает поведение и меняет семантику состояния и поведения базового класса.
На правах профессионального фольклора приведем еще один пример, демонстрирующий нарушение принципа подстановки. Положим, у нас в программе есть класс птиц Bird, у которого есть метод Fly (float height) (не важно какой: конкретный или абстрактный или виртуальный). От этого класса наследуются классы разных птиц: чиж Siskin, стриж Swift и другие. А теперь мы решили создать класс утка Duck. Утка – птица, но она не умеет летать. При вызове метода Fly программа поведет себя некорректно. Читатель, конечно, может справедливо заметить, что утка всё-таки умеет летать, а некоторые виды уток летают очень хорошо, высоко и далеко. Поэтому в некоторых изложениях утку заменяют пингвином. Или, другой вариант: есть базовый класс утка Duck и производный класс механическая утка на батарейках ElectroDuck. При этом утка может полететь всегда, а на батарейках – только если батарейки заряжены. Во всех вариантах производный класс сужает поведение базового класса, нарушая принцип подстановки.
Другой принцип проектирования, также имеющий непосредственное отношение к наследованию и полиморфизму, мы уже рассматривали в предыдущем разделе – принцип инверсии зависимости (dependency inversion).
Необходимо всегда использовать наиболее абстрактный (базовый) класс, а не наиболее конкретный.
Мы руководствовались этим правилом на протяжении всех предыдущих параграфов, например, когда рассматривали задачу с модульным тестированием. Читатель уже сам может объяснить это правило – его преимущества мы показывали, обсуждая соответствующие примеры. Прежде всего, это снижение взаимозависимостей частей программы (так как наш код зависит от меньшей части другого кода, чем если бы мы использовали производные классы) и повышение абстракции (так как мы можем принимать во внимание меньше частностей). Так, руководствуясь этим правилом, при объявлении параметров методов следует всегда задаваться вопросом – действительно ли нам нужен этот класс или мы можем обойтись его базовым классом или реализуемым им интерфейсом?
Нарушение правил проектирования существенно обесценивает преимущества ООП. Сегодня сформулировано множество таких правил, более общих, более частных, некоторые из них так или иначе пересекаются. Здесь мы ограничимся указанными двумя принципами, связанными с вопросом иерархических типов данных. Для дальнейшего изучения порекомендуем классическую книгу Р. Мартина «Чистая архитектура» [Мартин 11].
Вопросы и задания
Что такое полиморфизм?
Что такое «динамическое связывание»? Что с чем связывается? Почему при использовании полиморфизма мы не можем применять статическое связывание?
Вернитесь к главам 3.2, 3.3 и 3.4 и укажите, где именно в них шла речь о полиморфизме и какие именно практические преимущества мы получаем в каждом из случаев.
Можно ли написать программу, используя наследование, механизмы виртуальных и абстрактных методов, но не применяя полиморфизм?
Полиморфизм перегрузки методов (ad hoc) – это полиморфизм динамический (с динамическим связыванием) или статический (со статическим связыванием)?
Охарактеризуйте «проблему квадрата-прямоугольника». Сформулируйте и охарактеризуйте принцип подстановки Лисков. Приведите примеры.
* Сформулируйте и охарактеризуйте принцип инверсии зависимости. Приведите примеры.
Примечания:
54. Не следует путать статическое/динамическое связывание и статическую/динамическую типизацию. В случае статической типизации на этапе компиляции известен тип каждой переменной и, соответственно, перечень методов, которые поддерживаются этим типом. Однако конкретная реализация метода, которая должна быть вызвана, может быть и неизвестна. То есть статическая система типов (типизация) может поддерживать как статическое, так и динамическое связывание.
55. Или ромба-прямоугольника, или круга-эллипса. Формулировки задач идентичны, только используются соответственно другие фигуры.
56. Строго говоря, исходно правило имеет более строгую формулировку [https://dl.acm.org/citation.cfm?id=62141]: What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.