TypeScript – pierwsze kroki. Tworzenie klas w TypeScript. Część Trzecia.

Dziś ostatni, trzeci post z serii opisującej wykorzystanie mechanizmu klas w TypeScript. W poprzednich dwóch omówiłem już podstawowe zagadnienia. Nadszedł czas na sedno tematu czyli dziedziczenie.

Ponad to omówię działanie klas i metod abstrakcyjnych, a na koniec pokażę jak wykorzystać interfejsy do sprawnego zarządzania kodem w tworzonych obiektach.

Implementacja klasy w TypeScript

Dziediczenie

Na początek stworzę prostą klasę – Animal:

Posiada ona jedno prywatne pole typu string – sound. Jego zawartość definiuję przez argument konstruktora. Do tego w klasie znajduje się metoda makeSound, która loguje zawartość wspomnianego wcześniej pola.

Wszystko wygląda ok, mógłbym z łatwością stworzyć instancję tej klasy. Nie chcę jednak tego robić. Zamiast tego, wolałbym stworzyć drugą klasę, która dziedziczyłaby po Animal. Oznacza to, że posiadałaby wszystkie te pola i metody, które posiada Animal.

Dzięki EcmaScript 6 mogę bardzo łatwo dodać klasę dziedziczącą. Wystarczy że użyję operatora extends:

Mam teraz nową klasę Cat. Zachowuje się ona dokładnie tak jak klasa Animal. Dziedziczy całą logikę swojej „nad-klasy”. W tym miejscu warto zwrócić uwagę na metodę, makeSound. Ma ona dodany modyfikator dostępu protected. Oznacza to, że nie może zostać ona wywoływana poza klasą. Chyba, że w klasie pochodnej. Dlatego Cat, może bez problemu wywoływać tę metodę.

Teraz nic nie stoi na przeszkodzie aby stworzyć instancję Cat:

Niby jest ok, ale nie do końca podoba mi się to rozwiązanie. W końcu wszystkie koty miauczą. Musiałbym dodawać taki sam łańcuch znaków do każdej instancji. Od razu widać, że coś jest tu nie tak.

I tu wchodzi kolejna przydatna cecha klas – nadpisywanie metod. Każdą dziedziczoną metodę można nadpisać, włączając w to konstruktor. Co ciekawe, nic nie stoi na przeszkodzie, aby wewnątrz nadpisywanej metody, wywołać jej wersję „nadrzędną” (czyli tą z klasy-rodzica). Szczególnie przydaje się to w konstruktorze. Oto przykład:

Teraz klasa Cat posiada własny konstruktor, który definiuje nowe pole name. W jego wnętrzu wywołuję jednak specjalną metodę super. To słowo, jako metoda, oznacza wywołanie konstruktora klasy nadrzędnej. Wewnątrz nowego konstruktora, wywołuję konstruktor Animal, z wpisanym na sztywno łańcuchem znaków.

Teraz po wywołaniu konstruktora Cat, nowo tworzony obiekt automatycznie otrzyma pole sound, które dziedziczy po klasie Animal, z wartością zawsze równą meow. Do tego otrzyma też pole name, z wartością równą argumentowi konstruktora. Oto przykład:

Prawda, że przydatne 🙂 ? To jeszcze nie koniec. W podobny sposób można też nadpisywać zwykłe metody. Wtedy super nie będzie oznaczało konstruktora, a kontekst klasy-rodzica. Spójrzmy na przykład, w którym nadpiszę metodę makeSound:

Teraz klasa Cat posiada swoją wersję metody makeSound. Najpierw loguje ona wiadomość z polem name obiektu, a następnie wywołuje kod makeSound z klasy nadrzędnej. Robię to za pomocą słówka super.

Można wyobrazić sobie super jako takie this ale wskazujące na klasę nadrzędną.

Dla osób, które miały styczność z obiektowością w takich językach jak Java czy C#, to wszystko może wydawać się oczywiste. Ale jeśli to Twoja pierwsza styczność z tego typu mechanizmami, warto dokładnie przestudiować przykłady.

Oto trochę bardziej rozbudowany fragment kodu, w którym tworzę dwie klasy dziedziczące z Animal. Korzystają one z tych samych mechanizmów, ale ich funkcjonalność może być różna:

Klasy i metody abstrakcyjne

Do tej pory nie stworzyłem instancji klasy Animal. Wszystko wskazuje na to, że to nigdy się nie stanie. Jedyna funkcja Animal to dostarczenie bazy pod klasy, które z niej dziedziczą. Działające w ten sposób „klasy-bazy” są oczywiście zupełnie normalnie i występują bardzo często.

Dobrze byłoby jednak w jakiś sposób oznaczyć tego typu konstrukcję. Na szczęście, TypeScript dostarcza nam taką funkcjonalność. Wystarczy przed operatorem class dodać słówko abstract:

Dzięki temu wiemy, że ta klasa nie może mieć instancji. Tego typu klasy nazywamy klasami abstrakcyjnymi. Gdy spróbujemy stworzyć instancje z takiej klasy, TypeScript wyrzuci błąd:

abstractError

To bardzo przydatna funkcjonalność. Dzięki temu, już po pierwszym rzucie oka na klasę widać, jakie zamiary miał programista. Taki kod nie tylko czyta się dużo lepiej, ale też chroni przed błędami.

Jednak nie tylko klasy mogą być abstrakcyjne. TypeScript posiada funkcjonalność pozwalającą na dodawanie abstrakcyjnych metod. Tego typu metody, zapisuje się tylko jako deklaracje. Wszystkie klasy dziedziczące z klasy posiadającej abstrakcyjne metody, muszą mieć w sobie implementację tych metod. Oto przykład:

W klasie Animal zadeklarowałem metodę abstrakcyjną action, która zwracać ma łańcuch znaków. Teraz obie klasy dziedziczące od Animal, muszą posiadać w sobie definicję tej metody pasującej do deklaracji.

Jak widać zarówno Cat jak i Dog posiadają swoją implementację metody action. Gdyby brakowało jej w której, z tych klas, TypeScript zgłosiłby błąd.

Metody abstrakcyjne to kolejny skuteczny sposób na przekazanie intencji w kodzie. Teraz każdy pracujący z klasą Animal wie, że pochodne od niej klasy muszą posiadać metodę action, która zwraca łańcuch znaków.

Wykorzystanie interfejsów

Używanie abstrakcyjnych klas i metod, pomaga w utrzymaniu odpowiedniej struktury obiektów. Szczególnie widoczne jest to w sytuacji, gdy tworzona aplikacja zaczyna naprawdę się rozrastać i pracuje nad nią wiele osób.

Jest jednak jeszcze jeden sposób (który zresztą można łączyć z wyżej wymienionymi) – wykorzystanie interfejsów. O interfejsach pisałem już przy okazji omawiania tworzenia własnych typów. Nie powiedziałem wtedy o tym, że interfejsy można implementować również w klasach.

Załóżmy, że chciałbym aby klasa Animal podchodziła pod konkretny schemat. To znaczy posiadała pewne pola i metody, które posiadają też inne klas. Nie chcę tworzyć jeszcze jednej nadrzędnej klasy, tak naprawdę nie potrzebuję zdefiniować żadnej logiki. Chcę tylko aby Animal posiadało konkretne pola i metody i żeby pozostało to niezmienne podczas lifecycle’u programu.

Idealnym narzędziem w takiej sytuacji są interfejsy. Jeżeli zaimplementuję interfejs do klasy, będzie ona musiała posiadać zdefiniowane w nim pola i metody. Jeżeli któregoś elementu zabraknie, otrzymamy błąd. Spójrzmy na ten przykład:

Stworzyłem interfejs Actor, który posiada dwa elementy. Pierwszy to pole sound (typu string), a drugi to metoda action zwracająca łańcuch znaków.

Aby zaimplementować ten interfejs w klasie Animal wystarczy, że po jej nazwie dopiszę implements i nazwę interfejsu. Od teraz Animal musi posiadać pole sound oraz metode action. Na szczęście, ma już ona te elementy, jednak teraz mam pewność, że nie zostaną one przypadkowo usunięte.

Do tego wiem, że klasa Animal i pochodne jej obiekty, mają teraz konkretne elementy. Taki sam interfejs mogę zaimplementować w innych klasach programu na przykład Human czy Monster. Pomimo tego, że będą to zupełnie różne obiekty, reszta programu, będzie mogła tak samo je traktować, przez to wspólne ‚API’ (którego implementacja może być różna).

Niektóre z informacji przedstawionych w tym poście mogą być ciężkie do przetrawienia, szczególnie dla kogoś kto nie miał wcześniej do czynienia z klasycznym programowaniem obiektowym. Ale na tym etapie najważniejsze to rozumieć ogólne założenia opisanych mechanizmów. Niedługo przygotuję projekt, wykorzystujący wszystkie opisane tu aspekty TypeScriptu. Wtedy na pewno łatwiej będzie zrozumieć jakie korzyści niesie za sobą korzystanie z interfejsów czy klas abstrakcyjnych.

Na dziś to wszystko. Jeżeli chcesz być na bieżąco z postami na blogu zachęcam do polubienia mojej strony na facebooku. Zawsze zamieszczam tam informacje o wszystkich nowościach. Jest to też dobre miejsce na kontakt ze mną. Na wszystkie pytania zawsze odpowiem :).

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *