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

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

Рассмотрим принцип Абстракции используемый в объектно ориентированных языках программирования.

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

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

Эта статья предполагает что вы знакомы как минимум с одним ОО языком программирования. C++, Delphi, C# и Java являются примерами объектно ориентированных языков. Настоящий ОО язык программирования имеет классы, объекты и очень часто интерфейсы.

Что такое Абстракция?

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

Взаимодействие с объектами используя информацию описанную в классе

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

var
  LCircle: TCircle;
  LArea: double;
begin
  LCircle := TCircle.Create; // Мы берём класс TCircle и создаём на основе него объект

  // Мы можем устанавливать свойства в то время как объект будет сам обрабатывать побочные эффекты
  LCircle.Radius := 3; 

  // Мы можем вызывать высокоуровневые методы объекта
  LCircle.Translate(10, 15);
  LArea := LCircle.GetArea();
end;
1
2
3
4
5
6
7
8
9
10
11
12
13

Пока никаких сюрпризов. Всё что нам нужно было определено в TCircle и мы знаем что мы можем использовать свойства и методы класса который мы использовали для создания объекта.

Взаимодействие с объектами через информацию описанную в классе-предке

Объектно ориентированные языки программирования позволяют наследовать классы. При правильно ОО проектировании классы потомки к предкам находятся в отношении "является". Для примера мы можем наследовать классы TLine и TArc от класса TCurve. В этом примере предполагаем, что TCurve определяет тип линии (LineType).

procedure SetLinetype(ACurveList: TList<TCurve>; ALineType: Linetype_Enum); // Linetype_Enum не определено в этом посте
var
  LCurve: TCurve;
begin
  for LCurve in ACurveList do
    LCurve.LineType := ALineType;
end;
1
2
3
4
5
6
7

В этом примере мы не беспокоимся о том содержит ли список линии (TLine) или дуги (TArc) или другие неизвестные типы. Нас волнует только чтобы объекты были типа TCurve (или его наследники). Но нам не нужно знать действительно ли TCurve сейчас является TArc или TLine.

Взаимодействие с объектом используя информацию описанную в виртуальном (или абстрактном) классе

Рассматривая класс TCurve мы можем представить что он может быть абстракцией любого количества типов, не только TArc и TLine. Например, у нас может класс TSpline или TElipseArc которые тоже относятся через отношение "является" классу TCurve как к предку. Если подумать о геометрической кривой в общем виде мы можем представить методы и свойства которые могут быть абстрагированы. Например, мы можем сказать "кривая может иметь только одну точку начала и одну точку окончания", так что мы можем объявить функцию которая возвращает начальную точку и конечную точку.

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
16

Ключевое слово virtual в Delphi означает, что даже если мы вызовем этот метод в родительском типе (TCurve), то будет использоваться реализация наиболее специфичного класса (TArc или TLine). Потомки могут заменить реализацию на свою собственную. Мы рассмотрим это более подробно в концепции Полиморфизма в 3 части.

Для понимания давайте рассмотрим следующее

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;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

При объявлении GetStartPoint в классе TArc мы использовали директиву override. Если затем мы ссылаемся на объект через переменную типа TCurve и вызываем GetStartPoint то будет вызван TArc.GetStartPoint. Ключевое слово abstract в Delphi означает что если мы вызовем GetStartPoint у потомка который не предоставляет реализацию то мы получим "Abstract Error", эта ошибка означает что метод не был переопределён в потомках. Если мы уберём ключевое слово abstract то нам нужно обязательно добавить базовую реализацию на уровне класса TCurve. Если потомки не реализуют этот метод то будет вызываться базовая реализация без появления исключения.

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

TCurve = class abstract
public
   function GetStartPoint: TPoint; virtual; abstract;
end;
1
2
3
4

Абстракция через интерфейсы

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

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

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

  IComparable = interface(IInterface)
    function CompareTo(AObject: TObject): Integer;
  end;
1
2
3

Очевидно что эта сущность не может войти не в одну конкретную иерархию классов. Этот интерфейс может быть применён к любому классу который потенциально можно сравнивать с другим классом. Фактически интерфейс IComparable должен применяется ко многим иерархиям классов. Применённый к классу интерфейс заставляет реализовать метод в классе. Мы же ссылаемся на этот метод на высоком уровне абстракции интерфейса. Ниже пример процедуры которая сортирует любой список объектов которые поддерживают интерфейс IComparable.

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;
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

В итоге

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

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