Fundamental Object Oriented Design Principles (Part 1): Abstraction

7 minute read

A look at Abstraction as provided by Object Oriented languages and how it hides details

This is the first post in a series on Object Oriented design. In order to fully understand why the study of Design Patterns is important, we need to start by looking at what good Object Oriented (OO) software design is. Object Oriented languages offer some unique features that facilitate good design.

These design principles can be applied to non-objected oriented languages. However, applying OO concepts to non-OO languages takes some more effort.

This article assumes that you understand at least one object oriented programming language. C++, Delphi, C# and Java are examples of object oriented languages, Javascript is an object-based language (it uses object prototyping instead of classing). If you are unsure: a true object oriented language has classes, objects and very often interfaces.

What is Abstraction?

Abstraction in general means to operate on a code construct or data structure at a high level, without the need to understand the concrete nature of the structure or data. Here I will describe a specific forms of abstraction core to Object Oriented programming. Most of these should be familiar to you, but I want to explicitly list these for motivators for more complex design principles I will address in future posts:

Interacting with objects via direct class knowledge

Object Oriented languages have the concept of a class. A class is a definition which is used to create an object. We can also operate on an object according to the properties and methods exposed by its class - without understanding the actual implementation. We can also expect consistent behavior accross objects of the same class.

var
  LCircle: TCircle;
  LArea: double;
begin
  LCircle := TCircle.Create; //we take a class TCircle and instanciate an object from it
  
  // we can set properties while the object will deal with side effects 
  LCircle.Radius := 3; 
  
  // we can call methods on the object at a high level  
  LCircle.Translate(10, 15);
  LArea := LCircle.GetArea();
end; 

So far no surprises, everything we needed was defined in TCircle and we know that we can use properties and methods of the class that was used to instanciate the object.

Interacting with objects via inherited class knowlege

Object oriented languages also allow for inheritence of classes. In proper OO class design classes have an “is-a” relationship to ancestor classes. For instance we could inherited a TLine and TArc class from a TCurve class. For this example let us assume that TCurve defines a line type.

procedure SetLinetype(ACurveList: TList<TCurve>; ALineType: Linetype_Enum); //Linetype_Enum not defined in this post
var    
  LCurve: TCurve;
begin
  for LCurve in ACurveList do
    LCurve.LineType := ALineType;
end;

In the example above we don’t care if the list contains lines or arcs, or some other unknown type. We only care that they are of the type TCurve (perhaps via inheritence). Just as we operated on LCircle with knowledge of the private section of its class (TCircle) we can operate on LCurve with knowledge of TCurve. We don’t need to know if LCurve is actually a TArc or TLine.

Interacting with objects via virtual (or abstracted) class knowledge

Considering a class TCurve we can imagine that it could be an abstraction of any number of types beyond TArc and TLine. For instance we could have a TSpline or TElipseArc that could also have an “is-a” relationship with TCurve as an ancestor. If we step back and think of a geometric curve in abstract terms we can come up with methods and properties that could be abstracted. For instance we could say “a curve has only one start point and one end point” so we could define functions that would retrieve the start point and end point.

type

TCurve = class
public
   function GetStartPoint: TPoint; virtual; abstract;   // TPoint is not defined in this post. 
end;

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

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

The virtual keyword in Delphi here means that even if we call this method on a variable declared as ancestoral type (TCurve), the implementation at the most specific level of class (TArc or TLine) definition will be used. Decendent classes will essentially replace this function definition with their own. We will look at this more in detail when addressing the concept of Polymorphism in Part 3

To understand this lets consider the following

var    
  LCurve1, LCurve2, LCurve3: TCurve; // All declared as the ancestoral type
  LStartPoint1, LStartPoint1, LStartPoint3: TPoint;  // TPoint is not defined in this post
begin
  LCurve1 := TArc.Create;  //concrete types are constructed, but then assigned to an abstracted ancestoral variable type
  LCurve2 := TLine.Create;
  LCurve3 := TCurve.Create; //legal since the class is not marked abstract
  try
    LStartPoint1 := LCurve1.GetStartPoint; //will call TArc.GetStartPoint
    LStartPoint1 := LCurve2.GetStartPoint; //will call TLine.GetStartPoint
    // LStartPoint3 := LCurve3.GetStartPoint; //will raise an abstract error
  finally
    LCurve3.Free;
    LCurve2.Free;    
    LCurve1.Free;
 end;    

We implemented the GetStartPoint definition in TArc with the override directive. If we then refer to object via a variable of TCurve and call GetStartPoint the call will be made to TArc.GetStartPoint. The abstract keyword in Delphi means that if we call GetStartPoint on a decendent that has not provided an implementation then we will get an ‘Abstract Error’, this error means that the method was not replaced by a concrete class and there is no code to be reached by the call. If we were to omit the abstract keyword we are required to add a base implementation at the TCurve level. If a decendent class does not implement this method then the base implementation will be called without an error.

Another level of abstraction is to make the entire class as abstract. A class makes sense as pure abstract in cases where the level of abstraction in the class hierarchy so high that no object will be instanciated using that class. For instance a class definition like the following would produce an error if you attempt to directly create an object using this class (TCurve.create is not permitted)

TCurve = class abstract
public
   function GetStartPoint: TPoint; virtual; abstract;   
end;

Abstraction via Interfaces

There is a common misconception that interfaces are simply pure abstract classes. In class abstraction the relationship between a class and its ancestor class (including abstract ancestors) should almost always be an “is-a” relationship. The most common relationship beween a class and and interface is a “supports” relationship. An interface can be viewed as a contract. If an interface is applied to a class the class is obligated to fill the demands of the interface or delegate the requests made on that interface. I will deal with the topic of interface delegation in a future post.

Interfaces are even more abstact than abstract classes and can apply to multiple class hierarchies. They can even have independent inheritence hierarchies from the classes to which they are applied. The class hierarchy bakes in functionality and structure that is not easily changed. As we will see later in our design patterns journey we will note some of the problems that arrise from this.

Here as an example that will hopefully convince you that interfaces are different from abstract classes. Consider the following interface:

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

Clearly this will not fit into any one specific object hierarchy. This interface can be applied to any type that could potentially be compared to another type. In fact this interface applies broadly accross multiple class hierarchies. Once applied to those classes they are compelled to implement this method. When we refer to them via an interface only in high level functions of abstraction. Here is an example of an abstract procedure that can sort any list of objects that implement the IComparable interface

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;

In Summary

Abstraction allows us to focus on areas of code that we are interested in only. It allows us to generalize functionality improving readability and reusability of code. It declutters our code and simplifies the understanding of what happens in software at a higher level

Leave a Comment