Podklasy

> Dodaj do ulubionych

ES6 bez tajemnic to cykl artykułów poświęconych nowym składnikom języka JavaScript, które pojawiły się w 6. edycji standardu ECMAScript, w skrócie ES6.

W jednym z poprzednich artykułów zaprezentowaliśmy nowy system klas w ES6, pomagający w rozwiązaniu trywialnych problemów z tworzeniem konstruktora obiektów. Pokazaliśmy, jak z ich wykorzystaniem pisać kod, który wygląda następująco:

class Circle {
    constructor(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    };

    static draw(circle, canvas) {
        // kod rysunku na kanwie
    };

    static get circlesMade() {
        return !this._count ? 0 : this._count;
    };
    static set circlesMade(val) {
        this._count = val;
    };

    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    };

    get radius() {
        return this._radius;
    };
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Promień koła musi być liczbą całkowitą.");
        this._radius = radius;
    };
}

Niestety, jak zauważyli niektórzy czytelnicy, nie było czasu na omówienie pozostałych możliwości klas w ES6. Podobnie jak w tradycyjnych systemach klas (np. w języku C++ czy Java), także w ES6 możliwe jest dziedziczenie polegające na tym, że klasa wykorzystuje inną klasę jako bazę, którą następnie rozszerza o własne elementy funkcjonalności. Przyjrzyjmy się zatem bliżej możliwościom, jakie daje nam dziedziczenie w ES6.

Zanim powiemy o tworzeniu podklas, warto poświęcić chwilę na odświeżenie wiedzy na temat dziedziczenia własności i dynamicznego łańcucha prototypów.

Dziedziczenie w JavaScripcie

Podczas tworzenia obiektu możemy do niego dodać nowe własności, jednak niezależnie od tego dziedziczy on także własności swoich prototypów. Jeśli jesteś programistą JavaScriptu, to zapewne znasz interfejs Object.create, dzięki któremu z łatwością można zrobić coś takiego:

var proto = {
    value: 4,
    method() { return 14; }
}

var obj = Object.create(proto);

obj.value; // 4
obj.method(); // 14

Ponadto, jeśli dodamy do obiektu własności o takiej samej nazwie co własności prototypu, to własności utworzonego obiektu przesłonią własności proto.

obj.value = 5;
obj.value; // 5
proto.value; // 4

Tworzenie podklas – podstawy

Uzbrojeni w tę wiedzę możemy poznać techniki tworzenia łańcuchów prototypów obiektów utworzonych na podstawie klasy. Przypomnijmy, że tworząc klasę, tworzymy także funkcję odpowiadającą metodzie constructor w definicji klasy, która przechowuje wszystkie metody statyczne. Dodatkowo tworzymy obiekt, który ma stanowić własność prototype tej funkcji i przechowywać wszystkie metody wystąpień. By utworzyć nową klasę dziedziczącą wszystkie własności statyczne, musimy sprawić, aby nowy obiekt funkcyjny dziedziczył po obiekcie funkcyjnym klasy nadrzędnej. Analogicznie, by w obiekcie prototypu znalazły się metody wystąpień, musimy sprawić, aby obiekt prototype nowej funkcji dziedziczył po obiekcie prototype klasy nadrzędnej.

To bardzo zwięzłe i mało klarowne wyjaśnienie. Przyjrzyjmy się zatem przykładowi ilustrującemu powyższe operacje bez użycia nowej składni. Następnie dodamy proste rozszerzenie, aby nasz kod był bardziej estetyczny.

Bazując na poprzednim przykładzie, załóżmy że chcemy utworzyć podklasę istniejącej klasy Shape:

class Shape {
    get color() {
        return this._color;
    }
    set color(c) {
        this._color = parseColorAsRGB(c);
        this.markChanged();  // kolor kanwy zmieni się później
    }
}

Kiedy próbujemy napisać tego typu kod, napotykamy problem z własnościami statycznymi omówiony w poprzednim artykule: w trakcie definiowania funkcji nie możemy zmienić jej prototypu przy pomocy składni. Choć można obejść to ograniczenie przy pomocy metody Object.setPrototypeOf, takie rozwiązanie jest na ogół mniej wydajne i trudniejsze w optymalizacji pod kątem silnika niż utworzenie funkcji o zamierzonym prototypie.

class Circle {
    // Jak wyżej
}

// Dodaj własności instancji
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// Dodaj własności statyczne
Object.setPrototypeOf(Circle, Shape);

Wygląda to dosyć niechlujnie. Po to w końcu dodaliśmy składnię klas, aby w jednym miejscu przechowywać wszystkie informacje na temat ostatecznej postaci obiektu, a nie bawić się w osobne dodawanie poszczególnych składników. W Javie, Ruby i innych obiektowych językach programowania można określić, że deklaracja klasy stanowi podklasę innej klasy – my też powinniśmy być w stanie coś takiego zrobić. Skorzystamy więc ze słowa kluczowego extends:

class Circle extends Shape {
    // Jak wyżej
}

Po słowie extends może występować dowolne wyrażenie będące poprawnym konstruktorem mającym własność prototype, np.:

  • inna klasa,
  • przypominająca klasę funkcja z istniejącego systemu szkieletowego,
  • zwykła funkcja,
  • zmienna zawierająca funkcję lub klasę,
  • odwołanie do obiektu,
  • wywołanie funkcji.

Jeśli nie chcemy, by egzemplarze dziedziczyły po prototypie Object.prototype, możemy nawet wpisać null.

Superwłasności

Możemy więc tworzyć podklasy, dziedziczyć własności, a czasem nawet przesłaniać dziedziczone metody własnymi metodami. A co, gdybyśmy chcieli uniknąć przesłaniania?

Załóżmy, że chcemy napisać podklasę Circle, która ma obsługiwać skalowanie koła przez określoną wartość. W tym celu możemy napisać nieco zagmatwaną klasę:

class ScalableCircle extends Circle {
    get radius() {
        return this.scalingFactor * super.radius;
    }
    set radius() {
        throw new Error("Promień ScalableCircle ma wartość stałą." +
                        "Ustaw współczynnik skalowania.");
    }

    // Kod obsługujący scalingFactor
}

Zauważ, że metoda pobierająca radius korzysta z własności super.radius. Dzięki nowemu słowu kluczowemu super możemy obejść nasze własne własności i wyszukać własność zaczynając od prototypu – tym samym unikniemy ewentualnego przesłonięcia.

Odwołanie do własności poprzez słowo kluczowe super (także poprzez super[wyrażenie] ) można wykorzystać w dowolnej funkcji zdefiniowanej zgodnie ze składnią definiowania metod. Choć funkcje te można wydobyć z oryginalnego obiektu, odwołania będą powiązane z obiektem, w którym dana metoda została zdefiniowana po raz pierwszy. Oznacza to, że przypisanie metody do zmiennej lokalnej nie wpłynie na działanie odwołania super.

var obj = {
    toString() {
        return "MójObiekt: " + super.toString();
    }
}

obj.toString(); // MójObiekt: [object Object]
var a = obj.toString;
a(); // MójObiekt: [object Object]

Tworzenie podklas klas wbudowanych

Kolejną rzeczą jest pisanie rozszerzeń wbudowanych elementów JavaScriptu. Standardowe struktury danych znacznie zwiększają możliwości języka, a dodawanie nowych typów wykorzystujących ten potencjał jest niezwykle przydatne i stanowi jeden z fundamentów mechanizmu tworzenia podklas. Załóżmy, że chcielibyśmy napisać tablicę wersjonowaną. (Wiem, wiem, zaufaj mi). Powinniśmy mieć możliwość wprowadzania i zatwierdzania zmian, bądź powrotu do zmian wcześniej zatwierdzonych. Szybkim sposobem na napisanie czegoś takiego będzie utworzenie podklasy klasy Array.

