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 Animal, Car extends Vehicle, Square extends Figure i inne.

Composition jest to również bardzo ważna reguła projektowania obiektowego. Z logicznego punktu widzenia kompozycja jest to relacja typu posiadanieKomponujemy 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




Polecane wpisy:

Wzorzec budowniczy (builder)

Wzorzec projektowy budowniczy służy do dynamicznej budowy obiektów. Pozwala na kreowanie różnych typów obiektu w zależności od przesłanych wymagań. Dzięki temu posiadamy centralny punkt odpowiedzialny jedynie za tworzenie wariacji danego typu.

Sprawdź ten wpis

Wzorzec metoda wytwórza (factory metod)

Jeżeli masz do czynienia z tworzeniem różnych obiektów bazujących na predefiniowanym modelu, to powinieneś/aś skorzystać z metody wytwórczej. Kreacyjny wzorzec projektowy factory method pozwala być fancy w tworzeniu obiektów.

Sprawdź ten wpis

SOLID z przykładami w TypeScript

SOLID (Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle), czyli pięć zasad programowania obiektowego, które każdy powinien przestrzegać.

Sprawdź ten wpis

Jak zostać architektem oprogramowania?

Aby projektować dobrą architekturę oprogramowania trzeba najpierw zaznajomić się z jej przesłankami, rodzajami, a także sposobami wdrażania. Architektura nie jest przecież czymś stałym, co wszędzie implementowane jest w jednakowy sposób.

Sprawdź ten wpis

Command-Query Separation (CQS)

Command-Query Separation, czyli zasada o rozdzielaniu zadań metod tak, aby były jedynie komendami lub zapytaniami. Jeżeli Twoja metoda realizuje logikę i zwraca wyniki, lub podczas pobierania danych wykonuje side taski, to łamie ona CQS.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.