Kennisbank

SOLID principes in Delphi [5] – Het Dependency Inversion principe

Dit is het laatste SOLID principe: het Dependency Inversion Principle:

High-level modules mogen niet afhankelijk zijn van low-level modules. Beide moeten afhankelijk zijn van abstracties.
Abstracties mogen niet afhankelijk zijn van details. Details moeten afhankelijk zijn van abstracties.

Het algemene idee van dit principe is eenvoudig; Als high-level modules afhankelijk zijn van low-level modules, dan moeten we dat veranderen (omkeren), en zowel high-level als low-level modules moeten abstracties gebruiken, geen implementaties.

Laten we beginnen met het verschil tussen high-level modules en low-level modules. High-level modules zijn modules die complexe logica leveren of gebruiken en gebruik maken van andere modules of klassen. Low-level modules zijn meer als ‘utility’ modules, niet afhankelijk van of gebruik makend van andere modules. Kijk eens naar de volgende voorbeelden van low-level en high-level code:

Low-level voorbeelden:

type
  TLogger = class
  //<…>
  Public
    //<…>
    procedure LogMessage(const AMessage: string);
  end;


implementation

{ TLogger }

procedure TLogger.LogMessage(const AMessage: string);
begin
  Writeln(AMessage);
end;

of

type
  TEmailer = class
  //<…>
  Public
    //<…>
    procedure SendMessage(const AMessage, ARecipient: string);
  end;

implementation

{ TEmailer }

procedure TEmailer.SendMessage(const AMessage, ARecipient: string);
begin
  Writeln('Dummy implementation of email, just sent ' + AMessage + ' to ' + ARecipient);
end;

 

High-level voorbeelden:

type
  TTaskItem = class
  private
    FName: string;
    FDueDate: TDateTime;
    FIsFinished: Boolean;
    FUsername: string;
    procedure SetDueDate(const Value: TDateTime);
    procedure SetName(const Value: string);
    procedure SetUsername(const Value: string);
  public
    constructor Create;
    procedure CompleteTask;

    property Name: string read FName write SetName;
    property Username: string read FUsername write SetUsername;
    property DueDate: TDateTime read FDueDate write SetDueDate;
    property IsFinished: Boolean read FIsFinished;
  end;

  TTaskManager = class
  strict private
    FTasks: TList<TTaskItem>;
  public
    constructor Create;
    destructor Destroy; override;

    procedure AddTask(const ATask: TTaskItem);
    procedure CompleteTask(ATask: TTaskItem);
  end;

Een voorbeeldimplementatie van de klasse TTaskManager kan zijn:

procedure TTaskManager.AddTask(const ATask: TTaskItem);
var
  Emailer: TEmailer;
begin
  FTasks.Add(ATask);

  Emailer := TEmailer.Create;
  try
    Emailer.SendMessage('Task ' + ATask.Name + ' added', ATask.Username);
  finally
    Emailer.Free;
  end;
end;

Zoals je in dit voorbeeld kunt zien, hangt onze high-level module TTaskManager af van de low-level module TEmailer (vanwege de aanroep TEmailer.Create). En dat is een probleem, dus moeten we die afhankelijkheid omkeren. Waarom? Daar kom je later wel achter.

Zowel low-level als high-level modules moeten ook afhankelijk zijn van abstracties. Hoe veranderen we dit? De eenvoudigste manier is om interfaces te gebruiken. Een interface is per definitie een abstractie, dus door interfaces te gebruiken in plaats van klassen, verwijderen we de afhankelijkheid van implementaties. Als bijkomend voordeel voldoen we ook aan de tweede eis van de Dependency Inversion, omdat een interface geen kennis zal hebben van de details van hoe de dingen gedaan worden in de eigenlijke implementatie.

Omdat we ons hier concentreren op de uitleg van het vijfde Solid principe, zal ik niet in detail uitleggen hoe je een interface aan een klasse toevoegt en hoe je die gebruikt. In de vorige posts over de Solid principes hebben we dat al verschillende keren gedaan. Uiteindelijk zullen we de volgende interfaces hebben:

ILogger
IMessageSender
ITaskItem
ITaskManager

