Kompozycja ponad dziedziczenie
Jedną z możliwości programowania obiektowego jest dziedziczenie. Daje nam ono możliwość powtórnego wykorzystania kodu poprzez tworzenie podklas. Warto mieć na uwadze, że nie jest ono złotym środkiem, a jednak bywa ono często nadużywane.
Definicje
Dziedziczenie – mechanizm programowania obiektowego, służący do współdzielenia metod oraz składowych pomiędzy klasami. Klasa podrzędna dziedziczy po klasie bazowej, co oznacza, że oprócz własnych właściwości oraz zachowań, zawiera również te z klasy bazowej.
Kompozycja – tak jak dziedziczenie jest metodą pozwalającą na powtórne wykorzystanie danego rozwiązania. Z tym, że w przypadku kompozycji mamy do czynienia ze składaniem obiektów. Klasa zbudowana jest z innych klas, co znaczy, że obiekt takiej klasy agreguje obiekty innych klas i deleguje odpowiednie działania do nich.
Co jest nie tak z dziedziczeniem?
Dziedziczenie klas definiowane jest statycznie, co znaczy, że nie mamy możliwości zmiany implementacji w czasie wykonywania aplikacji. Implementacja podklas zależna jest od implementacji klasy bazowej, więc zmiany w klasie bazowej często wymuszają również zmiany w podklasach. Rozbudowane hierarchie dziedziczenia wpływają również negatywnie na testowanie kodu oraz analizę danego rozwiązania.
Dlaczego kompozycja jest lepsza niż dziedziczenie?
Poprzez zastosowanie kompozycji zyskujemy pewną elastyczność. W razie potrzeby możemy dynamicznie w czasie wykonywania aplikacji zmienić implementację jakiej używamy. Kolejnym plusem jest rozbicie klas na mniejsze, co pozwala nam na dostarczanie rozwiązań zgodnych z zasadami programowania obiektowego SOLID.
Załóżmy, że musimy zaprojektować system, w którym do czynienia będziemy mieli z pracownikami podzielonymi na poszczególne stanowiska np. Developer, Project Manager itp. Każdy pracownik będzie miał imię, nazwisko, jednak różnica będzie taka, że do developera będziemy mogli przypisać jego główny język programowania, natomiast do Project Managera będzie możliwość dodania projektów jakimi zarządza. Do tego momentu oczywiście myślimy o dziedziczeniu.
Jednak dodatkowo każdy pracownik może rozliczać swoje wynagrodzenie na różne sposoby np. umowa o pracę lub własna działalność(b2b). Czy widzisz tutaj jakiś dobry sposób dziedziczenia? Z racji, że zarówno Developer jak i Project Manager może wybrać dowolny sposób rozliczania, to najwygodniej jest tutaj zastosować kompozycję. Chcąc załatwić sprawę za pomocą dziedziczenia, nie jesteśmy w stanie dostarczyć rozwiązania łatwego w późniejszym utrzymaniu.
Przejdźmy zatem do implementacji. Zaczniemy najpierw od abstrakcyjnej klasy bazowej pracownika oraz odpowiednich interfejsów. Abstrakcyjnej dlatego, że zakładamy iż nie będzie „zwykłego” pracownika, a jedynie pracownicy z odpowiednimi tytułami: Developer etc., mającymi własne cechy charakterystyczne.
<?php namespace App\Employee; use App\Salary\SalaryCalculatorInterface; abstract class Employee implements SalaryCalculable { /** * @var string */ protected $firstName; /** * @var string */ protected $lastName; /** * @var float */ protected $netSalaryPerHour; /** * @var SalaryCalculatorInterface */ protected $salaryCalculator; public function __construct(SalaryCalculatorInterface $salaryCalculator) { $this->salaryCalculator = $salaryCalculator; $this->netSalaryPerHour = 0; } public function getFirstName(): string { return $this->firstName; } public function setFirstName(string $firstName): Employee { $this->firstName = $firstName; return $this; } public function getLastName(): string { return $this->lastName; } public function setLastName(string $lastName): Employee { $this->lastName = $lastName; return $this; } public function getNetSalaryPerHour(): float { return $this->netSalaryPerHour; } public function setNetSalaryPerHour(float $netSalaryPerHour): Employee { $this->netSalaryPerHour = $netSalaryPerHour; return $this; } public function getSalary(): float { $this->salaryCalculator->calcSalary($this->netSalaryPerHour); } }
<?php namespace App\Employee; interface SalaryCalculable { public function getSalary(): float; }
<?php namespace App\Salary; interface SalaryCalculatorInterface { public function calcSalary(float $netPerHour): float; }
Interfejs SalaryCalculatorInterface implementowany będzie przez klasy odpowiedzialne za różne sposoby rozliczania wynagrodzenia. Następnie implementujemy klasy reprezentujące konkretnych pracowników według założeń opisanych wyżej.
<?php namespace App\Employee; class Developer extends Employee { /** * @var string */ protected $programmingLanguage; /** * @return string */ public function getProgrammingLanguage(): string { return $this->programmingLanguage; } /** * @param string $programmingLanguage * @return Developer */ public function setProgrammingLanguage($programmingLanguage): Developer { $this->programmingLanguage = $programmingLanguage; return $this; } }
<?php namespace App\Employee; class ProjectManager extends Employee { /** * @var string[] */ protected $projects; /** * @return string[] */ public function getProjects(): array { return $this->projects; } public function addProject(string $project): ProjectManager { if (!in_array($project, $this->projects)) { $this->projects[] = $project; } return $this; } public function removeProject(string $project): ProjectManager { if ($index = array_search($project, $this->projects)) { unset($this->projects[$index]); } return $this; } }
Pozostała nam jedynie szczegółowa implementacja klas odpowiedzialnych za metody rozliczania.
<?php namespace App\Salary; final class EmploymentContractSalaryCalculator implements SalaryCalculatorInterface { public function calcSalary(float $netPerHour): float { return 15000; } }
<?php namespace App\Salary; final class B2bSalaryCalculator implements SalaryCalculatorInterface { public function calcSalary(float $netPerHour): float { return 30000; } }
Dla uproszczenia przykłady nie zagłębiamy się w szczegóły, tylko podajemy stałe kwoty. W prawdziwym projekcie warto byłoby jednak nazwać dokładniej czy zwracana kwota to brutto czy netto. Przedstawione klasy użyć możemy w następujący sposób:
<?php use App\Employee\Developer; use App\Employee\ProjectManager; use App\Salary\B2bSalaryCalculator; use App\Salary\EmploymentContractSalaryCalculator; $firstDeveloper = new Developer(new B2bSalaryCalculator()); $secondDeveloper = new Developer(new EmploymentContractSalaryCalculator()); $projectManager = new ProjectManager(new EmploymentContractSalaryCalculator());
Podsumowanie
Stosując kompozycję aplikacja przez większą liczbę klas będzie sprawiała wrażenie bardziej złożonej. Po głębszej analizie powinno okazać się jednak, że łatwiej jest przewidzieć zachowanie danego kodu niż ma to miejsce przy rozbudowanym dziedziczeniu.
Oczywiście kompozycja oraz dziedziczenie powinny ze sobą współgrać, a dostarczone rozwiązanie powinno zostać przemyślane pod kątem przyszłych zmian. Warto jednak pamiętać, że idealnych rozwiązań nie ma i często jest tak, że nie jesteśmy w stanie przewidzieć, że coś się zmieni. Ciężko jest również napisać kod przygotowany na wszystkie zmiany. Dążenie do perfekcji nie jest niczym dobrym, należy po prostu w razie wystąpienia zmian, których nie przewidzieliśmy dostosować odpowiednio kod i zostawić go lepszym niż zastaliśmy.
Subscribe and master unit testing with my FREE eBook (+60 pages)! 🚀
In these times, the benefits of writing unit tests are huge. I think that most of the recently started projects contain unit tests. In enterprise applications with a lot of business logic, unit tests are the most important tests, because they are fast and can us instantly assure that our implementation is correct. However, I often see a problem with good tests in projects, though these tests’ benefits are only huge when you have good unit tests. So in this ebook, I share many tips on what to do to write good unit tests.
Dobry tekst! Zdecydowanie w projektach które spotkałem jest za dużo dziedziczenia. Analiza zachowania klasy która dziedziczy po 8 nadrzędnych nie należy do najprzyjemniejszych.
Proponuję też wyrzucić wszystkie settery z powyższych klas a netSalaryPerHour, projects i programmingLanguage wstrzykiwać w konstruktorach 🙂
Myślałem o wyrzuceniu setterów, bo tutaj niby mamy niewiele pól, ale w realnym przypadku na pewno byłoby ich zdecydowanie więcej, więc taki gigantyczny konstruktor by z tego wyszedł, stąd decyzja, że settery zostają.
Warto dodać, że dodawanie w nazwie interfejsu słowa Interface to niepożądana praktyka.
Według PSR słowo Interface powinno się pojawić 😉
nie rozumiem kto i po co robił ten cały skomplikowany schemat OOP, ogólnie to nie mam nic przeciwko jak ktoś zrobi jedna bibliotekę bazująca na OOP, bo wtedy sobie z niej skorzystam jak potrzeba, i podziękuje za takie rozwiązania 🙂
Kompozycja i dziedziczenie to pewnie narzędzia, które udostępnia nam język. Odpowiedni ich dobór powinien wynikać z praktyki, aniżeli widzi mi się. Problem w tym, że nawet długie godziny planowania architektury nie są w stanie z całą dokładnością wykazać, z którego narzędzia powinniśmy skorzystać.
Warto jest więc mieć ogólny plan, ale trzeba pamiętać, że to które rozwiązanie jest lepsze okaże się zdecydowanie później i wyjdzie „w praniu”.
Stąd przede wszystkim nadrzędną zasadą powinno być, aby nic nie było wyryte w kamieniu. Aby każdy element był bezboleśnie wymienialny, moduły.
Czy klasa ProjectManager nie powinna extendować Employee?
Oczywiście, że powinna, poprawione 😉 Plus za spostrzegawczość 🙂
I co ja mogę z tym plusem teraz zrobić? 😉
Obawiam się, że nic 😀 Satysfakcja musi wystarczyć.
Czy nie powinno być tak, że
zamiast `class ProjectManager extends Developer`
powinno być tak:
`class ProjectManager extends Employee`?
Po co project managerowi: `programmingLanguage`
Powinna, w każdym razie tak jak wyżej odpisywałem w komentarzu, poprawione 🙂 pewnie zabrakło odświeżenia 😀
W przedstawionej sytuacji, gdyby tylko zmienił się np. sposób obliczania pensji dla umowy B2B, to miałbyś problem. Np. dochodzi założenie, że wynagrodzenie na B2B zależy od liczby rzeczywiście przepracowanych godzin, a na etacie jest stała dla miesiąca. Czyli metodzie calcSalary w SalaryCalculatorInterface musiałaby dojść dodatkowy parametr. Pytanie co wtedy zrobić, dodając nową metodę do interfejsu? Wtedy etatowiec też musiałby ją implementować, a jej nie wykorzystuje, więc prawdopodobnie implementacja zostałaby pusta i niewykorzystywana. Zmodyfikować metodę calcSalary, dodając nowy argument? Wtedy SalaryCalculator dla etatowca musiałby przyjmować argument którego nie wykorzystuje.
Myślę, że w takim wypadku lepiej sprawdziłby się wzorzec Visitor. Klasa Employee miałaby metodę accept, przyjmującą wizytatora jako argument, a każda podklasa przekazywała siebie jako argument dla wizytatora. Interfejs EmployeeVisitor byłby implementowany przez klasę SalaryCalculator, Miałaby obliczanie pensji dla każdego z typu pracowników i metodę calculateSalary:
public int calculateSalary(Employee employee) {
return employee.accept(this);
}
Dodatkowy plus jest taki, że masz jedną klasę odpowiedzialną za obliczanie pensji, jeżeli w jej środku korzystasz np. z bazy danych, to tylko raz zawiązywane będzie połączenie, a nie jak w Twoim przypadku raz na każdą klasę implementującą SalaryCalculatorInterface.
Jasna sprawa, podany przykład jest sporym uproszczeniem. Myślę, że można byłoby tutaj zdefiniować dodatkową klasę reprezentującą warunki zatrudnienia i sposób obliczania wynagrodzenia, bo tych wariantów będzie mnóstwo i odpowiednio agregować ją do klasy Employee. Wtedy do SalaryCalculator zamiast pojedynczych parametrów, moglibyśmy przekazywać obiekt z warunkami zatrudnienia. Także jakoś pogodzić się to da. Co do bazy danych to raczej nie ma znaczenia, bo przecież połączenie nie będzie za każdym razem nawiązywane, tylko odpowiednio wstrzykiwane. Visitor też oczywiście mógłby się tutaj sprawdzić.