Kennisbank

SOLID Principes in Delphi [4] – het Interface Segregation Principle

SOLID principe 4: Scheiding van interfaces. “Clients moeten niet gedwongen worden afhankelijk te zijn van interfaces die ze niet gebruiken.”

Het interface segregation principe gaat over het vinden van de meest geschikte abstracties in je code. Wikipedia heeft een mooie beschrijving van dit principe: “ISP splitst interfaces die erg groot zijn op in kleinere en meer specifieke, zodat clients alleen kennis hoeven te nemen van de methoden die voor hen van belang zijn. Zulke gekrompen interfaces worden ook wel rol interfaces genoemd “.

Laten we eens kijken naar onze vorige Delphi interfaces van het Liskov Substitutie Principe:

type
  IBaseEmployee = interface
<getters and setters>
    property Name: string read GetName write SetName;
    property Salary: Double read GetSalary write SetSalary;
  end;

  IManagedEmployee = interface(IBaseEmployee)
    procedure AssignManager(const AEmployee: IBaseEmployee);
  end;

  IManager = interface(IBaseEmployee)
    procedure DoAppraisal(const AEmployee: IBaseEmployee);
  end;

  ICEO = interface(IManager)
    procedure ReviewCompany;
  end;

Zoals je weet, krijgen projecten die over een lange periode worden onderhouden en uitgebreid onvermijdelijk features die moeten worden gemaakt. Stel dat je een verzoek krijgt om ook ingehuurde werknemers op te slaan, voor welk extern bedrijf ze werken en de kosten van het inhuren. De eenvoudigste manier om dit op te lossen zou zijn om eenvoudigweg twee nieuwe velden aan te maken in de IBaseEmployee: “HiringCosts” en “Company” van het type “TCompany”. Op deze manier voldoe je nog steeds aan het Liskov Substitutie Principe, want je kunt gewoon een IBaseEmployee vervangen door een IManager en alle code zal functioneel ook blijven werken.

IBaseEmployee = interface
{<'Implementations'>}
    property Name: string read GetName write SetName;
    property Salary: Double read GetSalary write SetSalary;

    property HiringCosts: Double read GetHiringCosts write SetHiringCosts;
    property Company: TCompany read GetCompany write SetCompany;
  end;

Mijn ervaring is dat je dit veel ziet gebeuren in projecten die al langer bestaan. Het is in feite de snelste manier om de nieuwe functionaliteit gewoon in de basis interface te plaatsen, of in een van de afgeleide interfaces. Hierdoor is de nieuwe functionaliteit onmiddellijk beschikbaar op de plaats in de implementatie waar je ze nodig hebt. Het voldoet ook aan het Single Responsibility Principle; vanuit het perspectief van een HR-medewerker hoort deze functionaliteit ook in de IBaseEmployee interface thuis.

Nu komt echter het Interface Segregation Principle om de hoek kijken. Het risico hiervan is dat je niet alleen de interface (en dus de klasse zelf) steeds groter maakt, maar dat je ook functionaliteit introduceert waar die helemaal niet nodig is.

Als we nadenken over het juiste abstractieniveau, is het veel zinvoller om aparte functionaliteit voor de ingehuurde mensen in een aparte interface te stoppen. En nu we toch bezig zijn, waarom zouden we de Manager interface niet ook aanpassen? Als we kijken naar de ISP, is een Manageable werknemer eigenlijk een aparte rol.

Als we dit allemaal toepassen, komen we uit op de volgende interfaces:

  IBaseEmployee = interface
  <getters and setters>
    property EmployeeID: Integer read GetEmployeeID write SetEmployeeID;
    property Name: string read GetName write SetName;
    property Salary: Double read GetSalary write SetSalary;
  end;

  IHireable = interface
  <getters and setters>
    property HiringCosts: Double read GetHiringCosts write SetHiringCosts;
    property Company: TCompany read GetCompany write SetCompany;
  end;

  IManageble = interface
    procedure AssignManager(const AEmployee: IBaseEmployee);
  end;

  IManaging = interface
    procedure DoAppraisal(const AEmployee: IBaseEmployee);
  end;

  ICEO = interface
    procedure ReviewCompany;
  end;

Omdat we de specifieke functionaliteit naar een rolinterface hebben verplaatst, moeten onze klassen deze veranderingen weerspiegelen. Wij hebben ook de TExternalEmployee toegevoegd. Hier worden alleen de headers van de klassen getoond:

  TBaseEmployee = class(TInterfacedObject, IBaseEmployee)
<>
  TExternalEmployee = class(TBaseEmployee, IHireable)
<>
  TManager = class(TBaseEmployee, IManaging)
<>
  TCEO = class(TBaseEmployee, ICEO, IManaging)
<>

Als we de interfaces en klassen zo ontwerpen, kunnen we de functionaliteit gebruiken met behulp van de interface support call:

var
  Employee: IBaseEmployee;
begin
  Employee := TExternalEmployee.Create;
  Employee.Name := 'Jack Smith';

  if Supports(Employee, IHireable) then
    (Employee as IHireable).HiringCosts := 3750;

Het gebruik van de Interface Segregation Principle maakt je interfaces klein, beheersbaar, en gemakkelijk te gebruiken. Als je een CEO wilt inhuren, voeg je gewoon de IHireable toe aan de klasse en je bent klaar om te gaan. In andere delen van de toepassing, laten we zeggen waar je de totale inhuurkosten wilt berekenen, hoef je alleen maar een lijst van IHireable items te voorzien om dat te doen (TList<IHireable>). Je hoeft niet te weten welk type werknemer het is (of zelfs als het een werknemer is, kan het van een heel ander type zijn als je dat wilt).

Dus, vraag je je misschien af; wat is het verschil tussen het Single Responsibility Principle en het Interface Segregation Principle? Ik denk dat het verschillende kanten van dezelfde medaille zijn; de SRP bekijkt een klasse of interface vanuit een ontwerpperspectief, waarbij je de dingen die om dezelfde reden veranderen groepeert en de dingen die verschillen scheidt. De ISP bekijkt een klasse of interface vanuit het standpunt van de gebruikers of consumenten; je moet alleen zien wat je werkelijk nodig hebt. Een laatste voorbeeld om het verschil aan te tonen:

SRP geldig, maar ISP ongeldig:

  IUserManagement = interface
    function GetUsers: TList<TUser>;
    function SaveUser(AUser: TUser);
  end;

ISP geldig, maar SRP ongeldig:

  IObjectManagement = interface
    function SaveUser(AUser: TUser);
    function SaveProduct(AProduct: TProduct);
  end;

ISP en SRP geldig:

  IUserProvider = interface
    function GetUsers: TList<TUser>;
  end;

  IUserSaver = interface
    function SaveUser(AUser: TUser);
  end;

(En hetzelfde voor het TProduct natuurlijk).

Bedankt voor het lezen, en nog maar één principe te gaan! Tot de volgende keer. 😊

Geschreven door Marco Geuze
Directeur

Contact

Laat ons helpen jouw ambities concreet te maken.