Przypuśćmy, że naszym dzisiejszym celem jest usprawnienie masowej produkcji krzeseł.
Dziedziczenie klasowe
Postrzegając ten problem przez pryzmat języków obiektowych zorientowanych na klasy, zanim przejdziemy do wytwarzania, musimy przygotować specyfikację produktu.
Rolę specyfikacji będzie stanowiła klasa Krzesło. Zamieścimy w niej wszystkie właściwości i funkcjonalności krzeseł, które będziemy produkować. Najpierw skupimy się na najprostszym modelu, jaki przychodzi nam do głowy, zostawiając sobie furtkę do dalszych usprawnień w przyszłości.
Mając do dyspozycji taki plan, wystarczy wydzielić odpowiednią ilość zasobów i możemy zabierać się do pracy. Wychodzimy z założenia, że każdy egzemplarz powinien być idealnym odzwierciedleniem przygotowanej specyfikacji. Stąd, mówi się, że wyprodukowane przez nas krzesła są instancjami klasy Krzesło.
Jeżeli chcielibyśmy stworzyć nowy model krzesła, nie musimy tracić czasu na tworzenie zupełnie nowej specyfikacji. Krzesła dzielą ze sobą wiele cech charakterystycznych, niezależnie od różnic w szczegółach. Każde z nich będzie miało cztery nogi, siedzenie i oparcie, a jego głównym przeznaczeniem będzie możliwość zajęcia na nim miejsca. Wszystkie te cechy są opisane w naszej klasie Krzesło.
Możemy uznać nasz pierwotny projekt za punkt wyjścia. Wystarczy go skopiować, nanieść na niego innowacyjne zmiany, i voila – mamy do dyspozycji nowy produkt. Taki proces nazywamy dziedziczeniem klasowym.
Dziedziczenie prototypowe
Jeżeli zdecydujemy się na podejście prototypowe, zabierzemy się za ten problem zupełnie inaczej. Wychodzimy z założenia, że proces produkcji pierwszego egzemplarza dostarczy nam know-how, które pozwoli na sprawną produkcję kolejnych. Nie będziemy tracić czasu na tworzenie specyfikacji. Bierzemy materiały, instrukcję z IKEA (lub zaprzyjaźnionego bloga programistycznego) i po chwili jesteśmy posiadaczami prototypu krzesła.
To krzesło, konkretny obiekt, będzie stanowiło wzór dla wszystkich następnych krzeseł.
Takie nastawienie, ignorujące rygor przestrzegania specyfikacji, daje nam możliwość późniejszego nanoszenia modyfikacji. Tworząc kolejne krzesła, interesują nas tylko nowości w stosunku do prototypu. W razie wątpliwości co do własności produktu, wystarczy zerknąć na jego prototyp. Takie odwoływanie się do prototypów, wskazywanie ich za pomocą referencji, nazywamy łańcuchem prototypowym. Wykorzystywanie własności prototypu nazywamy dziedziczeniem prototypowym.
Warto wiedzieć: Słowo kluczowe class
pojawiło się w składni języka dopiero za sprawą ES6. Jest to jedynie upiększenie składni, które w ostateczności sprowadza się do wykorzystywania prototypów.
Prototypy w JS
Każdy obiekt w momencie utworzenia otrzymuje ukrytą właściwość [[Prototype]]
. Może ona przyjąć dwie wartości: null
lub referencję do obiektu nazywanego prototypem. Jeżeli spróbujemy odwołać się do właściwości, która nie zostanie odnaleziona w obiekcie, silnik będzie kontynuował poszukiwania, w obiekcie wskazywanym przez [[Prototype]]
.
Jeżeli właściwość zostanie odnaleziona w prototypie, silnik zwróci przypisaną do niej wartość, tak jakby znajdowała się w naszym obiekcie od samego początku.
Podczas swoich poszukiwań silnik nie zatrzymuje się na bezpośrednim prototypie naszego obiektu. Przemierzy on cały łańcuch prototypów. Będzie szukał w prototypie prototypu etc., aż do momentu natrafienia na [[Prototype]]
z wartością null
.
Domyślnie ostatnim ogniwem łańcucha prototypów jest Object.prototype
. Jeżeli wyszukiwana właściwość nie zostanie odnalezionaw w żadnym z przeszukiwanych obiektów, to silnik uraczy nas wartością undefined
.
Jak w przypadku wszystkich ukrytych właściwości, nie mamy możliwości bezpośredniej interakcji z [[Prototype]]
.
Jako interfejs służą nam metody obiektu globalnego Object
.
Object.getPrototypeOf(obj)
zwraca referencję do [[Prototype]]
, podczas gdy Object.setProtytypeOf(obj, proto)
pozwala ustawić prototyp obj
na obiekt proto
.
Jeżeli chcemy ustawić prototyp obiektu w momencie jego inicjalizacji, należy wykorzystać Object.create(proto, objectProperties)
. Ta metoda zwróci nam nowy obiekt z właściwością [[Prototype]]
wskazującą na proto
.
Czasami zależy nam na sprawdzeniu, czy obiekt posiada swoją prywatną właściwość, czy uzyskuje do niej dostęp z
łańcucha prototypów. W tym wypadku wykorzystuje się metodę Object.hasOwnProperty(obj, prop)
.
Tyle z suchej teorii, przejdźmy do analizy kodu. Przygotowałem dla Was dwie wersje prostego programu generującego obiekty graczy NBA. Pierwsza z nich jest zaimplementowana w oparciu o wzorzec dziedziczenia prototypowego. Druga wersja zawiera alternatywny wzorzec, delegację zachowań.
Zanim przejdziemy dalej, upewnij się, że rozumiesz, jak działa wskaźnik this w JS. Dodam tylko, że prototypy nie mają żadnego wpływu na wartość tego wskaźnika.
Dziedziczenie prototypowe – analiza wzorca
function Player(name, age, discipline) { this.name = name; this.age = age; this.discipline = discipline; } Player.prototype.identify = function() { console.log(`Hello, I'm ${this.name}. I'm ${this.age} and I play ${this.discipline}`); }; function BasketballPlayer(name, age, discipline, team, position) { Player.call(this, name, age, discipline); this.team = team; this.position = position; } BasketballPlayer.prototype = Object.create(Player.prototype); BasketballPlayer.prototype.sayHi = function() { console.log(`${this.identify()} My team is called ${this.team} and I play as ${this.position}`); }; const lebron = new BasketballPlayer('Lebron James', 32, 'Basketball', 'Cleveland Cavaliers', 'Power forward'); lebron.sayHi();
Dziedziczenie prototypowe to najpopularniejszy wzorzec programowania obiektowego w JS. Programiści wykorzystują go praktycznie od samych początków języka.
Polega on na imitowaniu dziedziczenia klasowego za pomocą połączenia własności obiektów funkcjnych i operatora new
.
Bazuje na wykorzystaniu efektów ubocznych, przez co może wydawać się skomplikowany dla początkujących.
Dziedziczenie prototypowe opiera się na wykorzystaniu obiektu F.prototype
oraz konstrukcyjnego wywołania funkcji.
F.prototype
to wbudowana właściwość każdej funkcji w JS. Gwoli ścisłości, mówimy o właściwości prototype
hipotetycznej funkcji o nazwie F
. Domyślnie F.prototype
jest obiektem,
który zawiera wyłącznie jedną właściwość. Nazywa się ona constructor
i wskazuje z powrotem na F
. W naszym programie wykorzystujemy dwa takie obiekty: BasketballPlayer.prototype
i Player.prototype
.
Jeżeli zmienimy prototyp funkcji na nowy obiekt, musimy liczyć się z tym, że jego właściwość constructor
przestanie wskazywać na F
.
Aby tego uniknąć, modyfikujemy prototyp poprzez dodawanie/usuwanie właściwości – F.prototype.method = ...
. Jednak, często zależy nam na ustawieniu konkretnego prototypu dla hodowanych obiektów. Właśnie z tego powodu, przypisałem do BasketballPlayer.prototype
pusty obiekt za pośrednictwem Object.create()
. Jego [[Prototype]]
wskazuje na Player.prototype
. Dzięki temu, mogę odwoływać się do właściwości Player.prototype
, z poziomu BasketballPlayer.prototype
, za pomocą wskaźnika this
.
Ten sam mechanizm wykorzystują wbudowane obiekty globale, które również posiadają właściwość prototype
. Najpopularniejsze z nich to: Object
, String
, Array
.
Podczas inicjalizacji zmiennej tablicowej, silnik automatycznie ustawia [[Prototype]]
na Array.prototype
. Właśnie dzięki temu mamy możliwość wykorzystywania wielu metod, m.in. Array.prototype.filter, Array.prototype.map, Array.prototype.reduce.
Do tworzenia nowych obiektów na bazie prototypu, stosujemy konstrukcyjne wywołanie funkcji, czyli poprzedzone operatorem new
. Wiążą się z tym następujące efekty uboczne:
- Wywołanie funkcji z operatorem
new
skutkuje utworzeniem obiektu. - Nowy obiekt będzie miał własność
[[Prototype]]
wskazującą naF.prototype
. - Wiązanie
this
wewnątrz wywoływanej funkcji będzie wskazywało na nowo utworzony obiekt. - Jeżeli funkcja nie zwraca innego obiektu, dojdzie do automatycznego zwrócenia nowego obiektu.
Uwaga: nie należy mylić ze sobą F.prototype
oraz [[Prototype]]
. To dwie różne właściwości. Jedna jest charakterystyczna
wyłącznie dla funkcji oraz obiektów globalnych, podczas gdy [[Prototype]]
to ukryta właściwość każdego obiektu w JS.
Delegacja zachowań – analiza wzorca
const Player = { init(name, age, discipline) { this.name = name; this.age = age; this.discipline = discipline; } const BasketballPlayer = Object.create(Player); BasketballPlayer.setup = function(name, age, discipline, team, position) { this.init(name, age, discipline); this.team = team; this.position = position; } Player.identify = function() { console.log(`Hello, I'm ${this.name}. I'm ${this.age} and I play ${this.discipline}`); }; BasketballPlayer.sayHi = function() { console.log(`${this.identify()} My team is called ${this.team} and I play as ${this.position}`); }; const lebron = Object.create(BasketballPlayer); lebron.setup('Lebron James', 32, 'Cleveland Cavaliers', 'Power Forward'); lebron.sayHi();
W tym wzorcu korzystamy z dwóch typów obiektów: delegatów (Player
, BasketballPlayer
) i delegujących (lebron
).
Warto zwrócić uwagę, że stan jest przechowany w obiekcie delegującym. Delegaci służą nam wyłącznie jako kontenery zachowań. Aby w pełni wykorzystywać zalety prototypowania, nie stosujemy cieniowania metod. Specjalnie rozgraniczyłem metody init
i setup
oraz identify
i sayHi
, tak, abym mógł odwołać się do każdej z nich w obiekcie delegującym, z użyciem wskaźnika this
.
Więcej informacji o OLOO znajdziecie w drugim materiale uzupełniającym, który czeka na Was w dalszej części wpisu.
Materiały uzupełniające
- javascript.info – po raz kolejny odsyłam Was w ręce Ilyi Kantora. Tym razem wykorzystamy aż cztery treściwe wpisy o prototypach – Prototypal inheritance, F.prototype, Native prototypes oraz Methods for prototypes.
Każdy z nich jest pełen grafik pozwalających na zwizualizowanie sobie omawianych zagadnień. Co najważniejsze, na końcu każdego wpisu czeka na Was kilka zadań. Łącznie będzie ich aż 10!
Po takiej ilości solidnych materiałów i przećwiczonych przykładów, prototypy nie będą miały przed Wami tajemnic. - YDKJS: this and object prototypes, Chapter V i Chapter VI – podstawy delegacji zachowań i dziedziczenia prototypowego poznaliście podczas analizy przykładów.
Wytłumaczenie tajników tych wzorców pozostawiam Kyle’owi Simpsonowi. Będzie to świetna okazja do ugruntowania Waszej wiedzy z całego programowania obiektowego w JS.
Pytania kontrolne
Okej, na koniec mam dla Was listę pytań. Z ich pomocą możecie sprawdzić ile udało się Wam zapamiętać. Jeżeli uważnie przeczytaliście wpis, to nie powinno być problemów z udzieleniem poprawnych odpowiedzi. W razie wątpliwości, służę pomocą w komentarzach.
- Czym jest właściwość
[[Prototype]]
obiektów? - Czym jest obiekt prototypowy?
- Czy można dziedziczyć metody i właściwości za pomocą łańcucha prototype?
- Czy istnieje możliwość bezpośredniej interakcji z właściwością
[[Prototype]]
code>? - Jakie są sposoby na modyfikację
[[Prototype]]
? - Czy prototyp może być wartością prymitywną?
- Jaka jest maksymalna ilość prototypów pojedynczego obiektu?
- Czy prototyp ma wpływ na wskaźnik this?
- Na co wskazuje właściwość
F.prototype
? - Jaka jest domyślna wartość
F.prototype
? - Czy silnik JS zapewnia utrzymanie poprawnej wartości dla właściwości
constructor
? - Jak utrzymać poprawną wartość
constructor
, jednocześnie modyfikującF.prototype
? - Jaka jest różnica pomiędzy
[[Prototype]]
, aF.prototype
? - Jakie wartości przyjmuje
F.prototype
? - Na czym polega wzorzec delegacji zachowań?
- Na czym polega wzorzec dziedzieczenia prototypowego?
Zachęcam do przekucia powyższych pytań w karty Anki. W przypadku konieczności zapamiętania takiej ilości zasad i niuansów, metoda Anki jest wyjątkowo skuteczna.