De uitvoering van de klassen wordt als volgt:

TLogger = class(TInterfacedObject, ILogger)
TEmailer = class(TInterfacedObject, IMessageSender)
TTaskItem = class(TInterfacedObject, ITaskItem)
TTaskManager = class(TInterfacedObject, ITaskManager)

 

Ok, laten we beginnen met de overgang naar het Dependency Inversion principe door dit voorbeeld van de TTaskManager aan te passen. Er is meer dan één manier om dit te doen. Eén daarvan is door gebruik te maken van Dependency Injection. Dependency Inversion is het principe, en Dependency Injection is een manier om dit principe te laten werken. Het is echter niet verplicht om Dependency Injection te gebruiken. Laat me dit uitleggen.

Denk aan de afhankelijkheid van onze TTaskManager van TEmailer. Waar vindt deze afhankelijkheid eigenlijk plaats? De makkelijkste manier om afhankelijkheden te identificeren is door te kijken naar .Create calls. In dit geval, de regel

Emailer := TEmailer.Create;

introduceert een afhankelijkheid van de TEmailer klasse. In essentie is elke .Create aanroep een afhankelijkheid van een andere klasse. Zoals we al zeiden, willen we niet dat modules op hoog niveau afhankelijk zijn van modules op laag niveau. Dus, voor deze class, kunnen we Dependency Injection gebruiken om dit op te lossen. Het is heel eenvoudig; injecteer gewoon de afhankelijkheid via de constructor van de TTaskManager klasse:

constructor TTaskManager.Create(AMessageSender: IMessageSender);
begin
  FMessageSender := AMessageSender;
  FTasks := TList<ITaskItem>.Create;
end;

 

We hebben nu de afhankelijkheid van de emailer (of eigenlijk, van de IMessageSender) klasse geïnjecteerd, zodat we die zonder probleem kunnen gebruiken:

procedure TTaskManager.AddTask(const ATask: ITaskItem);
begin
  FTasks.Add(ATask);

  FMessageSender.SendMessage('Task ' + ATask.Name + ' added', 
    ATask.Username);
end;

 

En dat is precies hoe dependency injection werkt. Zolang je consequent de creatie van klassen terugschuift (d.w.z. nergens de .Create gebruikt) zul je automatisch Dependency Injection gebruiken.

Maar waar leidt dit ons naartoe? Uiteindelijk moet je toch ergens een klasse creëren?

Als je de creatie van klassen uitstelt, kom je terecht in de programma root (de DPR). Kijk maar eens naar mijn voorbeeld voor dit TaskManager voorbeeld programma:

program Solid5;
{$APPTYPE CONSOLE}
uses
//<…>

var
  ATask: ITaskItem;
  ATaskManager: ITaskManager;
  AMessageSender: IMessageSender;
begin
  AMessageSender := TEmail.Create;

  ATaskManager := TTaskManager.Create(AMessageSender);

  ATask := TTaskItem.Create;
  ATask.Name := 'Develop module X';
  ATask.UserName := 'Marco Geuze';
  ATask.DueDate := Tomorrow;
  ATaskManager.AddTask(ATask);

  ATask := TTaskItem.Create;
  ATask.Name := 'Codereview module Y';
  ATask.UserName := 'Marco Geuze';
  ATask.DueDate := IncWeek(now, 2);
  ATaskManager.AddTask(ATask);

  ATask.CompleteTask;

  Readln;
end.

 

In de voorbeeldcode maken we twee taken aan, voegen de taken toe aan de Taskmanager en voeren één taak uit. We creëren nu alleen classes in de DPR. Maar is de DPR in feite zelf ook niet een high-level module? Ik denk het wel, en nogmaals, we moeten geen afhankelijkheid creëren in een high-level module.

Ik heb je laten zien hoe je dit in klassen kunt doen via Dependency Injection. Hier wil ik je laten zien hoe het gedaan wordt via een factory class. De basis rol van een factory class is om instances van interfaces te leveren. En aangezien we ons op het hoogste niveau van ons programma bevinden, is dit de plaats waar we onze classes zullen creëren. Laten we een nieuwe unit maken en onze factory class in deze nieuwe unit maken. Aangezien dit een factory class is, zullen we class-functies gebruiken om instanties van classes terug te geven:

