Вольный перевод статьи Fundamental Object Oriented Design principles (Part 1): Abstraction
Рассмотрим принцип Абстракции используемый в объектно ориентированных языках программирования.
Это первая часть серии об объектно ориентированном проектировании. Для того чтобы понять почему изучение шаблонов проектирования важно, мы должны начать с рассмотрения того что такое хороший объектно ориентированный дизайн приложения.
ОО-языки предоставляют уникальные возможности, которые содействуют хорошему дизайну приложения. Рассматриваемые принципы проектирования могут быть применены и для не объектно ориентированных языков, но с большими сложностями.
Эта статья предполагает что вы знакомы как минимум с одним ОО языком программирования. C++, Delphi, C# и Java являются примерами объектно ориентированных языков. Настоящий ОО язык программирования имеет классы, объекты и очень часто интерфейсы.
Что такое Абстракция?
Абстракция, в общем смысле, означает оперирование кодом и структурой данных на высоком уровне, без необходимости понимания конкретной природы структуры данных. Ниже я опишу конкретные формы абстракции лежащие в основе ООП. Большинство из них должны быть знакомы вам, но я перечислю их в явной форме для того чтобы ссылаться на них из более сложных принципов проектирования в будущих постах.
Взаимодействие с объектами используя информацию описанную в классе
Объектно ориентированные языки программирования содержат концепцию классов. Класс — это определение, которое используется для создания объектов. Мы можем оперировать объектом через свойства и методы объявленные в классе, без понимания их действительной реализации. Также мы можем ожидать одинакового поведения от объектов одного и того же класса.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var LCircle: TCircle; LArea: double; begin LCircle := TCircle.Create; // Мы берём класс TCircle и создаём на основе него объект // Мы можем устанавливать свойства в то время как объект будет сам обрабатывать побочные эффекты LCircle.Radius := 3; // Мы можем вызывать высокоуровневые методы объекта LCircle.Translate(10, 15); LArea := LCircle.GetArea(); end; |
Пока никаких сюрпризов. Всё что нам нужно было определено в TCircle
и мы знаем что мы можем использовать свойства и методы класса который мы использовали для создания объекта.
Взаимодействие с объектами через информацию описанную в классе-предке
Объектно ориентированные языки программирования позволяют наследовать классы. При правильно ОО проектировании классы потомки к предкам находятся в отношении "является". Для примера мы можем наследовать классы TLine
и TArc
от класса TCurve
. В этом примере предполагаем, что TCurve определяет тип линии (LineType
).
1 2 3 4 5 6 7 |
procedure SetLinetype(ACurveList: TList<TCurve>; ALineType: Linetype_Enum); // Linetype_Enum не определено в этом посте var LCurve: TCurve; begin for LCurve in ACurveList do LCurve.LineType := ALineType; end; |
В этом примере мы не беспокоимся о том содержит ли список линии (TLine
) или дуги (TArc
) или другие неизвестные типы. Нас волнует только чтобы объекты были типа TCurve
(или его наследники). Но нам не нужно знать действительно ли TCurve
сейчас является TArc
или TLine
.
Взаимодействие с объектом используя информацию описанную в виртуальном (или абстрактном) классе
Рассматривая класс TCurve
мы можем представить что он может быть абстракцией любого количества типов, не только TArc
и TLine
. Например, у нас может класс TSpline
или TElipseArc
которые тоже относятся через отношение "является" классу TCurve
как к предку. Если подумать о геометрической кривой в общем виде мы можем представить методы и свойства которые могут быть абстрагированы. Например, мы можем сказать "кривая может иметь только одну точку начала и одну точку окончания", так что мы можем объявить функцию которая возвращает начальную точку и конечную точку.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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; |
Ключевое слово virtual в Delphi означает, что даже если мы вызовем этот метод в родительском типе (TCurve
), то будет использоваться реализация наиболее специфичного класса (TArc
или TLine
). Потомки могут заменить реализацию на свою собственную. Мы рассмотрим это более подробно в концепции Полиморфизма в 3 части.
Для понимания давайте рассмотрим следующее
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var LCurve1, LCurve2, LCurve3: TCurve; // Все переменные объявлены с типом родителя LStartPoint1, LStartPoint1, LStartPoint3: TPoint; // TPoint не опредлён в этом посте begin LCurve1 := nil; LCurve2 := nil; LCurve3 := nil; try LCurve1 := TArc.Create; // Создаются объекты типов потомков, но присваиваются переменной с типом предка LCurve2 := TLine.Create; LCurve3 := TCurve.Create; // Допустимая инструкция, так как класс не помечен как абстрактный, абстрактный только отдельный метод LStartPoint1 := LCurve1.GetStartPoint; // Будет вызван TArc.GetStartPoint LStartPoint1 := LCurve2.GetStartPoint; // Будет вызван TLine.GetStartPoint // LStartPoint3 := LCurve3.GetStartPoint; // Будет вызван an abstract error finally LCurve3.Free; LCurve2.Free; LCurve1.Free; end; |
При объявлении GetStartPoint
в классе TArc
мы использовали директиву override. Если затем мы ссылаемся на объект через переменную типа TCurve
и вызываем GetStartPoint
то будет вызван TArc.GetStartPoint
. Ключевое слово abstract в Delphi означает что если мы вызовем GetStartPoint
у потомка который не предоставляет реализацию то мы получим "Abstract Error", эта ошибка означает что метод не был переопределён в потомках. Если мы уберём ключевое слово abstract то нам нужно обязательно добавить базовую реализацию на уровне класса TCurve
. Если потомки не реализуют этот метод то будет вызываться базовая реализация без появления исключения.
Другой уровень абстракции - сделать весь класс абстрактным. Имеет смысл делать класс абстрактным если он настолько высоко в иерархии, что нет смысла создавать объекты этого класса. В данный момент проверки компилятара при создании объектов такого класса нет, но она может появится в будущих версиях.
1 2 3 4 |
TCurve = class abstract public function GetStartPoint: TPoint; virtual; abstract; end; |
Абстракция через интерфейсы
Существует заблуждение что интерфейсы это просто абстрактные классы. В случае классов между классом-потомком и классом-предком (включая абстрактные классы) существует отношение "является". Наиболее распространённое отношение между классом и интерфейсом — "поддерживает". Интерфейс может рассматриваться как контракт. Если класс поддерживает интерфейс, то класс берет на себя обязательство выполнить требования интерфейса, или делегировать их. Подробнее про делегирования интерфейсов будет в следующих статьях.
Интерфейсы даже более абстрактны чем абстрактные классы: один интерфейс может быть применён для нескольких иерархий классов одновременно. Можно даже создать свою иерархию наследования интерфейсов независящую от иерархии наследования классов к которым эти интерфейсы применяются. По сравнению с интерфейсами иерархии классов содержат структуры и функции которые трудно менять.
Рассмотрим пример который показывает отличия интерфейса от абстрактного класса.
1 2 3 |
IComparable = interface(IInterface) function CompareTo(AObject: TObject): Integer; end; |
Очевидно что эта сущность не может войти не в одну конкретную иерархию классов. Этот интерфейс может быть применён к любому классу который потенциально можно сравнивать с другим классом. Фактически интерфейс IComparable
должен применяется ко многим иерархиям классов. Применённый к классу интерфейс заставляет реализовать метод в классе. Мы же ссылаемся на этот метод на высоком уровне абстракции интерфейса. Ниже пример процедуры которая сортирует любой список объектов которые поддерживают интерфейс IComparable
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
procedure SortComparableList(AList: TList<IComparable>); procedure QuickSort(ALeftIdx, ARightIdx: Integer); var i, j : Integer; LPivotItem: TObject; LTempItem: IComparable; begin repeat i := ALeftIdx; j := ARightIdx; LPivotItem := TObject(AList[(ALeftIdx + ARightIdx) shr 1]); repeat while AList[i].CompareTo(LPivotItem) < 0 do Inc(i); while AList[j].CompareTo(LPivotItem) > 0 do Dec(j); if i <= j then begin if (i <> j) then begin LTempItem := Items[i]; Items[i] := Items[j]; Items[j] := LTempItem; end; Inc(i); Dec(j); end; until i > j; if ALeftIdx < j then QuickSort(ALeftIdx, j); ALeftIdx := i; until i >= ARightIdx; end; begin if AList.Count > 1 then QuickSort( 0, AList.Count - 1); end; |
В итоге
Абстракция позволяет нам фокусироваться только на той области кода которая важна для нас. Она позволяет нам обобщать функциональность повышая читаемость и упрощая повторное использование кода. Также она упрощает понимание того что происходит в программе на высоком уровне.