Zasada projektowa Kompozycja ponad Dziedziczenie

Dlaczego warto rozważyć „kompozycję ponad dziedziczenie” w Twoim projekcie? Może nadszedł czas, by zastanowić się nad ograniczeniem dziedziczenia na rzecz stosowania kompozycji? Czy tak naprawdę jest composition over inheritance w paradygmacie obiektowym?

Załóżmy, że projektujemy aplikację do obsługi konfiguracji pojazdów 🚗, która ma obsługiwać ich rożne rodzaje. Każdy pojazd ma wiele atrybutów, takich jak nazwa, opis, cena, kolor, waga, itp.

W podejściu z dziedziczeniem, moglibyśmy stworzyć klasę bazową „Vehicle” i klasy pochodne dla każdego rodzaju produktu, np. 🚗„Car”, 🛺„Truck”, 🛵„Motorbike”, itp. Każda z tych klas pochodnych dziedziczyłaby atrybuty i metody klasy bazowej, a także dodawała własne specyficzne dla danego typu pojazdu.

Jednakże, w przypadku gdy dodajemy nowe rodzaje pojazdów, musielibyśmy tworzyć nowe klasy dziedziczące po klasie bazowej, co może prowadzić do powstania drzewa dziedziczenia, które jest trudne do utrzymania i rozwijania.

Zamiast tego, można zastosować kompozycję, tworząc klasę „Vehicle” zawierającą wszystkie atrybuty wspólne dla wszystkich typów pojazdów oraz klasę „VehicleType”, która zawiera atrybuty i metody specyficzne dla danego typu pojazdu. Klasa „Vehicle” zawierałaby również referencję do obiektu „VehicleType”, który definiuje szczegóły danego typu produktu.

Dzięki takiemu podejściu, łatwiej jest dodawać nowe rodzaje pojazdów, ponieważ nie trzeba tworzyć nowych klas dziedziczących, a jedynie nowe obiekty klasy „VehicleType”. Dodatkowo, możemy łatwo zmienić atrybuty specyficzne dla danego typu pojazdu, bez wpływu na resztę kodu 🙂.

Spis treści

Czym jest dziedziczenie?

Wielu z nas jest zaznajomionych z pojęciem dziedziczenia. Dziedziczenie umożliwia tworzenie kodu, który można ponownie wykorzystać, poprzez definiowanie klasy bazowej, z której następnie korzystają inne klasy. Klasa bazowa niesie ze sobą definicje czy deklaracje (jeśli jest abstrakcyjna) metod oraz predefiniowane właściwości. Przykładem może być klasa Car rozszerzająca Vehicle. Dziedziczenie to szczególny przypadek agregacji. Jeden obiekt zawiera inny obiekt. Na diagramach UML agregację oznacza się pustym rombem.

Czym jest diagram UML?

Diagram klas UML jest statycznym diagramem, przedstawiającym strukturę aplikacji bądź systemu w paradygmacie programowania obiektowego.

Dziedziczenie umożliwia wyodrębnienie cech wspólnych dla kilku klas i zamknięciu tych cech w klasie bardziej ogólnej – o wyższym poziomie abstrakcji.

Klasy dziedziczące po klasie bazowej przejmują jej cechy. Pozwala to znacznie skrócić kod i zorganizować kod od strony logicznej.

  • Pojazd: Jest to klasa bazowa, która zawiera wspólne cechy i metody, które będą dziedziczone przez wszystkie typy pojazdów (takie jak marka, model, rok produkcji).
  • Samochód, Motocykl, Ciężarówka: Są to klasy pochodne, które dziedziczą z klasy Pojazd i mogą zawierać dodatkowe cechy lub metody specyficzne dla danego typu pojazdu.
// Klasa bazowa dla wszystkich pojazdów
class Vehicle {
    protected string $brand;
    protected string $model;
    protected int $productionYear;

    public function __construct(
        string $brand,
        string $model,
        int $productionYear
    ) {
        $this->brand = $brand;
        $this->model = $model;
        $this->productionYear = $productionYear;
    }