unit ClassFactory;

interface

uses
//<…>

type
  TClassFactory = class
  public
    class function CreateTaskManager: ITaskManager;
    class function CreateTask: ITaskItem;
    class function CreateLogger: ILogger;
    class function CreateMessageSender: IMessageSender;
  end;

Laten we als voorbeeld eens kijken naar de implementatie van de functie CreateTaskManager:

class function TClassFactory.CreateTaskManager: ITaskManager;
begin
  Result := TTaskManager.Create(CreateMessageSender);
end;

 

en voor de functie CreateMessageSender:

class function TClassFactory.CreateMessageSender: IMessageSender;
begin
  Result := TEmailer.Create;
end;

en als laatste weer naar het DPR bestand:

program Solid5;
{$APPTYPE CONSOLE}
uses
//<…>

var
  ATask: ITaskItem;
  ATaskManager: ITaskManager;
begin
  ATaskManager := TClassFactory.CreateTaskManager;

  ATask := TClassFactory.CreateTask;
  ATask.Name := 'Develop module X';
  ATask.UserName := 'Marco Geuze';
  ATask.DueDate := Tomorrow;
  ATaskManager.AddTask(ATask);

  ATask := TClassFactory.CreateTask;
  ATask.Name := 'Codereview module Y';
  ATask.UserName := 'Marco Geuze';
  ATask.DueDate := IncWeek(now, 2);
  ATaskManager.AddTask(ATask);

  ATask.CompleteTask;

  Readln;
end.

 

Om samen te vatten wat we tot nu toe hebben gedaan: Om te voldoen aan het vijfde Solid principe, gebruiken we eerst dependency injection om de afhankelijkheden zo ver mogelijk naar boven te halen. Hierdoor wordt nergens in de klassen een create call gebruikt. Ten tweede hebben we de code omgezet naar interfaces, zodat we niet meer afhankelijk zijn van details, maar van abstracties. En tenslotte hebben we één factory class gemaakt waarin alle objecten worden aangemaakt.

Laten we als laatste eens kijken welk voordeel dit ons oplevert. In de code hierboven hebben we een Emailer klasse die er voor zorgt dat er een email wordt verstuurd wanneer een taak is voltooid. Stel nu dat we dit niet via email willen doen, maar via een tekstbericht. In de oude situatie zouden we de instantie TEmailer overal waar het gebruikt wordt moeten veranderen in TTexter. Dit komt omdat high-level modules afhankelijk waren van deze low-level modules. In de nieuwe structuur is het heel eenvoudig om dit te veranderen. We hoeven alleen maar de klasse CreateMessageSender in de ClassFactory te veranderen:

class function TClassFactory.CreateMessageSender: IMessageSender;
begin
  Result := TTexter.Create;
end;

 

en met slechts één regel code wordt de hele applicatie aangepast aan de nieuwe eisen. Rekening houdend met de andere Solid-principes (Liskov Substitution, Open Closed), kunnen we deze aanpassing zonder problemen doorvoeren. Maar er zijn meer voordelen. Laten we zeggen dat we unit tests willen toevoegen aan onze applicatie. Als je gewoon een unit test project maakt en deze applicatie 50 keer per dag test, zou je eindigen met 50 emails, of 50 sms-jes in je inbox. Dus, in je unit test project, kun je nu gewoon een andere factory class hebben, met een dummy of mock implementatie van de MessageSender class. En dat is alles, geen emails of sms’jes meer.

Zo, dat is de laatste van de vijf SOLID principes. Ik hoop dat je een hoop nuttige informatie hebt gekregen die je kunt gebruiken bij het ontwikkelen in Delphi. Ik ben ervan overtuigd dat jouw applicaties robuuster, leesbaarder en uitbreidbaarder zullen zijn als je de vijf SOLID principes consequent toepast bij het ontwikkelen.

Geschreven door Marco Geuze
Directeur

Contact

Laat ons helpen jouw ambities concreet te maken.