Kompozycja zamiast dziedziczenia (composition over inheritance)

Czym jest podejście composition over inheritance? Dzisiaj o tym, dlaczego każdy powinien przemyśleć czy dziedziczenie klas w jego projekcie jest naprawdę potrzebne. Prawdopodobnie powinieneś ograniczyć dziedziczenie klas na rzecz ich kompozycji.

Trochę teorii

Prawdopodobnie większość z nas wie czym jest dziedziczenie. Ze studiów, szkoleń, bo przecież jest to coś czego uczymy się na początkach naszych karier. Niemniej jednak inheritance to jeden ze sposobów na posiadanie re-używalnego kodu. Definiujemy klasę bazową, która jest następnie używana do rozszerzenia innej. Klasa bazowa niesie ze sobą definicje czy deklaracje (jeśli jest abstrakcyjna) metod oraz predefiniowane właściwości. Przykładów do przytoczenia jest tutaj mnóstwo, gdzie klasy typu potomek dziedziczą po klasie rodzica: Dog extends AnimalCar extends VehicleSquare extends Figure i inne.

Composition jest to również bardzo ważna reguła programowania obiektowego. Z logicznego punktu widzenia kompozycja jest to relacja typu posiadanie. Komponujemy pewien byt za pomocą danych składowych. Przykładem z życiu wziętym może być kompozycja kolorów, kwiatów czy posypki do lodów. Pewne elementy są „wkładane” do kontenera tworząc tym samym skomponowaną instancję. W żargonie technicznym kompozycji używać możemy na klasach czy funkcjach, a sam koncept implementowalny jest zgoła inaczej w paradygmacie funkcyjnym i obiektowym.

Od dziedziczenia do kompozycji

Myślę, że najłatwiejszym sposobem na odkrycie zalet stosowania kompozycji zamiast dziedziczenia będzie zaprezentowanie przykładu. Zacznijmy od podejścia używającego dziedziczenia, a następnie przerobimy pokazany kodzik tak, aby używał kompozycji. Jak zwykle posłużmy się przykładem z życia wziętym. 

W internecie możemy znaleźć wiele przykładów kodu, który używa kompozycji zamiast dziedziczenia. Najczęściej są to przykłady pokazujące, jak zmienia się sposób wyliczania pewnej wartości na podstawie warunków początkowych, np. pensji czy dni urlopowych. Chciałbym pójść o krok dalej i zaprezentować Ci dzisiaj przykład mniej abstrakcyjny, z którym chyba wszyscy się spotkaliśmy. Mowa o walidacji danych przy pomocy nadrzędnej klasy sprawdzającej.

export abstract class Form {
  protected validationSchema = Joi.object();

  protected validationOptions = { 
    allowUnknown: true, 
    presence: 'required' 
  };

  validate() {
    const { error } = this.validationSchema.validate(this, this.validationOptions);

    if (error) {
      throw new ValidationError(error.message);
    }
    
    this.dataValidated = true;
  }
}

export class CalculateYearIncomeForm extends Form {
  protected schema = Joi.object().keys({
    year: Joi.number.min(2000).max(new Date().getFullYear()),
    companyId: Joi.string().guid(),
  });

  constructor (
    private readonly year: number,
    private readonly companyId: string
  ) {}
}

Powyższy kod to klasyczny przykład dziedziczenia. W klasie potomka używamy funkcjonalności dostępnych w rodzicu. Po utworzeniu obiektu klasy CalculateYearIncomeForm możemy użyć na niej metody validate() pochodzącej z Form. I w tym przykładzie nie byłoby żadnych problemów, gdyby nie fakt, że właśnie zamknęliśmy się na rozbudowę…