    public function printInformation(): string
    {
        return "Marka: " . $this->brand . ", Model: " . $this->model . ", Rok produkcji: " . $this->productionYear;
    }
}

// Klasa dla samochodów
class Car extends Vehicle {
    private string $bodyType;

    public function __construct(
        string $brand,
        string $model,
        int $productionYear,
        string $bodyType
    ) {
        parent::__construct($brand, $model, $productionYear);
        $this->bodyType = $bodyType;
    }

    public function printInformation(): string
    {
        return parent::printInformation() . ", Typ nadwozia: " . $this->bodyType;
    }
}

// Klasa dla motocykli
class Motorbike extends Vehicle {
    private $engineCapacity;

    public function __construct(
        string $brand,
        string $model,
        int $productionYear,
        string $engineCapacity
    ) {
        parent::__construct($brand, $model, $productionYear);
        $this->engineCapacity = $engineCapacity;
    }

    public function printInformation(): string
    {
        return parent::printInformation() . ", Pojemność silnika: " . $this->engineCapacity;
    }
}

// Klasa dla ciężarówek
class Truck extends Vehicle {
    private $capacity;

    public function __construct(
        string $brand,
        string $model,
        int $productionYear,
        string $capacity
    ) {
        parent::__construct($brand, $model, $productionYear);
        $this->capacity = $capacity;
    }

    public function printInformation(): string
    {
        return parent::printInformation() . ", Ładowność: " . $this->capacity;
    }
}

// Tworzenie instancji różnych pojazdów
$samochod = new Car("Toyota", "Corolla", 2021, "sedan");
echo $samochod->printInformation() . "\n";

$motocykl = new Motorbike("Yamaha", "R1", 2022, "998cc");
echo $motocykl->printInformation() . "\n";

$ciezarowka = new Truck("Scania", "R450", 2020, "24000kg");
echo $ciezarowka->printInformation() . "\n";

Czym jest kompozycja?

Kompozycja (ang. composition) definiuje relacje typu posiadanie. Jeden obiekt posiada drugi. Komponujemy pewien byt za pomocą danych składowych. W praktyce, kompozycja pozwala budować obiekty z mniejszych części. Można to porównać do tworzenia obrazu z kolorowych kawałków mozaiki, gdzie każdy element wnosi coś unikatowego do całości. W programowaniu, kompozycja pozwala łączyć klasy w nowe struktury. Na diagramach UML kompozycję oznacza się wypełnionym rombem.

Agregacja całkowita (kompozycja) jest szczególnym rodzajem asocjacji. Dwie klasy w relacji agregacji całkowitej tworzą nierozerwalną jedność i nie mogą istnieć bez siebie niezależnie. Mają one także wspólny cykl życia.

Powyższy obrazek można czytać w następujący sposób: klasa Car ma (na własność) klasę Vehicle. Pamiętaj, że nie chodzi tutaj zagnieżdżenie klas tylko o związek relacji całkowitej, czyli odpowiedzialność klasy głównej za istnienie klasy częściowej.

Na czym polega kompozycja?

Kompozycja jest to wstrzyknięcie przez konstruktor lub setter własności danej klasy.

W tym podejściu kompozycji, zamiast dziedziczyć klasy Samochod, Motocykl, i Ciezarowka bezpośrednio z klasy Pojazd, one zawierają instancję klasy Pojazd jako element składowy.

// Class defining basic properties of a vehicle
class Vehicle {
    private $brand;
    private $model;
    private $yearOfManufacture;

    public function __construct($brand, $model, $yearOfManufacture) {
        $this->brand = $brand;
        $this->model = $model;
        $this->yearOfManufacture = $yearOfManufacture;
    }

    public function displayInformation() {
        return "Brand: " . $this->brand . ", Model: " . $this->model . ", Year of Manufacture: " . $this->yearOfManufacture;
    }
}

// Class for specific characteristics of a car
class Car {
    private $vehicle;
    private $bodyType;

    public function __construct(Vehicle $vehicle, $bodyType) {
        $this->vehicle = $vehicle;
        $this->bodyType = $bodyType;
    }