class VersionedArray extends Array {
    constructor() {
        super();
        this.history = [[]];
    }
    commit() {
        // Zapisz historię zmian.
        this.history.push(this.slice());
    }
    revert() {
        this.splice(0, this.length, this.history[this.history.length - 1]);
    }
}

Egzemplarze VersionedArray zachowały kilka istotnych własności. Są one pełnoprawnymi instancjami klasy Array zawierającymi własności map, filter i sort. Metoda Array.isArray() potraktuje je jak zwykłe tablice. Otrzymają nawet samoaktualizującą się własność length tablicy. Co więcej, funkcje które normalnie zwróciłyby zwykłą nową tablicę (jak np. Array.prototype.slice()) będą zwracać tablicę wersjonowaną!

Konstruktory klas pochodnych

Być może zauważyłeś metodę super() w konstruktorze z poprzedniego przykładu. No właśnie, co ona tam robi?

W tradycyjnych modelach klasowych konstruktory służą do inicjalizowania dowolnego stanu wewnętrznego instancji klasy. Każda kolejna podklasa odpowiada za inicjalizację stanu skojarzonego z tą konkretną podklasą. Chcielibyśmy połączyć te wywołania, aby podklasy dzieliły ten sam kod inicjalizacyjny z klasą nadrzędną.

W celu wywołania konstruktora nadrzędnego ponownie skorzystamy ze słowa kluczowego super, lecz tym razem użyjemy go jak funkcji. Taka składnia jest poprawna jedynie wewnątrz metody constructor klasy, która jest rozszerzeniem. Korzystając ze słowa super, możemy przepisać klasę Shape.

class Shape {
    constructor(color) {
        this._color = color;
    }
}

class Circle extends Shape {
    constructor(color, radius) {
        super(color);

        this.radius = radius;
    }

    // Jak wyżej
}

W JavaScripcie zazwyczaj pisze się konstruktory operujące na obiekcie this, umieszczające w nim własności i inicjalizujące jego stan wewnętrzny. Zwykle obiekt this zostaje utworzony po wywołaniu konstruktora ze słowem kluczowym new, tak jakby na własności prototype konstruktora została wywołana metoda Object.create(). Niektóre wbudowane konstrukcje języka mają jednak inny wewnętrzny układ obiektów. Przykładowo tablice są inaczej zapisane w pamięci niż zwykłe obiekty. Jako że chcemy tworzyć podklasy wbudowanych klas, skorzystamy z konstruktora klasy bazowej, by dokonał alokacji obiektu this w pamięci. Jeśli mamy do czynienia z klasą wbudowaną, to otrzymamy taki układ obiektu jak chcemy, a jeśli używamy zwykłego konstruktora, to zgodnie z oczekiwaniami otrzymamy domyślny obiekt this.

Najdziwniejszą konsekwencją takiego rozwiązania jest chyba sposób, w jaki element this jest powiązany w konstruktorze podklasy. Dopóki nie uruchomimy konstruktora klasy bazowej i nie pozwolimy mu dokonać alokacji obiektu this, nie będziemy mieć wartości this. W rezultacie wszystkie odwołania do this w konstruktorach podklasy występujące przed wywołaniem konstruktora super spowodują błąd ReferenceError.

Jak przekonaliśmy się w poprzednim artykule, tam gdzie można pominąć metodę constructor, można też opuścić konstruktory klas pochodnych – to tak, jakbyśmy napisali poniższy kod:

constructor(...args) {
    super(...args);
}

Czasami konstruktory nie wchodzą w interakcję z obiektem this. Tworzą zamiast tego obiekt w inny sposób, inicjalizują go, a następnie zwracają bezpośrednio. W takim przypadku słowo super nie jest wymagane. Dowolny konstruktor może bezpośrednio zwrócić obiekt niezależnie od tego, czy konstruktory nadrzędne zostały wywołane.

new.target

W przypadku alokacji obiektu this poprzez klasę bazową może się też zdarzyć, że klasa bazowa nie będzie wiedzieć, jaki rodzaj obiektu ma alokować. Załóżmy, że piszemy szkieletową bibliotekę obiektów i chcemy mieć klasę bazową Collection, kilka podklas będących tablicami oraz kilka podklas-słowników. W takim przypadku do czasu wywołania konstruktora Collection nie da się stwierdzić, który rodzaj obiektu należy utworzyć.

Ponieważ możemy tworzyć podklasy wbudowanych klas, to wywołując wbudowany konstruktor wewnętrznie musimy już znać prototyp klasy wyjściowej. Bez tego nie bylibyśmy w stanie utworzyć obiektu z właściwymi metodami wystąpień. Aby uporać się z tym dziwnym przypadkiem z klasą Collection, dodaliśmy składnię, za pośrednictwem której udostępnimy JavaScriptowi informację na temat prototypu. Dodaliśmy nową metawłasność new.target odnoszącą się do konstruktora, który został wywołany bezpośrednio poprzez słowo new. Wywołanie funkcji za pomocą new sprawia, że wywoływana jest własność new.target, a wywołanie w niej metody super przekaże wartość new.target.

Trudno to zrozumieć, zatem zilustruję co mam na myśli:

class foo {
    constructor() {
        return new.target;
    }
}

class bar extends foo {
    // Dodałem ten kod dla ułatwienia. Nie jest on konieczny,
    // by uzyskać takie rezultaty.
    constructor() {
        super();
    }
}

// foo wywołane bezpośrednio, zatem new.target to foo
new foo(); // foo

// 1) bar wywołane bezpośrednio, zatem new.target to bar
// 2) bar wywołuje foo poprzez metodę super(), więc new.target to wciąż bar
new bar(); // bar

Udało nam się rozwiązać problem z klasą Collection, ponieważ konstruktor Collection może teraz po prostu użyć własności new.target do określenia pokrewieństwa klas i dzięki temu wybrać odpowiednią klasę wbudowaną.

Własność new.target działa w każdej funkcji. Jeśli funkcja nie została wywołana z użyciem słowa new, wartość new.target zostanie ustawiona na undefined.

Połączyć zalety obu rozwiązań

Mam nadzieję, że udało ci się przebrnąć przez ten wyczerpujący opis nowych elementów funkcjonalności. Dzięki, że wciąż ze mną jesteś. Poświęćmy teraz chwilę i zastanówmy się, czy są one pomocne w rozwiązywaniu problemów. Wielu ludzi głośno zastanawiało się, czy dziedziczenie jest wystarczająco dobre, by być w ogóle częścią języka. Możesz sądzić, że dziedziczenie nigdy nie sprawdza się tak dobrze w tworzeniu obiektów co kompozycja albo że nie warto dla schludniejszej nowej składni rezygnować z elastyczności, jaką zapewnia stary model prototypów. Nie można zaprzeczyć, że domieszki stały się głównym narzędziem do tworzenia obiektów, które współdzielą kod w rozszerzalny sposób. Jest ku temu dobry powód – dzięki domieszkom można łatwo udostępnić niepowiązany kod temu samemu obiektowi i nie trzeba przy tym rozumieć, jak dwa niepowiązane fragmenty kodu mają stanowić część jednej struktury dziedziczenia.

Temat ten jest przedmiotem zagorzałej dyskusji. Warto w tym miejscu wspomnieć o kilku rzeczach. Po pierwsze, dodanie klas do języka nie oznacza, że należy z nich obowiązkowo korzystać. Po drugie, i równie ważne, klasy nie muszą być zawsze najlepszym sposobem na rozwiązanie problemów z dziedziczeniem! Niektóre z nich lepiej rozwiązać za pomocą modelowania opartego na dziedziczeniu prototypowym. Koniec końców, klasy są tylko kolejnym narzędziem do dyspozycji programisty – nie jedynym ani niekoniecznie najlepszym.

