Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!
⛔ Potrzebujesz wsparcia? Oceny CV? A może Code Review? ✅ Dołącz do Discorda!
Praca programisty polega na rozwiązywaniu problemów. Ich lwia część okazuje się uniwersalna, to znaczy niezależna od języka programowania. To właśnie zachęca programistów do tworzenia gotowych „przepisów” na budowanie rozwiązań. Są nimi wzorce projektowe, które bazują na programowaniu obiektowym. W tym artykule poznasz cztery filary OOP i nauczysz się z nich korzystać!
Chcesz być (lepszym) programistą i lepiej zarabiać? Umów się na rozmowę - powiem Ci jak to zrobić!
Wzorce projektowe to propozycje rozwiązań problemów spotykanych podczas tworzenia kodu. Bazują one na programowaniu obiektowym. Istnieje pokusa, by traktować wzorzec projektowy jako gotowy algorytm, kawałek kodu, który można skopiować. Niestety to niemożliwe, gdyż wzorce to raczej wysokopoziomowy opis rozwiązania, który najpierw musimy zrozumieć, by skutecznie go zastosować. Można je porównać np. do podręcznika ekonomicznej jazdy samochodem, a nie instrukcji budowy ekonomicznego samochodu.
Początkowy wysiłek związany ze zrozumieniem wzorców szybko się zwróci. Dzięki nim napiszemy kod oparty na sprawdzonych rozwiązaniach, powszechnie wykorzystywanych w branży.
Wzorce projektowe oparte są na programowaniu obiektowym, jednak JavaScript nie powstał jako język obiektowy. Rodzi to kilka problemów. Ukazują się one w dynamicznym typowaniu zmiennych czy braku klas abstrakcyjnych i interfejsów.
Programiści JS znaleźli jednak rozwiązanie w postaci stosowania konwencji (umownych rozwiązań). Z czasem ECMA dostrzegła potencjał drzemiący w programowaniu obiektowym w JS, w związku z czym kolejne aktualizacje tego języka, takie jak ES6 i ES22, wprowadziły rozwiązania upodabniające JS np. do Javy czy Pythona. Dzięki temu możemy cieszyć się dobrodziejstwami wzorców projektowych w JavaScripcie.
Kolejnym przełomowym momentem, jeśli chodzi o programowanie obiektowe w JS, było powstanie języka TypeScript. Rozwiązuje on znaczną część problemów występujących w czystym JavaScripcie i jednocześnie rozszerza jego możliwości. Dzięki temu korzystanie z zasad programowania obiektowego staje się proste. TypeScript będzie tematem kolejnych artykułów w cyklu – na razie skupmy się na czystym JS-ie.
Dzięki programowaniu zorientowanemu obiektowo (ang. object oriented programming, OOP) możemy:
Programowanie obiektowe opiera się na czterech filarach, o których krótko sobie opowiemy. Są to:
Istotą programowania obiektowego jest stworzenie modelu rozwiązywanego problemu za pomocą obiektów i klas.
Obiekty to złożone struktury danych, które grupują powiązane właściwości i metody. Przykładem obiektu może być poniższy warsawAirport, który zawiera zarówno właściwości, jak i metody (funkcje) związane z lotniskiem Chopina w Warszawie.
const warsawAirport = { name: 'EPWA', runways: [11, 15, 29, 33], activeRunway: null, communication: { tower: 118.3, ground: 121.9, approach: 125.05, }, setActiveRunway: function (runway) { this.activeRunway = runway; }, };
Załóżmy, że dla wszystkich lotnisk w Europie chcemy stworzyć podobne obiekty. Problematyczne byłoby definiowanie wszystkich właściwości i metod od nowa dla każdego lotniska. Z pomocą przychodzą klasy.
Klasy to swego rodzaju szablony, na podstawie których tworzymy nowe obiekty. Można je sobie wyobrazić jako fabryki, np. zabawek czy samochodów. Teoretycznie rzecz ujmując, są to po prostu funkcje, które korzystają z konstruktorów i prototypów. Słowo kluczowe class to lukier składniowy, który pojawił się wraz z ES6.
Rozważmy przykład klasy Airport, dzięki której mógł powstać przedstawiony wcześniej obiekt warsawAirport.
class Airport { constructor(name, runways, activeRunway, communication) { this.name = name; this.runways = runways; this.activeRunway = activeRunway; this.communication = communication; } setActiveRunway(runway) { this.activeRunway = runway; } }
Na podstawie tej klasy stworzymy dowolne lotnisko w taki sposób:
const warsawAirport = new Airport('EPWA', [11, 15, 29, 33], null, { tower: 118.3, ground: 121.9, approach: 125.05 });
Dzięki klasom możemy tworzyć nie tylko obiekty, ale i kolejne klasy. Wtedy klasę-rodzica nazwiemy nadrzędną, a jej dziecko – potomną. Klasy potomne dziedziczą zarówno pola (właściwości), jak i akcje (metody) po rodzicu. Mogą różnić się zaś dodatkowymi polami i akcjami. Tworzymy je za pomocą słowa kluczowego extends. Spójrz na poniższy przykład klasy PolishAirport, która dziedziczy po klasie Airport i jednocześnie wprowadza nowe pole country.
class PolishAirport extends Airport { constructor(name, runways, activeRunway, communication) { super(name, runways, activeRunway, communication); this.country = 'Poland'; } } const krakowAirport = new PolishAirport('EPKK', [7, 25], 7, { tower: 123.25, ground: 118.1, approach: 121.07 });
Sprawdzenie w konsoli krakowAirport
pokaże wszystkie pola i metody, które występują w klasie Airport, oraz pole country o wartości Poland. W ten sposób w języku JavaScript działa dziedziczenie.
Metodę super(), której użyłem w konstruktorze, wywołujemy wtedy, gdy chcemy rozszerzyć jakąś klasę.
Kod użyty w tym rozdziale i kolejnych możesz podejrzeć w repozytorium.
Wspominałem wcześniej, że duszą programowania obiektowego jest modelowanie pewnego wycinka rzeczywistości – stanowi to również istotę abstrakcji.
Wróćmy do wcześniej omawianego przykładu. Klasa Airport zawiera kilka pól i metod, które przekazują nam informacje dotyczące lotniska. Domyślasz się, że to niewiele danych jak na tak spory kompleks. Oprócz startów i lądowań statków powietrznych dzieje się w nim dużo innych rzeczy.
Właśnie dlatego klasa Airport może wyglądać zupełnie inaczej w aplikacji przeznaczonej do rezerwowania podróży, a inaczej w symulatorze lotu. Wolelibyśmy, żeby zamiast informacji o poczekalniach, sklepach czy położeniu najbliższych hoteli znalazły się tam informacje dotyczące komunikacji radiowej czy pasa startowego w użyciu. Zobrazuję to na poniższym przykładzie klas PassengerAirport oraz SimulatorAirport, które swoją drogą, będą dziedziczyć po klasie Airport.
// PassengerAirport dziedziczy po klasie Airport // i dodaje własne właściwości jak terminals i restaurants class PassengerAirport extends Airport { constructor(name, runways, terminals, restaurants) { super(name, runways); this.terminals = terminals; this.restaurants = restaurants; } } // SimAirport dziedziczy po klasie Airport // i dodaje własne właściwości jak activeRunway i communication class SimulatorAirport extends Airport { constructor(name, runways, activeRunway, communication) { super(name, runways); this.activeRunway = activeRunway; this.communication = communication; } }
Zastosujmy nasze nowe klasy do stworzenia obiektów:
// Przykady zastosowania const newPassengerAirport = new PassengerAirport( 'EPGD', [6, 24], { terminalName: 'A', capacity: 10000 }, { terminalName: 'B', capacity: 5000 }, ['McDonalds', 'KFC', 'Starbucks'] ); const newSimAirport = new SimulatorAirport('EPGD', [6, 24], 6, [ { tower: 118.3, isOnline: true }, { ground: 121.9, isOnline: false } ]);
Mimo że obie klasy dziedziczą po Airport, to dzięki użyciu abstrakcji mają całkiem różne zastosowanie. To, jak modelujesz swój program, zależy tylko od Ciebie. Powyższy kod jest dostępny w repozytorium.
Hermetyzacja, inaczej enkapsulacja, jest kolejnym filarem programowania obiektowego. Polega ona na tym, by ukryć pewne pola lub metody z klas – aby nie były one widoczne dla użytkowników czy innych programistów. Z reszty elementów danej klasy można korzystać – stanowią one interfejs.
Z interfejsem mogłeś spotkać się już wcześniej, pisząc kod w innych językach programowania. Jednak interfejs, o którym wspominam, to nie to samo co typ interfejs np. pochodzący z TypeScriptu. Niestety jest to mylące nazewnictwo, które weszło do powszechnego użytku w branży.
Interfejs w rozumieniu elementów, które wystawia dana klasa, ułatwia korzystanie ze stworzonych rozwiązań. Wyobraź sobie, że ktoś z Twojego zespołu napisał bardzo skomplikowaną klasę o nazwie Navigation. Ma ona dużo akcji i pól, które wzajemnie na siebie oddziałują, ale Ty wcale ich nie potrzebujesz.
Dla przykładu może to być pole określające stałą systemu GLONASS (L1) o wartości 1602.0 MHz. Jako że znajomy z zespołu niekonieczne chce, by ktokolwiek zmieniał jej wartość, może ją po prostu ukryć przed innymi programistami. Za to zaproponuje Ci metodę z interfejsu nazwaną getDistanceTo(x). Możliwe, że metoda getDistanceTo(x) korzysta z ukrytego pola ze stałą L1, ale niekoniecznie musisz o tym wiedzieć, ani tym bardziej to modyfikować.
Aby hermetyzować w JavaScripcie, możesz korzystać z pól prywatnych(#) i chronionych(_).
Pola prywatne weszły do kanonu JavaScriptu stosunkowo niedawno, tj. wraz z ES2022. Takie pole może być modyfikowane oraz dostępne tylko wewnątrz klasy, która je implementuje. Jeśli spróbujesz skorzystać z pola prywatnego poza ciałem klasy, to w konsoli zobaczysz błąd. Spójrz na przykładową implementację pola prywatnego:
class Navigation { // # jako pole prywatne #L1 = 1602; getDistanceTo(x) { const position = this._getCurrentPosition(); return this._calculateDistanceToX(position, x, this.#L1); } } const carNavigation = new Navigation(); // SyntaxError: Private field '#L1' must be declared in an enclosing class console.log(carNavigation.#L1);
Niestety pola chronione, oznaczane podkreślnikiem (_), nadal nie są częścią JS i stanowią jedynie konwencję stosowaną przez programistów. Od pól prywatnych różni je to, że są dostępne zarówno wewnątrz klasy, jak i wewnątrz klas-dzieci powstałych przez dziedziczenie. Aby lepiej to zrozumieć, spójrz na poniższy przykład ilustrujący różnicę między polem chronionym a prywatnym:
class Navigation { // # jako pole prywatne #L1 = 1602; // _ jako pole chronione _L2 = 3218; getDistanceTo(x) { const position = this._getCurrentPosition(); return this._calculateDistanceToX(position, x, this.#L1); } } class BikeNavigation extends Navigation { getFieldValues() { console.log(this._L2); // 3218 console.log(this.#L1); // Error Private field '#L1' } } const bikeNavigation = new BikeNavigation(); bikeNavigation.getFieldValues();
Polimorfizm oznacza, że różne obiekty lub klasy powstałe jako dzieci klasy-rodzica mogą wykonywać tę samą operację.
Wyobraź sobie klasy KrakowAirport oraz WarsawAirport. Powstały one poprzez dziedziczenie po klasie Airport. Dzięki temu mogą skorzystać akcji initApproach(), która jest dostępna w klasie-rodzicu. Mogą ją zaimplementować taką, jaka jest w rodzicu, lub zmienić. Mimo to obie klasy mają dostęp do domyślnie utworzonej metody w rodzicu. Spójrz na poniższy przykład:
class Airport { navigationalAid = 'GPS'; initApproach() { console.log(`Approach initiated with ${this.navigationalAid}`); } } class WarsawAirport extends Airport {} class KrakowAirport extends Airport { // polimorfizm metody initApproach() initApproach() { this.navigationalAid = 'ILS'; console.log(`Approach initiated with ${this.navigationalAid}`); } } const airport = new Airport(); const warsawAirport = new WarsawAirport(); const krakowAirport = new KrakowAirport(); airport.initApproach(); // Approach initiated with GPS warsawAirport.initApproach(); // Approach initiated with GPS krakowAirport.initApproach(); // Approach initiated with ILS
Mimo że użyliśmy tej samej metody initApproach(), to w dwóch potomnych obiektach otrzymaliśmy dwa różne wyniki jej działania.
Znasz już fundamenty programowania obiektowego: cztery filary OOP. Dzięki temu możesz pisać kod, który:
Dzięki tej wiedzy łatwiej zrozumiesz filozofię działania wzorców projektowych. Powiemy sobie o nich w następnych artykułach.
Udostępnij ten artykuł:
Potrzebujesz cotygodniowej dawki motywacji?
Zapisz się i zgarnij za darmo e-book o wartości 39 zł!
PS. Zazwyczaj rozsyłam 1-2 wiadomości na tydzień. Nikomu nie będę udostępniał Twojego adresu email.
Chcesz zostać (lepszym) programistą i lepiej zarabiać?
🚀 Porozmawiajmy o nauce programowania, poszukiwaniu pracy, o rozwoju kariery lub przyszłości branży IT!
Umów się na ✅ bezpłatną i niezobowiązującą rozmowę ze mną.
Chętnie porozmawiam o Twojej przyszłości i pomogę Ci osiągnąć Twoje cele! 🎯