    public function displayInformation() {
        return $this->vehicle->displayInformation() . ", Body Type: " . $this->bodyType;
    }
}

// Class for specific characteristics of a motorcycle
class Motorcycle {
    private $vehicle;
    private $engineCapacity;

    public function __construct(Vehicle $vehicle, $engineCapacity) {
        $this->vehicle = $vehicle;
        $this->engineCapacity = $engineCapacity;
    }

    public function displayInformation() {
        return $this->vehicle->displayInformation() . ", Engine Capacity: " . $this->engineCapacity;
    }
}

// Class for specific characteristics of a truck
class Truck {
    private $vehicle;
    private $loadCapacity;

    public function __construct(Vehicle $vehicle, $loadCapacity) {
        $this->vehicle = $vehicle;
        $this->loadCapacity = $loadCapacity;
    }

    public function displayInformation() {
        return $this->vehicle->displayInformation() . ", Load Capacity: " . $this->loadCapacity;
    }
}

// Creating instances of vehicle and its specific characteristics
$vehicle1 = new Vehicle("Toyota", "Corolla", 2021);
$car = new Car($vehicle1, "sedan");
echo $car->displayInformation() . "\n";

$vehicle2 = new Vehicle("Yamaha", "R1", 2022);
$motorcycle = new Motorcycle($vehicle2, "998cc");
echo $motorcycle->displayInformation() . "\n";

$vehicle3 = new Vehicle("Scania", "R450", 2020);
$truck = new Truck($vehicle3, "24000kg");
echo $truck->displayInformation() . "\n";

🚀 Zalety kompozycji

Elastyczność 🔄: Kompozycja jest bardziej elastyczna niż dziedziczenie. Pozwala na zmianę zachowania obiektów w czasie rzeczywistym poprzez zmianę ich składowych; można łatwiej modyfikować logikę bez zmiany całych klas.

Unikanie nadmiernego sprzęgania 🔗: Dziedziczenie często prowadzi do silnego sprzęgania między klasą bazową a klasami pochodnymi, co może być problematyczne przy późniejszych zmianach w kodzie. Kompozycja promuje luźne sprzęganie, co ułatwia utrzymanie i skalowanie kodu.

Większa przejrzystość 📖: Obiekty skomponowane z innych obiektów są zazwyczaj łatwiejsze do zrozumienia, ponieważ lepiej odzwierciedlają rzeczywisty świat, wykorzystując bardziej naturalne relacje.

Unikanie problemów z dziedziczeniem wielokrotnym ⚔️: Języki, które nie obsługują wielokrotnego dziedziczenia (jak Java czy C#), mogą korzystać z kompozycji, aby osiągnąć podobną funkcjonalność bez komplikacji związanych z dziedziczeniem z wielu klas.

Reużywalność kodu ♻️: Komponenty używane w kompozycji mogą być łatwo ponownie użyte w różnych kontekstach, bez konieczności dziedziczenia z nich, co zwiększa ponowną użyteczność i modularność kodu.

Przestrzeganie zasady pojedynczej odpowiedzialności 🎯: Kompozycja sprzyja tworzeniu klas, które realizują pojedyncze zadania, co jest zgodne z zasadą pojedynczej odpowiedzialności (Single Responsibility Principle) – jednym z kluczowych założeń programowania obiektowego.

Lepsze zarządzanie cyklem życia obiektów 🔄: Kompozycja pozwala na lepsze zarządzanie cyklem życia składowych obiektów, ponieważ każdy z nich może być niezależnie tworzony, modyfikowany i niszczony.

Wybór między kompozycją a dziedziczeniem zależy od konkretnego przypadku i wymagań projektowych. Kompozycja często jest zalecana jako bardziej elastyczne i mniej problematyczne podejście, szczególnie w złożonych systemach, gdzie zmiany są częste i kosztowne.

Moje początki programowania sięgają 2010r. W trakcie wielu projektów zdobywałem doświadczenie, rozwijając nie tylko umiejętności techniczne, ale także kompetencje miękkie. Programuje głównie w PHP i Python.