Zakładam, że czytelnicy są zaznajomieni z JavaScriptem, a w szczególności z konceptami dodanymi w ECMAScript 2015 takimi jak class
oraz let
i const
. Jeśli jednak coś jest niejasne to chętnie odpowiem na pytania w komentarzach.
Argumenty konstruktora
Zanim przejdziemy dalej, chciałbym zacząć od jednej przydatnej rzeczy. Prawdopodobnie bardzo często będziemy powtarzać kod podobny do tego:
class X {
private pole:typ;
constructor(wartosc:typ) {
this.pole = wartosc;
}
}
Czyli: definiujemy pole w klasie, a następnie w konstruktorze przyjmujemy wartość jako argument i przypisujemy do tego pola. Czy da się to jakoś krócej zapisać? Otóż tak, da się! TypeScript sprawia, że powyższy zapis jest równoważny temu poniżej:
class X {
constructor(private pole:typ) {}
}
Dziedziczenie
W poprzednim wpisie pobieżnie omówiłem klasy i podstawy interfejsów w TypeScripcie. Nie napisałem jednak ani słowa o jednej z bardziej znanych cech programowania orientowanego obiektowo: O dziedziczeniu.
Dziedziczenie jest bez wątpienia przydatnym elementem programowania obiektowego, jednak jego nadużywanie jest bardzo szkodliwym antywzorcem. Zdecydowanie lepiej jest faworyzować kompozycję nad dziedziczeniem.
Weźmy typowy, całkowicie oderwany od rzeczywistości przykład, używany chyba we wszystkich kursach programowania obiektowego. Stwórzmy klasę reprezentującą zwierzę!1
class Animal {
constructor(protected name:string) {}
giveVoice() {
console.log(`Nazywam się ${this.name}!`);
}
}
W ten sposób stworzyliśmy klasę Animal
, którą będziemy nazywać klasą bazową, gdyż inne klasy będą po niej dziedziczyć. Dziedziczenie oznacza się słowem kluczowym extends
:
class Dog extends Animal {
constructor(protected name:string) {
super(name);
}
giveVoice() {
console.log('Szczek, szczek!');
super.giveVoice();
}
eat() {
console.log('Mniam.');
}
}
Pojawia się tutaj nowe słowo kluczowe: super
. super
jest tak jakby odwołniem do klasy bazowej, po której dziedziczymy. Wywołując super(name)
wykonujemy wszystkie operacje z konstruktora klasy bazowej. Natomiast wywołanie super.giveVoice()
spowoduje wywołanie metody giveVoice()
z klasy Animal
:
const pies = new Dog('reksio');
pies.giveVoice();
// Szczek, szczek!
// Nazywam się reksio!
Klasę Dog
nazywa się klasą pochodną. Dodatkowo warto zauważyć, że klasa Dog
nie tylko dziedziczy po klasie Animal
, ale również dodaje nową metodę eat
. Oczywiście klasy pochodne mogą rozszerzać klasy bazowe – taki jest ich główny cel.
Dziedziczenie wielopoziomowe
Wiele nowych klas może dziedziczyć po klasie Animal
– moglibyśmy stworzyć na przykład klasy Horse
i Pig
. Dodatkowo po klasach pochodnych również mogą dziedziczyć kolejne klasy. W tym przypadku można by się pokusić o stworzenie klasy ShibaInu
i Poodle
, które dziedziczyłyby po klasie Dog
.
class Horse extends Animal {
constructor(protected name:string) {
super(name);
}
giveVoice() {
console.log('Parsk, parsk!');
super.giveVoice();
}
}
class ShibaInu extends Dog {
constructor(protected name:string) {
super(name);
}
giveVoice() {
console.log('Ale jestem slodki!');
super.giveVoice();
}
}
TypeScript nie posiada dziedziczenia wielokrotnego (multiple inheritance). Wynika to bezpośrednio z idei prototypowego dziedziczenia w JavaScript. Obejściem tego ograniczenia jest przykładowo skorzystanie z mixinów.
Polimorfizm
Ogromną zaletą dziedziczenia jest nie tylko współdzielnie kodu pomiędzy różnymi klasami, ale także polimorfizm. O teorii można sobie doczytać, bo polimorfizm ma wiele aspektów teoretycznych. W tym przypadku mam na myśli to, że w niektórych warunkach instancje klasy Horse
czy ShibaInu
można traktować tak, jakby były instancjami klasy Animal
. Przykładowo wyobraźmy sobie, że chcemy stworzyć tablicę przechowującą różne zwierzęta. Jak poprawnie zadeklarować typ takiej tablicy? Korzystając z zaawansowanych typów, możemy użyć tzw. union type (więcej o tym w kolejnym wpisie z tej serii):
const animals:Array<Horse|Dog|ShibaInu|Poodle> = [];
Jednak to w tym przypadku całkowicie niepotrzebne, a do tego byłoby trudne w utrzymaniu, gdyż przy tworzeniu nowej klasy pochodnej musielibyśmy ją tutaj dopisać. Możemy jednak zadeklarować taką tablicę po prostu, jako przechowującą obiekty typu Animal
, gdyż wszystkie nasze zwierzęta po tej klasie dziedziczą:
const animals:Array<Animal> = [];
Możemy więc teraz stworzyć taką tablicę, wrzucić do niej różne zwierzęta i wywołać na wszystkich po kolei w pętli metodę giveVoice
:
const piesek = new Dog('Burek');
const qn = new Horse('Rafał');
const animals:Array<Animal> = [piesek, qn];
for (const animal of animals) {
animal.giveVoice();
}
Musimy być jednak świadomi, że w ten sposób ograniczamy sobie niejako możliwości wywoływania metod na obiektach z tej tablicy wyłącznie do metod zadeklarowanych w klasie Animal
. Przykładowo nie mamy dostępu do metody eat
dodanej w klasie Dog
:
const piesek = new Dog('Burek');
const animals:Array<Animal> = [piesek];
piesek.eat() // ok!
animals[0].eat() // błąd! widoczne są tylko metody z klasy Animal
protected
W poprzedniej części wspominałem o dwóch modyfikatorach dostępu: public
i private
. Tutaj jednak użyłem czegoś innego: protected
. Jest to koncept powszechnie wykorzystywany w różnych językach programowania i nie chcę poświęcać mu tutaj więcej uwagi. Powiem tylko, że protected
daje taki sam rezultat jak private
, za wyjątkiem sytuacji, w których korzystamy z dziedziczenia. Do pól zadeklarowanych jako protected
możemy uzyskać dostęp również z poziomu klas pochodnych, a do pól private
nie.
Interfejsy
Wspominałem już o interfejsach, za pomocą których możemy deklarować „kształt” obiektu. Jednak interfejsy dają nam znacznie większe możliwości. Przede wszystkim: Interfejsy możemy implementować w klasach. Oznacza to, że możemy wymusić na programiście zaimplementowanie konkretnych metod w klasie, aby klasa spełniała interfejs. Przykładowo wyobraźmy sobie, że nasza funkcja przyjmuje tylko i wyłącznie obiekty, które mają zdefiniowaną funkcję toJSON
. Aby to zaznaczyć, tworzymy interfejs:
interface Serializable {
toJSON():string;
}
function naszaFunkcja(obiekt:Serializable) {
obiekt.toJSON(); // ta funkcja tutaj na pewno jest!
}
Teraz możemy wymusić na klasie implementację tej metody. Używamy do tego słowa implements
:
class User implements Serializable {
toJSON() {
return '{}';
}
}
const user = new User();
naszaFunkcja(user);
Gdybyśmy nie zaimplementowali metody toJSON
w klasie to otrzymalibyśmy błąd kompilacji.
Implementacja wielu interfejsów
Warto zwrócić jeszcze uwagę na to, że klasa może implementować kilka interfejsów w tym samym czasie – w odróżnieniu od dziedziczenia; dziedziczyć można tylko po jednej klasie na raz. Kolejne implementowane interfejsy wystarczy napisać po przecinku:
interface Drawable {
draw(ctx:CanvasRenderingContext2D):void;
}
class User implements Serializable, Drawable {
toJSON() {
return '{}';
}
draw(ctx:CanvasRenderingContext2D) {
ctx.drawImage(…);
}
}
Poza metodami i polami, w interfejsach możliwe jest również zadeklarowanie konstruktorów, index signatures i wywołań (callable interface). Interfejsy możemy też rozszerzać. Jednak nie wystarszy mi miejsca, aby to wszystko tutaj opisać, dlatego żądnych wiedzy odsyłam do dokumentacji TypeScript.
Klasy abstrakcyjne
Klasy abstrakcyjne to klasy bazowe, po których można dziedziczyć, ale nie można ich instancjonować. Czym się więc różnią od interfejsów? Przede wszystkim klasy abstrakcyjne nie tylko zawierają deklaracje pól i metod, ale mogą też zawierać ich implementacje. Mogą, ale nie muszą. Idealnym kandydatem na klasę abstrakcyjną jest w takim razie wspomniana wcześniej klasa Animal
, gdyż jej raczej nie chcemy instancjonować bezpośrednio. Dodajmy też do niej deklarację metody eat
, ale bez implementacji (tzw. metodę abstrakcyjną). W ten sposób każda klasa pochodna będzie musiała zaimplementować eat
:
abstract class Animal {
constructor(protected name:string) {}
giveVoice() {
console.log(`Nazywam się ${this.name}!`);
}
abstract eat():void;
}
const animal = new Animal(''); // blad!
Podsumowanie
Starałem się najkrócej jak się da omówić tutaj aspekty TypeScripta związane z klasami, klasami abstrakcyjnymi i interfejsami. Po takiej dawce wiedzy możemy już z łatwością przystąpić do pisania aplikacji w TypeScripcie. W kolejnym wpisie poruszę temat typów zaawansowanych oraz inferencji typów. Zachęcam do komentowania i zadawania pytań!
Codziennie implementuję aplikacje dla ZOO i tam klasa Animal jest niezwykle przydatna! ↩