Kennisbank

SOLID Principes in Delphi [3] – Het Liskov Substitution Principle

Het Liskov Substitution Principle in Delphi! Ik begin met de officiële definitie:

Subtype Eis: Laat Φ(x) een eigenschap zijn die bewijsbaar is over objecten x or type T. Dan moet Φ(y) waar zijn voor objecten y of type S waar S een subtype is van T.

Ben je nog steeds hier en vraag je je af wat dat betekent? Goed, ik had ook wat moeite om dit te begrijpen. 😊

Laten we LSP eens wat praktischer benaderen: Het principe bepaalt dat objecten van een superklasse vervangbaar moeten zijn door objecten van zijn subklassen zonder de toepassing te breken. Of, om in Delphi termen te blijven: Als TChild van een subtype is van TParent, dan mogen objecten van het type TParent vervangen worden door objecten van het type TChild, zonder de logica van het programma te laten falen.

Dit betekent dat de onderliggende klassen ongeveer op dezelfde manier moeten werken als de bovenliggende klasse. In tegenstelling tot de vorige SOLID principes die we behandeld hebben, gaat dit principe meer over het gedrag van klassen, en niet direct over de structuur van deze klassen. Laten we weer aan de slag gaan met een voorbeeld.

TEmployee = class
  strict private
    FName: string;
    FManager: TEmployee;
    FSalary: Double;

    procedure SetName(const Value: string);
    procedure SetSalary(const Value: Double);
    function GetName: string;
    function GetSalary: Double;
  public
    procedure AssignManager(const AEmployee: TEmployee); virtual;

    property Name: string read GetName write SetName;
    property Salary: Double read GetSalary write SetSalary;
  end;

Zoals je kunt zien, is dit een zeer eenvoudige Employee-class, die we bijvoorbeeld als volgt zouden kunnen gebruiken:

var
  Employee: TEmployee;
  Manager: TEmployee;
begin
  Manager := TEmployee.Create;
  Manager.Name := 'Jane Smith';
  Manager.Salary := 31000;

  Employee := TEmployee.Create;
  Employee.Name := 'John Smith';
  Employee.Salary := 22500;
  Employee.AssignManager(Manager);
end;

Tot zover alles goed. Maar laten we zeggen dat we wat functionaliteit willen toevoegen aan de Manager, omdat hij een beoordeling moet doen voor een werknemer. Misschien herinnert je je dat het beter is om te programmeren tegen interfaces, niet tegen implementaties. Maar omwille van dit voorbeeld, gaan we onze TEmployee klasse overriden om te zien wat het Liskov Substitutie principe is, voordat we dit weer gaan refactoren naar interfaces. Dus, laten we de volgende class maken:

  TManager = class(TEmployee)
  public
    procedure DoAppraisal(const AEmployee: TEmployee);
  end;

Dit is prima, want we hebben wat functionaliteit toegevoegd. We kunnen nu gewoon onze implementatie veranderen in dit:

var
  Employee: TEmployee;
  Manager: TEmployee;
begin
  Manager := TManager.Create;
  Manager.Name := 'Jane Smith';
  Manager.Salary := 31000;

  Employee := TEmployee.Create;
  Employee.Name := 'John Smith';
  Employee.AssignManager(Manager);
  Manager.Salary := 22500;
end;

We hebben de implementatie van het Manager-object veranderd in een TManager, en ons programma werkt nog steeds zoals voorheen. Laten we nog een class maken, en wel voor onze CEO:

  TCEO = class(TManager)
  public
    procedure AssignManager(const AEmployee: TEmployee); override;
    procedure ReviewCompany;
  end;

en de eigenlijke uitvoering:

{ TCEO }
procedure TCEO.AssignManager(const AEmployee: TEmployee);
begin
  raise Exception.Create('The CEO can''t have a manager!');
end;

procedure TCEO.ReviewCompany;
begin
  // Do the company review
end;

Onze TCEO overerft van de TManager, implementeert een nieuwe procedure ReviewCompany, en overrides de AssignManager, omdat het geen zin heeft om een manager te hebben voor een CEO. Maar nu hebben we een probleem. Laten we zeggen dat we onze implementatie veranderen in een instantie van TCEO:

var
  Employee: TEmployee;
  Manager: TEmployee;
begin
  Manager := TManager.Create;
  Manager.Name := 'Jane Smith';
  Manager.Salary := 31000;

  Employee := TCEO.Create;
  Employee.Name := 'John Smith';
  Employee.AssignManager(Manager);
  Employee.Salary := 22500;
end;

Hoewel ons project nog steeds compileert, hebben we nu een probleem als we dit programma uitvoeren, omdat we plotseling een exception krijgen bij de AssignManager call. Aangezien dit ons project breekt, is dit duidelijk een overtreding van de LSP. We zouden in staat moeten zijn om onze TParent te veranderen in TChild zonder dat dit een neveneffect heeft op de functionaliteit.

Zoals je ziet, is de implementatie van de TCEO klasse strikter dan zijn bovenliggende klasse. Het Liskov Substitutie Principe stelt dat je minder beperkende validatie regels mag implementeren, maar dat je geen strengere regels mag afdwingen in je child classes. Dezelfde regels gelden voor de return waarde van een functie. De return waarde van een functie van de child class moet aan dezelfde regels voldoen als de return waarde van de functie van de parent.

Dus, hoe kunnen we dit oplossen?

Het eerste wat we ons moeten afvragen is; is een CEO eigenlijk wel een Werknemer? Een CEO kan een salaris hebben, maar een manager aanstellen heeft geen zin. Laten we proberen dit op te lossen met het gebruik van interfaces (nogmaals, programma tegen interfaces, niet implementatie). We beginnen met een aantal interfaces:

type
  IBaseEmployee = interface
    procedure SetName(const Value: string);
    procedure SetSalary(const Value: Double);
    function GetName: string;
    function GetSalary: Double;

    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 kunt zien, hebben we een interface voor de basiseigenschappen van een werknemer, we hebben een specifieke interface voor de manager en de beheerde werknemer, en een voor de CEO. De implementaties van onze klassen zijn nu als volgt:

 TBaseEmployee = class(TInterfacedObject, IBaseEmployee)
  strict private
    FName: string;
    FManager: IBaseEmployee;
    FSalary: Double;

    procedure SetName(const Value: string);
    procedure SetSalary(const Value: Double);

    function GetName: string;
    function GetSalary: Double;
  public
    property Name: string read GetName write SetName;
    property Salary: Double read GetSalary write SetSalary;
  end;

  TEmployee = class(TBaseEmployee, IManagedEmployee)
  strict private
    FManager: TEmployee;
  public
    procedure AssignManager(const AEmployee: IBaseEmployee);
  end;

  TManager = class(TEmployee, IManager)
  public
    procedure DoAppraisal(const AEmployee: IBaseEmployee);
  end;

  TCEO = class(TBaseEmployee, ICEO, IManager)
  public
    procedure ReviewCompany;
    procedure DoAppraisal(const AEmployee: IBaseEmployee);
  end;

En tenslotte de gewijzigde oproepen naar deze klassen:

var
  Employee: IBaseEmployee;
  Manager: IBaseEmployee;
begin
  Manager := TManager.Create;
  Manager.Name := 'Jane Smith';
  Manager.Salary := 31000;

  Employee := TCEO.Create;
  Employee.Name := 'John Smith';
  Employee.AssignManager(Manager);
  Employee.Salary := 22500;
end;

Als je goed naar de implementatie kijkt, zie je een fout; de laatste Employee.AssignManager zal een compileerfout geven, omdat de TCEO klasse de functie AssignManager niet meer heeft. Dus, onze logica moet veranderen, om de werkelijke situatie weer te geven.

En hoewel we overerving gebruiken met onze klassen, programmeren we nog steeds tegen interfaces, zoals je kunt zien in een ander codevoorbeeld hieronder:

var
  CEO: ICEO;
  Manager: IManagedEmployee;
begin
  CEO := TCEO.Create;
  CEO.Name := 'John Smith';
  CEO.Salary := 75000;

  CEO.ReviewCompany;

  Manager := TManager.Create;
  Manager.Name := 'Jane Smith';
  Manager.Salary := 31000;
  Manager.AssignManager(CEO);
end;

De implementatie van onze klassen is nu ook in overeenstemming met het Liskov Substitutie Principe, aangezien we nu de TManager.Create kunnen verwisselen met een TEmployee.Create zonder het runtime gedrag te beïnvloeden.

Dus, om het Liskov Substitutie Principe nog eens samen te vatten: Als TChild van een subtype is van TParent, dan mogen objecten van het type TParent vervangen worden door objecten van het type TChild, zonder de logica van het programma te breken. Dwing geen strenger gedrag af in je child classes dan definities in je parent classes.

Geschreven door Marco Geuze
Directeur

Contact

Laat ons helpen jouw ambities concreet te maken.