Jeśli chcesz w dalszym ciągu używać domieszek, mogą przydać ci się klasy dziedziczące po kilku innych elementach. Mógłbyś zatem dziedziczyć po każdej domieszce, a wszystko będzie działać jak trzeba. Niestety, wprowadzenie zmian do modelu dziedziczenia byłoby obecnie dosyć kłopotliwe, dlatego JavaScript nie obsługuje wielodziedziczenia klas. Istnieje jednak hybrydowe rozwiązanie umożliwiające korzystanie z domieszek w strukturze klasowej. Przyjrzyj się poniższym funkcjom bazującym na standardowym rozszerzaniu domieszek.

function mix(...mixins) {
    class Mix {}

    // Za pomocą programu dodaj wszystkie metody
    // domieszek do klasy Mix.
    for (let mixin of mixins) {
        copyProperties(Mix, mixin);
        copyProperties(Mix.prototype, mixin.prototype);
    }
    
    return Mix;
}

function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
        if (key !== "constructor" && key !== "prototype" && key !== "name") {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

Możemy teraz za pomocą funkcji mix utworzyć złożoną nadklasę, bez potrzeby bezpośredniego definiowania dziedziczenia pomiędzy różnymi domieszkami. Wyobraź sobie, że piszesz narzędzie do zespołowej edycji tekstu, które rejestruje w dzienniku wszystkie zmiany i których treść musi być serializowana. Możesz skorzystać z funkcji mix do napisania klasy DistributedEdit:

class DistributedEdit extends mix(Loggable, Serializable) {
    // Metody zdarzeniowe
}

W ten sposób możemy połączyć zalety obu przedstawionych rozwiązań. Widzimy również, jak można rozszerzyć taki model, by obsługiwał klasy domieszek mające własne nadklasy: wystarczy przekazać nadklasę funkcji mix i rozszerzyć ją o zwróconą klasę.

Dostępność omówionych składników

No dobra, sporo powiedzieliśmy na temat tworzenia podklas wbudowanych struktur i innych nowości, ale czy można z nich już w ogóle korzystać?

Odpowiedź brzmi: tak jakby. Jeśli chodzi o główne przeglądarki, w Chrome dostępna jest większość rzeczy, które zaprezentowaliśmy. Pracując w trybie ścisłym, nie powinieneś mieć z niczym problemów, za wyjątkiem podklasy Array – jest ona nieco bardziej wymagająca niż pozostałe wbudowane typy, dlatego nic dziwnego, że nie została jeszcze udostępniona. Ja sam pracuję nad implementacją tych składników (wszystkich poza Array) w Firefoksie i powinna się ona ukazać już niedługo w wersji Nightly. Więcej informacji znajdziesz w błędzie 1141863.

Ponadto przeglądarka Edge obsługuje słowo kluczowe super, ale nie w przypadku tworzenia podklas wbudowanych elementów. Safari go zaś nie obsługuje.

Transpilatory są w tym przypadku na straconej pozycji. Choć można z ich pomocą tworzyć klasy i korzystać ze słowa kluczowego super, to właściwie nie da się tworzyć podklas wbudowanych klas, ponieważ do pobrania wystąpień klasy bazowej z wbudowanych metod (typu Array.prototype.splice) potrzebujemy wsparcia silnika.

Uff! Wreszcie dotarliśmy do końca. W następnym artykule Jason Orendorff przedstawi system modułowy ES6.

Autor: Eric Faust

Źródło: https://hacks.mozilla.org/2015/08/es6-in-depth-subclassing/

Tłumaczenie: Joanna Liana

Treść tej strony jest dostępna na zasadach licencji CC BY-SA 3.0