Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

🔴 OSTATNI DZIEŃ SPRZEDAŻY (do 23.01) 10-miesięczny kurs front endu 🔴

4 filary programowania obiektowego

Wprowadzenie do wzorców projektowych w JavaScript

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ć!

Spis treści

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 w JavaScript – czy to możliwe?

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.

 Powtórka z programowania obiektowego

Dzięki programowaniu zorientowanemu obiektowo (ang. object oriented programming, OOP) możemy:

  • zredukować ilość pisanego kodu,
  • wprowadzić łatwe w zrozumieniu abstrakcje,
  • grupować kod o podobnym zastosowaniu.

Programowanie obiektowe opiera się na czterech filarach, o których krótko sobie opowiemy. Są to:

  • dziedziczenie,
  • abstrakcja,
  • hermetyzacja (enkapsulacja),
  • polimorfizm.

 Dziedziczenie

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.

 Abstrakcja

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 (enkapsulacja)

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 w JavaScript

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);

Pola chronione w JavaScript

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

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.

 Podsumowanie

Znasz już fundamenty programowania obiektowego: cztery filary OOP. Dzięki temu możesz pisać kod, który:

  • nie powtarza się (dziedziczenie i polimorfizm),
  • obrazuje problem, który chcesz rozwiązać (abstrakcja),
  • jest prosty i odporny na błędy (hermetyzacja),
  • a do tego przewidywalny w działaniu (polimorfizm).

Dzięki tej wiedzy łatwiej zrozumiesz filozofię działania wzorców projektowych. Powiemy sobie o nich w następnych artykułach.

 Źródła

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.