Фундаментальные принципы объектно ориентированного проектирования (Часть 3): Полиморфизм

Вольный перевод статьи Fundamental Object Oriented Design principles (Part 3): Polymorphismopen in new window.

Рассмотрим Полиморфизм предоставляемый объектно ориентированными языками программирования.

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

Рассматриваемые принципы проектирования применены для не объектно ориентированных языков, но с большими сложностями.

Что такое Полиморфизм?

Когда мы ссылаемся на объект мы хотим чтобы его поведение определялось типом объекта, а не типом ссылки которую мы используем. Обращение может идти даже через абстрактный тип (например, class abstract в Delphi), всё равно должны вызываться методы типа конкретного экземпляра объекта на который указывает ссылка. Это суть полиморфизма. Как вы видели в разделе про абстракциюopen in new window мы можем взаимодействовать с объектами через переменную с типом класса-предка, хотя объект, на который ссылается переменная, на самом деле является экземпляром класса-потомка.

Наиболее общее понимание полиморфизма в том что метод может быть виртуальным. Это означает что класс-потомок может переопределить метод и заменить реализацию предка на свою собственную.

Переопределение виртуального метода

Мы рассмотрели некоторые вещи когда рассматривали концепцию абстракцииopen in new window через виртуальный метод, но в том случае базовый метод был абстрактный и не имел реализации.

type
  TCurve = class
  public
     function GetStartPoint: TPoint; virtual; abstract;   // TPoint не объявлен в этом посте
  end;

  TArc = class(TCurve)
  public
    function GetStartPoint: TPoint; override;
  end;

  TLine = class(TCurve)
  public
    function GetStartPoint: TPoint; override;
  end;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

type
  TDog = class
  public
    function Bark: string; virtual;   // Woof!
  end;

  TPoodle = class(TDog)
  public
    // это метод подменяет однодоменный метод в TDog
    function Bark: string; override;   // Yap!
  end;

  TToyPoodle = class(TPoodle)
  public
    // это метод подменяет однодоменный метод в TDog и в TPoodle
    function Bark: string; override; // Yip!
  end;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Если мы создали объекты классов TPoodle, TToyPoodle или TDog и ссылаемся на них через переменную типа TDog то мы можем вызвать метод Bark(), причём будет вызван метод именно того класса к которому относится реальный объект, мы даже можем не знать что именно это за класс.

// Если в переменной ADogList содержаться объекты классов TPoodle, TDog и TToyPoodle то функция вернёт "Yap! Woof! Yip!"

function MidnightChoir(ADogList: TList<TDog>) : string;
var
  LDog : TDog;
  LBarkString: string;
begin
  for LDog in ADogList do
    LBarkString := LBarkString + ' ' + LDog.Bark(); // Нам не требуется знать действительный тип объекта. Всегда будет вызван подходящий метод Bark
  result := copy(LBarkString, 2, Length(LBarkString)-1);     // удаляем ' '
end;
1
2
3
4
5
6
7
8
9
10
11

Перезагрузка функций и перегрузка операторов

Мы обычно не думаем о перегрузке (overload) как о полиморфизме, но она также позволяет добавить в класс больше функциональности оставляя интерфейс более сжатым. Часто этот тип полиморфизма применяется и без ООП. При вызове перегруженной функции объекта корректный метод выбирается на основе названия функции и списка параметров функции, которые уникальны для каждой перегруженного метода.

Ниже общий пример использования полиморфизма через перегрузку функций. Объект потока (TMyStreamReader1) содержит методы для чтения данных: ReadBoolean, ReadDouble, ReadInteger. При использовании объекта придётся в каждом случае проверять тип переменной и использовать соответствующую функцию. Гораздо проще вызвать метод Read и получить вызов корректной версии метода в зависимости от сигнатуры (TMyStreamReader2).

TMyStreamReader1 = class(TMyGenericStreamReader)
public
 function ReadBoolean(var Buffer: Boolean): Longint;
 function ReadInteger(var Buffer: Integer): Longint;
 function ReadSingle(var Buffer: Single): Longint;
 function ReadDouble(var Buffer: Double): Longint;
end;

TMyStreamReader2 = class(TMyGenericStreamReader)
public
 function ReadData(var Buffer: Boolean): Longint; overload;
 function ReadData(var Buffer: Integer): Longint; overload;
 function ReadData(var Buffer: Single): Longint; overload;
 function ReadData(var Buffer: Double): Longint; overload;
end;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

PointRecord = record
  x,y,z : double;

 class operator Multiply(P1: PointRecord; P2: PointRecord): Double; // скалярное произведение P1*P2
 class operator Multiply(P: PointRecord; d: double): PointRec; // d*P
 class operator Multiply(d: double; P: PointRecord): PointRec; // P*d
end;
1
2
3
4
5
6
7

Наследование

Мы уже видели что можем унаследовать одни класс от другого и переопределить виртуальные (virtual) методы родительского класса. Если не переопределять виртуальные методы, то они унаследуются из родительского класса. Например, если мы объявим публичный не виртуальный метод TDog.Pant(), то мы увидим его когда будем ссылаться на наш объект через более специфичный класс (класс-потомок).

type
  TDog = class
  public
    function Pant: string; 
    function Bark: string; virtual;   // Woof!
  end;

  THound = class(TDog) // наследуется из TDog, но не переопределяет Bark(). Если мы вызовем Bark, то мы получим Woof! объявленный в TDog 
  end;

  TPoodle = class(TDog)
  public
   // Этот метод заменит одноимённый в TDog 
   function Bark: string; override;   // Yap!
  end;

  TMaltesePoodle = class(TPoodle) // Наследуется из TPoodle, но не переопределяет Bark(). Если мы вызовем Bark, то получим Yap! объявленный в TPoodle
  end;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Субтипирование

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

type
  IComparable = interface(IInterface)
    function CompareTo(AObject: TObject): Integer;
  end;

  IEquatable = interface(IComparable)
    function EqualTo(AObject: TObject): Boolean;
  end;
1
2
3
4
5
6
7
8

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

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

type
  IMyABCList = Interface
    // Детали не важны. Предполагайте что интерфейс определяет индексирование, добавление и удаление элементов
  end;

  IMyEnumerableABCList = Interface(IMyABCList)
    function GetEnumerator: IEnumerator;
  end;
1
2
3
4
5
6
7
8

IMyEnumerableABCList действительно выглядит как подтип IMyABCList. Можно применить эту иерархию интерфейсов к классам независимо от их структуры наследования. Сигнатуры определения методов и свойств наследуются от супертипа к подтипу, но поведения реализованных методов не определено в подтипах (или вообще в интерфейсах).

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

Резюме

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

Последниее изменение: 24.08.2023, 06:42:55