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 programowania 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:

KISS: keep it simple, stupid!

Zasada KISS: "Keep it simple, stupid", może zostać dosłownie przetłumaczona na: "rób to prosto, głupku". Mówi ona o tym, abyśmy tworzyli kod w jak najprostszy i najbardziej czytelny sposób. Już dziś sprawdź, czego się wystrzegać, aby spełniać KISS.

Sprawdź ten wpis

Wzorce projektowe

Wzorce projektowe zostały stworzone po to, aby nie wymyślać przysłowiowego koła na nowo. Znajomość wzorców projektowych i umiejętność ich stosowania pozwala na szybkie rozwiązywanie problemów. Wpis ten radzi, jakie wzorce zastosować u siebie.

Sprawdź ten wpis

Wzorzec singleton

Kolejny kreacyjny wzorzec projektowy omawiany na łamach tego bloga: singleton. Wzorzec dookoła którego narosło wiele mitów i legend. Dziś o tym dlaczego singleton jest antywzorcem, jakie problemy powoduje oraz kiedy warto po niego sięgnąć.

Sprawdź ten wpis

Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

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.