Programowanie obiektowe jest próbą przedstawienia świata rzeczywistego oraz relacji w nim zachodzących za pomocą obiektów. Abstrakcja, hermetyzacja, polimorfizm oraz dziedziczenie to cztery główne cechy, które opisują ten rodzaj programowania. Niektóre z nich istnieją również w innych paradygmatach programowania jak strukturalnym czy funkcyjnym. Aby jednak dobrze zrozumieć czym jest programowanie obiektowe warto nieco bardziej pochylić się nad tymi zagadnieniami.
Abstrakcja
Abstrakcja upraszcza nam pewne rzeczy przez usunięcie informacji, które nie są niezbędne do ich zrozumienia.
Abstrakcja w życiu realnym
Abstrakcja jest dla nas czymś naturalnym i używamy jej codziennie nawet nie zdając sobie z tego sprawy. Jeśli kolega spyta nas w jaki sposób dojeżdżamy do pracy to przecież nie odpowiadamy, że poruszamy się czterokołowym pojazdem, przeznaczonym konstrukcyjnie do przewozu nie więcej niż 9 osób łącznie z kierowcą, napędzanym za pomocą silnika benzynowego, który ma 2 litry pojemności i 10 tyś. przebiegu. Dla niego są to nieistotne informacje, które w żaden sposób nie pomagają mu zrozumieć czym tak naprawdę jeździmy, a wręcz to komplikują.
Łatwiej było by mu zrozumieć gdybyśmy po prostu powiedzieli, że poruszamy się samochodem. Właśnie owy samochód jest tutaj abstrakcją pod którą kryją się te wszystkie niepotrzebne szczegóły takie jak: ile ma kół czy ile osób może przewozić. Inaczej sprawa ma się w przypadku mechanika, którego może już interesować więcej szczegółów związanych z naszym pojazdem.
Wielowarstwowość
Abstrakcje mogą posiadać wiele poziomów. W naszym przypadku pierwszym poziomem abstrakcji jest samochód, natomiast drugim może być silnik, który wprawia go w ruch czy układ skrętny, który pozwala zmieniać kierunek jazdy. Poziomów abstrakcji jest nieskończenie wiele. Silnik jest abstrakcją wyższego poziomu dla części ruchomych z których się składa. Stalowa zębatka jest abstrakcją wyższego poziomu dla kształtu i rodzaju metalu, a z kolei dany metal jest abstrakcją wyższego poziomu dla różnych protonów, elektronów czy też neutronów.
Abstrakcja w IT
W programowaniu podobnie jak w prawdziwym życiu również mamy abstrakcje. Przykładowo aplikacja z której korzysta użytkownik jest abstrakcją, która ukrywa różne moduły, które z kolei mogą ukrywać klasy, które z kolei składają się z metod. Nawet sam język programowania jest warstwą abstrakcji dla systemu binarnego, które z kolei jest warstwą abstrakcji dla fluktuacji strukturalnych w polu magnetycznym itd, itp.
Skupmy się nieco bardziej na wcześniej wspomnianej aplikacji. Powiedzmy, że tworzymy prosty sklep internetowy. Spójrzmy jak taki sklep może wyglądać na różnych poziomach abstrakcji. Abstrakcje stanowi już wcześniej wspomniany sklep. Powiedzmy, że sklep składa się z trzech systemów. Aplikacji mobilnej, serwera oraz bazy danych. Serwer jest abstrakcją dla modułów, które się w nim znajdują. Moduły są natomiast abstrakcją dla klas, które zawierają itd. Warstw abstrakcji jest nieskończenie wiele. Jak możesz zauważyć im wyższa warstwa abstrakcji tym mniejsza złożoność naszego systemu. Użytkownik aplikacji nie musi się przejmować w jaki sposób oraz w jakiej kolejności są wywoływane niskopoziomowe metody. Wystarczy, że kliknie odpowiedni przycisk.
Abstrakcja w programowaniu
Skoro już mamy pogląd na to czym jest abstrakcja skupmy się teraz na użyciu jej w programowaniu. Abstrakcje opisują zachowania naszych modułów, klas, metod czy też funkcji. Weźmy za przykład klasę odpowiedzialną za wysyłkę wiadomości email. W przypadku tej klasy abstrakcją może być klasa abstrakcyjna lub interfejs. Wykorzystując je jesteśmy w stanie określić jakie metody ma posiadać nasza klasa bez informacji o tym jak dokładnie mają zostać zaimplementowane. Jedyne co będziemy znać to nazwy tych metod, przyjmowane przez nie argumenty i zwracane dane. Abstrakcja jest swego rodzaju kontraktem. Przykładowo nasz kontrakt może zakładać, że nasza klasa będzie posiadać metodę sendEmail(email: string, data: any): void
. Jak widzisz nie ma tutaj żadnej informacji o tym jak dokładnie ta metoda ma być zaimplementowana a mimo to posiadamy wszystkie wymagane informacje by móc z niej korzystać.
// Nasza abstrakcja interface EmailService { sendEmail(email: string, data: any): void; } // Klasa, która implementuje naszą abstrakcje class AwsEmailService implements EmailService { private awsInstance = new AWS({...}); public sendEmail(email: string, data: any): void { this.awsInstance.send(email, data); } } // Klasa, która implementuje naszą abstrakcje class GoogleEmailService implements EmailService{ private googleInstance = new Google({...}); public sendEmail(email: string, data: any): void { this.googleInstance.send(email, data); } } // Klasa, która korzysta z naszej abstrakcji class AuthService { constructor( private readonly emailService: EmailService, ) {} public register(data: Data): void { // Logika odpowiedzialna za rejestracje // Wysyłanie wiadomości email this.emailService.sendEmail(data.email, {...}) } } const googleEmailService = new GoogleEmailService(); const awsEmailService = new AwsEmailService(); // Dzięki temu, że nasza klasa bazuje na abstrakcji, a nie faktycznej implementacji możemy bezproblemowo podmienić klasę odpowiadającą za wysyłanie wiadomości email const authService = new AuthService(googleEmailService); const authService2 = new AuthService(awsEmailService);
Jak możesz zauważyć abstrakcja jest o wiele bardziej stabilna od implementacji, ponieważ posiada ona jedynie informacje o zachowaniu jakie udostępnia nasza klasa, a nie o jego faktycznej implementacji. Zachowanie zmienia się zdecydowanie rzadziej niż implementacja. Dzięki temu możemy dowolnie zmieniać szczegóły implementacyjne, bez obaw, że nasze zmiany będą oddziaływać na resztę kodu. Oczywiście pod warunkiem, że spełniamy wszystkie założenia naszej umowy. Co więcej dzięki abstrakcji możemy również pominąć niepotrzebne dla nas informacje.
// Abstrakcja opisująca cały pojazd abstract class Vehicle { abstract color: string; abstract engine: string; abstract checkEngine(): string; abstract start(): void; abstract accelerate(): void; abstract brake(): void abstract refuel(): void; } // Abstrakcja opisująca pojazd wyścigowy interface RacingVehicle { start(): void; accelerate(): void; brake(): void } // Abstrakcja opisująca pojazd do naprawy interface CarToBeRepaired { engine: string; checkEngine(): string; } // Wyścigówka, która rozszerza naszą abstrakcje oraz implementuje interfejsy class Racer extends Vehicle implements RacingVehicle, CarToBeRepaired { public color = 'red'; public engine = 'v10'; public checkEngine(): string { return 'Engine works fine' } public start(): void { // ... } public accelerate(): void { // ... } public brake(): void { // ... } public refuel(): void { // ... } } // Kierowcy, który używa naszej wyścigówki nie interesuje jaki ma kolor, rodzaj slinika ani jak się ją tankuje // Dla niego ważne jest tylko to, że może ją odpalić, przyspieszać i hamować class Driver { constructor( private readonly racer: RacingVehicle, ) {} } // Mechanika interesuje tylko możliwość sprawdzenia czy silnik działa oraz jaki jest jego rodzaj. // Nie interesuje go natomiast cała reszta. Ba! Nawet nie interesuje go czy pojazd jest wyścigówką. class Mechanic { constructor( private readonly vehicle: CarToBeRepaired, ) {} } const racingVehicle = new Racer(); const driver = new Driver(racingVehicle); const mechanic = new Mechanic(racingVehicle);
Dzięki abstrakcji nasze klasy zależą tylko od metod i właściwości, których rzeczywiście potrzebują. Usuwają nieistotne szczegóły, których nie potrzebujemy w danym kontekście co znacząco ułatwia zrozumienie. Dzięki czemu reprezentacja naszego pojazdu w poszczególnych klasach jest o wiele prostsza. Co więcej, żeby np. przetestować naszą klasę Mechanic
nie musimy mockować całej klasy Vehicle
, a jedynie te rzeczy z których korzysta nasz mechanik.
Nie tylko klasy abstrakcyjne i interfejsy stanowią warstwę abstrakcji!
Każda klasa, metoda, funkcja czy nawet zmienna jest abstrakcją. Spójrzmy na przykład.
// Klasa PaymentService jest abstrakcją dla metod pay oraz cancelPayment class PaymentService { // Metoda pay jest abstrakcją dla złożonej logiki biznesowej, która się pod nią znajduje public pay(): void { // Zmienna paymentSystem jest abstrakcją, dla instancji PayPal. const paymentSystem = new PayPal({ ... }) } // Metoda cancelPayment jest abstrakcją dla złożonej logiki biznesowej, która się pod nią znajduje public cancelPayment(): void { // Skomplikowana polityka sprawdzająca czy daną płatność można anulować oraz proces anulacji } }
Dlaczego potrzebujesz abstrakcji?
Abstrakcja pomaga nam uprościć reprezentacje
- Zmniejsza poziom złożoności (skomplikowania) przez co ułatwia zrozumienie i testowanie kodu
- Opisuje tylko interesujące nas zachowania i ukrywa nieistotne szczegóły co bardzo ułatwia i przyspiesza implementacje i integracje
- Pomaga podzielić program na wiele niezależnych części
- Pomaga w wymianie informacji między członkami zespołu, ponieważ pomijamy nieistotne szczegóły i skupiamy się tylko na tym co jest ważne w danym kontekście
Hermetyzacja (enkapsulacja)
Hermetyzacja jest sposobem na ukrycie rzeczy i chronieniem ich przed niepowołanym dostępem. Stanowi pewnego rodzaju barierę. Użytkownik ma do nich dostęp tylko i wyłącznie za pomocą ściśle określonych mechanizmów. Nie jest w stanie ich bezpośrednio odczytywać, zmieniać lub modyfikować. Hermetyzacja ukrywa również logikę, której użytkownik końcowy nie musi lub nie powinien być świadom.
Hermetyzacja w życiu realnym
Codziennie możemy zauważyć wiele przykładów hermetyzacji. Jednym z nich jest chociażby gniazdko. Jeśli chcesz podładować telefon to nie możesz bezpośrednio udać się do elektrowni i podłączyć do reaktora w celu pozyskania energii. Dostęp do niego jest ściśle chroniony, ponieważ moglibyśmy nieumyślnie coś popsuć i zagrać na żywo w stalkera. Jedynym wyjściem aby podładować telefon jest podpięcie ładowarki do gniazdka. Gniazdko to właśnie ściśle określony mechanizm, który udostępnia energię elektryczną. Dzięki niemu nie musisz przejmować się temperaturą reaktora, jego chłodzeniem czy napięciem jakie generuje. Wystarczy, że podepniesz swój telefon do gniazda i masz pewność, że zostanie on naładowany (pod warunkiem, że płacisz rachunki).
Kolejnym przykładem może być ekspres do kawy. Ekspres składa się z wielu skomplikowanych części: zbiornika na ziarno, wodę oraz mleko, podgrzewacza, młynka do mielenia kawy itp. Działają one w ściśle określony sposób. Żeby zrobić latte ekspres musi dobrać odpowiedni rodzaj ziaren, zmielić je, podgrzać wodę do odpowiedniej temperatury, spienić oraz dolać odpowiednią ilość mleka itd. Jak widzisz to bardzo skomplikowany proces w którym wiele rzeczy może pójść nie tak jeśli robilibyśmy to ręcznie.
Natomiast szczęśliwi posiadacze ekspresu nie muszą się o to martwić. Jedyne co muszą zrobić to włączyć ekspres i wybrać odpowiedni rodzaj kawy. O resztę zadbali ludzie, którzy go zaprojektowali. Jest on pewnego rodzaju pudełkiem w którym znajdują się wszystkie odpowiednio zaprogramowane elementy do których nie mamy dostępu przez co nie jesteśmy w stanie niczego popsuć. Interakcja między nami, a elementami znajdującymi się wewnątrz jest ściśle określona za pomocą mechanizmu jakim jest interfejs, który udostępnia. Wszystkie części oraz mechanizmy upakowane są w jednej jednostce jaką jest ekspres.
Wielowarstwowość
Hermetyzacja, podobnie jak abstrakcja ma wiele warstw. Ekspres hermetyzuje poszczególne części i mechanizmy jakie się w nim znajdują i umożliwia Ci interakcje z nimi za pomocą ściśle określonego interfejsu. Wewnątrz ekspresu znajduje młynek do kawy, który również hermetyzuje części i mechanizmy potrzebne do zmielenia kawy. Przez co ludzie odpowiedzialni za konstrukcje ekspresu nie muszą dokładnie wiedzieć w jaki sposób kawa jest mielona. Przecież młynek może pochodzić od zupełnie innego producenta. Jedyne do czego mają dostęp to gniazdo zasilania oraz odpowiedni interfejs dzięki któremu mogą wybrać grubość ziaren. Dzięki takiemu podejściu nie mogą niczego popsuć, ponieważ mechanizm mielenia jest niedostępny z zewnątrz.
Hermetyzacja w IT
Podobnie jak w przypadku abstrakcji możemy wydzielić kilka warstw hermetyzacji. Zasadniczą różnicą jest natomiast to, że w przypadku abstrakcji wiedzieliśmy co robi nasz system, moduł, czy klasa ale nie wiedzieliśmy w jaki sposób. Czyli nie znaliśmy szczegółów implementacyjnych. Mówiliśmy tylko o zachowaniach jakie udostępnia. W hermetyzacji natomiast posiadamy już wiedzę o implementacji, ale chcemy ją ukryć przed światem zewnętrznym posługując się własnie systemem, modułem, klasą czy metodą.
Nasz sklep ukrywa systemy jakie się pod nim znajdują czyli aplikacje mobilną, serwer oraz bazę danych. Możemy traktować nasz sklep jako pojedynczą jednostkę wdrożeniową. Dzięki temu osoba stawiająca naszą aplikacje nie musi się zastanawiać w jakiej kolejności oraz w jaki sposób odpalić i połączyć poszczególne systemy. Wystarczy, że odpali jedną komendę.
Przyglądając się aplikacji mobilnej, zauważymy, że ona również ukrywa jakieś zachowania. Konkretniej mówiąc ukrywa logikę odpowiedzialną za wysyłanie odpowiednich zapytań do naszego serwera. Użytkownik korzystający z naszej aplikacji nie musi się przejmować tym jakie zapytania wysłać do serwera oraz w jakiej kolejności.
Nasz serwer natomiast ukrywa moduły, które się w nim znajdują. Zewnętrzny użytkownik ma dostęp tylko do tych funkcjonalności, które udostępnia nasze API. Nie jest upoważniony do bezpośredniego korzystania ze wszystkich modułów znajdujących się w naszym serwerze, dzięki czemu nie może niczego popsuć, ani ominąć zabezpieczeń oraz reguł biznesowych. Moduły natomiast ukrywają klasy, które się w nich znajdują przez co osoba, która chce z nich skorzystać musi używać API konkretnego modułu. Idąc dalej klasy udostępniają publiczne metody, które pozwalają na interakcje z nimi. Nie powinniśmy być upoważnieni do bezpośredniej edycji pól znajdujących się w klasie. Jedyną opcją umożliwiającą ich zmianę powinny być wcześniej wspomniane publiczne metody udostępniane przez klasę. Metody również stanowią warstwę hermetyzacji, ponieważ ukrywają logikę biznesową, która się w nich znajduje.
Hermetyzacja w programowaniu
Hermetyzacja w programowaniu pozwala nam ukryć dane oraz logikę biznesową przed użytkownikiem. Użytkownik jest w stanie wyciągać dane lub je zmieniać tylko za pomocą ściśle określonych mechanizmów jakimi są zachowania czyli metody, gettery oraz setery. Spójrzmy na przykład konta bankowego, w którym nie stosujemy hermetyzacji.
class BankAccount { public email: string; public firstName: string; public lastName: string; public balance: number; constructor(email: string, firstName: string, lastName: string, balance: number) { this.email = email; this.firstName = firstName; this.lastName = lastName; this.balance = balance; } } class BankService { public makeTransfer(from: BankAccount, to: BankAccount, amount: number): void { from.balance = from.balance - amount; to.balance = to.balance + amount; } } // Dwie osoby tworzą konta w naszym banku const bankAccount1 = new BankAccount('jan@kowalski.pl', 'Jan', 'Kowalski', 1000); const bankAccount2 = new BankAccount('grzegorz@brzęczyszczykiewicz.pl', 'Grzegorz', 'Brzęczyszczykiewicz'); // Klasa odpowiedzialna za przelewy const bankService = new BankService(); // Jan Kowalski jest dłużny Grzegorzowi 1000zł więc chce je przelać na jego konto. bankService.makeTransfer(bankAccount1, bankAccount2, 1000); console.log(bankAccount1.balance); // 0 console.log(bankAccount2.balance); // 2000 // Co się stanie w sytuacji, kiedy Jan Kowalski będzie chciał ponownie przelać 1000zł? Nic! // Klasa payments, która korzysta z naszego obiektu jest w stanie przelać pieniądze z konta, pomimo że nie ma na nim żadnych pieniędzy // Nie istnieje żadne zabezpieczenie, które by temu zapobiegało, ponieważ dostęp do salda jest ogólno dostępny bankService.makeTransfer(bankAccount1, bankAccount2, 1000); console.log(bankAccount1.balance); // -1000 console.log(bankAccount2.balance); // 3000 // Możemy zaimplementować zabezpieczenie w klasie BankService, ale co gdy w przyszłości pojawi się wiecej tego typu serwisów? // Dodatkowo możemy bezpośrednio odwołać się do wartości klasy BankAccount i zmienić np. adres email. // Już w naszym systemie to by było skrajnie niebezpieczne i nieodpowiedzialne, a co jeśli udostępnilibyśmy taki obiekt dla innej aplikacji?! // Chyba nikty by nie chciał aby ktoś niepowołany zmienił adres email naszego konta bankowego. bankAccount1.email = 'nowy@email.pl';
Jak widzimy przez brak hermetyzacji oraz posiadanie możliwości bezpośredniej edycji naszych danych mamy wiele poważnych problemów. Możemy modyfikować informacje o naszym koncie bez żadnej walidacji czy jest to w ogóle możliwe. Hermetyzacja istnieje jedynie w metodzie makeTransfer
, która ukrywa to w jaki sposób przelewane są pieniądze. Spójrzmy jak będzie wyglądał nasz przykład po zastosowaniu hermetyzacji.
class BankAccount { private _email: string; private _firstName: string; private _lastName: string; private _balance: number; constructor(email: string, firstName: string, lastName: string, balance: number) { this._email = email; this._firstName = firstName; this._lastName = lastName; this._balance = balance; } // Przed pobraniem pieniędzy sprawdzamy, czy mamy wystarczającą ilość public getMoney(amount: number): void { if (this._balance - amount >= 0) { this._balance = this._balance - amount; } throw new Error('Not enough money!') } // Przy dodawaniu pieniędzy sprawdzamy, czy wartość jaką chcemy dodać nie jest ujemna public addMoney(amount: number): void { if (amount > 0) { this._balance = this._balance + amount; } } // Wysyłamy odpowiedni email z kodem public requestChangeEmail(newEmail: string): void { sendEmail(this._email); } // Aby zmienić email potrzebujemy kodu, który został wysłany na stary adres public changeEmail(newEmail: string, code: string): void { // Jeśli kod jest poprawny to zmieniamy adres email if (isValid(code)) { this._email = newEmail; } } get email(): string { return this._email; } get balance(): number { return this._balance; } // ... } class BankService { public makeTransfer(from: BankAccount, to: BankAccount, amount: number): void { from.getMoney(amount); to.addMoney(amount); } } // Dwie osoby tworzą konta w naszym banku const bankAccount1 = new BankAccount('jan@kowalski.pl', 'Jan', 'Kowalski', 1000, 'PLN'); const bankAccount2 = new BankAccount('grzegorz@brzęczyszczykiewicz.pl', 'Grzegorz', 'Brzęczyszczykiewicz', 1000, 'EUR'); // Klasa odpowiedzialna za przelewy const bankService = new BankService(); // Jan Kowalski jest dłużny Grzegorzowi 1000zł więc chce je przelać na jego konto. bankService.makeTransfer(bankAccount1, bankAccount2, 1000); console.log(bankAccount1.balance); // 0 console.log(bankAccount2.balance); // 2000 // Co się stanie w sytuacji, kiedy Jan Kowalski będzie chciał ponownie przelać 1000zł? // Otrzyma bład, który powiadomi go o niewystarczającej ilości gotówki bankService.makeTransfer(bankAccount1, bankAccount2, 1000); console.log(bankAccount1.balance); // 0 console.log(bankAccount2.balance); // 2000 // Bezpośrednia zmiana adresu również nie jest już możliwa. Adres możemy jedynie wyświetlić. bankAccount1.email = 'nowy@email.pl'; // Nasze wartości są prywatne i udostępniane jedynie przez gettery więc nie jesteśmy w stanie ich zmienić bezpośrednio. // Należy użyć odpowiedniej metody // Aby zmienić adres email należy najpierw wysłać email ze specjalnym kodem bankAccount1.requestChangeEmail('nowy@email.pl'); // A następnie go wprowadzić w celu potwierdzenia bankAccount1.changeEmail('nowy@email.pl', 'ultrasecurespecialcode');
Abstrakcja vs Hermetyzacja
Często ludzie, mylą abstrakcje z hermetyzacją, ponieważ ich definicje brzmią bardzo podobnie. Zapytani czym jest abstrakcja odpowiadają, że jest to jedna z czterech cech OOP, która ukrywa nieistotne dla nas informacje i skupia się tylko na tym czego potrzebujemy. Zapytani czym jest zatem hermetyzacja odpowiadają, że jest to kolejna cecha, która również ukrywa detale. Te definicje nie są złe, ponieważ zarówno abstrakcja jak i hermetyzacja coś ukrywają, ale kluczowe jest to na jakim poziomie to robią. Abstrakcja ukrywa detale na poziomie designu (nie musimy tutaj wiedzieć nic o szczegółach implementacyjnych). Możemy zdefiniować abstrakcje nie mając o nich zupełnie pojęcia. Natomiast hermetyzacja ukrywa informacje na temat już zaimplementowanych szczegółów. Prościej mówiąc hermetyzacja ukrywa faktyczną implementacje.
Spójrzmy na wcześniej wspomniany ekspres. Abstrakcją jest tutaj fakt, że patrząc na ekspres widzimy ekspres, a nie części z których się składa. Widzimy uproszczony obraz ekspresu, który rozumiemy. Jeśli ktoś wymieni części znajdujące się w środku my nie zauważymy zupełnie żadnej różnicy. Hermetyzacja natomiast to ukrycie tych wszystkich części w środku. Dostęp do nich jest możliwy jedynie przez udostępniony interfejs w postaci ekranu lub przycisków. Dzięki temu nie mamy bezpośredniej możliwości oddziaływania na poszczególne części, przez co stają się one odporne na czynniki zewnętrzne.
Abstrakcja | Hermetyzacja |
Abstrakcja rozwiązuje problemy na poziomie designu | Hermetyzacja rozwiązuje problemy na poziomie implementacji |
Abstrakcja służy do ukrywania niepotrzebnych informacji i skupia się na istotnych kwestiach | Hermetyzacja ukrywa kod i dane w jednej jednostce przez co chroni je przed światem zewnętrznym |
Abstrakcja pozwala skupić się na tym co obiekt robi zamiast tego jak to robi | Hermetyzacja ukrywa detale i mechanizmy, które są odpowiedzialne za to jak obiekt coś robi |
Abstrakcja jest bardziej ogólnym spojrzeniem na obiekt podczas desingu. Na przykład: Jeśli popatrzymy na stary telefon to widzimy, że ma ekran oraz przyciski, które służą do wybierania numeru. | Hermetyzacja ukrywa szczegóły impementacyjne odpowiedzialne za działanie Na przykład: Wewnętrzne szczegóły implementacyjne telefonu to: w jaki sposób działa klawiatura i ekran, oraz w jaki sposób są ze sobą połączone |
Zalety hermetyzacji
Hermetyzacja ma wiele zalet. Pomaga ukryć dane oraz logikę odpowiedzialną za ich modyfikację przez co nasze obiekty są bardziej odporne na niepożądane działania, ponieważ nie można ich zmienić bezpośrednio, a jedynie za pomocą udostępnionych mechanizmów. Co więcej dzięki hermetyzacji skupiamy powiązane ze sobą dane oraz logikę w jednej jednostce jaką jest klasa, funkcja lub metoda. Dzięki temu są o wiele łatwiejsze w użyciu i bardziej odporne na błędy. Dodatkowo stosując hermetyzację testowanie jest o wiele łatwiejsze dzięki podejściu black box.
Dziedziczenie
Dziedziczenie jest mechanizmem, w którym jedna klasa nabywa własności (właściwości i metody) innej klasy po której dziedziczy. Dzięki dziedziczeniu możemy ponownie wykorzystać pola i metody istniejącej klasy bez konieczności ich ponownej implementacji.
Dziedziczenie w życiu realnym
W życiu codziennym często możemy usłyszeć stwierdzenie, że dziecko jest podobne do mamy lub taty. Jest to naturalny skutek dziedziczenia. Każdy człowiek dziedziczy jakieś cechy po swoich rodzicach np. kolor oczu, kształt nosa lub charakterystyczne rysy twarzy. Natura osiąga to przekazując geny rodziców dzieciom.
Dziedziczenie w programowaniu
W programowaniu ten mechanizm wygląda podobnie. Klasa po której dziedziczymy często jest nazywana super klasą, lub klasą rodzicem. Natomiast klasa, która dziedziczy po super klasie często jest nazywana subklasą lub klasą dzieckiem. Możemy dziedziczyć metody oraz właściwości po naszej klasie bazowej, dzięki czemu nie jesteśmy zmuszeni do ich ponownej implementacji. Co więcej możemy je również rozszerzać oraz zmieniać (czego nie polecam robić bo łamie to jedną z zasad SOLID). Dzięki temu możemy nawet podmienić implementacje danej metody co jest możliwe dzięki polimorfizmowi. Subklasy posiadają również typ super klasy po której dziedziczą. Oznacza to, że możemy ich bezproblemowo użyć w miejscach w których użylibyśmy klasy bazowej. W niektórych językach programowania jesteśmy wstanie dziedziczyć po kilku klasach jednocześnie, natomiast inne języki ograniczają nas do dziedziczenia po jednej klasie.
Aby lepiej zrozumieć zasadę posłużmy się prostym przykładem. Wyobraźmy sobie, że tworzymy grę o zwierzętach. Jak wiemy istnieje wiele rodzajów zwierząt np. psy, koty, ptaki itp. Spróbujmy zaimplementować je do naszej gry. Na pierwszy rzut oka wszystkie te zwierzęta się od siebie różnią, ale jeśli przyjrzymy się bliżej to zauważymy wiele cech wspólnych. Zarówno ptak, kot jak i pies potrafią się poruszać, wydają jakieś dźwięki oraz potrafią jeść. Natomiast robią to w zupełnie różny sposób np. kot miauczy, pies szczeka, a ptak ćwierka. Ponadto mają wiele wspólnych właściwości takich jak kolor, typ czy rasę. Z tego możemy wywnioskować, że do ich implementacji powinniśmy użyć mechanizmu jakim jest dziedziczenie. W przeciwnym razie powielimy znaczną część kodu, co jest poważnym problemem, ponieważ dokonując jakiejś zmiany, będziemy zmuszeni zrobić to w kilku miejscach.
Stwórzmy zatem super klasę abstrakcyjną o nazwie Animal
w której będziemy trzymać wspólne cechy oraz zachowania. Następnie na jej podstawie stwórzmy subklasy reprezentujące nasze zwierzęta Dog
, Cat
oraz Bird
.
abstract class Animal { // Właściwości lub metody z flagą private nie są bezpośrednio dostępne w naszych subklasach (nie możemy się do nich odwołać ani ich zmieniać) private breed: string; private name: string; // Właściwości lub metody z flagą protected lub public są dostępne z poziomu naszej subklasy. protected color: string; // Definiując konstruktor w klasie Animal mamy pewność, że każda subklasa będzie musiała zainicjować podane właściwości przy jej tworzeniu. constructor(breed: string, color: string, name: string) { this.breed = breed; this.color = color; this.name = name; } public abstract eat(): void; public abstract makeSound(): void; public abstract move(): void; public getName(): string { return this.name; } public getColor(): string { return this.color; } } // Dzięki słowu kluczowemu extends rozszerzamy naszą klasę Dog class Dog extends Animal { public eat(): void { // Implemenacja metody eat dla psa } public makeSound(): void { console.log('Hau! Hau!') } public move(): void { // Implementacja biegania } } class Cat extends Animal { public eat(): void { // Implemenacja metody eat dla kota } public makeSound(): void { console.log('Miau! Miau!') } public move(): void { // Implementacja skradania } } class Bird extends Animal { public eat(): void { // Implemenacja metody eat dla ptaka } public makeSound(): void { console.log('Ćwir! Ćwir!') } public move(): void { // Implementacja latania } } const dog = new Dog('Owczarek', 'black', 'Szarik'); const cat = new Cat('Pers', 'white', 'Hakier'); const bird = new Bird('Papuga', 'red', 'Bits'); // Pomimo, że klasa Dog bezpośrednio nie implementuje metody getName to może z niej skorzystać, ponieważ dziedziczy ją po swoim rodzicu dog.getName(); // Szarik // Klasa Bird, również udostępnia metodę, którą dziedzyczy po swoim rodzicu bird.getColor() // white // Klasa Cat zawiera implementacje abstrakcyjnej metody znajdującej się w klasie Animal cat.makeSound();
Zalety dziedziczenia
Dzięki dziedziczeniu, jesteśmy wstanie uniknąć zbędnego powielania kodu co znacząco wpływa na jego czytelność, testowalność oraz możliwość rozszerzania. Dodatkowo dziedziczenie ściśle współgra z poliformizmem, dzięki czemu w miejsce naszej klasy bazowej możemy podstawiać subklasy, które po niej dziedziczą, ponieważ posiadają taki sam typ. Warto jednak pamiętać, że dziedziczenia należy używać z umiarem. W małej ilości dziedziczenie pozytywnie wpływa na nasz projekt, natomiast nadużywając go możemy znacząco pogorszyć aspekty o których wspomniałem powyżej. Np. zmieniając coś w naszej klasie bazowej, modyfikujemy również nasze subklasy.
Polimorfizm
Polimorfizm to inaczej występowanie danej rzeczy pod różnymi postaciami, które mogą się różnić zachowaniem.
Polimorfizm w życiu realnym
Ludzie mogą wykonywać różne zawody jak lekarz, policjant, strażak czy też programista. Każdy z nich wykonuje jakąś pracę, której wynik końcowy będzie taki sam – zarobek. Jednak każdy z nich wykonuje swoją pracę w inny sposób.
Kolejnym przykładem polimorfizmu występującego w otaczającym nas świecie są pojazdy. Niezależnie od tego czy mówimy o samochodzie, rowerze, statku czy też samolocie każdy z niech może się przemieszczać.
Polimorfizm w programowaniu
W programowaniu sytuacja wygląda podobnie. Możemy posiadać wiele klas i obiektów, które będą posiadać takie same zachowania (będą udostępniać takie same funkcjonalności), ale będą różnić się od siebie implementacją (czyli tym w jaki sposób wykonują daną rzecz). Dzięki temu możemy używać ich zamiennie. W programowaniu mamy wiele narzędzi, które pozwalają nam uzyskać polimorfizm.
Interfejsy
Polimorfizm możemy osiągnąć między innymi z wykorzystaniem interfejsu. Dzięki niemu jesteśmy w stanie zdefiniować zachowania, których potrzebujemy, a następnie zaimplementować je w docelowych klasach. Następnie w miejscu w którym potrzebujemy naszych funkcjonalności, odwołujemy się do interfejsu. Dzięki temu możemy bezproblemowo podmieniać nasze klasy, ponieważ są traktowane jak ten sam obiekt. Spójrzmy na przykład:
// Definiujemy interfejs, który określa jakie zachowania powinny posiadać nasze klasy interface Employee { work(numberOfHours: number): number; } // Klasa zawierająca implementacje class Doctor implements Employee { work(numberOfHours: number): number { const hourlyRate = 40; // Leczenie chorych return numberOfHours * hourlyRate; } } // Klasa zawierająca implementacje class Programmer implements Employee { work(numberOfHours: number): number { const hourlyRate = 10; // Pisanie aplikacji return numberOfHours * hourlyRate; } } // Klasa zawierająca implementacje class Fireman implements Employee { work(numberOfHours: number): number { const hourlyRate = 20; // Gaszenie pożaru return numberOfHours * hourlyRate; } } class Job { private employees: Employee[] = []; // Dzięki temu, że bazujemy na interfejsie jestesmy w stanie bezproblemowo dodawać klasy, które go implementują public addEmployee(employee: Employee): void { this.employees.push(employee); } public makeMoney(): number { // Dzięki interfejsowi, jesteśmy w stanie skorzystać z metody work w taki sam sposób niezaleznie od tego jaki rodzaj pracownika przekażemy return this.employees.reduce((totalMoney, employee) => { return totalMoney + employee.work(8); }, 0); } } const programmer = new Programmer(); const doctor = new Doctor(); const fireman = new Fireman(); const job = new Job(); job.addEmployee(programmer); job.addEmployee(doctor); job.addEmployee(fireman); job.makeMoney();
Dziedziczenie
Polimorfizm jesteśmy również w stanie osiągnąć za pomocą dziedziczenia. Możemy rozszerzać bądź nadpisywać klasę rodzica na której bazujemy. Pomimo modyfikacji lub zmiany implementacji, nasze subklasy będą traktowane w taki sam sposób jak klasa rodzica. Najprościej będzie to wytłumaczyć na przykładzie:
// Za pomocą klasy abstrakcyjnej definiujemy bazową implementacje zachowania abstract class Vehicle { public drive(): void { console.log('Drive...') } } // Klasa Car dziedziczy po klasie Vehicle i posiada taką samą implementacje jak klasa rodzica (Vehicle) class Car extends Vehicle {} // Klasa Bike dziedziczy po klasie Vehicle, ale rozszerza jej implementacje przez dodanie console.log class Bike extends Vehicle { public drive(): void { console.log('Use your legs'); super.drive(); } } // Klasa Plane również dziedziczy po Vehicle, ale nadpisuje jej zachowanie class Plane extends Vehicle { public drive(): void { console.log('Fly...') } } // Klasa warehouse wymaga przekazania klasy Vehicle, która jest naszą klasą bazową class Warehouse { constructor( private readonly vehicle: Vehicle ) {} carryItems(): void { // Wszystkie nasze pojazdy posiadają metodę drive więc bezproblemowo możemy je podmieniać this.vehicle.drive(); } } const car = new Car(); const bike = new Bike(); const plane = new Plane(); const warehouse1 = new Warehouse(car); const warehouse2 = new Warehouse(bike); const warehouse3 = new Warehouse(plane);
Overloading
Kolejnym mechanizmem umożliwiającym polimorfizm jest overloading. Dzięki temu narzędziu jesteśmy w stanie zdefiniować metody lub funkcje o takiej samej nazwie pod warunkiem, że będą posiadały inny typ argumentów oraz wyniku. Umożliwia to używanie ich w identyczny sposób.
class Helpers { add(a: string, b: string): string { return a + b; } add(a: number, b: number): number { return a + b; } } const helpers = new Helpers(); // Dzięki temu możemy skorzystać z nich w taki sam sposób, mimo tego, że posiadają różną implementacje helpers.add(1, 2); helpers.add('1024', 'bits');
Generyki
Generyki również umożliwiają polimorfizm. Dzięki nim możemy zdefiniować jaki typ argumentu chcemy przekazać, dzięki czemu jesteśmy w stanie skorzystać z tej samej metody bez względu na rodzaj przekazanego argumentu.
function log<T>(arg: T): void { console.log(arg); } log<string>('1024bits'); log<number>(2);
Zalety poliformizmu
Polimorfizm umożliwia nam posiadanie wielu rodzajów implementacji jednej rzeczy. Dzięki temu jesteśmy w stanie bezproblemowo podmieniać nasze klasy, funkcje, metody czy obiekty, które udostępniają takie same zachowania, ale różnią się implementacją.
Podsumowanie
Abstrakcja, hermetyzacja, dziedziczenie oraz polimorfizm to cztery główne cechy OOP z których istnienia powinien sobie zdawać sprawę każdy szanujący się programista. Posiadając wiedzę na ich temat jesteśmy w stanie pisać lepszy kod oraz zrozumieć wiele innych zagadnień takich jak np. wzorce projektowe, które bazują na tych pojęciach.
Źródła:
Clean Architecture : A Craftsman’s Guide to Software Structure and Design