Fundamental Object Oriented Design Principles (Part 1): Abstraction
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