const form = new CalculateYearIncomeForm(2022, 'wrongId);
form.validate();

Powiedzmy, że mechanizm walidacji jest nam teraz potrzebny w innym miejscu systemu i nie chcemy, aby w przypadku błędu wyrzucany był tam wyjątek ValidationError. Oczywiście, wyjątek można złapać, możemy także nadpisać metodę validate i samemu, ponownie zaimplementować ją w potomku. Jednak, co w przypadku kiedy tych innych implementacji będzie więcej, a my nie chcemy w tych wszystkich miejscach używać try-catch? Dodatkowo reguły walidacji także mogą się zmieniać. 

Z pomocą przychodzi kompozycja

Przeróbmy wcześniej zaprezentowany kod tak, aby używał on kompozycji, a nie dziedziczenia. W tym celu zamieńmy klasę Form w zwykłą, nieabstrakcyjną, która przyjmie dane i reguły walidacji dynamicznie, a także udostępni metodę walidującą.

export interface ValidatorInterface {
  validate(data: any);
}

export class BasicValidator implements ValidatorInterface {
  private validationOptions = { 
    allowUnknown: true, 
    presence: 'required' 
  };

  validate(data: any, schema: JoiSchema) {
    const { error } = schema.validate(data, this.validationOptions);

    if (error) {
      throw new ValidationError(error.message);
    }
  }
}

export class Form {
  constructor (
    private validationSchema: JoiSchema,
    private validator: ValidatorInterface
  ) {}

  validate(data: any) {
    this.validator.validate(data, this.validationSchema);
  }
}

Możemy teraz dowolnie modyfikować nasze zachowania poprzez tworzenie różnych klas implementujących ValidatorInterface. Dodatkowo, jeżeli chcemy udostępnić możliwość zmiany reguł walidacji lub samego walidatora, możemy pokusić się o dodanie setterów dla tych pól. Z setterami zalecam jednak ostrożność z uwagi na fakt, że mogą one sprowadzić na naszą implementację dozę niespójności.

Przykład w paradygmacie funkcyjnym

Z uwagi na fakt, iż JavaScript jest językiem wieloparadygmatowym, nie mogę oprzeć się pokusie zaprezentowania kompozycji w paradygmacie funkcyjnym. Samo podejście composition over inheritance wywodzi się przecież po części z programowania funkcyjnego. Weźmy teraz na tapet przykład nieco rozszerzonej walidacji. Powiedzmy, że chcemy zaimplementować mechanizm walidacji treści, np. wpisów na blogu. Wpisy z pewnych kategorii muszą być walidowane tylko pod kątem powiedzmy ortograficznym i interpunkcyjnym, a inne, dodatkowo pod kątem plagiatu. W tym celu stwórzmy trzy funkcje walidujące.

const spellingValidatable = (content: string) => ({
  validateSpelling: () => {
    // content spelling validation
  }
});

const punctuationValidatable = (content: string) => ({
  validatePunctuation: () => {
    // content punctuation validation
  }
});

const plagiarismValidatable = (content: string) => ({
  validatePlagiarism: () => {
    // content plagiarism validation
  }
});

Następnie utwórzmy walidator dla treści wpisu, który sprawdzi jego poprawność przy użyciu wymaganych komponentów Validatable.

const form = (content: string, entryType: EntryType) => ({
  content,
  entryType,
  ...spellingValidatable(content),  
  ...punctuationValidatable(content),  
  ...plagiarismValidatable(content),  
});

const someForm = form('Some awesome content', EntryType.scientific);

someForm.validateSpelling();

someForm.validatePunctuation();

someForm.validatePlagiarism();

To już zawsze kompozycja?

Wyobrażam sobie już błysk w oku niektórych osób. To postanowienie, że od dziś już tylko kompozycja, żadnego dziedziczenia. Hold your fire! Nie popadajmy ze skrajności w skrajność, oba podejścia zostały stworzone, aby rozwiązywać pewne problemy. Jeżeli dany problem wydaje Ci się, że lepiej będzie rozwiązać za pomocą dziedziczenia, to zrób to! Powiem Ci jednak, że w dzisiejszych czasach nie widzę już sensu używania dziedziczenia na dłuższą metę i już spieszę z wytłumaczeniem dlaczego. Kompozycja opisuje zachowania, mówi o tym, co instancje mogą robić, zaś dziedziczenie to opis czym obiekty są. Era modelowania oprogramowania zorientowana na rzeczowniki  (opis czym coś jest) odchodzi do lamusa. Podejścia typu Domain Driven Design zmuszają nas do myślenia o modelach w kategorii, co one robią, a nie jak wyglądają. Kompozycja pozwala lepiej odzwierciedlić skalę problemu i wprowadza dużo elastyczności.

Podsumowując

  • Stosowanie kompozycji pozwala na elastyczność zmiany implementacji poprzez wymianę komponentów składowych.
  • Composition over inheritance, to mniejszy coupling oraz małe klasy, dzięki większej granulacji.
  • Porzucenie dziedziczenia na rzecz komponowania to otwarcie się na modelowanie zorientowane na zachowania.
Autor wpisu

blog@orbisbit.com

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również
  • CQRS – Command Query Responsibility Segregation

    Command Query Responsibility Segregation czyli CQRS. Jest to wzorzec projektowy, który rozdziela zadania odczytu i zapisu do osobnych modeli. Sprawdź ten wpis, aby dowiedzieć się kiedy i jak z niego skorzystać.

    Zobacz wpis

  • GRASP – kolejny zbiór zasad Clean Code do zapamiętania

    Pewnie większość z Was słyszała o zasadach SOLID. Są one bardzo rozpowszechnione i dosyć często stosowane, ale czy słyszeliście o GRASP? General Responsibility Assignment Software Patterns, to kolejna dawka zasad czystego kodu do zapamiętania.

    Zobacz wpis

  • Wzorzec strategia (strategy pattern)

    Jeżeli masz dość if-ologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

    Zobacz wpis