Podsumowanie
Transkrypt
Podsumowanie
helion kopia dla: Dawid Karwot [email protected] Tytuł oryginału: You Don't Know JS: this & Object Prototypes Tłumaczenie: Robert Górczyński ISBN: 978-83-283-2183-0 © 2016 Helion SA. Authorized Polish translation of the English edition of You Don't Know JS: this & Object Prototypes ISBN 9781491904152 © 2014 Getify Solutions, Inc. This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from the Publisher. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/tajejs_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Poleć książkę na Facebook.com Kup w wersji papierowej Oceń książkę Księgarnia internetowa Lubię to! » Nasza społeczność helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Spis treści Przedmowa ........................................................................................ 5 Wprowadzenie ................................................................................... 7 1. this, czyli co? .................................................................................... 13 Dlaczego this? Zamieszanie Czym jest this? Podsumowanie 13 15 22 22 2. To wszystko ma teraz sens! ............................................................... 23 Źródło wywołania funkcji Tylko reguły Wszystko w odpowiedniej kolejności Wyjątki dotyczące wiązań Leksykalne this Podsumowanie 23 25 35 41 46 48 3. Obiekty ............................................................................................ 51 Składnia Typ Zawartość obiektu Iteracja Podsumowanie 51 52 55 77 82 3 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 4. Mieszanie obiektów „klas” ................................................................85 Teoria klas Mechanika klas Dziedziczenie klasy Domieszki Podsumowanie 85 89 92 98 106 5. Prototypy ....................................................................................... 107 [[Prototype]] „Klasa” Funkcje „klasy” Dziedziczenie (prototypowe) Łącza obiektu Podsumowanie 107 113 113 123 131 137 6. Delegowanie .................................................................................. 139 Projekt oparty na delegowaniu Klasy kontra obiekty Prostszy projekt Ładniejsza składnia Introspekcja Podsumowanie 140 153 158 165 168 171 A ES6 class ......................................................................................... 173 B Podziękowania ............................................................................... 183 Skorowidz ...................................................................................... 187 4 Spis treści helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Przedmowa Kiedy czytałem tę książkę, przygotowując się do napisania przedmowy, byłem zmuszony do przypomnienia sobie, jak wyglądała droga, która doprowadziła mnie do poznania języka JavaScript, i ile zmieniło się przez tych piętnaście lat, odkąd zajmuję się programowaniem w tym języku. Gdy piętnaście lat temu zacząłem programować w JavaScript, używanie na stronach internetowych technologii innych niż HTML, takich jak CSS lub JavaScript, było określane jako DHTML, czyli Dynamic HTML. Wtedy jednak użyteczność języka JavaScript była zupełnie inna i przypominała raczej dodawanie animowanych płatków śniegu na stronie internetowej lub dynamicznych zegarów wyświetlających godzinę w pasku stanu. Muszę przyznać, że tak naprawdę na początku mojej kariery nie zwracałem zbyt wielkiej uwagi na JavaScript, przede wszystkim ze względu na nowatorski charakter implementacji często spotykanych w internecie. Dopiero w 2005 roku odkryłem na nowo JavaScript i zacząłem go postrzegać jako prawdziwy język programowania, któremu należy się uważniej przyjrzeć. Po zapoznaniu się z pierwszą wersją beta aplikacji Mapy Google dostrzegłem potencjał kryjący się w tym języku. W tamtym czasie aplikacja Mapy Google jako jedyna pozwalała na poruszanie się po mapie za pomocą myszki, na zmianę poziomu powiększenia i wykonywanie żądań do serwera bez konieczności ponownego wczytywania strony — a to wszystko dzięki językowi JavaScript. To przypominało czary! 5 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Kiedy coś wydaje się magią, oznacza to zwykle nowy rozdział i nową jakość. Okazało się, że miałem rację. Mimo upływu czasu JavaScript nadal jest jednym z podstawowych języków, których używam do programowania zarówno po stronie klienta, jak i serwera. Szczerze mówiąc, nie wyobrażam sobie, aby można było robić to za pomocą innego języka. Patrząc na ostatnich piętnaście lat, żałuję tylko, że nie dałem językowi JavaScript większej szansy jeszcze przed 2005 rokiem. Po prostu nie przewidziałem, że stanie się on prawdziwym językiem programowania, podobnie jak C++, C#, Java i wiele innych. Gdybym na początku mojej kariery miał do dyspozycji serię książek Tajniki języka JavaScript, moja droga zawodowa być może potoczyłaby się zupełnie inaczej. Cecha, za którą uwielbiam tę serię, to solidna dawka wiedzy o języku JavaScript przedstawiona w wyczerpujący i zabawny sposób. Pozycja Wskaźnik this i prototypy obiektów stanowi świetną kontynuację serii. Nie tylko uzupełnia wiedzę przekazaną we wcześniejszej książce Zakresy i domknięcia, ale niewątpliwie jeszcze ją poszerza o omówienie bardzo ważnych aspektów języka JavaScript, jakimi są słowo kluczowe this i prototypy. Te dwa zagadnienia są konieczne do opanowania materiału przedstawionego w kolejnych książkach serii, ponieważ stanowią podstawy prawdziwego programowania w JavaScript. Koncepcja dotycząca sposobu tworzenia obiektów, łączenia ich ze sobą i rozszerzania jest niezbędna do zbudowania ogromnych i skomplikowanych aplikacji w JavaScript. Bez wspomnianych koncepcji opracowanie złożonych aplikacji, takich jak Mapy Google, byłoby niemożliwe. Muszę stwierdzić, że większa część programistów sieciowych prawdopodobnie nigdy nie utworzyła żadnego obiektu JavaScript i dlatego traktuje ten język jako oparty na zdarzeniach sposób połączenia przycisków z żądaniami wykonywanymi w technologii Ajax. W pewnym momencie swojej kariery też tak uważałem, ale po poznaniu prototypów i sposobów tworzenia obiektów w JavaScript dostrzegłem ogrom możliwości, jakie znajdują się dosłownie na wyciągnięcie ręki. Jeśli zaliczasz się do tej samej kategorii programistów, koniecznie przeczytaj tę książkę. Natomiast jeśli potrzebujesz jedynie odświeżenia wiadomości, także i Ty znajdziesz tu wiele przydatnych informacji. Z pewnością nie rozczarujesz się. Zaufaj mi! — Nick Berardi nickberardi.com, @nberardi 6 Przedmowa helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Wprowadzenie Jestem pewien, że zwróciłeś już na to uwagę — „JS” w tytule serii książek nie jest tylko skrótem wyrazów oznaczających kurs języka JavaScript. Kurs dotyczący dziwactw języka JavaScript to prawdopodobnie coś, na co wszyscy czekamy! Od pierwszych dni istnienia internetu język JavaScript zawsze był podstawową technologią odpowiedzialną za dostarczanie interaktywnej treści użytkownikowi. Wprawdzie na początku cechami charakterystycznymi języka JavaScript były na przykład migający kursor myszy lub irytujące wyskakujące okna, ale teraz, niemal dwie dekady później, zarówno technologia, jak i możliwości języka JavaScript zwiększyły się o kilka rzędów wielkości. Już tylko nieliczni wątpią w to, że JavaScript jest sercem najbardziej rozpowszechnionej platformy oprogramowania na świecie — internetu. Jednak jako język JavaScript jest też nieustannie przedmiotem krytyki wynikającej po części z jego dziedzictwa, choć w znacznie większej mierze dotyczącej przyjętej filozofii projektowej. Krytykowana jest nawet jego nazwa — jak to określił Brendan Eich — traktuje się go jako „głupszego młodszego brata” porównywanego z jego „starszym i dojrzalszym bratem” — Javą. Warto w tym miejscu dodać, że nazwa „JavaScript” powstała przypadkiem, na styku polityki i marketingu. Te dwa języki są zupełnie odmienne, pomimo że ich nazwy zawierają ciąg liter „Java”. JavaScript ma taki związek z Javą jak ser z serpentyną. Ponieważ JavaScript zapożyczył koncepcje i idiomy składni od kilku innych języków — między innymi silne korzenie stylu proceduralnego stosowanego w języku C, jak również subtelne elementy stylu funkcjonalnego pochodzące 7 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 z języków Scheme i Lisp — jest przystępny dla wielu programistów, nawet tych z jedynie niewielkim lub nawet żadnym doświadczeniem w programowaniu. Utworzenie programu typu „Witaj, świecie!” w JavaScript jest tak proste, że trudno o lepszą zachętę do poznania i używania tego języka. Choć JavaScript jest prawdopodobnie jednym z najłatwiejszych języków programowania i dość szybko można rozpocząć z nim pracę, to jego dziwactwa powodują, że mało kto decyduje się na solidne jego opanowanie. Utworzenie kompletnego programu w językach takich jak C i C++ wymaga dobrej ich znajomości, natomiast pełny program w JavaScript można napisać, znając jedynie jego podstawy. Istnieje tendencja, aby zaawansowane koncepcje głęboko zakorzenione w języku wyrażać w pozornie prosty sposób. Przykładem może być tutaj przekazywanie funkcji w postaci wywołań zwrotnych. To zachęca programistów JavaScript do użycia języka w istniejącej postaci i nieprzejmowania się tym, co dzieje się „pod maską”. JavaScript to łatwy i prosty w użyciu język cieszący się szerokim uznaniem, choć jednocześnie zawiera kolekcję skomplikowanych i zróżnicowanych mechanizmów. Bez wnikliwej analizy prawdziwe ich zrozumienie może się okazać trudne nawet dla najbardziej doświadczonych programistów JavaScript. Na tym polega paradoks języka JavaScript — to jego pięta Achillesa i zarazem wyzwanie, któremu próbujemy sprostać. Ponieważ JavaScript może być używany bez większej wiedzy na jego temat, programiści często nie starają się jej posiąść. Misja Gdyby za każdym razem, gdy w języku JavaScript coś Cię zaskakuje lub wywołuje frustrację, Twoją odpowiedzią było zniechęcenie się i umieszczenie tego języka na czarnej liście (jak to niektórzy mają w zwyczaju), to bardzo szybko utraciłbyś możliwość korzystania z wielu jego zalet. Chociaż część przedstawionego tutaj materiału powiela dobrze znaną książkę JavaScript — mocne strony, zachęcam do potraktowania niniejszej jako przedstawiającej jasne strony, bezpieczne strony lub nawet komplementarne strony języka JavaScript. 8 Wprowadzenie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Seria książek Tajniki języka JavaScript stawia przed Tobą inne wyzwania — naukę i dokładne poznanie wszystkich aspektów języka JavaScript, a w szczególności jego ciemnych stron. Chciałbym zmienić powszechną wśród programistów JavaScript tendencję do uczenia się tego języka tylko w niezbędnym zakresie, bez konieczności podejmowania trudu i dokładnego zrozumienia, dlaczego zachowuje się w taki, a nie inny sposób. Co więcej, uważam, że nie należy się wycofywać, gdy podczas nauki pojawiają się trudności. Nie jestem usatysfakcjonowany (i Ty również nie powinieneś), kiedy coś działa, a ja nie bardzo wiem dlaczego. Zachęcam Cię zatem do porzucenia łatwych i prostych dróg na rzecz tych nieco wyboistych i z pozoru prowadzących na manowce, aby przekonać się, jak niezwykłym językiem jest JavaScript i jakie efekty można otrzymać przy jego wykorzystaniu. Gdy masz taką wiedzę, żadna technologia, żaden framework lub krzykliwy nagłówek nigdy Cię nie zaskoczą i zawsze będą zrozumiałe. Wszystkie książki z tej serii są poświęcone kluczowym elementom języka, które dość powszechnie są błędnie rozumiane lub nadinterpretowane. Materiał przedstawiany w książkach umożliwia dogłębne i wnikliwe zapoznanie się z omawianym tematem. Lektura powinna pozostawić w Tobie silne przekonanie o zdobytej wiedzy, nie tylko teoretycznej, ale i praktycznej. Język JavaScript, jaki poznałeś do tej pory, to prawdopodobnie tylko pewna część wiedzy o nim, dostarczona Ci przez innych, być może nieco zniechęconych swym niepełnym zrozumieniem tematu. Taki JavaScript to zaledwie cień prawdy. Wprawdzie jeszcze nie znasz tego języka, ale jeśli zgłębisz książki z tej serii, na pewno go poznasz. Zapraszam więc do lektury. JavaScript czeka na Ciebie! Podsumowanie JavaScript jest wspaniałym językiem. Jest prosty, gdy uczymy się go stopniowo, i dużo trudniejszy, jeśli chcemy opanować go w całości (lub jedynie wystarczająco). Kiedy programiści napotykają trudności, zwykle obwiniają język, a nie własny brak wiedzy. Seria książek Tajniki języka JavaScript ma to zmienić i pomóc Ci docenić język, który powinieneś dobrze znać. Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 9 0 Wiele przykładów przedstawionych w książce wykorzystuje nowoczesne (i wykraczające w przyszłość) funkcje wprowadzone w specyfikacji ES6. Niektóre fragmenty kodu mogą nie działać w oczekiwany sposób, jeśli zostaną uruchomione w starszych (niezgodnych ze specyfikacją ES6) silnikach JavaScript. Konwencje zastosowane w książce W tej książce zastosowano następujące konwencje typograficzne: Kursywa Oznacza nowe i ważne pojęcia, adresy URL i e-mail, nazwy plików, rozszerzenia plików itd. Czcionka o stałej szerokości Użyta w przykładowych fragmentach kodu, a także w samym tekście, aby odwołać się do pewnych poleceń bądź innych elementów programistycznych, takich jak nazwy zmiennych lub funkcji, baz danych, typów danych, zmiennych środowiskowych, poleceń i słów kluczowych. Pogrubiona czcionka o stałej szerokości Użyta w celu wyeksponowania poleceń bądź innego tekstu, który powinien być wprowadzony przez Czytelnika. Pochylona czcionka o stałej szerokości Wskazuje tekst, który powinien być zastąpiony wartościami podanymi przez użytkownika bądź wynikającymi z kontekstu. Taka ikona oznacza ogólną uwagę. Użycie przykładowych kodów Książka ta ma pomóc Ci w pracy. Ogólnie rzecz biorąc, można wykorzystywać przykłady z tej książki w swoich programach i dokumentacji. Nie trzeba kontaktować się z nami w celu uzyskania zezwolenia, dopóki nie powiela się znaczących ilości kodu. Na przykład pisanie programu, w którym znajdzie się kilka fragmentów kodu z tej książki, nie wymaga zezwolenia, jednak sprzedawanie lub rozpowszechnianie płyty CD-ROM zawierającej przykłady z książki 10 Wprowadzenie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 wydawnictwa O’Reilly wymaga zezwolenia. Odpowiedź na pytanie przez cytowanie tej książki lub przykładowego kodu nie wymaga zezwolenia, ale już włączenie znaczących ilości przykładowych kodów do dokumentacji produktu Czytelnika takiego zezwolenia wymaga. Jesteśmy wdzięczni za przypisy, ale nie wymagamy ich. Przypis zwykle zawiera tytuł, autora, wydawcę i ISBN. Na przykład: Kyle Simpson, Tajniki języka JavaScript. Wskaźnik this i prototypy obiektów, ISBN 978-83-283-2175-5, Helion, Gliwice 2016. Jeżeli jednak masz jakiekolwiek wątpliwości dotyczące użycia przykładowych kodów, po prostu skontaktuj się z nami, korzystając z adresu permissions@ oreilly.com. Użycie przykładowych kodów helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 11 0 12 Wprowadzenie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 1. this, czyli co? Jednym z mechanizmów, które sprawiają najwięcej problemów w języku JavaScript, jest słowo kluczowe this. Jest to specjalne słowo kluczowe, które jest automatycznie definiowane w zakresie każdej funkcji. Jednak to, do czego się ono odwołuje, może sprawiać trudności nawet najbardziej doświadczonym programistom JavaScript. Każda wystarczająco zaawansowana technologia staje się nie do odróżnienia od magii. — Arthur C. Clark Wprawdzie mechanizm this w języku JavaScript nie jest aż tak bardzo zaawansowany, ale programiści często parafrazują powyższy cytat, wstawiając słowo „skomplikowany” lub „zagmatwany”. Nie ulega wątpliwości, że może Ci się wydawać, iż w działaniu mechanizmu this jest coś magicznego — aby go odczarować, trzeba po prostu dogłębnie go zrozumieć. Dlaczego this? Skoro mechanizm this sprawia spore trudności nawet doświadczonym programistom JavaScript, być może zastanawiasz się, czy w ogóle jest on użyteczny. Czy po prostu z istnienia mechanizmu this nie wynika więcej problemów niż pożytku? Zanim przejdziemy do poznania sposobu, w jaki działa this, najpierw musisz się dowiedzieć, dlaczego ten mechanizm istnieje. 13 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Spróbujmy zilustrować motywację stojącą za utworzeniem mechanizmu this: function identify() { return this.name.toUpperCase(); } function speak() { var greeting = "Witaj, jestem " + identify.call( this ); console.log( greeting ); } var me = { name: "Kyle" }; var you = { name: "Czytelnik" }; identify.call( me ); // KYLE. identify.call( you ); // CZYTELNIK. speak.call( me ); // Witaj, jestem KYLE. speak.call( you ); // Witaj, jestem CZYTELNIK. Jeżeli zrozumienie powyższego fragmentu kodu pokazującego sposób działania this sprawia Ci trudność, nie przejmuj się tym! Wkrótce wszystko stanie się jasne. Po prostu na chwilę odstaw na bok wcześniejsze pytania, co pozwoli nam dokładniej przyjrzeć się, dlaczego istnieje mechanizm this. W powyższym fragmencie kodu funkcje identify() i speak() mogą być wielokrotnie użyte względem wielu obiektów kontekstu (me i you). Dzięki temu eliminujemy konieczność przygotowania oddzielnych wersji funkcji dla każdego obiektu. Zamiast opierać się na słowie kluczowym this, równie dobrze można by wyraźnie przekazywać obiekt kontekstu w wywołaniach funkcji identify() i speak(): function identify(context) { return context.name.toUpperCase(); } function speak(context) { var greeting = "Witaj, jestem " + identify( context ); console.log( greeting ); } identify( you ); // CZYTELNIK. speak( me ); // Witaj, jestem KYLE. 14 Rozdział 1. this, czyli co? helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Jednak mechanizm this zapewnia znacznie bardziej elegancki sposób niejawnego przekazywania odwołania do obiektu, więc efektem jest bardziej przejrzysty projekt API oraz łatwiejsza możliwość wielokrotnego użycia. Im bardziej skomplikowany wzorzec użycia, tym wyraźniej można dostrzec, że przekazanie kontekstu w postaci jawnie podanego parametru najczęściej oznacza znacznie większy bałagan niż przekazanie kontekstu this. Podczas analizy obiektów i prototypów przekonasz się o użyteczności kolekcji funkcji, które mogą automatycznie odwoływać się do właściwego obiektu kontekstu. Zamieszanie Wkrótce rozpocznę wyjaśnianie rzeczywistego sposobu działania this, jednak wcześniej chciałbym położyć kres pewnym błędnym wyobrażeniom dotyczącym tego, jak mechanizm this w rzeczywistości nie działa. Nazwa this1 może wywoływać pewne zamieszanie, gdy programista zbyt dosłownie próbuje potraktować jej znaczenie. Najczęściej są przyjmowane dwa założenia, przy czym oba są nieprawidłowe. Odniesienie do siebie Pierwszą pokusą jest przyjęcie założenia, że this odwołuje się do samej funkcji. Na takie założenie wpływ może mieć gramatyka. Dlaczego pochodzące z wewnątrz odwołanie do danej funkcji może być użyteczne? Wśród najczęściej pojawiających się powodów jest na przykład rekurencja (wywołanie funkcji z wewnątrz) lub przygotowanie procedury obsługi zdarzeń, która może być odłączona po jej pierwszym wywołaniu. Programiści dopiero poznający mechanizmy JavaScript bardzo często traktują wspomniane odniesienie do funkcji jako obiekt (wszystkie funkcje w JavaScript są obiektami!) pozwalający na przechowywanie informacji o stanie (wartości właściwości) między wywołaniami funkcji. Wprawdzie tego rodzaju możliwość niewątpliwie istnieje i ma pewne ograniczone zastosowania, ale w pozostałej części książki poznasz wiele innych i lepszych niż obiekt funkcji miejsc przeznaczonych do przechowywania informacji o stanie. 1 Dosłownie „to” — przyp. tłum. Zamieszanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 15 0 Rozważmy jednak w tej chwili powyższy wzorzec, aby pokazać, że this nie pozwala funkcji na pobranie odniesienia do samej siebie, jak można by zakładać. Przeanalizuj poniższy fragment kodu, w którym próbujemy określić liczbę wywołań funkcji foo(): function foo(num) { console.log( "foo: " + num ); // Próba określenia liczby wywołań funkcji foo(). this.count++; } foo.count = 0; var i; for (i=0; i<10; i++) { if (i > 5) { foo( i ); } } // foo: 6 // foo: 7 // foo: 8 // foo: 9 // Ile razy została wywołana funkcja foo()? console.log( foo.count ); // 0 — Co u licha? Wartość foo.count nadal wynosi 0, pomimo że cztery wywołania console.log() wyraźnie wskazują na czterokrotne wywołanie funkcji foo(..). Frustracja wynika ze zbyt dosłownej interpretacji znaczenia słowa kluczowego this w poleceniu this.count++ . Wykonanie polecenia foo.count = 0 faktycznie oznacza dodanie właściwości o nazwie count do obiektu funkcji foo(). Jednak w przypadku odniesienia this.count wewnątrz funkcji this tak naprawdę w ogóle nie odwołuje się do obiektu funkcji. Dlatego też pomimo takich samych nazw właściwości obiekty, do których następuje odwołanie, są inne — i to właśnie wywołuje zamieszanie. 16 Rozdział 1. this, czyli co? helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Odpowiedzialny programista powinien w tym miejscu zadać następujące pytanie: „Skoro przeprowadziłem inkrementację właściwości count, ale innej, niż sądziłem, to którą właściwość tak naprawdę inkrementowałem?”. Jeżeli zaczniesz się nad tym pytaniem dokładnie zastanawiać, przekonasz się, że przypadkowo utworzyłeś zmienną globalną o nazwie count (w rozdziale 2. dowiesz się, jak do tego doszło!), a jej bieżącą wartością jest NaN. Oczywiście po dokonaniu tego zadziwiającego odkrycia pojawi się mnóstwo innych pytań, na przykład: „Jak to możliwe, że powstała zmienna globalna i dlaczego ma wartość NaN zamiast prawidłowej wartości licznika?”. Odpowiedzi na te pytania znajdziesz w rozdziale 2. Zamiast zatrzymać się w tym miejscu, dalej próbując zrozumieć, dlaczego słowo kluczowe this nie zachowuje się w oczekiwany sposób, i szukać odpowiedzi na zadane wcześniej trudne, choć ważne pytania, wielu programistów po prostu stara się uniknąć problemu poprzez zaimplementowanie zupełnie innego rozwiązania. Przykładem alternatywnego podejścia może być utworzenie obiektu przeznaczonego do przechowywania właściwości count: function foo(num) { console.log( "foo: " + num ); // Próba określenia liczby wywołań funkcji foo(). data.count++; } var data = { count: 0 }; var i; for (i=0; i<10; i++) { if (i > 5) { foo( i ); } } // foo: 6 // foo: 7 // foo: 8 // foo: 9 // Ile razy została wywołana funkcja foo()? console.log( data.count ); // 4 Zamieszanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 17 0 Wprawdzie powyższe podejście „rozwiązuje” problem, ale niestety po prostu na zasadzie zignorowania tego prawdziwego, co wynika z braku zrozumienia, co tak naprawdę oznacza this i na jakiej zasadzie działa. Zamiast tego mamy powrót do komfortowej strefy znacznie lepiej znanego mechanizmu, czyli zakresu leksykalnego. Zakres leksykalny jest doskonałym i użytecznym mechanizmem. W tym miejscu w żaden sposób nie próbuję lekceważyć lub deprecjonować jego użycia (patrz inna książka z tej serii zatytułowana Zakresy i domknięcia2). Jednak nieustanne zgadywanie, jak należy używać this, i zwykle jego błędne stosowanie to jeszcze nie powód do powrotu do zakresu leksykalnego. Jeżeli będziesz tak postępował, nigdy nie dowiesz się, dlaczego this działa inaczej, niż tego oczekujesz. W celu odwołania się do obiektu funkcji z wewnątrz danej funkcji samo this zwykle nie wystarcza. Najczęściej konieczne jest odwołanie się do obiektu funkcji za pomocą identyfikatora leksykalnego (zmienna) wskazującego tę funkcję. Spójrz na poniższe przykłady dwóch funkcji: function foo() { foo.count = 4; // Tutaj 'foo' odwołuje się do tej funkcji. } setTimeout( function(){ // Funkcja anonimowa (pozbawiona nazwy) // nie może się odwoływać do samej siebie. }, 10 ); W pierwszej funkcji, określanej mianem „funkcji nazwanej”, foo jest odniesieniem, którego można użyć w celu odwołania się do danej funkcji z jej wnętrza. Jednak w przykładzie drugiej funkcji wywołanie zwrotne przekazane do setTimeout(..) nie zawiera identyfikatora nazwy (mamy tutaj „funkcję anonimową”), więc nie ma prawidłowego sposobu odniesienia się do obiektu tej funkcji. 2 K. Simpson, Tajniki języka JavaScript. Zakresy i domknięcia, Helion, 2016. 18 Rozdział 1. this, czyli co? helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Pewien dość stary i obecnie już uznany za przestarzały sposób oparty na odwołaniu arguments.callee wewnątrz funkcji również prowadzi do obiektu aktualnie wykonywanej funkcji. Wymienione odwołanie to zwykle jedyny sposób pozwalający na uzyskanie dostępu do obiektu funkcji anonimowej z jej wnętrza. Jednak najlepsze podejście polega na unikaniu w ogóle stosowania funkcji anonimowych, przynajmniej jeśli zachodzi potrzeba odwoływania się do nich z ich wnętrza. Zamiast anonimowych najlepiej używać funkcji nazwanych. Podejście oparte na arguments.callee jest uznawane za przestarzałe i nie powinno być stosowane. Innym rozwiązaniem w przedstawionym przykładzie będzie użycie we wszystkich miejscach identyfikatora foo jako odwołania do obiektu funkcji i w ogóle uniknięcie this. Tego rodzaju rozwiązanie działa bez problemów: function foo(num) { console.log( "foo: " + num ); // Próba określenia liczby wywołań funkcji foo(). foo.count++; } foo.count = 0; var i; for (i=0; i<10; i++) { if (i > 5) { foo( i ); } } // foo: 6 // foo: 7 // foo: 8 // foo: 9 // Ile razy została wywołana funkcja foo()? console.log( foo.count ); // 4 Jednak powyższe podejście również ma efekt uboczny w postaci braku rzeczywistego zrozumienia sposobu działania this, co wynika z oparcia się całkowicie na leksykalnym zakresie zmiennej foo. Zamieszanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 19 0 Kolejnym podejściem do problemu jest wymuszenie na this faktycznego wskazania obiektu funkcji foo(): function foo(num) { console.log( "foo: " + num ); // Próba określenia liczby wywołań funkcji foo(). // Uwaga: 'this' to teraz naprawdę JEST 'foo', // co wynika ze sposobu wywołania 'foo' (patrz opis poniżej). this.count++; } foo.count = 0; var i; for (i=0; i<10; i++) { if (i > 5) { // Używając 'call(..)', mamy pewność, że 'this' // odwołuje się do obiektu funkcji ('foo'). foo.call( foo, i ); } } // foo: 6 // foo: 7 // foo: 8 // foo: 9 // Ile razy została wywołana funkcja foo()? console.log( foo.count ); // 4 Zamiast unikać mechanizmu this, skwapliwie z niego korzystamy. Później wyjaśnię dokładniej sposób działania przedstawionych powyżej technik, więc nie przejmuj się, jeśli wszystko nie jest jeszcze jasne. Zakres Kolejnym błędnym wyobrażeniem dotyczącym znaczenia this jest przekonanie, że mechanizm ten w pewien sposób odwołuje się do zakresu funkcji. To dość trudna kwestia, ponieważ w jednym sensie jest prawdziwa, zaś w innym nie. Dlatego też może być to dość mylące. Chcę w tym miejscu wyraźnie powiedzieć, że this w żaden sposób nie odwołuje się do zakresu leksykalnego funkcji. To prawda, że wewnętrznie zakres jest pewnego rodzaju obiektem wraz z właściwościami dla każdego z dostępnych 20 Rozdział 1. this, czyli co? helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 identyfikatorów. Jednak „obiekt” zakresu nie jest dostępny dla kodu JavaScript — to wewnętrzna część implementacji silnika. Przeanalizuj poniższy fragment kodu, który (bez powodzenia!) próbuje przekroczyć granicę i użyć słowa kluczowego this w celu niejawnego odniesienia się do zakresu leksykalnego funkcji: function foo() { var a = 2; this.bar(); } function bar() { console.log( this.a ); } foo(); //ReferenceError: niezdefiniowane. W powyższym fragmencie kodu mamy więcej błędów. Wprawdzie może się wydawać, że powyższy kod to jedynie przykład, ale tak naprawdę to pochodna rzeczywistego kodu pojawiającego się często na publicznych forach pomocy. To doskonała (choć jednocześnie smutna) ilustracja, pokazująca, jak bardzo błędne mogą być założenia dotyczące this. Przede wszystkim podejmowana jest próba odwołania się do funkcji bar() za pomocą this.bar(). Przez zupełny przypadek to rzeczywiście działa, ale szczegóły na ten temat poznasz wkrótce. Najbardziej naturalnym sposobem wywołania funkcji bar() będzie pominięcie słowa kluczowego this i po prostu użycie leksykalnego odwołania się do identyfikatora. Jednak programista tworzący tego rodzaju kod próbuje wykorzystać this w celu przygotowania pomostu między leksykalnymi zakresami funkcji foo() i bar(), aby funkcja bar() mogła uzyskać dostęp do zmiennej znajdującej się w wewnętrznym zakresie foo(). Nie ma możliwości stworzenia wspomnianego pomostu. Nie można użyć słowa kluczowego this do wyszukania innego elementu w zakresie leksykalnym. To jest po prostu niemożliwe. Za każdym razem, gdy próbujesz połączyć operacje wyszukiwania w zakresach leksykalnych za pomocą słowa kluczowego this, powtarzaj sobie: tego rodzaju połączenie nie istnieje. Zamieszanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 21 0 Czym jest this? Skoro poznałeś różne nieprawidłowe założenia dotyczące this, teraz możemy skierować naszą uwagę na sposób, w jaki mechanizm this faktycznie działa. Wcześniej dowiedziałeś się, że this to nie wiązanie utworzone przez programistę w trakcie tworzenia kodu, ale wiązanie powstające w czasie jego wykonywania. Kontekst tego wiązania jest oparty na warunkach istniejących w trakcie wywołania funkcji. Wiązanie this nie ma nic wspólnego z deklaracją funkcji, natomiast ma dużo wspólnego ze sposobem wywoływania danej funkcji. Kiedy funkcja jest wywoływana, następuje utworzenie rekordu aktywacji, nazywanego także kontekstem wykonywania. Wspomniany rekord zawiera informacje o źródle wywołania funkcji (tak zwany stos wywołań), sposobie wywołania funkcji, przekazanych parametrach itd. Jedną z właściwości omawianego rekordu jest odwołanie this, które będzie stosowane w trakcie wykonywania funkcji. W następnym rozdziale dowiesz się, jak odszukać źródło wywołania funkcji, co pozwoli na określenie, jak w trakcie jej wykonywania zostanie utworzone wiązanie this. Podsumowanie Wiązanie this nieustannie wywołuje zamieszanie wśród programistów JavaScript, którzy nie poświęcili czasu na poznanie rzeczywistego sposobu działania tego mechanizmu. Zgadywanie, metoda prób i błędów, bezmyślne kopiowanie i wklejanie kodu z odpowiedzi znalezionych w serwisie Stack Overflow — to nie są ani efektywne, ani odpowiednie sposoby wykorzystania tak ważnego mechanizmu. Aby poznać mechanizm this, w pierwszej kolejności musisz się dowiedzieć, czym on nie jest, tak by wykluczyć wszelkie błędne założenia i wyobrażenia, które mogą spowodować wymienione wcześniej problemy. Słowo kluczowe this nie oznacza odwołania do bieżącej funkcji ani nie jest odniesieniem do zakresu leksykalnego tej funkcji. Mechanizm this to wiązanie powstające w trakcie wywoływania funkcji. Dlatego też to, do czego się odwołuje this, jest określane w całości przez źródło wywołania danej funkcji. 22 Rozdział 1. this, czyli co? helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 2. To wszystko ma teraz sens! W rozdziale 1. przedstawiłem różne błędne wyobrażenia dotyczące słowa kluczowego this. Dowiedziałeś się, że this to wiązanie powstające w chwili wywołania danej funkcji i całkowicie oparte na źródle (sposobie) jej wywołania. Źródło wywołania funkcji W celu zrozumienia wiązania this konieczne jest poznanie koncepcji źródła wywołania funkcji, czyli miejsca w kodzie, gdzie nastąpiło wywołanie danej funkcji (a nie miejsca, gdzie jest ona zadeklarowana). Aby odpowiedzieć na pytanie, do czego odwołuje się this, trzeba przejrzeć wszystkie źródła wywołania funkcji. To zadanie, ogólnie rzecz biorąc, polega na odszukaniu wszystkich wywołań funkcji. Jednak nie zawsze będzie to takie łatwe, ponieważ pewne wzorce tworzenia kodu mogą ukrywać faktyczne miejsce wywołania danej funkcji. Bardzo ważne jest uwzględnienie tak zwanego stosu wywołań (czyli stosu funkcji wywołanych w celu przejścia do bieżącego momentu działania programu). Interesujące nas źródło wywołania funkcji znajduje się w wywołaniu przed aktualnie wykonywaną funkcją. Poniżej przedstawiam przykład stosu wywołań i źródła wywołania funkcji: function baz() { // Stos wywołań to: 'baz', więc źródło wywołania // funkcji znajduje się w zakresie globalnym. console.log( "baz" ); bar(); // <-- Źródło wywołania funkcji 'bar'. 23 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 } function bar() { // Stos wywołań to: 'baz' -> 'bar', więc źródło wywołania // funkcji znajduje się w 'baz'. console.log( "bar" ); foo(); // <-- Źródło wywołania funkcji 'foo'. } function foo() { // Stos wywołań to: 'baz' -> 'bar' -> 'foo', // więc źródło wywołania funkcji znajduje się w 'bar'. console.log( "foo" ); } baz(); // <-- Źródło wywołania funkcji 'baz'. Bądź ostrożny podczas analizy kodu i wyszukiwania rzeczywistych źródeł wywołania funkcji (w stosie wywołań), ponieważ to jedyna kwestia, która ma znaczenie dla wiązania this. Stos wywołań można sobie wizualizować w myślach jako kolejne wywołania funkcji. Takie podejście przedstawiłem w komentarzach w powyższym fragmencie kodu. Takie rozwiązanie jest jednak nieco uciążliwe i łatwo popełnić w nim błąd. Innym sposobem sprawdzenia stosu wywołań jest użycie narzędzia do debugowania w przeglądarce internetowej. Większość nowoczesnych przeglądarek internetowych w komputerach stacjonarnych ma wbudowane narzędzia programistyczne obejmujące między innymi debugger JavaScript. W przypadku poprzedniego fragmentu kodu w narzędziach programistycznych możesz ustawić punkt kontrolny w pierwszym wierszu funkcji foo() lub po prostu wstawić polecenie debugger; w pierwszym wierszu. Po uruchomieniu strony debugger zatrzyma jej działanie we wskazanym miejscu i pokaże listę funkcji wywołanych przed dotarciem do tego miejsca — w ten sposób otrzymasz upragniony stos wywołań. Dlatego też jeśli próbujesz zdiagnozować wiązanie this, wykorzystaj narzędzia programistyczne do otrzymania stosu wywołań. Następnie odszukaj drugie wywołanie od góry, a poznasz rzeczywiste źródło wywołania funkcji. 24 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Tylko reguły Teraz kierujemy naszą uwagę na sposób, w jaki źródło wywołania funkcji określa miejsce, do którego będzie się odwoływać słowo kluczowe this w trakcie wykonywania danej funkcji. Konieczne jest przeanalizowanie wszystkich źródeł wywołania funkcji i ustalenie, które z czterech reguł mają zastosowanie. Zacznę od wyjaśnienia wszystkich czterech reguł niezależnie od siebie, a następnie przejdę do kolejności ich pierwszeństwa, jeśli wiele z nich można zastosować do źródła wywołania funkcji. Wiązanie domyślne Pierwsza omawiana tutaj reguła wynika z najczęściej występującego przypadku dla wywołań funkcji, czyli wywołania samodzielnej funkcji. Tę regułę można potraktować jako domyślną i stosować wtedy, gdy nie uda się wykorzystać żadnej innej. Spójrz na poniższy fragment kodu: function foo() { console.log( this.a ); } var a = 2; foo(); // 2 Jeżeli jeszcze tego nie zrobiłeś, zwróć uwagę na zmienną zadeklarowaną w zakresie globalnym (var a = 2). To synonim właściwości obiektu globalnego o takiej samej nazwie. Nie mamy tutaj do czynienia z kopią właściwości — to są zupełnie różne właściwości. Możesz je potraktować jako dwie strony tej samej monety. Następnie zauważ, że w trakcie wywołania funkcji foo() polecenie this.a odwołuje się do zmiennej globalnej a. Dlaczego tak się dzieje? Ponieważ w omawianym przypadku nastąpiło zastosowanie wiązania domyślnego this, a zatem this odwołuje się do obiektu globalnego. Tylko reguły helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 25 0 Skąd wiemy, że tutaj ma zastosowanie reguła wiązania domyślnego? Przeanalizujemy źródło wywołania funkcji, aby poznać sposób wywołania foo(). W omawianym fragmencie kodu funkcja foo() jest wywoływana za pomocą zwykłego, nieoznaczonego w żaden dodatkowy sposób odwołania funkcji. Ponieważ nie mają tutaj zastosowania żadne inne reguły, mamy do czynienia z wiązaniem domyślnym. Po zastosowaniu trybu ścisłego obiekt globalny nie może być używany w wiązaniu domyślnym i dlatego wtedy this odnosi się do wartości niezdefiniowanej (undefined): function foo() { "use strict"; console.log( this.a ); } var a = 2; foo(); // TypeError: 'this' odwołuje się do wartości 'undefined'. Warto w tym miejscu wspomnieć o subtelnym, choć ważnym szczególe: ogólnie rzecz biorąc, reguły dotyczące wiązania this są określane całkowicie na podstawie źródła wywołania funkcji, ale dla obiektu globalnego można zastosować tylko wiązanie domyślne, jeśli zawartość foo() nie działa w trybie ścisłym. Stan trybu ścisłego źródła wywołania funkcji foo() pozostaje nieistotny: function foo() { console.log( this.a ); } var a = 2; (function(){ "use strict"; foo(); // 2 })(); Celowe łączenie w kodzie trybów ścisłego i nieścisłego jest niemile widziane. Cały program powinien działać w jednym z wymienionych trybów. Czasami jednak zdarza się, że dołączona jest biblioteka zewnętrzna pozostająca w innym trybie działania niż kod programu. Dlatego też należy zachować ostrożność i zwracać uwagę na te subtelne szczegóły dotyczące zgodności. 26 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Wiązanie niejawne Inną regułą do uwzględnienia jest istnienie obiektu kontekstu dla źródła wywołania funkcji, określanego także mianem obiektu właściciela lub obiektu zawierającego, choć wymienione wyrażenia alternatywne mogą się okazać nieco mylące. Spójrz na poniższy fragment kodu: function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; obj.foo(); // 2 Przede wszystkim zwróć uwagę na sposób zadeklarowania funkcji foo(), a następnie dodania jako właściwości odniesienia w obiekcie obj. Niezależnie od tego, czy funkcja foo() została początkowo zadeklarowana w obj, czy dodana później jako odniesienie (taki wariant zastosowałem w powyższym fragmencie kodu), w żadnym z wymienionych przypadków funkcja foo() nie jest „w posiadaniu” obiektu obj ani „nie zawiera się” w nim. Jednak to źródło wywołania funkcji używa kontekstu obj w celu odwołania się do funkcji, można więc powiedzieć, że w trakcie wywoływania funkcji obiekt obj jest właścicielem odwołania do funkcji lub je zawiera. Niezależnie od nazwy nadanej temu wzorcowi w chwili wywołania funkcji foo() mamy odwołanie do obj. Kiedy istnieje obiekt kontekstu dla odwołania do funkcji, zgodnie z regułą wiązania niejawnego to właśnie ten obiekt powinien być użyty dla wiązania this w danej funkcji. A ponieważ obiekt obj będzie wskazywany przez this dla funkcji foo(), this.a jest synonimem obj.a. Dla źródła wywołania funkcji znaczenie ma tylko odniesienie do właściwości obiektu najwyższego (ostatniego) poziomu. Na przykład: function foo() { console.log( this.a ); } var obj2 = { Tylko reguły helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 27 0 a: 42, foo: foo }; var obj1 = { a: 2, obj2: obj2 }; obj1.obj2.foo(); // 42 Niejawna utrata Jednym z najbardziej frustrujących zachowań związanych z tworzeniem wiązania this jest utrata przez funkcję wiązania niejawnego. Wiąże się to zwykle z zastosowaniem domyślnego wiązania do obiektu globalnego lub wartości undefined (w zależności od tego, czy zastosowany jest tryb ścisły). Spójrz na poniższy fragment kodu: function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var bar = obj.foo; // Odwołanie do funkcji/alias! var a = "oops, global"; // 'a' jest również właściwością w obiekcie globalnym. bar(); // "Ups! Obiekt globalny". Choć wydaje się, że bar to odwołanie do obj.foo, tak naprawdę mamy do czynienia z kolejnym odwołaniem do foo(). Co więcej, źródło wywołania funkcji ma znaczenie — w omawianym przypadku to bar(), czyli zwykłe, niczym nieudekorowane wywołanie, a tym samym zostało zastosowane wiązanie domyślne. Ze znacznie bardziej subtelnym, częściej występującym i dużo bardziej nieoczekiwanym przypadkiem mamy do czynienia, gdy rozważamy przekazanie funkcji wywołania zwrotnego: 28 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 function foo() { console.log( this.a ); } function doFoo(fn) { // 'fn' to po prostu kolejne odwołanie do 'foo'. fn(); // <-- Źródło wywołania funkcji! } var obj = { a: 2, foo: foo }; var a = "oops, global"; // 'a' jest również właściwością w obiekcie globalnym. doFoo( obj.foo ); // "Ups! Obiekt globalny". Przekazanie parametrów to niejawne przypisanie, a ponieważ mamy do czynienia z przekazaniem funkcji, to nieuniknione jest niejawne przypisanie odwołania. Efekt będzie dokładnie taki sam jak w przypadku poprzedniego fragmentu kodu. Co zrobić w sytuacji, gdy przekazywane wywołanie zwrotne nie zostało utworzone przez Ciebie, ale jest wbudowane w język? Tutaj nie ma żadnej różnicy, wynik pozostaje taki sam: function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; var a = "oops, global"; // 'a' jest również właściwością w obiekcie globalnym. setTimeout( obj.foo, 100 ); // "Ups! Obiekt globalny". Spójrz na poniższą teoretyczną pseudoimplementację setTimeout() dostarczoną jako funkcja wbudowana środowiska JavaScript: function setTimeout(fn, delay) { // Oczekiwanie przez podaną (wartość 'delay') liczbę milisekund. fn(); // <-- Źródło wywołania funkcji! } Tylko reguły helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 29 0 Dość często zdarza się, że funkcje wywołań zwrotnych tracą swoje wiązania this, tak jak to wcześniej przedstawiłem. Jednak mechanizm this może zaskoczyć także w inny sposób, gdy funkcja, do której przekazujemy wywołanie zwrotne, celowo zmienia this dla danego wywołania. Procedury obsługi zdarzeń w popularnych bibliotekach JavaScript zmuszają, aby w przypadku wywołań zwrotnych słowo kluczowe this prowadziło na przykład do elementu modelu DOM, który wywołał dane zdarzenie. Wprawdzie takie rozwiązanie może być czasami użyteczne, ale w innych sytuacjach będzie doprowadzało do szału. Niestety wspomniane narzędzia rzadko kiedy dają Ci wybór na tym polu. Tak czy inaczej, w przypadku nieoczekiwanej zmiany this nie masz kontroli nad sposobem wykonania odwołania funkcji wywołania zwrotnego, więc nie masz (jeszcze) żadnej możliwości kontroli, czy źródło wywołania funkcji dostarczy oczekiwanego wiązania. Wkrótce dowiesz się, jak można „usunąć” ten problem przez poprawę this. Wiązanie jawne W przypadku wiązania niejawnego — jak to wcześniej widziałeś — konieczne było zmodyfikowanie obiektu, aby miał odwołanie do funkcji, a następnie użycie właściwości zawierającej odwołanie do funkcji w celu pośredniego (niejawnego) wiązania this z obiektem. A co zrobić w sytuacji, gdy chcesz zmusić wywołanie funkcji do użycia określonego obiektu dla wiązania this, ale bez umieszczania w obiekcie właściwości zawierającej odwołanie do funkcji? Wszystkie funkcje języka mają pewne narzędzia dostępne za pomocą [[Pro totype]] (więcej informacji na ten temat znajdziesz w dalszej części książki), co jest niezwykle użyteczne do wykonania wspomnianego zadania. Szczególnie interesują nas metody o nazwach call(..) i apply(..). Pod względem technicznym środowisko hosta JavaScript czasami dostarcza funkcje, które są na tyle specjalne (na pewien sposób), że nie zawierają wspomnianej funkcjonalności. To tylko niewielka część funkcji. Ogromna większość dostarczanych funkcji i praktycznie wszystkie tworzone przez Ciebie mają dostęp do metod call(..) i apply(..). Na czym polega działanie wymienionych metod narzędziowych? Jako pierwszy parametr pobierają obiekt, do którego ma się odwoływać słowo kluczowe this. 30 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Następnie wywołują funkcję wskazywaną przez this. Ponieważ wyraźnie wskazujemy, do czego ma się odwoływać this, mamy do czynienia z wiązaniem jawnym. Spójrz na poniższy fragment kodu: function foo() { console.log( this.a ); } var obj = { a: 2 }; foo.call( obj ); // 2 Wywołanie foo() wraz z jawnym wiązaniem dzięki wywołaniu foo.call(..) pozwala na wymuszenie, aby słowo kluczowe this odwoływało się do obj. Jeżeli jako wiązanie this przekażesz wartość typu prostego (na przykład string, boolean lub number), wspomniana wartość zostanie opakowana obiektem (odpowiednio new String(..), new Boolean(..) i new Number(..)). Tego rodzaju działanie jest często określane jako pakowanie. W kontekście wiązania this nie ma różnicy między wywołaniami call(..) i apply(..). Mogą się zachowywać różnie po zastosowaniu parametrów dodatkowych, ale takim wariantem nie będziemy się tutaj zajmować. Niestety wiązanie jawne samo w sobie nie oferuje żadnego rozwiązania wspomnianego wcześniej problemu, czyli „utraty” przez funkcję oczekiwanego wiązania this. Po prostu mamy do czynienia z utorowaniem drogi przez framework. Wiązanie twarde Jednak pewna odmiana wiązania jawnego będzie w stanie zagwarantować nam otrzymanie oczekiwanego zachowania. Spójrz na poniższy fragment kodu: function foo() { console.log( this.a ); } var obj = { a: 2 }; Tylko reguły helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 31 0 var bar = function() { foo.call( obj ); }; bar(); // 2 setTimeout( bar, 100 ); // 2 // W przypadku trwałego wiązania 'bar' nie będziemy mieli do czynienia z nadpisywaniem 'this'. bar.call( window ); // 2 Przeanalizujmy teraz sposób działania powyższego wariantu. Tworzymy funkcję bar(), która wewnętrznie wywołuje foo.call(obj), a tym samym wymusza wywołanie foo wraz z obj jako wiązaniem dla this. Niezależnie od tego, jak później wywołasz funkcję bar(), zawsze będzie wywoływała foo wraz z obj. Tego rodzaju wiązanie jest zarówno jawne, jak i trwałe, więc możemy je nazwać wiązaniem twardym. Najbardziej typowym sposobem opakowania funkcji wiązaniem twardym jest utworzenie „okna” przeznaczonego do przekazywania wszelkich argumentów oraz pobierania wartości zwrotnej: function foo(something) { console.log( this.a, something ); return this.a + something; } var obj = { a: 2 }; var bar = function() { return foo.apply( obj, arguments ); }; var b = bar( 3 ); // 2 3 console.log( b ); // 5 Innym sposobem wyrażenia powyższego wzorca jest utworzenie funkcji pomocniczej przeznaczonej do wielokrotnego użycia: function foo(something) { console.log( this.a, something ); return this.a + something; } // Prosta funkcja pomocnicza bind(). function bind(fn, obj) { return function() { 32 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 return fn.apply( obj, arguments ); }; } var obj = { a: 2 }; var bar = bind( foo, obj ); var b = bar( 3 ); // 2 3 console.log( b ); // 5 Ponieważ wiązanie twarde jest dość często stosowanym wzorcem, począwszy od wydania specyfikacji ES5, dostępna jest dla niego funkcja pomocnicza Function.prototype.bind(), z której można korzystać w następujący sposób: function foo(something) { console.log( this.a, something ); return this.a + something; } var obj = { a: 2 }; var bar = foo.bind( obj ); var b = bar( 3 ); // 2 3 console.log( b ); // 5 Wartością zwrotną bind(..) jest nowa funkcja, która ma na stałe zdefiniowane wywołania do funkcji początkowej wraz z kontekstem this ustawionym w podany sposób. Kontekst wywołań funkcji API Większość funkcji bibliotek oraz wiele nowych funkcji wbudowanych w język JavaScript i środowisko hosta dostarcza dodatkowy parametr, nazywany zwykle kontekstem. Wymieniony parametr został opracowany jako rozwiązanie pozwalające na uniknięcie konieczności użycia bind(..) w celu zagwarantowania, że funkcja wywołania zwrotnego skorzysta z podanego this. Na przykład: function foo(el) { console.log( el, this.id ); } Tylko reguły helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 33 0 var obj = { id: "wspaniale" }; // Użycie 'obj' jako 'this' dla wywołań 'foo(..)'. [1, 2, 3].forEach( foo, obj ); // 1 wspaniale 2 wspaniale 3 wspaniale Wewnętrznie te różne funkcje niemal na pewno używają wiązania jawnego za pomocą call(..) i apply(..), co pozwala uniknąć konieczności ręcznego wywoływania wymienionych metod narzędziowych. Wiązanie za pomocą operatora new Czwarta i ostatnia reguła dotycząca wiązania this wymaga przeanalizowania bardzo często spotykanego błędnego wyobrażenia o funkcjach i obiektach w języku JavaScript. W tradycyjnych językach obiektowych konstruktor jest specjalną metodą dołączoną do klasy. Podczas tworzenia egzemplarza klasy za pomocą operatora new następuje wywołanie wspomnianego konstruktora. Z reguły mamy do czynienia z następującym wywołaniem: something = new MyClass(..); JavaScript ma operator new, a wzorzec kodu do użycia jest niemal identyczny z tym, który spotykamy w innych językach obiektowych. Większość programistów przyjmuje więc założenie, że działanie mechanizmu w JavaScript jest podobne, ale tak nie jest. Przede wszystkim jednak uściślmy, czym tak naprawdę jest „konstruktor” w języku JavaScript. Konstruktor to po prostu funkcja, która ma zostać wywołana po użyciu operatora new. Konstruktor nie jest dołączony do klasy ani nie odpowiada za tworzenie egzemplarza klasy. Nie jest to również funkcja specjalnego typu. Konstruktor to zwykła funkcja, która tak naprawdę została przechwycona przez użycie słowa kluczowego new w jej wywołaniu. Na przykład rozważ funkcję Number(..) działającą w charakterze konstruktora. Poniższy fragment pochodzi ze specyfikacji ES5.1: 15.7.2. Konstruktor Number Kiedy funkcja Number() jest wywoływana jako część wyrażenia new, jest konstruktorem i inicjalizuje nowo utworzony obiekt. 34 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Tak więc praktycznie każda funkcja, włącznie z wbudowanymi funkcjami, takimi jak Number(..) (patrz rozdział 3.), może być wywołana wraz z poprzedzającym ją słowem kluczowym new — w ten sposób to wywołanie funkcji staje się wywołaniem konstruktora. Mamy tutaj subtelny i zarazem bardzo ważny szczegół: tak naprawdę nie ma czegoś takiego jak „funkcja konstruktora”, ale raczej wykonanie funkcji za pomocą wywołania konstruktora. Kiedy mamy do czynienia z wywołaniem funkcji wraz z użyciem słowa kluczowego new, czyli z tak zwanym wywołaniem konstruktora, poniższe operacje są wykonywane automatycznie: 1. Utworzenie (skonstruowanie) zupełnie nowego obiektu. 2. Nowo utworzony obiekt zostaje połączony z łańcuchem [[Prototype]]. 3. Nowo skonstruowany obiekt ma ustawione wiązanie this prowadzące do danego wywołania funkcji. 4. Jeżeli wartością zwrotną nie jest alternatywny obiekt, wywołanie wraz ze słowem kluczowym new automatycznie zwróci nowo skonstruowany obiekt. Dla aktualnie omawianego zagadnienia znaczenie mają kroki 1., 3. i 4. Wprawdzie na razie pominiemy krok 2., ale powrócimy do niego w rozdziale 5. Spójrz na poniższy fragment kodu: function foo(a) { this.a = a; } var bar = new foo( 2 ); console.log( bar.a ); // 2 W przypadku wywołania funkcji foo(..) wraz ze słowem kluczowym new wynikiem jest utworzenie nowego obiektu i określenie go jako wiązania this dla wywołania foo(..). Dlatego new stanowi ostatni sposób, na jaki wywołanie funkcji może utworzyć wiązanie dla this. Ten rodzaj wiązania nazywamy wiązaniem new. Wszystko w odpowiedniej kolejności W poprzednim podrozdziale przedstawiłem cztery reguły wiązania this w wywołaniu funkcji. Twoje zadanie sprowadza się do odszukania źródła wywołania funkcji i sprawdzenia, która z omówionych reguł ma zastosowanie w danym przypadku. Mógłbyś w tym miejscu zapytać: co w sytuacji, gdy dla danego źródła Wszystko w odpowiedniej kolejności helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 35 0 wywołania funkcji można zastosować wiele reguł? Przecież musi istnieć pewna kolejność pierwszeństwa dla reguł, prawda? Faktycznie, istnieje kolejność, w jakiej stosowane są omówione wcześniej reguły. Powinno być jasne, że wiązanie domyślne ma najniższy priorytet z wszystkich czterech omówionych reguł, i dlatego na razie odkładamy to wiązanie na bok. Które z wymienionych — wiązanie niejawne czy wiązanie jawne — ma pierwszeństwo? Przekonajmy się o tym: function foo() { console.log( this.a ); } var obj1 = { a: 2, foo: foo }; var obj2 = { a: 3, foo: foo }; obj1.foo(); // 2 obj2.foo(); // 3 obj1.foo.call( obj2 ); // 3 obj2.foo.call( obj1 ); // 2 Jak wynika z powyższego fragmentu kodu, wiązanie jawne ma pierwszeństwo przed wiązaniem niejawnym, co oznacza, że w pierwszej kolejności należy sprawdzić, czy zastosowanie ma wiązanie jawne, a dopiero później przeprowadzić sprawdzenie wiązania niejawnego. Teraz pozostało nam określenie, jaki priorytet ma wiązanie new: function foo(something) { this.a = something; } var obj1 = { foo: foo }; var obj2 = {}; 36 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 obj1.foo( 2 ); console.log( obj1.a ); // 2 obj1.foo.call( obj2, 3 ); console.log( obj2.a ); // 3 var bar = new obj1.foo( 4 ); console.log( obj1.a ); // 2 console.log( bar.a ); // 4 Z powyższego fragmentu kodu wynika, że wiązanie new ma pierwszeństwo przed wiązaniem niejawnym. Jak sądzisz, czy wiązanie new ma, czy nie ma pierwszeństwa przed wiązaniem jawnym? Nie można używać razem operatora new i wywołań call()/apply(). Dlatego też wywołanie new foo.call(obj1) jest niedozwolonym sposobem bezpośredniego sprawdzenia wiązania new i wiązania jawnego. Nadal jednak można wykorzystać wiązanie twarde do sprawdzenia, która z wymienionych dwóch reguł ma pierwszeństwo. Zanim przejdziemy do kodu w poszukiwaniu odpowiedzi na powyższe pytanie, zastanówmy się przez chwilę nad fizycznym mechanizmem działania wiązania twardego. Funkcja Function.prototype.bind(..) tworzy nową funkcję opakowującą, w której został umieszczony kod zastępujący wiązanie this (niezależnie od tego, jakie ono będzie) wiązaniem dostarczonym przez nas. Dlatego też może się wydawać oczywiste przyjęcie założenia, że wiązanie twarde (będące formą wiązania jawnego) ma pierwszeństwo przed wiązaniem new, a tym samym nie może być nadpisane za pomocą użycia słowa kluczowego new. Sprawdźmy to: function foo(something) { this.a = something; } var obj1 = {}; var bar = foo.bind( obj1 ); bar( 2 ); console.log( obj1.a ); // 2 var baz = new bar( 3 ); console.log( obj1.a ); // 2 console.log( baz.a ); // 3 Wszystko w odpowiedniej kolejności helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 37 0 Hola, nie tak prędko! Wprawdzie bar ma wiązanie twarde do obj1, ale wywołanie new bar(3) nie powoduje zmiany wartości obj1.a na 3, jak można by tego oczekiwać. Zamiast tego wywołanie wiązania twardego (obj1) do bar(..) może być nadpisane za pomocą słowa kluczowego new. Ponieważ zostało zastosowane słowo kluczowe new, otrzymujemy z powrotem nowo utworzony obiekt, któremu nadajemy nazwę baz. Jak można zobaczyć, baz.a faktycznie ma wartość 3. To może być zaskakujące, gdy powrócimy do wspomnianej wcześniej przykładowej funkcji pomocniczej bind(..): function bind(fn, obj) { return function() { fn.apply( obj, arguments ); }; } Jeżeli przeanalizujesz działanie kodu powyższej funkcji pomocniczej, zauważysz, że nie ma żadnego sposobu, aby wywołanie operatora new mogło nadpisać wiązanie twarde do obj, jak to zostało zaobserwowane. Wbudowana funkcja Function.prototype.bind(..) w wydaniu specyfikacji ES5 jest jednak znacznie bardziej skomplikowana. Poniżej przedstawiam (nieco zmodyfikowany) fragment skryptu typu polyfill znalezionego na stronie MDN poświęconej bind(..): if (!Function.prototype.bind) { Function.prototype.bind = function(oThis) { if (typeof this !== "function") { // Rozwiązanie najbliższe specyfikacji ECMAScript 5 // to wewnętrzna funkcja IsCallable. throw new TypeError( "Function.prototype.bind - nastąpiła próba wywołania " + "tego, czego nie można wywołać." ); } var aArgs = Array.prototype.slice.call( arguments, 1 ), fToBind = this, fNOP = function(){}, fBound = function(){ return fToBind.apply( ( this instanceof fNOP && oThis ? this : oThis ), 38 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 aArgs.concat( Array.prototype.slice.call( arguments ) ); } ; fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound; }; } Przedstawiony powyżej skrypt polyfill dla bind(..) różni się od funkcji bind(..) wbudowanej w ES5 przy założeniu, że funkcje wiązania twardego będą używane wraz ze słowem kluczowym new (czytaj dalej, aby dowiedzieć się, dlaczego jest to użyteczne rozwiązanie). Ponieważ skrypt typu polyfill nie może utworzyć funkcji bez .prototype, jak ma to miejsce w przypadku wbudowanej funkcji pomocniczej, mamy pewną rozbieżność w ocenie tego samego zachowania. Postępuj ostrożnie, jeśli zamierzasz używać słowa kluczowego new z funkcją wiązania twardego i w tym celu opierasz się na skrypcie typu polyfill. Fragment pozwalający na nadpisanie new przedstawia się następująco: this instanceof fNOP && oThis ? this : oThis // …i: fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); Nie zamierzam tutaj dokładnie wyjaśniać sposobu działania tej sztuczki (to nieco bardziej skomplikowane i wykracza poza zakres omawianego zagadnienia). Działanie funkcji narzędziowej w zasadzie sprowadza się do ustalenia, czy wywołanie funkcji wiązania twardego nastąpiło z użyciem słowa kluczowego new (co skutkuje skonstruowaniem nowego obiektu wskazywanego przez this). Jeżeli tak, ten nowo utworzony obiekt będzie wskazywany przez this, a nie wcześniej podany w wiązaniu twardym. Dlaczego możliwość nadpisania wiązania twardego przez new można uznać za użyteczną? Wszystko w odpowiedniej kolejności helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 39 0 Podstawowym zastosowaniem dla tego rodzaju rozwiązania jest utworzenie funkcji (która wraz z operatorem new może służyć do konstruowania obiektów), praktycznie ignorującej this wiązania twardego, choć jednocześnie definiującej wybrane lub wszystkie argumenty funkcji. Jedną z możliwości bind(..) jest to, że dowolne argumenty przekazane po pierwszym argumencie wiązania this są domyślnie argumentami funkcji podstawowej (technicznie jest to określane mianem aplikacji częściowej). Na przykład: function foo(p1,p2) { this.val = p1 + p2; } // Poniżej używamy 'null', ponieważ nie przejmujemy się // 'this' w wiązaniu twardym w przedstawionym przypadku. // Poza tym i tak dojdzie do nadpisania 'this' przez 'new'! var bar = foo.bind( null, "p1" ); var baz = new bar( "p2" ); baz.val; // p1p2 Ustalenie this Podsumujmy teraz reguły dotyczące określenia this na podstawie źródła wywołania funkcji, zachowując kolejność pierwszeństwa tych reguł. Zadawaj sobie poniższe pytania i zakończ operację po znalezieniu pierwszej dopasowanej reguły. 1. Czy funkcja została wywołana za pomocą new (wiązanie twarde)? Jeżeli tak, wówczas this prowadzi do nowo skonstruowanego obiektu. var bar = new foo() 2. Czy funkcja została wywołana za pomocą wywołania call() lub apply() (wiązanie jawne), nawet gdy to wywołanie zostało ukryte wewnątrz bind wiązanie twarde? Jeżeli tak, wówczas this prowadzi do wyraźnie podanego obiektu. var bar = foo.call( obj2 ) 3. Czy funkcja została wywołana wraz z kontekstem (wiązanie niejawne), czyli z obiektem właściciela lub obiektem zawierającym? Jeżeli tak, wówczas this prowadzi do tego obiektu kontekstu. var bar = obj1.foo() 40 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 4. W przeciwnym razie mamy domyślne this (wiązanie domyślne). W przypadku trybu ścisłego wybierz wartość undefined, natomiast w pozostałych przypadkach obiekt globalny. var bar = foo() Tak to wygląda. I to już wszystko, co jest wymagane do zrozumienia reguł wiązania this dla zwykłych wywołań funkcji. Cóż, prawie wszystko. Wyjątki dotyczące wiązań Jak zwykle istnieją pewne wyjątki dotyczące omówionych reguł. W pewnych sytuacjach zachowanie dotyczące wiązania this może być zaskakujące. Na przykład oczekujesz konkretnego wiązania, a ostatecznie okazuje się, że otrzymujesz wiązanie domyślne. Zignorowane this Jeżeli w wywołaniu call(), apply() lub bind() przekażesz wartość null bądź undefined jako parametr wiązania this, wówczas wymienione wartości zostaną zignorowane i zamiast tego w trakcie danego wywołania nastąpi utworzenie wiązania domyślnego: function foo() { console.log( this.a ); } var a = 2; foo.call( null ); // 2 Mógłbyś w tym miejscu zapytać: jaki jest powód celowego przekazywania wartości takiej jak null dla wiązania this? Bardzo często można się spotkać z użyciem wywołania apply(..) do przekazania tablic wartości jako parametrów wywołania funkcji. Podobnie funkcja bind(..) może przekazywać parametry (ustalone wartości), co okazuje się niezwykle użyteczne: function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } Wyjątki dotyczące wiązań helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 41 0 // Przekazanie tablicy jako parametrów. foo.apply( null, [2, 3] ); // a:2, b:3 // Przekazanie danych za pomocą 'bind(..)'. var bar = foo.bind( null, 2 ); bar( 3 ); // a:2, b:3 Oba przedstawione powyżej rozwiązania wymagają pierwszego parametru w postaci wiązania this. Jeżeli dana funkcja nie przejmuje się this, potrzebna jest pewna wartość wstawiana w miejsce zarezerwowane dla this. W takim przypadku null wydaje się rozsądnym rozwiązaniem, jak pokazałem w powyższym fragmencie kodu. Wprawdzie nie omówię tego w książce, ale specyfikacja ES6 definiuje operator ..., który syntaktycznie pozwala na „rozpowszechnienie” tablicy jako parametrów bez konieczności stosowania apply(..), na przykład w postaci foo(..[1,2]), co jest równoznaczne z foo(1,2). Syntaktycznie unikamy wiązania this, o ile nie jest potrzebne. Niestety specyfikacja ES6 nie oferuje syntaktycznego odpowiednika rozwijania funkcji, więc parametr this wywołania bind(..) nadal wymaga uwagi. Istnieje jednak nieco ukryte niebezpieczeństwo związane z użyciem wartości null, gdy nie trzeba się przejmować wiązaniem this. Jeżeli kiedykolwiek zastosujesz takie rozwiązanie względem wywołania funkcji (na przykład wywołania funkcji biblioteki zewnętrznej, nad którą nie masz kontroli) i wspomniana funkcja używa odwołania do this, wówczas reguła wiązania domyślnego oznacza powstanie przypadkowego odniesienia (co gorsza modyfikowalnego!) do obiektu global (w przeglądarce internetowej to window). Oczywiście tego rodzaju niebezpieczeństwo może prowadzić do różnych błędów, które okazują się bardzo trudne do zdiagnozowania i wyśledzenia. Bezpieczniejsze this Prawdopodobnie bezpieczniejszym rozwiązaniem jest przekazanie jako this konkretnie skonfigurowanego obiektu, co do którego mamy pewność, że nie będzie powodować efektów ubocznych w aplikacji. Zapożyczając terminologię z dziedziny sieci komputerowych (i wojska), można powiedzieć o utworzeniu obiektu DMZ (strefy zdemilitaryzowanej) — to nic specjalnego, po prostu zupełnie pusty obiekt (patrz rozdziały 5. i 6.). 42 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Jeżeli zawsze będziesz przekazywać obiekt DMZ dla zignorowanego wiązania this, którym nie musisz się przejmować, to masz pewność, że wszelkie ukryte lub nieoczekiwane użycie tego this będzie ograniczone jedynie do pustego obiektu. Tym samym unikniesz efektów ubocznych, jakie mogłyby powstać podczas korzystania z obiektu global w programie. Ponieważ obiekt jest całkowicie pusty, przechowującej go zmiennej lubię nadawać nazwę ø (mały znak matematyczny oznaczający zbiór pusty). W wielu układach klawiatur (na przykład w systemach Mac OS) ten symbol otrzymasz po naciśnięciu klawiszy Option+o. Niektóre systemy pozwalają również na przypisanie określonych symboli do wybranych klawiszy. Jeżeli nie lubisz symbolu ø lub jego wpisanie nie jest wcale takie łatwe na Twojej klawiaturze, możesz oczywiście nadać tej zmiennej dowolną nazwę. Niezależnie od nazwy nadanej zmiennej najłatwiejszym sposobem utworzenia całkowicie pustego obiektu jest użycie wywołania Object.create(null) — patrz rozdział 5. Wymienione wywołanie jest podobne do { }, ale odbywa się bez delegowania do Object.prototype, więc otrzymujemy „bardziej pusty” obiekt niż w przypadku użycia { }: function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } // Nasz pusty obiekt DMZ. var ø = Object.create( null ); // Przekazanie tablicy jako parametrów. foo.apply( ø, [2, 3] ); // a:2, b:3 // Rozwinięcie funkcji za pomocą wywołania 'bind(..)'. var bar = foo.bind( ø, 2 ); bar( 3 ); // a:2, b:3 Przedstawione rozwiązanie jest nie tylko bezpieczniejsze pod względem funkcjonalnym, ale jednocześnie pokazuje możliwość użycia symbolu jako nazwy zmiennej. Pod względem semantycznym podana nazwa oznacza „this ma być pustym obiektem” i jest znacznie czytelniejsza niż null. Warto w tym miejscu przypomnieć ponownie, że obiektowi DMZ możesz nadać dowolną nazwę. Wyjątki dotyczące wiązań helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 43 0 Odwołania pośrednie Inną kwestią, na którą trzeba zwrócić uwagę, jest możliwość (celowego bądź nie!) utworzenia pośrednich odwołań do funkcji. W takim przypadku, gdy zostanie wywołana wspomniana funkcja, zastosowana będzie reguła wiązania domyślnego. Pośrednie odwołania powstają najczęściej w wyniku przypisania: function foo() { console.log( this.a ); } var a = 2; var o = { a: 3, foo: foo }; var p = { a: 4 }; o.foo(); // 3 (p.foo = o.foo)(); // 2 W powyższym fragmencie kodu wartość wynikowa wyrażenia przypisania p.foo = o.foo to odwołanie do obiektu funkcji podstawowej. Dlatego też źródłem wywołania funkcji jest po prostu foo(), a nie p.foo() lub o.foo(), jak można by tego oczekiwać. Z omówionych wcześniej reguł zastosowanie ma reguła wiązania domyślnego. Przypomnienie: niezależnie od sposobu wywołania funkcji za pomocą reguły wiązania domyślnego stan trybu ścisłego dla zawartości wywołanej funkcji powoduje, że odwołanie do this (a nie źródło wywołania funkcji) — określa wartość wiązania domyślnego: obiekt global w przypadku trybu innego niż ścisły lub undefined w przypadku trybu ścisłego. Osłabienie wiązania Wcześniej dowiedziałeś się, że wiązanie twarde to jedna ze strategii uniemożliwiających wywołaniu funkcji zastosowanie rozwiązania awaryjnego w postaci reguły wiązania domyślnego. Dzieje się tak, ponieważ w przypadku wiązania twardego mamy do czynienia z wymuszeniem użycia określonego this (o ile nie będzie wykorzystane słowo kluczowe new do nadpisania this). Problem polega na tym, że wiązanie twarde znacznie zmniejsza elastyczność funkcji i uniemożliwia ręczne nadpisanie this za pomocą wiązania niejawnego lub nawet w trakcie kolejnych prób wiązania jawnego. 44 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Byłoby dobrze, gdyby istniał sposób pozwalający na dostarczenie innej wartości domyślnej (niż global lub undefined) dla wiązania domyślnego przy jednoczesnym pozostawieniu funkcji możliwości zmiany wiązania this za pomocą techniki wiązania niejawnego lub wiązania jawnego. Możemy utworzyć funkcję narzędziową nazywaną wiązaniem miękkim, która będzie emulowała wymienione powyżej zachowanie: if (!Function.prototype.softBind) { Function.prototype.softBind = function(obj) { var fn = this, curried = [].slice.call( arguments, 1 ), bound = function bound() { return fn.apply( (!this || (typeof window !== "undefined" && this === window) || (typeof global !== "undefined" && this === global) ) ? obj : this, curried.concat.apply( curried, arguments ) ); }; bound.prototype = Object.create( fn.prototype ); return bound; }; } Przedstawiona powyżej funkcja softBind(..) działa podobnie jak wbudowana w ES5 funkcja pomocnicza bind(..), z wyjątkiem oczekiwanego przez nas zachowania w postaci wiązania miękkiego. Wskazana funkcja zostaje opakowana w logikę sprawdzającą this w chwili wywołania. Jeżeli this prowadzi do global lub undefined, używany jest przygotowany wcześniej alternatywny obiekt domyślny (obj). W przeciwnym razie this pozostaje niezmodyfikowane. Ponadto omawiana tutaj funkcja pomocnicza dostarcza również alternatywne rozwinięcie funkcji (patrz wcześniejszy fragment poświęcony bind(..)). Oto przykład użycia przygotowanej funkcji pomocniczej softBind(..): function foo() { console.log("nazwa: " + this.name); } var obj = { name: "obj" }, obj2 = { name: "obj2" }, obj3 = { name: "obj3" }; Wyjątki dotyczące wiązań helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 45 0 var fooOBJ = foo.softBind( obj ); fooOBJ(); // nazwa: obj obj2.foo = foo.softBind(obj); obj2.foo(); // nazwa: obj2 <---- spójrz!!! fooOBJ.call( obj3 ); // nazwa: obj3 <---- spójrz! setTimeout( obj2.foo, 10 ); // nazwa: obj <---- rozwiązanie awaryjne polegające na użyciu wiązania miękkiego. Przedstawiona powyżej wersja funkcji foo() może mieć ręcznie zmieniony element, do którego odwołuje się this, na przykład obj2 lub obj3, jak przedstawiłem w kodzie. W sytuacji, w której standardowo jest używane wiązanie domyślne, omawiana funkcja stosuje rozwiązanie awaryjne, czyli odwołanie this do obj. Leksykalne this Zwykłe funkcje stosują się do czterech omówionych wcześniej reguł. Jednak w specyfikacji ES6 wprowadzono specjalny rodzaj funkcji, która nie używa wymienionych reguł — jest to tak zwana arrow function (funkcja z użyciem strzałek). Funkcje typu arrow function nie są oznaczane za pomocą słowa kluczowego function, ale przez tak zwany operator fat arrow, czyli =>. Zamiast czterech standardowych reguł funkcja typu arrow function podczas określania wiązania this adaptuje je z zakresu nadrzędnego (funkcji lub zakresu globalnego). Poniżej przedstawiłem zakres leksykalny funkcji typu arrow function: function foo() { // Zwrot funkcji typu arrow function. return (a) => { // Tutaj 'this' pod względem leksykalnym dziedziczy po 'foo()'. console.log( this.a ); }; } var obj1 = { a: 2 }; var obj2 = { a: 3 }; 46 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 var bar = foo.call( obj1 ); bar.call( obj2 ); // 2, nie 3! Utworzona w foo() funkcja typu arrow function w chwili jej wywołania leksykalnie przechwytuje wiązanie this zdefiniowane dla foo(). Ponieważ w funkcji foo() wiązanie this prowadzi do obj1, bar (odniesienie zwrócone przez funkcję typu arrow function) również ma wiązanie this wskazujące obj1. Takie leksykalne wiązanie funkcji typu arrow function nie może być nadpisane nawet za pomocą słowa kluczowego new! Najczęściej spotykany przypadek użycia to prawdopodobnie wywołania zwrotne, takie jak procedury obsługi zdarzeń lub liczniki czasu: function foo() { setTimeout(() => { // Tutaj 'this' pod względem leksykalnym dziedziczy po 'foo()'. console.log( this.a ); },100); } var obj = { a: 2 }; foo.call( obj ); // 2 Chociaż funkcje typu arrow function stanowią atrakcyjną alternatywę dla użycia bind(..) w celu wywołania funkcji ze ściśle określonym this, to jednak trzeba zwrócić uwagę na jeden bardzo ważny szczegół: w zasadzie wyłączają tradycyjny mechanizm this, zastępując go znacznie rozleglejszym zakresem leksykalnym. W specyfikacji wcześniejszej niż ES6 mieliśmy dość często stosowany wzorzec przeznaczony do tego celu, który jest praktycznie nie do odróżnienia od wprowadzonych w ES funkcji typu arrow function: function foo() { var self = this; // Leksykalne przechwycenie 'this'. setTimeout( function(){ console.log( self.a ); }, 100 ); } var obj = { a: 2 }; foo.call( obj ); // 2 Leksykalne this helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 47 0 Choć wydaje się, że self = this i funkcje typu arrow function są dobrymi rozwiązaniami w przypadku chęci uniknięcia wywołania bind(..), to jednak w praktyce oznaczają ucieczkę przed mechanizmem this zamiast próby jego poznania i wykorzystania. Jeżeli często korzystasz z podobnych rozwiązań, musisz mieć świadomość, że tym samym odrzucasz mechanizm this i zastępujesz go leksykalnym self = this lub sztuczkami z arrow function. W takim przypadku rozważ: 1. Użycie jedynie zakresu leksykalnego. Zapomnij o pozorach tworzenia kodu w stylu this. 2. W pełni wykorzystaj mechanizm this, co oznacza użycie wywołania bind(..), gdy to konieczne, a także unikanie sztuczek, takich jak self = this i funkcje typu arrow function, które zapewniają leksykalne this. Program może efektywnie używać obu stylów kodu (leksykalnego i this), ale wewnątrz tej samej funkcji i w podobnych sytuacjach mieszanie dwóch mechanizmów to zwykle prosta droga do problemów z kodem. Podsumowanie Określenie wiązania this dla wykonywanej funkcji wymaga odszukania bezpośredniego źródła wywołania funkcji. Po przeanalizowaniu kodu dla źródła wywołania funkcji można zastosować cztery reguły — dokładnie w podanej kolejności: 1. Czy funkcja została wywołana za pomocą new? W takim przypadku użyj nowo skonstruowanego obiektu. 2. Czy funkcja została wywołana za pomocą call() bądź apply() (lub bind())? W takim przypadku użyj podanego obiektu. 3. Czy funkcja została wywołana wraz z obiektem kontekstu zawierającym dane wywołanie? W takim przypadku użyj tego obiektu kontekstu. 4. Domyślnie zastosowana zostanie wartość undefined w trybie ścisłym, a w przeciwnym razie — obiekt global. 48 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Zachowaj ostrożność, aby przypadkowo nie wywołać reguły wiązania domyślnego. Jeżeli chcesz w bezpieczny sposób zignorować wiązanie this, obiekt DMZ, taki jak ø = Object.create(null), będzie skutecznie chronił obiekt global przed nieoczekiwanymi efektami ubocznymi. Zamiast czterech standardowych reguł wiązania wprowadzone w specyfikacji ES6 funkcje typu arrow function używają zakresu leksykalnego dla wiązania this. Oznacza to, że wspomniane funkcje dziedziczą wiązanie this (niezależnie od jego rodzaju) po wywołaniu funkcji nadrzędnej. Dlatego też pod względem syntaktycznym funkcje typu arrow function mogą być uznawane za odpowiednik self = this stosowany w kodzie przed wprowadzeniem specyfikacji ES6. Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 49 0 50 Rozdział 2. To wszystko ma teraz sens! helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 3. Obiekty W rozdziałach 1. i 2. wyjaśniłem, że wiązanie this prowadzi do różnych obiektów, w zależności od źródła wywołania funkcji. Mógłbyś w tym miejscu zapytać: czym dokładnie jest obiekt i dlaczego trzeba go wskazywać? Właśnie tym zajmiemy się w tym rozdziale. Składnia Obiekt jest dostępny w dwóch postaciach: deklaracyjnej (literalnej) i konstrukcyjnej. Literalna składnia obiektu przedstawia się następująco: var myObj = { key: value // … }; Z kolei obiekt skonstruowany prezentuje się jak poniżej: var myObj = new Object(); myObj.key = value; Formy konstrukcyjna i literalna powodują powstanie dokładnie tego samego rodzaju obiektu. Jedyna różnica polega na możliwości umieszczenia w deklaracji literalnej jednej lub większej liczby par klucz-wartość, podczas gdy w przypadku obiektów konstruowanych właściwości muszą być dodawane pojedynczo. 51 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Postać konstrukcyjna jest niezwykle rzadko stosowana do tworzenia obiektów w pokazany powyżej sposób. Praktycznie zawsze używana jest składnia w postaci literalnej. To samo dotyczy większości wbudowanych obiektów (więcej na ten temat dowiesz się w dalszej części rozdziału). Typ Obiekty są ogólnymi elementami konstrukcyjnymi, na bazie których zbudowano większość języka JavaScript. Obiekt to jeden z sześciu typów podstawowych (w specyfikacji nazywanych typami języka) w JavaScript: string, number, boolean, null, undefined, object. Zwróć uwagę na pewien fakt: typy proste (string, boolean, number, null i un defined) same w sobie nie są obiektami. Wprawdzie typ null jest czasami określany jako typ obiektu, ale to niewłaściwe podejście wynikające z błędu w języku, który powoduje że polecenie typeof null nieprawidłowo (i myląco) zwraca ciąg tekstowy object. Tak naprawdę null jest oddzielnym typem prostym. Bardzo często można się spotkać z nieprawdziwym stwierdzeniem, że „wszystko w JavaScript jest obiektem”. Nie ulega wątpliwości, że wymienione zdanie jest nieprawdziwe. Istnieje także kilka specjalnych podtypów obiektów, które określamy jako typy złożone. Z kolei function to podtyp obiektu (technicznie rzecz biorąc, jest to obiekt możliwy do wywołania). Mówi się, że funkcje w JavaScript są „pierwszorzędne”, ponieważ w zasadzie to zwykłe obiekty (wraz z semantyką pozwalającą na ich wywoływanie), a więc mogą być obsługiwane w dokładnie taki sam sposób jak każdy inny zwykły obiekt. 52 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Tablica to również pewna forma obiektu, choć wyposażona w dodatkowe możliwości. Organizacja zawartości tablicy jest znacznie bardziej ustrukturyzowana niż w przypadku ogólnych obiektów. Obiekty wbudowane Istnieje kilka innych podtypów obiektu, zwykle określanych mianem obiektów wbudowanych. W przypadku niektórych nazwa wyraźnie pokazuje bezpośrednie powiązanie z odpowiednikiem wśród typów prostych. Jednak w rzeczywistości relacje są znacznie bardziej skomplikowane (wkrótce powrócimy do tego zagadnienia). Obiekty wbudowane: String, Number, Boolean, Object, Function, Array, Date, RegExp, Error wyglądają na rzeczywiste typy, a nawet klasy, o ile posłużymy się podobieństwem do innych języków programowania, na przykład Javy i jej klasy String. Jednak w JavaScript są to po prostu wbudowane funkcje. Każda z nich może być użyta w charakterze konstruktora (to znaczy wywołania funkcji wraz z operatorem new, patrz rozdział 2.), a wynikiem jest nowo skonstruowany obiekt podanego podtypu. Na przykład: var strPrimitive = "To jest ciąg tekstowy."; typeof strPrimitive; // "string" strPrimitive instanceof String; // Fałsz. var strObject = new String( "To jest ciąg tekstowy." ); typeof strObject; // "object" strObject instanceof String; // Prawda. // Sprawdzenie podtypu obiektu. Object.prototype.toString.call( strObject ); // [object String] Typ helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 53 0 W dalszej części rozdziału szczegółowo przedstawię sposób działania wywołania Object.prototype.toString(), ale teraz wystarczy wiedzieć, że można sprawdzić wewnętrzny podtyp za pomocą domyślnej metody bazowej toString(). Wynik działania metody ujawnia, że strObject jest obiektem faktycznie utworzonym przez konstruktor String. Wartość prosta "To jest ciąg tekstowy." nie jest obiektem, ale literałem typu prostego i jednocześnie niemodyfikowalną wartością. W celu przeprowadzenia operacji na tego rodzaju wartości — na przykład sprawdzenia wielkości, uzyskania dostępu do poszczególnych znaków itd. — konieczne jest użycie obiektu String. Na szczęście język automatycznie przeprowadza koercję typu prostego string na obiekt String, gdy zachodzi taka potrzeba. To oczywiście oznacza, że prawie nigdy nie trzeba wyraźnie tworzyć formy Object. Większość użytkowników JavaScript zdecydowanie preferuje użycie formy literalnej wartości, gdy tylko istnieje taka możliwość, zamiast formy skonstruowanego obiektu. Spójrz na poniższy fragment kodu: var strPrimitive = "To jest ciąg tekstowy."; console.log( strPrimitive.length ); // 22 console.log( strPrimitive.charAt( 3 ) ); // "j" W obu przypadkach wywołujemy metodę lub właściwość względem typu prostego (tutaj string), a silnik JavaScript automatycznie przeprowadza jej koercję na obiekt String. Dlatego też operacja dostępu do właściwości i metoda działają zgodnie z oczekiwaniami. Ten sam rodzaj koercji jest przeprowadzany względem literalnej liczby typu prostego, na przykład wartości 42, i obiektu opakowania new Number(42) podczas użycia metod takich jak 42.359.toFixed(2). Podobnie wygląda to w przypadku obiektów Boolean utworzonych na podstawie typu prostego boolean. Dla typów prostych null i undefined nie ma obiektów opakowania, a jedynie wartości wymienionych typów prostych. Z kolei wartości takie jak Date mogą być tworzone wyłącznie za pomocą formy skonstruowanych obiektów, ponieważ nie mają odpowiedników w postaci form literalnych. Object, Array, Function i RegExp (wyrażenia regularne) są obiektami niezależnie od użytej formy. Jednak forma skonstruowanego obiektu w pewnych przypadkach 54 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 oferuje więcej opcji podczas tworzenia obiektu niż dostępne w trakcie użycia formy literalnej. Ponieważ obiekty mogą być tworzone na dowolny z wymienionych sposobów, wybierana jest niemal zawsze prostsza forma literalna. Formy skonstruowanego obiektu używaj tylko wtedy, jeśli zachodzi potrzeba skorzystania z dodatkowych opcji. Obiekty Error są rzadko wyraźnie tworzone w kodzie, najczęściej odbywa się to automatycznie w trakcie zgłaszania wyjątków. Wprawdzie obiekt Error można utworzyć za pomocą formy skonstruowanego obiektu new Error(..), ale najczęściej jest to niepotrzebne. Zawartość obiektu Jak wcześniej wspomniałem, zawartość obiektu składa się z wartości (dowolnego typu) przechowywanych w nazwanych lokalizacjach, które określamy mianem właściwości. Trzeba koniecznie zwrócić uwagę na pewien fakt. Kiedy mówimy o zawartości, zwykle mamy na myśli te wartości, które faktycznie są przechowywane wewnątrz obiektu, ale to tylko pozory. Silnik przechowuje wartości w sposób zależny od implementacji i dlatego równie dobrze może nie przechowywać wartości w pewnych kontenerach obiektów. Natomiast w kontenerze są przechowywane nazwy właściwości, które działają w charakterze wskaźników (technicznie rzecz biorąc, są to odwołania) do miejsc rzeczywistego przechowywania wartości. Spójrz na poniższy fragment kodu: var myObject = { a: 2 }; myObject.a; // 2 myObject["a"]; // 2 W celu uzyskania dostępu do wartości znajdującej się w lokalizacji a obiektu myObject konieczne jest użycie operatora . lub [ ]. Składnia .a jest zwykle określana jako „dostęp do właściwości”, podczas gdy składnia ["a"] jako „dostęp do klucza”. W rzeczywistości obie zapewniają uzyskanie dostępu do tej samej lokalizacji i pobiorą tę samą wartość (tutaj 2), a więc oba wymienione Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 55 0 wyrażenia mogą być używane zamiennie. Od tego miejsca w książce będę stosował pojęcie „dostęp do właściwości”, które jest znacznie bardziej rozpowszechnione. Podstawowa różnica między dwoma wymienionymi składniami polega na tym, że operator . wymaga podania nazwy właściwości zgodnej z Identifier, podczas gdy składnia [".."] może pobrać jako nazwę właściwości praktycznie dowolny ciąg tekstowy zgodny z UTF-8/Unicode. Na przykład w celu uzyskania dostępu do właściwości o nazwie "Super-Fun!" należy użyć składni ["Super-Fun!"], ponieważ Super-Fun! nie jest poprawną nazwą właściwości zgodną z Identifier. Ponadto, skoro składnia [".."] używa wartości ciągu tekstowego do podania lokalizacji, aplikacja może w sposób programowy przygotować wartość ciągu tekstowego, na przykład: var myObject = { a: 2 }; var idx; if (wantA) { idx = "a"; } // Później. console.log( myObject[idx] ); // 2 Nazwy właściwości w obiektach zawsze są ciągami tekstowymi. Jeżeli dla nazwy właściwości użyjesz innej wartości niż string (typ prosty), w pierwszej kolejności zostanie ona przekonwertowana na ciąg tekstowy. To dotyczy także liczb, które są bardzo często używane w charakterze indeksów tablic. Dlatego też zachowaj ostrożność i nie pomyl liczb z obiektami i tablicami: var myObject = { }; myObject[true] = "foo"; myObject[3] = "bar"; myObject[myObject] = "baz"; myObject["true"]; // "foo" myObject["3"]; // "bar" myObject["[object Object]"]; // "baz" 56 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Nazwy obliczanych właściwości Omówiona powyżej składnia myObject[..] dostępu do właściwości jest użyteczna, jeśli konieczne będzie użycie wartości obliczanego wyrażenia jako nazwy klucza, na przykład myObject[prefiks + nazwa]. Jednak nie okaże się zbyt pomocna w trakcie deklarowania obiektów za pomocą składni literalnego obiektu. W specyfikacji ES6 wprowadzono nazwy właściwości obliczanych pozwalających na podanie wyrażenia w nawiasie kwadratowym, umieszczanego w miejscu nazwy klucza w deklaracji literalnego obiektu: var prefix = "foo"; var myObject = { [prefix + "bar"]: "Witaj,", [prefix + "baz"]: "świecie!" }; myObject["foobar"]; // Witaj, myObject["foobaz"]; // świecie! Najczęstszym sposobem wykorzystania nazw właściwości obliczanych będzie prawdopodobnie typ Symbol, wprowadzony w specyfikacji ES6, którego jednak nie omawiam w tej książce. Pokrótce — jest to nowy typ prosty danych, który ma przeciwną, niemożliwą do odgadnięcia wartość (technicznie rzecz biorąc, to wartość string). Będziesz usilnie zniechęcany do pracy z rzeczywistą wartością typu Symbol (teoretycznie ta wartość może być inna w różnych silnikach JavaScript). Dlatego też stosowana będzie nazwa Symbol, taka jak Symbol.Cokolwiek (to tylko nazwa!). var myObject = { [Symbol.Something]: "Witaj, świecie!" }; Właściwości kontra metody Niektórzy programiści lubią dokonywać rozróżnienia podczas rozważania dostępu do właściwości w obiekcie, jeżeli pobierana wartość okazuje się funkcją. Ponieważ kuszące może być potraktowanie funkcji jako należącej do obiektu, a poza tym w innych językach programowania funkcje należące do obiektów (czyli „klas”) są określane mianem „metod”, często można się spotkać ze stosowaniem określenia „dostęp do metody” zamiast „dostęp do właściwości”. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 57 0 Co interesujące, w specyfikacji również istnieje wspomniane rozróżnienie. Pod względem technicznym funkcje nigdy nie „należą” do obiektów. Dlatego też automatyczne określanie mianem „metody” funkcji, która po prostu jest wywoływana w obiekcie, wydaje się przykładem naciągania semantyki. To prawda, że pewne funkcje zawierają odwołania do this, a czasami wspomniane odwołania mogą prowadzić do obiektów w źródle wywołania funkcji. Jednak taki sposób użycia danej funkcji nie powoduje, że jest ona bardziej metodą niż inne funkcje, ponieważ wiązanie this jest tworzone dynamicznie w trakcie działania aplikacji, w źródle wywołania funkcji. Dlatego też relacja z obiektem pozostaje w najlepszym razie tylko pośrednia. Za każdym razem, gdy uzyskujesz dostęp do właściwości w obiekcie, masz do czynienia z dostępem do właściwości, niezależnie od typu otrzymanej wartości zwrotnej. Jeżeli skutkiem operacji dostępu do właściwości jest funkcja, w tym momencie nie stanie się ona w magiczny sposób metodą. Nie ma nic specjalnego (poza możliwością utworzenia wiązania niejawnego this, jak to wcześniej wyjaśniłem) w funkcji pochodzącej z operacji dostępu do właściwości. Na przykład: function foo() { console.log( "foo" ); } var someFoo = foo; // Odwołanie zmiennej do 'foo'. var myObject = { someFoo: foo }; foo; // function foo(){..} someFoo; // function foo(){..} myObject.someFoo; // function foo(){..} W powyższym fragmencie kodu someFoo i myObject.someFoo to dwa oddzielne odwołania do tej samej funkcji. Żadne z nich nie wskazuje na nic specjalnego ani nic będącego „własnością” innego obiektu. Jeżeli funkcja foo() zawiera wewnątrz odwołanie this, niejawne wiązanie myObject.someFoo będzie 58 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 jedyną możliwą do zaobserwowania różnicą między tymi dwoma odwołaniami do tej samej funkcji. Nie ma sensu, aby którekolwiek z tych odwołań nazywać metodą. Prawdopodobnie znajdą się osoby przekonane, że funkcja staje się metodą nie w trakcie jej definiowania, ale podczas działania programu, a dokładnie w chwili jej wywołania, w zależności od jego sposobu (czy będzie to wywołanie z kontekstem obiektu, czy bez niego — więcej informacji na ten temat znajdziesz w rozdziale 2.). Jednak nawet taka interpretacja jest nieco naciągana. Najbezpieczniej będzie przyjąć założenie, że pojęcia „funkcja” i „metoda” są używane w języku JavaScript wymiennie. Specyfikacja ES6 zawiera nowe odwołanie super, które zwykle jest używane wraz ze słowem kluczowym class (patrz dodatek A). Sposób zachowania odwołania super (wiązanie statyczne zamiast późnego, jak w przypadku this) jeszcze bardziej podkreśla wagę tego, że funkcja wraz z odwołaniem super jest bardziej metodą niż funkcją. To jednak tylko subtelne niuanse semantyczne (i mechaniczne). Nawet po zadeklarowaniu wyrażenia funkcji jako części literału obiektu ta funkcja w sposób magiczny nie będzie bardziej należeć do obiektu. Nadal istnieje po prostu wiele odwołań do tego samego obiektu funkcji: var myObject = { foo: function foo() { console.log( "foo" ); } }; var someFoo = myObject.foo; someFoo; // function foo(){..} myObject.foo; // function foo(){..} W rozdziale 6. przedstawię wprowadzony w specyfikacji ES6 skrót dla składni foo: function foo() { .. } w literale obiektu. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 59 0 Tablice Tablice również używają formy dostępu [ ], ale jak wcześniej wspomniałem, charakteryzują się znacznie bardziej strukturalną organizacją sposobu i miejsca przechowywania wartości (choć wciąż nie ma żadnych ograniczeń dotyczących typu przechowywanych wartości). W tablicach stosowane są indeksy liczbowe, co oznacza, że wartości są przechowywane w lokalizacjach zwykle nazywanych indeksami i określanych nieujemnymi liczbami całkowitymi, takimi jak 0 lub 42: var myArray = [ "foo", 42, "bar" ]; myArray.length; // 3 myArray[0]; // "foo" myArray[2]; // "bar" Tablice są obiektami i pomimo że poszczególne indeksy są dodatnimi liczbami całkowitymi, to wciąż istnieje możliwość dodania właściwości do tablicy: var myArray = [ "foo", 42, "bar" ]; myArray.baz = "baz"; myArray.length; // 3 myArray.baz; // "baz" Zwróć uwagę na fakt, że dodanie nazwanych właściwości (niezależnie od użytej składni operatora . lub [ ]) nie zmienia podawanej wielkości tablicy. Istnieje możliwość użycia tablicy jako zwykłego obiektu klucz-wartość bez dodawania do niej żadnych indeksów liczbowych, ale to kiepski pomysł, ponieważ tablice są zoptymalizowane pod kątem ich typowego przeznaczenia, podobnie jak w przypadku zwykłych obiektów. Dlatego też używaj obiektów do przechowywania par klucz-wartość, natomiast tablic do przechowywania wartości w indeksach liczbowych. Zachowaj ostrożność: jeżeli spróbujesz dodać właściwość do tablicy, ale nazwa właściwości będzie się prezentowała jak liczba, zostanie ona potraktowana jako numeryczny indeks (a tym samym zmodyfikuje zawartość tablicy): 60 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 var myArray = [ "foo", 42, "bar" ]; myArray["3"] = "baz"; myArray.length; // 4 myArray[3]; // "baz" Powielanie obiektów Jedną z funkcji najczęściej interesujących programistów, którzy dopiero poznają JavaScript, jest powielanie obiektu. Do tego celu powinna być dostępna wbudowana metoda copy(), prawda? Okazuje się jednak, że zadanie jest nieco bardziej skomplikowane, ponieważ nie do końca jest jasne, jaki algorytm powinien zostać użyty w trakcie powielania obiektu. Spójrz na przykład na poniższy obiekt: function anotherFunction() { /*..*/ } var anotherObject = { c: true }; var anotherArray = []; var myObject = { a: 2, b: anotherObject, // Odwołanie, a nie kopia! c: anotherArray, // Inne odwołanie! d: anotherFunction }; anotherArray.push( anotherObject, myObject ); W jaki dokładnie sposób powinna zostać przedstawiona kopia myObject? Przede wszystkim należy odpowiedzieć na pytanie o rodzaj kopii: czy jest płytka, czy głęboka. Kopia płytka powoduje utworzenie nowego obiektu a jako kopii wartości 2. Natomiast właściwości b, c i d są po prostu odwołaniami do tych samych miejsc znajdujących się w pierwotnym obiekcie. Z kolei kopia głęboka powoduje powielenie nie tylko obiektu myObject, ale również another Object i anotherArray. Następnie pojawia się problem: obiekt anotherArray zawiera odwołania do anotherObject i myObject, więc powinny być one powielone. Niestety mamy tylko odwołania do nich. W ten sposób dochodzimy do problemu nieustannego powielania z powodu odwołań cyklicznych. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 61 0 Czy należy wykryć stan odwołań cyklicznych i po prostu przerwać łańcuch, pozostawiając przy tym najgłębiej umieszczony element nie w pełni powielony? Czy powinien zostać wygenerowany błąd? A może trzeba zastosować jakieś rozwiązanie pośrednie? Co więcej, nie jest do końca jasne, co oznacza termin „funkcja powielająca”. Możemy skorzystać z różnych obejść, takich jak serializacja toString() w kodzie źródłowym funkcji (który różni się między poszczególnymi implementacjami i nie jest niezawodny we wszystkich silnikach w zależności od typu analizowanej funkcji). Jak można udzielić odpowiedzi na te wszystkie trudne pytania? W poszczególnych frameworkach JavaScript ich autorzy zdecydowali się na własne interpretacje i podjęli pewne decyzje projektowe. Jednak które (o ile w ogóle) interpretacje powinny być zaadaptowane w JavaScript jako standard? Przez długi czas nie było jasnej odpowiedzi na to pytanie. Jedno z rozwiązań opiera się na założeniu, że obiekty, których można bezpieczne używać wraz z formatem JSON (to znaczy mogą być serializowane na ciągi tekstowe JSON, a następnie przywracane jako obiekty o takiej samej strukturze i wartościach), mogą być łatwo powielone za pomocą poniższego polecenia: var newObj = JSON.parse( JSON.stringify( someObj ) ); Oczywiście konieczne jest upewnienie się, że obiekt może być bezpiecznie stosowany wraz z formatem JSON. W pewnych sytuacjach będzie to wręcz trywialne, z kolei w innych — całkowicie niewystarczające. Jednocześnie koncepcja płytkiej kopii pozostaje całkiem zrozumiała i powoduje znacznie mniej problemów. Dlatego też w specyfikacji ES6 znalazło się zdefiniowane wywołanie Object.assign(..), przeznaczone właśnie do tworzenia płytkiej kopii. Wywołanie Object.assign(..) jako pierwszy parametr pobiera obiekt docelowy, natomiast kolejnymi parametrami są jeden lub większa liczba obiektów źródłowych. Wywołanie przeprowadza iterację przez wszystkie widoczne w typach wyliczeniowych klucze (patrz poniższy fragment kodu) obiektu źródłowego i kopiuje je (tylko za pomocą przypisania operatora =) do obiektu docelowego. Wartością zwrotną jest (mamy nadzieję) obiekt docelowy, jak można zobaczyć poniżej: 62 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 var newObj = Object.assign( {}, myObject ); newObj.a; // 2 newObj.b === anotherObject; // Prawda. newObj.c === anotherArray; // Prawda. newObj.d === anotherFunction; // Prawda. W kolejnej sekcji przedstawię deskryptory właściwości (czyli cechy charakterystyczne właściwości) i pokażę użycie wywołania Object.defineProperty(..). Jednak powielenie przeprowadzane przez Object.assign(..) jest wyłącznie przypisaniem w stylu =, więc wszelkie specjalne cechy charakterystyczne właściwości (takie jak writable) w obiekcie źródłowym nie zostaną zachowane w obiekcie docelowym. Deskryptory właściwości Przed wprowadzeniem specyfikacji ES5 język JavaScript nie oferował żadnego bezpośredniego sposobu rozróżniania z poziomu kodu cech charakterystycznych właściwości, na przykład czy dana właściwość jest tylko do odczytu. Jednak począwszy od ES5, wszystkie właściwości są opisywane w kategoriach deskryptora właściwości. Spójrz na poniższy fragment kodu: var myObject = { a: 2 }; Object.getOwnPropertyDescriptor( myObject, "a" ); // { // value: 2, // writable: true, // enumerable: true, // configurable: true // } Jak możesz zobaczyć, deskryptor właściwości (nazywany deskryptorem danych, ponieważ przechowuje jedynie dane) dla naszej właściwości a zwykłego obiektu to znacznie więcej niż tylko jego wartość wynosząca 2. Właściwość zawiera także trzy inne cechy charakterystyczne określone jako writable, enumerable i configurable. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 63 0 Wprawdzie widać wartości domyślne przypisywane cechom charakterystycznym właściwości podczas jej tworzenia, ale za pomocą wywołania Object.de fineProperty(..) można dodać nową właściwość lub zmodyfikować istniejącą (o ile cecha configurable ma wartość true!) zgodnie z oczekiwaniami. Na przykład: var myObject = {}; Object.defineProperty( myObject, "a", { value: 2, writable: true, configurable: true, enumerable: true } ); myObject.a; // 2 Za pomocą defineProperty(..) w wyraźny sposób ręcznie dodaliśmy zwykłą, normalną właściwość a do obiektu myObject. Jednak podejście ręczne nie jest stosowane, o ile nie zachodzi potrzeba modyfikacji cech charakterystycznych deskryptora w celu określenia zachowania innego niż domyślne. Writable Możliwość zmiany wartości właściwości jest kontrolowana przez cechę charakterystyczną o nazwie writable. Spójrz na poniższy fragment kodu: var myObject = {}; Object.defineProperty( myObject, "a", { value: 2, writable: false, // Brak możliwości zmiany wartości! configurable: true, enumerable: true } ); myObject.a = 3; myObject.a; // 2 Jak możesz zobaczyć, próba modyfikacji wartości (value) zakończyła się cichym niepowodzeniem. Po przejściu do trybu ścisłego (strict mode) otrzymamy błąd: 64 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 "use strict"; var myObject = {}; Object.defineProperty( myObject, "a", { value: 2, writable: false, // Brak możliwości zmiany wartości! configurable: true, enumerable: true } ); myObject.a = 3; // Błąd TypeError. Błąd TypeError wskazuje na brak możliwości zmiany wartości właściwości. Metodami typu getter i setter zajmiemy się już wkrótce. Możesz jednak zaobserwować, że writable: false oznacza brak możliwości zmiany wartości, co pod pewnymi względami jest odpowiednikiem zdefiniowania metody typu setter, która nie wykonuje operacji. W rzeczywistości nieprzeprowadzająca zapisu metoda typu setter będzie musiała zgłosić błąd TypeError podczas próby jej wywołania, aby tym samym była w pełni zgodna z writable: false. Configurable Jeżeli właściwość pozwala na jej konfigurację, można zmodyfikować jej definicję deskryptora za pomocą tej samej funkcji pomocniczej o nazwie define Property(..): var myObject = { a: 2 }; myObject.a = 3; myObject.a; // 3 Object.defineProperty( myObject, "a", { value: 4, writable: true, configurable: false, // Brak możliwości konfiguracji! enumerable: true } ); myObject.a; // 4 myObject.a = 5; Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 65 0 myObject.a; // 5 Object.defineProperty( myObject, "a", { value: 6, writable: true, configurable: true, enumerable: true } ); // Błąd TypeError. Ostatnie wywołanie defineProperty(..) powoduje powstanie błędu TypeError niezależnie od tego, czy jest używany tryb ścisły, jeśli podejmujesz próbę zmiany definicji deskryptora we właściwości niepozwalającej na konfigurację. Zachowaj ostrożność: jak możesz zobaczyć, zmiana w configurable wartości na false jest operacją jednokrotną, która nie może być cofnięta! Mamy jeszcze pewien wyjątek, którego istnienia trzeba być świadomym: nawet jeśli właściwość jest aktualnie określona jako configurable:false, to wartość writable zawsze można zmienić z true na false i nie spowoduje to błędu, ale nie można zrobić tego w drugą stronę, to znaczy zmienić z false na true. Kolejną restrykcją nakładaną przez configurable:false jest brak możliwości użycia operatora delete w celu usunięcia istniejącej właściwości: var myObject = { a: 2 }; myObject.a; // 2 delete myObject.a; myObject.a; // Wartość undefined. Object.defineProperty( myObject, "a", { value: 2, writable: true, configurable: false, enumerable: true } ); myObject.a; // 2 delete myObject.a; myObject.a; // 2 Jak możesz zobaczyć w powyższym fragmencie kodu, ostatnie wywołanie delete zakończyło się (cichym) niepowodzeniem, ponieważ właściwość a nie pozwala na zmianę konfiguracji. 66 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Operator delete jest używany jedynie do usunięcia właściwości obiektu (które mogą być usunięte) bezpośrednio z danego obiektu. Jeżeli właściwość obiektu jest ostatnim pozostającym odwołaniem do pewnego obiektu lub funkcji, jej usunięcie za pomocą delete powoduje także wykasowanie wspomnianego odwołania. W takim przypadku wskazywany przez to odwołanie obiekt lub funkcja mogą być później usunięte przez mechanizm usuwania nieużytków. To jednak nie oznacza, że można traktować operator delete jako narzędzie do zwalniania zaalokowanej pamięci, jak ma to miejsce w innych językach programowania (na przykład w C i C++). Operator delete jest przeznaczony jedynie do usuwania właściwości obiektu. Enumerable Ostatnią (wprawdzie są jeszcze dwie inne, ale omówimy je pokrótce przy okazji poznawania metod typu getter i setter) omawianą tutaj cechą charakterystyczną deskryptora jest enumerable. Nazwa prawdopodobnie nie pozostawia wątpliwości co do przeznaczenia tej cechy charakterystycznej — jest nim określenie, czy właściwość będzie widoczna w pewnych typach wyliczeniowych, takich jak pętla for-in. Przypisanie enumerable wartości false powoduje, że właściwość nie będzie widoczna we wspomnianych typach wyliczeniowych, nawet jeśli pozostaje w pełni dostępna. Z kolei przypisanie wartości true powoduje uwzględnienie właściwości przez typy wyliczeniowe. Wszystkie normalne właściwości zdefiniowane przez użytkownika mają domyślnie przypisaną wartość true dla enumerable, co prawdopodobnie będzie oczekiwanym zachowaniem. Jeżeli masz właściwość specjalną, którą chcesz ukryć przed typem wyliczeniowym, ustaw w niej enumerable:false. Do tematu widoczności właściwości w typach wyliczeniowych wkrótce jeszcze powrócimy, przygotuj się więc na kontynuację omawiania tego zagadnienia. Niemodyfikowalność Czasami zachodzi konieczność, aby pewne właściwości lub obiekty pozostawały niemodyfikowalne (przypadkowo lub celowo). W specyfikacji ES5 dodano obsługę modyfikowalności na wiele różnych sposobów. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 67 0 Trzeba koniecznie zwrócić uwagę na fakt, że wszystkie omówione tutaj podejścia powodują utworzenie płytkiej niemodyfikowalności. Oznacza to, że wpływają jedynie na obiekt i cechy charakterystyczne jego bezpośrednich właściwości. Jeżeli obiekt zawiera odwołanie do innego obiektu (tablicy, obiektu, funkcji itd.), zawartość tego obiektu pozostaje bez zmian: myImmutableObject.foo; // [1,2,3] myImmutableObject.foo.push( 4 ); myImmutableObject.foo; // [1,2,3,4] W powyższym fragmencie kodu przyjąłem założenie, że obiekt myImmutableObject został już utworzony i jest chroniony jako niemodyfikowalny. Jednak aby ochronić zawartość myImmutableObject.foo (czyli oddzielny obiekt — tablicę), należy ustawić foo jako obiekt niemodyfikowalny za pomocą jednej lub większej liczby omówionych poniżej funkcjonalności. W programach JavaScript często tworzy się głęboko zagnieżdżone niemodyfikowalne obiekty. Niewątpliwie niekiedy mają miejsce wyjątkowe sytuacje, które wymagają zastosowania tego rodzaju podejścia. Jednak ogólnie rzecz biorąc, jeśli okaże się, że musisz szczelnie zamknąć lub zamrozić wszystkie obiekty, warto zrobić krok wstecz w celu ponownego przeanalizowania projektu programu i zmodyfikować go w taki sposób, aby zapewniał większą niezawodność w przypadku potencjalnych zmian w wartościach obiektów. Stała obiektu Dzięki połączeniu writable:false i configurable:false można w zasadzie utworzyć stałą (brak możliwości zmiany, przedefiniowania czy usunięcia) będącą właściwością obiektu. Przykład pokazałem poniżej: var myObject = {}; Object.defineProperty( myObject, "FAVORITE_NUMBER", { value: 42, writable: false, configurable: false } ); 68 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Uniemożliwienie rozszerzania Jeżeli chcesz uniemożliwić dodawanie nowych właściwości do obiektu i jednocześnie pozostawić w spokoju pozostałe właściwości danego obiektu, wywołaj Object.preventExtensions(..): var myObject = { a: 2 }; Object.preventExtensions( myObject ); myObject.b = 3; myObject.b; // Wartość undefined. Jeżeli nie został włączony tryb ścisły, próba utworzenia właściwości b zakończy się cichym niepowodzeniem. Z kolei w trybie ścisłym nastąpi zgłoszenie błędu TypeError. Szczelne opakowanie Wywołanie Object.seal(..) powoduje utworzenie „szczelnie opakowanego” obiektu. W praktyce oznacza to pobranie istniejącego obiektu i wywołanie w nim Object.preventExtensions(..), a także oznaczenie wszystkich istniejących właściwości jako configurable:false. Dlatego też uniemożliwiasz nie tylko dodawanie kolejnych właściwości, ale również ponowną konfigurację lub usunięcie istniejących właściwości (choć nadal można modyfikować ich wartości). Zamrożenie Wywołanie Object.freeze(..) tworzy zamrożony obiekt. W praktyce oznacza to pobranie istniejącego obiektu i wywołanie w nim Object.seal(..), a także oznaczenie wszystkich właściwości dostępu do danych jako writable:false, co uniemożliwia zmianę ich wartości. Takie podejście jest najwyższym poziomem niemodyfikowalności, który można zastosować względem obiektu. Uniemożliwia on wprowadzenie jakichkolwiek zmian w obiekcie oraz wszystkich jego bezpośrednich właściwościach (choć jak wcześniej wspomniałem, zawartość innych obiektów, do których prowadzą odwołania, pozostaje niezmieniona). Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 69 0 Istnieje możliwość głębokiego zamrożenia obiektu przez wywołanie w nim Object.freeze(..), a następnie przeprowadzenie rekurencyjnej iteracji przez wszystkie (pozostające dotąd nienaruszone) obiekty, do których prowadzą odwołania, i również wywołanie w nich Object.freeze(..). Zachowaj jednak ostrożność, ponieważ powyższe działanie może mieć wpływ na inne (współdzielone) obiekty, których nie chciałeś zamrażać. [[Get]] Istnieje pewien subtelny, choć ważny szczegół dotyczący działania właściwości dostępu. Spójrz na poniższy fragment kodu: var myObject = { a: 2 }; myObject.a; // 2 W powyższym kodzie myObject.a to właściwość dostępu, ale nie sprawdza jedynie obiektu myObject w poszukiwaniu właściwości o nazwie a, jak mogłoby się wydawać. Zgodnie ze specyfikacją przedstawiony powyżej fragment kodu w rzeczywistości przeprowadza operację [[Get]] (coś w rodzaju wywołania funkcji [[Get]]()) w obiekcie myObject. Domyślna wbudowana operacja [[Get]] w obiekcie najpierw sprawdza obiekt w poszukiwaniu właściwości o podanej nazwie i, jeśli ją znajdzie, zwraca odpowiednią wartość. Jednak algorytm [[Get]] definiuje jeszcze inne ważne zachowanie podejmowane w momencie, gdy nie zostanie znaleziona właściwość o podanej nazwie. W rozdziale 5. dowiesz się, co się stanie później (przejście przez łańcuch [[Prototype]], o ile taki istnieje). Ważnym wynikiem operacji [[Get]] jest to, że jeśli nie będzie ona w stanie odszukać żadnej wartości dla żądanej właściwości, zwróci wartość undefined: var myObject = { a: 2 }; myObject.b; // Wartość undefined. 70 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 To zachowanie jest odmienne od tego, w którym do zmiennych odwołujemy się za pomocą ich identyfikatorów. W przypadku niemożliwego do rozwiązania w akceptowalnym zakresie leksykalnym odwołania do zmiennej wynikiem nie będzie wartość undefined, jak ma to miejsce w przypadku właściwości obiektu, ale raczej nastąpi zgłoszenie błędu ReferenceError: var myObject = { a: undefined }; myObject.a; // Wartość undefined. myObject.b; // Wartość undefined. Z perspektywy wartości nie ma żadnej różnicy między dwoma wymienionymi odwołaniami — w obu przypadkach wynikiem jest wartość undefined. Jednak operacja [[Get]] w tle, choć na pierwszy rzut oka subtelna, potencjalnie przeprowadza nieco więcej „pracy” w przypadku odwołania myReference.b niż w przypadku odwołania myObject.a. Analizując jedynie otrzymane wartości, nie można stwierdzić, czy właściwość istnieje i faktycznie przechowuje wartość undefined, czy jednak właściwości nie ma, a undefined to wartość domyślna zwrócona przez operację [[Get]] z powodu braku możliwości zwrotu innej wartości. Wkrótce przekonasz się, że można odróżnić dwa wymienione scenariusze. [[Put]] Ponieważ istnieje wewnętrznie zdefiniowana operacja [[Get]] przeznaczona do pobierania wartości z właściwości, oczywiste jest, że mamy także domyślną operację [[Put]]. Uznanie, że przypisanie wartości właściwości obiektu spowoduje wywołanie operacji [[Put]] w celu utworzenia lub ustawienia danej właściwości, może być kuszące, jednak sytuacja jest nieco bardziej skomplikowana. W trakcie wywołania operacji [[Put]] jej zachowanie będzie uzależnione od wielu różnych czynników, przede wszystkim od tego, czy właściwość już istnieje w obiekcie (ten czynnik ma największe znaczenie). Jeżeli właściwość znajduje się w obiekcie, wówczas algorytm [[Put]] przeprowadzi następujące sprawdzenie: Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 71 0 1. Czy właściwość jest deskryptorem akcesora (patrz sekcja „Metody typu getter i setter” w dalszej części rozdziału)? Jeżeli tak, należy wywołać metodę typu setter, o ile istnieje. 2. Czy właściwość jest deskryptorem danych wraz z wartością false dla writable? Jeżeli tak, następuje cicha awaria w trybie innym niż ścisły, natomiast w trybie ścisłym zgłaszany jest błąd TypeError. 3. W przeciwnym razie następuje przypisanie wartości do istniejącej właściwości w zwykły sposób. Jeżeli w danym obiekcie właściwość jeszcze nie istnieje, operacja [[Put]] staje się nieco bardziej skomplikowana. Do takiego scenariusza powrócimy w rozdziale 5. podczas analizy [[Prototype]], co powinno nieco rozjaśnić sytuację. Metody typu getter i setter Domyślne operacje [[Put]] i [[Get]] w obiektach zachowują pełną kontrolę nad sposobem przypisywania wartości istniejących lub nowych właściwości, a także nad pobieraniem wartości z istniejących właściwości. Być może w przyszłości, dzięki bardziej zaawansowanym funkcjonalnościom języka, będzie można nadpisywać domyślne operacje [[Put]] i [[Get]] dla całego obiektu (a nie jedynie dla poszczególnych właściwości). Ten temat wykracza jednak poza zakres tematyczny książki i być może zostanie omówiony w kolejnych pozycjach z serii Tajniki języka JavaScript. Specyfikacja ES5 wprowadziła możliwość nadpisania części wspomnianych operacji domyślnych nie na poziomie obiektu, ale na poziomie właściwości — za pomocą metod typu getter i setter. Getter to właściwość, która w rzeczywistości wywołuje ukrytą funkcję w celu pobrania wartości. Z kolei setter to właściwość, która tak naprawdę wywołuje ukrytą funkcję w celu przypisania wartości. Podczas definiowania właściwości, aby zawierała ona getter, setter lub obie możliwości, jej definicja staje się deskryptorem akcesora (przeciwieństwem deskryptora danych). W przypadku deskryptorów akcesora cechy charakterystyczne value i writable są ignorowane. Zamiast nich JavaScript uwzględnia cechy set i get właściwości, a także configurable i enumerable. 72 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Spójrz na poniższy fragment kodu: var myObject = { // Zdefiniowanie gettera dla 'a'. get a() { return 2; } }; Object.defineProperty( myObject, // Element docelowy. "b", // Nazwa właściwości. { // Deskryptor. // Zdefiniowanie gettera dla 'b'. get: function(){ return this.a * 2 }, // Upewniamy się, że 'b' będzie właściwością obiektu. enumerable: true } ); myObject.a; // 2 myObject.b; // 4 Niezależnie od tego, czy korzystamy ze składni literalnego obiektu (get a() { .. }), czy też z wyraźnej definicji za pomocą defineProperty(..), dochodzi do utworzenia w obiekcie właściwości nieprzechowującej w rzeczywistości wartości. Próba uzyskania dostępu do tej właściwości automatycznie spowoduje wywołanie ukrytej funkcji getter, która zwróci wartość będącą wynikiem operacji dostępu do danej właściwości: var myObject = { // Zdefiniowanie gettera dla 'a'. get a() { return 2; } }; myObject.a = 3; myObject.a; // 2 Ponieważ dla a zdefiniowaliśmy jedynie getter, późniejsza próba przypisania wartości a spowoduje, że operacja set nie zgłosi błędu, ale zakończy się cichym odrzuceniem przypisania. Nawet jeśli setter będzie poprawny, nasz niestandardowy getter ma zdefiniowany na stałe zwrot jedynie wartości 2, a tym samym przeprowadzanie operacji set nie ma sensu. Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 73 0 Aby rozwiązanie było bardziej sensowne, właściwości powinny być definiowane wraz z setterami, które nadpisują domyślną operację [[Put]] (czyli przypisanie) dla poszczególnych właściwości zgodnie z oczekiwaniami. Praktycznie zawsze będziesz deklarował zarówno getter, jak i setter (zdefiniowanie tylko jednego z nich często prowadzi do nieoczekiwanego lub zaskakującego zachowania): var myObject = { // Zdefiniowanie gettera dla 'a'. get a() { return this._a_; }, // Zdefiniowanie gettera dla 'a'. set a(val) { this._a_ = val * 2; } }; myObject.a = 2; myObject.a; // 4 W omówionym przykładzie podana wartość 2 przypisania (operacja [[Put]] ) była w rzeczywistości przechowywana w innej zmiennej _a_. Nazwa zmiennej jest jedynie konwencją i nie ma żadnego specjalnego wpływu na jej zachowanie — to zwykła właściwość, jak każda inna. Istnienie Pokazałem wcześniej, że dostęp do właściwości takiej jak myObject.a może się zakończyć otrzymaniem wartości undefined, jeżeli przechowywana jest wyraźna wartość undefined lub właściwość w ogóle nie istnieje. A skoro wartość pozostaje taka sama w obu przypadkach, to jak można je rozróżnić? Istnieje możliwość sprawdzenia, czy obiekt zawiera określoną właściwość, bez pobierania wartości tej właściwości: var myObject = { a: 2 }; ("a" in myObject); // Prawda. ("b" in myObject); // Fałsz. myObject.hasOwnProperty( "a" ); // Prawda. myObject.hasOwnProperty( "b" ); // Fałsz. 74 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Operator in sprawdzi, czy właściwość znajduje się w obiekcie lub też na jakimkolwiek wyższym poziomie łańcucha [[Prototype]] (patrz rozdział 5.). Z kolei wywołanie hasOwnProperty(..) sprawdza jedynie, czy myObject ma właściwość, ale nie analizuje łańcucha [[Prototype]]. W rozdziale 5. powrócimy do ważnych różnic między tymi dwoma operacjami, wtedy też dokładniej przeanalizujemy mechanizm [[Prototype]]. Wywołanie hasOwnProperty(..) jest dostępne dla wszystkich zwykłych obiektów za pomocą delegowania do Object.prototype (patrz rozdział 5.). Istnieje jednak możliwość utworzenia obiektu pozbawionego połączenia z Object.pro totype — to się odbywa za pomocą (omówionego również w rozdziale 5.) wywołania Object.create(null). W takim przypadku wywołanie metody typu myObject.hasOwnProperty(..) zakończy się niepowodzeniem. W omawianym scenariuszu najbardziej niezawodny sposób przeprowadzenia operacji sprawdzenia to wywołanie Object.prototype.hasOwnProperty.call (myObject,"a"), które pożycza bazową metodę hasOwnProperty(..) i używa wiązania jawnego (patrz rozdział 2.) względem myObject. Może się wydawać, że operator in sprawdzi istnienie wartości wewnątrz kontenera, ale tak naprawdę sprawdza istnienie nazwy właściwości. O tej różnicy trzeba koniecznie pamiętać w kontekście tablic, ponieważ próba sprawdzenia za pomocą wywołania 4 in [2, 4, 6] nie będzie działała zgodnie z oczekiwaniami. Uwzględnienie w typach wyliczeniowych Wcześniej, podczas omawiania cechy charakterystycznej enumerable deskryptora właściwości, pokrótce wyjaśniłem problem widoczności w typach wyliczeniowych. Powróćmy jeszcze do tego zagadnienia i przeanalizujmy je nieco dokładniej: var myObject = { }; Object.defineProperty( myObject, "a", // Właściwość 'a' będzie uwzględniona w typach wyliczeniowych, jak zwykle. { enumerable: true, value: 2 } ); Zawartość obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 75 0 Object.defineProperty( myObject, "b", // Właściwość 'b' nie będzie uwzględniona w typach wyliczeniowych. { enumerable: false, value: 3 } ); myObject.b; // 3 ("b" in myObject); // Prawda. myObject.hasOwnProperty( "b" ); // Prawda. // …… for (var k in myObject) { console.log( k, myObject[k] ); } // "a" 2 Zwróć uwagę na pewien fakt: myObject.b istnieje i ma wartość, do której można uzyskać dostęp. Mimo wszystko wymieniona właściwość nie jest uwzględniana przez pętlę for-in (choć, co zaskakujące, jej istnienie jest ujawniane przez operator in). Widoczność w typach wyliczeniowych oznacza po prostu, że dana właściwość zostanie uwzględniona w przypadku iteracji przez właściwości obiektu. Pętle for-in zastosowane w tablicach mogą być źródłem nieoczekiwanych wyników, ponieważ widoczność tablicy w typach wyliczeniowych nie zawiera jedynie wszystkich liczbowych indeksów, ale również wszystkie właściwości, które mają przypisaną wartość true dla enumerable. Dlatego też dobrym rozwiązaniem będzie użycie pętli for-in tylko dla obiektów, natomiast do iteracji tablic korzystaj z pętli for wraz z iteracją indeksów liczbowych. Spójrz na jeszcze inny sposób, na jaki można rozróżnić właściwości widoczne i niewidoczne w typach wyliczeniowych: var myObject = { }; Object.defineProperty( myObject, "a", // Właściwość 'a' będzie uwzględniona w typach wyliczeniowych, jak zwykle. { enumerable: true, value: 2 } ); 76 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Object.defineProperty( myObject, "b", // Właściwość 'b' nie będzie uwzględniona w typach wyliczeniowych. { enumerable: false, value: 3 } ); myObject.propertyIsEnumerable( "a" ); // Prawda. myObject.propertyIsEnumerable( "b" ); // Fałsz. Object.keys( myObject ); // ["a"] Object.getOwnPropertyNames( myObject ); // ["a", "b"] Wywołanie propertyIsEnumerable(..) sprawdza, czy podana nazwa właściwości istnieje bezpośrednio w obiekcie oraz czy została ona zdefiniowana jako enumerable:true. Wartością zwrotną wywołania Object.keys(..) jest tablica wszystkich właściwości widocznych w typach wyliczeniowych, podczas gdy wywołanie Object.getOwnPropertyNames(..) zwraca tablicę wszystkich właściwości, niezależnie od tego, czy są widoczne w typach wyliczeniowych, czy nie. Podczas gdy różnica w działaniu in i hasOwnProperty(..) wiąże się ze sprawdzeniem (lub jego brakiem) łańcucha [[Prototype]], oba wywołania Object.keys(..) i Object.getOwnPropertyNames(..) sprawdzają jedynie bezpośrednio podany obiekt. Aktualnie nie ma żadnego wbudowanego w JavaScript sposobu na pobranie listy wszystkich właściwości, które będą odpowiednikiem zbioru sprawdzanego przez in (sprawdzenie wszystkich właściwości całego łańcucha [[Prototype]], jak to dokładnie wyjaśnię w rozdziale 5.). Działanie tego rodzaju narzędzia można zasymulować za pomocą rekurencyjnego poruszania się po łańcuchu [[Prototype]] obiektu, a następnie przechwytywania na każdym poziomie listy właściwości wygenerowanej przez wywołanie Object.keys(..), które zwraca jedynie właściwości widoczne w typach wyliczeniowych. Iteracja Pętla for-in przeprowadza iterację przez listę właściwości widocznych w typach wyliczeniowych w obiekcie (uwzględniając także jego łańcuch [[Prototype]]). Co zrobić w sytuacji, gdy chcesz jedynie przeprowadzić iterację przez wartości? Iteracja helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 77 0 W przypadku tablic zindeksowanych liczbowo iteracja przez wartości jest zwykle przeprowadzana za pomocą standardowej pętli for, na przykład: var myArray = [1, 2, 3]; for (var i = 0; i < myArray.length; i++) { console.log( myArray[i] ); } // 1 2 3 Powyższy kod nie przeprowadza iteracji przez wartości, a jedynie indeksy, gdy używany jest indeks w odwołaniu do wartości, jak myArray[i]. W specyfikacji ES5 dodano kilka funkcji pomocniczych do przeprowadzania iteracji przez tablice. Przykładami wspomnianych funkcji są forEach(..), every(..) i some(..). Każda z nich akceptuje funkcję wywołania zwrotnego stosowaną dla każdego elementu tablicy, a ich działanie różni się jedynie sposobem reakcji na wartość otrzymaną z wywołania zwrotnego. Funkcja forEach(..) przeprowadza iterację przez wszystkie wartości w tablicy i ignoruje wszelkie wartości zwrócone przez wywołania zwrotne. Z kolei funkcja every(..) działa aż do końca lub do chwili, gdy wartością zwróconą przez wywołanie zwrotne będzie false. Natomiast działanie some(..) jest kontynuowane aż do końca lub do chwili, gdy wartością zwróconą przez wywołanie zwrotne będzie true. Te specjalne wartości zwrotne wewnątrz wywołań every(..) i some(..) działają na zasadzie podobnej do polecenia break wewnątrz zwykłej pętli for, ponieważ powodują wcześniejsze zatrzymanie iteracji, zanim zostanie przeprowadzona do końca. Jeżeli przeprowadzasz iterację przez obiekt za pomocą pętli for-in, wartości pobierasz jedynie pośrednio, ponieważ tak naprawdę masz do czynienia z iteracją przez właściwości obiektu widoczne w typach wyliczeniowych. Dostęp do właściwości w celu pobrania ich wartości musi się odbyć ręcznie. W przeciwieństwie do iteracji przez liczbowe indeksy tablicy (na przykład w pętli for lub w inny sposób), kolejność iteracji przez właściwości obiektu nie może być zagwarantowana i może być różna w poszczególnych silnikach JavaScript. W trakcie wykonywania zadań wymagających zachowania spójności między różnymi środowiskami uruchomieniowymi nie opieraj się na zaobserwowanej kolejności, ponieważ jest ona bardzo niepewna i zmienna. 78 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Co można zrobić w sytuacji, gdy zachodzi potrzeba bezpośredniej iteracji przez wartości, a nie indeksy tablicy (lub właściwości obiektu)? Na szczęście w specyfikacji ES6 dodano składnię pętli for-of przeznaczonej do iteracji przez tablice (i obiekty, o ile obiekt definiuje własny niestandardowy iterator): var myArray = [ 1, 2, 3 ]; for (var v of myArray) { console.log( v ); } // 1 // 2 // 3 Pętla for-of prosi o podanie iteratora obiektu (z wewnętrznej domyślnej funkcji znanej jako @@iterator) iterowanego elementu, a następnie przeprowadza iterację przez kolejne wartości zwrotne uzyskane na skutek wywoływania metody next() obiektu, jednokrotnie dla każdej iteracji pętli. Tablice mają wbudowaną funkcję @@iterator, a więc pętla for-of bez problemów działa w pokazany sposób. Spróbujmy teraz przeprowadzić ręczną iterację przez tablicę za pomocą wbudowanej funkcji @@iterator, aby przekonać się, jak to działa: var myArray = [ 1, 2, 3 ]; var it = myArray[Symbol.iterator](); it.next(); it.next(); it.next(); it.next(); // { value:1, done:false } // { value:2, done:false } // { value:3, done:false } // { done:true } Wewnętrzną właściwość obiektu pobieramy w @@iterator za pomocą oferowanej przez ES6 składni Symbol: Symbol.iterator. Semantykę typu Symbol pokrótce przedstawiłem we wcześniejszej części rozdziału (patrz sekcja „Nazwy obliczanych właściwości”) — tutaj mamy dokładnie takie samo uzasadnienie jej użycia. Do właściwości specjalnych zawsze należy się odwoływać za pomocą nazwy odwołania Symbol zamiast przy użyciu wartości specjalnej, która może być przechowywana przez tę właściwość. Ponadto pomimo nazwy (@@iterator) nie jest to sam obiekt iteratora, ale funkcja zwracająca jego obiekt — to bardzo subtelny, ale jednocześnie ważny szczegół! Iteracja helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 79 0 Jak można zobaczyć w poprzednim fragmencie kodu, wartość zwrotna pochodząca z wywołania next() jest obiektem w postaci { value: .., done: .. }, gdzie value to wartość bieżącej iteracji, natomiast done to wartość boolowska wskazująca, czy istnieją jeszcze jakiekolwiek elementy do iteracji. Zauważ, że została zwrócona wartość 3 wraz z done:false, co na pierwszy rzut oka wydaje się dziwne. Konieczne jest wywołanie funkcji next() po raz czwarty (co pętla for-of w poprzednim fragmencie kodu robi automatycznie), aby otrzymać wynik done:true i tym samym faktycznie poinformować o zakończeniu iteracji. Powód takiego zachowania wykracza poza zakres tematyczny omawianego tutaj zagadnienia, ale mogę Ci powiedzieć, że wiąże się z semantyką funkcji generatorów w ES6. Kiedy tablica automatycznie przeprowadza iterację w pętli for-of, zwykłe obiekty nie mają wbudowanej funkcji @@iterator. Powody tego celowego pominięcia są znacznie bardziej skomplikowane i nie będziemy się nimi tutaj zajmować. Ogólnie rzecz biorąc, zdecydowanie lepiej jest nie dodawać żadnej implementacji, która mogłaby się okazać problematyczna w przyszłych typach obiektów. Istnieje możliwość zdefiniowania własnej funkcji @@iterator dla dowolnego obiektu, który ma być poddawany iteracji. Na przykład: var myObject = { a: 2, b: 3 }; Object.defineProperty( myObject, Symbol.iterator, { enumerable: false, writable: false, configurable: true, value: function() { var o = this; var idx = 0; var ks = Object.keys( o ); return { next: function() { return { value: o[ks[idx++]], done: (idx > ks.length) }; } }; } 80 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 } ); // Ręczna iteracja przez 'myObject'. var it = myObject[Symbol.iterator](); it.next(); // { value:2, done:false } it.next(); // { value:3, done:false } it.next(); // { value:undefined, done:true } // Iteracja przez 'myObject' za pomocą pętli 'for-of'. for (var v of myObject) { console.log( v ); } // 2 // 3 Użyliśmy wywołania Object.defineProperty(..) w celu zdefiniowania własnej funkcji @@iterator (moglibyśmy między innymi sprawić, by nie była ona widoczna w typach wyliczeniowych), ale wykorzystanie Symbol jako nazwy właściwości obliczonej (zostały omówione we wcześniejszej części rozdziału) pozwala na zadeklarowanie jej bezpośrednio, na przykład var myObject = { a:2, b:3, [Symbol.iterator]: function() { /* .. */ } }. Za każdym razem, gdy pętla for-of wywołuje funkcję next() iteratora obiektu myObject, wewnętrzny wskaźnik przesuwa się do przodu i zwraca kolejną wartość z listy właściwości obiektu (patrz przedstawiona nieco wcześniej uwaga dotycząca kolejności iteracji wartości lub właściwości obiektu). Przedstawiona powyżej iteracja to prosty przykład iteracji „wartość po wartości”, ale oczywiście można definiować dowolnie skomplikowane iteracje przeznaczone dla niestandardowych typów danych. Własne iteratory w połączeniu z wprowadzoną w specyfikacji ES6 pętlą for-of stanowią nowe narzędzie syntaktyczne o dużych możliwościach, przeznaczone do operacji na obiektach zdefiniowanych przez użytkownika. Na przykład lista obiektów Pixel (wraz z wartościami współrzędnych x i y) może ustalić kolejność iteracji na podstawie liniowej odległości od punktu początkowego (0,0) lub filtrować punkty, które są zbyt daleko itd. Jeśli iterator po zakończeniu iteracji zwraca z wywołań next() oczekiwaną wartość w postaci { value: .. } oraz { done: true }, oferowana przez ES6 pętla for-of będzie w stanie przeprowadzić iterację przez obiekty. Iteracja helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 81 0 W rzeczywistości można nawet zdefiniować iteratory działające w nieskończoność, które nigdy nie zakończą pracy i zawsze będą zwracały nową wartość (na przykład losowo wygenerowaną liczbę, inkrementowaną wartość, unikalny identyfikator itd.). Jednak prawdopodobnie nie będziesz używać tego rodzaju iteratorów bez ich powiązania z pętlą for-of, ponieważ w przeciwnym razie nigdy się nie skończą i zawieszą działanie programu. var randoms = { [Symbol.iterator]: function() { return { next: function() { return { value: Math.random() }; } }; } }; var randoms_pool = []; for (var n of randoms) { randoms_pool.push( n ); // Nie kontynuuj działania bez powiązania z pętlą for-of! if (randoms_pool.length === 100) break; } Ten iterator będzie w nieskończoność generować losowo wybrane liczby, a więc ostrożnie decydujemy się na pobranie jedynie stu wartości, aby nie zawiesić działania programu. Podsumowanie Obiekty w JavaScript mają dwie formy: literalną (na przykład var a = { .. }) i skonstruowaną (na przykład var a = new Array(..)). Preferowana jest zazwyczaj pierwsza z nich, choć forma obiektu skonstruowanego — w pewnych przypadkach — oferuje więcej możliwości podczas tworzenia obiektu. Wielu programistów błędnie twierdzi, że w JavaScript wszystko jest obiektem. Obiekty to jeden z sześciu (a nawet siedmiu, w zależności od perspektywy) typów prostych. Obiekty mają podtypy, między innymi function, a ponadto mogą być wyspecjalizowane w pewnych zadaniach, na przykład [object Array] to wewnętrzna etykieta przedstawiająca tablicę jako podtyp obiektu. 82 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Obiekty są kolekcjami par klucz-wartość. Do wartości, podobnie jak do właściwości, dostęp można uzyskać za pomocą składni .nazwaWłaściwości lub ["nazwaWłaściwości"]. W trakcie uzyskiwania dostępu do właściwości silnik w rzeczywistości wywołuje domyślną wewnętrzną operację [[Get]] (lub [[Put]] w przypadku ustawiania wartości), która nie tylko wyszukuje właściwość bezpośrednio w obiekcie, ale również sprawdza łańcuch [[Prototype]] (patrz rozdział 5.), jeśli właściwość nie zostanie znaleziona w obiekcie. Właściwości mają pewne cechy charakterystyczne, które mogą być kontrolowane za pomocą deskryptorów właściwości. Przykładem mogą być tutaj cechy writable i configurable. Ponadto obiekty mogą mieć zdefiniowaną modyfikowalność (dotyczy to także właściwości obiektu), kontrolowaną na różnych poziomach za pomocą wywołań Object.preventExtensions(..), Object.seal(..) i Object.freeze(..). Właściwości nie muszą zawierać wartości — mogą być również właściwościami akcesora, czyli getterami i setterami. Ponadto właściwość może być zarówno widoczna, jak i niewidoczna w typach wyliczeniowych, co decyduje o jej ewentualnym uwzględnieniu podczas iteracji przeprowadzanej na przykład za pomocą pętli for-in. Istnieje również możliwości iteracji wartości w strukturach danych (tablicach, obiektach itd.) za pomocą składni pętli for-of wprowadzonej w specyfikacji ES6. Wymieniona pętla szuka wbudowanej lub niestandardowej funkcji @@iterator obiektu zawierającej metodę next() przeznaczoną do posuwania się do przodu jednorazowo o jedną wartość danych. Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 83 0 84 Rozdział 3. Obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 4. Mieszanie obiektów „klas” Kontynuujemy rozpoczęty w poprzednim rozdziale temat obiektów. Oczywiście, teraz skierujemy naszą uwagę na programowanie zorientowane obiektowo (ang. object-oriented programming, OOP) z użyciem klas. Najpierw przyjrzymy się klasom jako wzorcom projektowym, a następnie przejdziemy do mechaniki klas: utworzenia egzemplarza, dziedziczenia i (względnego) polimorfizmu. Na podstawie materiału przedstawionego w rozdziale zobaczysz, że omówione koncepcje nie mają bezpośredniego przełożenia na mechanizm obiektowy w JavaScript, ale wielu programistów JavaScript podejmuje pewne wysiłki, na przykład domieszki (ang. mixin) itd., w celu pokonania problemów związanych ze wspomnianym mapowaniem. W tym rozdziale poświęcę całkiem sporą ilość miejsca (pierwsza połowa!) na przedstawienie teorii programowania zorientowanego obiektowo. Ostatecznie omówione idee powiążę z konkretnymi fragmentami kodu JavaScript w drugiej połowie rozdziału, w której zajmiemy się mechanizmem domieszek. Jednak na początku czeka Cię wiele koncepcji i pseudokodu, więc postaraj się nie zgubić i nadążać za mną! Teoria klas Klasa i dziedziczenie opisują pewną formę organizacji i architektury kodu — sposób modelowania rzeczywistych problemów w oprogramowaniu domen. W programowaniu obiektowym z danymi są nierozerwalnie powiązane funkcje przeprowadzające na nich operacje (oczywiście różne, w zależności od typu i natury samych danych!). Dlatego też poprawny projekt powinien pakować 85 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 (inaczej hermetyzować) dane wraz ze sposobem zachowania względem tych danych. W informatyce mówi się czasami o strukturze danych. Na przykład seria znaków przedstawiających słowo lub wyrażenie zwykle jest nazywana ciągiem tekstowym. Znaki są danymi. Jednak prawie nigdy nie przejmujesz się danymi, a najczęściej oczekujesz wykonania pewnych zadań względem danych. Oznacza to, że przygotowane sposoby zastosowania konkretnych operacji na danych (obliczenie długości ciągu tekstowego, dołączenie kolejnych znaków, wyszukanie znaków itd.) są opracowane w postaci metod klasy String. Każdy ciąg tekstowy jest po prostu egzemplarzem klasy, czyli elegancko przygotowanym pakietem zawierającym dane w postaci znaków oraz zachowanie w postaci funkcji, które mogą być wywołane względem tych danych. Klasy określają także sposób tak zwanej klasyfikacji określonych struktur danych. W tym celu daną strukturę traktujemy jako konkretny wariant znacznie ogólniejszej definicji bazowej. Przeanalizujmy teraz proces klasyfikacji na podstawie często stosowanego przykładu. I tak samochód (ang. car) można potraktować jako konkretną implementację znacznie ogólniejszej „klasy” rzeczy, nazwanej na przykład pojazd (ang. vehicle). W oprogramowaniu przedstawiony związek będziemy modelować poprzez zdefiniowanie dwóch klas o nazwach Vehicle i Car. Definicja klasy Vehicle może zawierać takie możliwości jak napęd (silnik itd.), przewóz osób itd., które są rodzajem zachowania. Funkcjonalności zdefiniowane w klasie Vehicle mają zastosowanie dla wszystkich (lub większości) różnego rodzaju typów pojazdów (samolot, pociąg, samochód itd.). W przypadku oprogramowania nieustanne definiowanie funkcjonalności „możliwość przewozu osób” dla poszczególnych typów pojazdów nie ma sensu. Zamiast tego definiujemy ją tylko jednokrotnie w klasie Vehicle, a następnie podczas definiowania klasy Car po prostu wskazujemy, że dziedziczy (inaczej rozszerza) ona po definicji bazowej zawartej w Vehicle. Mówimy więc, że Car to specjalizowana wersja ogólnej definicji Vehicle. Podczas gdy klasy Vehicle i Car wspólnie definiują zachowanie za pomocą metod, dane w egzemplarzu klasy będą przedstawiały na przykład unikatowy numer VIN konkretnego samochodu. 86 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Dlatego też w programowaniu pojawiły się klasy, dziedziczenie i inicjalizacja. Kolejną kluczową koncepcją w teorii klas jest polimorfizm. Oznacza on, że ogólne zachowanie zdefiniowane w klasie nadrzędnej może być nadpisane w klasie potomnej, aby jeszcze dokładniej wyrazić zadanie konieczne do wykonania. W rzeczywistości względny polimorfizm pozwala nam odwoływać się do zachowania bazowego z poziomu zachowania nadpisanego. Teoria klas silnie sugeruje, że klasy nadrzędna i potomna współdzielą tę samą nazwę metody dla określonego zachowania, a więc metoda w klasie potomnej nadpisuje tę w klasie nadrzędnej. Jak się wkrótce przekonasz, tego rodzaju podejście zastosowane w kodzie JavaScript może doprowadzić do frustracji i zawodnie działającego kodu. Wzorzec projektowy klasy Być może nigdy nie traktowałeś klasy jako wzorca projektowego, ponieważ w przypadku programowania obiektowego bardzo często omawiane są wzorce projektowe takie jak iterator, obserwator, fabryka, singleton itd. Przedstawiając zagadnienia w taki właśnie sposób, prawie zawsze przyjmuje się założenie, że klasy w programowaniu zorientowanym obiektowo to mechanika niskiego poziomu, za pomocą której implementujemy wszystkie (wysokiego poziomu) wzorce projektowe, ponieważ programowanie obiektowe stanowi podstawę dla całego (prawidłowego) kodu. Jeśli jesteś doświadczonym programistą, mogłeś się już spotkać z programowaniem proceduralnym jako sposobem opisania kodu składającego się z procedur (funkcji) wywołujących inne funkcje bez żadnej dodatkowej abstrakcji wysokiego poziomu. Być może dowiedziałeś się także, że klasy są prawidłowym sposobem przekształcenia proceduralnego stylu „kodu spaghetti” na doskonale uformowany i zorganizowany kod. Oczywiście jeśli masz doświadczenie w programowaniu funkcjonalnym (konstruktor Monad itd.), doskonale wiesz, że klasy to tylko jeden z najczęściej stosowanych wzorców projektowych. Jednak niektórzy być może po raz pierwszy spotykają się z pytaniem, czy klasy to naprawdę absolutna podstawa podczas tworzenia kodu. Czy istnieje jakakolwiek alternatywna abstrakcja na bazie kodu? Teoria klas helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 87 0 Niektóre języki programowania (na przykład Java) nie dają żadnego wyboru, a więc w ogóle nie mamy warstwy opcjonalnej — wszystko jest klasą. Z kolei w innych językach programowania, takich jak C/C++ i PHP, mamy składnię dla programowania zarówno proceduralnego, jak i zorientowanego obiektowo. Wybór odpowiedniego stylu lub decyzja o ich łączeniu należy do programistów. „Klasy” JavaScript Gdzie można umieścić JavaScript pod tym względem? Od całkiem długiego czasu w języku znajdziesz pewne elementy syntaktyczne przypominające klasy (na przykład new i instanceof), zaś w najnowszym wydaniu specyfikacji (ES6) pojawiły się pewne nowości, na przykład słowo kluczowe class (patrz dodatek A). Czy to w praktyce oznacza, że JavaScript naprawdę ma klasy? Odpowiem głośno i wyraźnie: NIE! Ponieważ klasy są wzorcem projektowym, przy pewnym wysiłku (jak się przekonasz w pozostałej części rozdziału) można zaimplementować funkcjonalność, która mniej więcej przypomina klasy. Za pomocą składni przypominającej klasy JavaScript próbuje spełnić niezwykle silnie wyrażane przez programistów żądanie możliwości projektowania z użyciem klas. Wprawdzie mamy składnię przypominającą klasy, ale korzystanie z niej oznacza tak naprawdę jedynie walkę z mechaniką JavaScript za pomocą wzorca projektowego klas, ponieważ w tle budowany przez Ciebie mechanizm działa zupełnie inaczej. Syntaktyczne sedno i (wyjątkowo często używane) biblioteki „klas” JavaScript wykonują ciężką pracę, ukrywając przed Tobą rzeczywistość. Jednak wcześniej czy później i tak odkryjesz, że klasy w innych językach programowania nie są takie same jak „klasy” w JavaScript. Wszystko sprowadza się do tego, że klasy są opcjonalnym wzorcem podczas projektowania oprogramowania. Masz więc wybór, czy chcesz ich używać w kodzie JavaScript, czy jednak nie. Ponieważ wielu programistów jest silnie przywiązanych do projektowania oprogramowania z użyciem podejścia zorientowanego obiektowo, pozostałą część rozdziału przeznaczam na omówienie sposobów obsługi oferowanej przez JavaScript iluzji klas oraz bolesnych kwestii, które na pewno napotkasz. 88 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Mechanika klas W wielu językach programowania obiektowego biblioteka standardowa dostarcza strukturę danych stosu (umieszczanie danych na stosie, usuwanie danych ze stosu itd.) w postaci klasy Stack. Wymieniona klasa zawiera wewnętrzny zbiór zmiennych przechowujących dane, a także zbiór publicznie dostępnych zachowań (metod) dostarczanych przez klasę. Dzięki metodom kod zyskuje możliwość interakcji z (ukrytymi) danymi (na przykład dodawania danych czy ich usuwania). Jednak w tego rodzaju językach programowania tak naprawdę nie operujemy bezpośrednio na klasie Stack (o ile nie skorzystamy z odwołania do statycznego elementu składowego, co wykracza poza zakres tematyczny omawianego tutaj zagadnienia). Klasa Stack stanowi zaledwie abstrakcyjne wyjaśnienie tego, co dowolny stos powinien robić, ale tak naprawdę sama w sobie nie jest stosem. Konieczne jest utworzenie egzemplarza klasy Stack, aby otrzymać element konkretnej struktury danych, względem którego będzie można przeprowadzać operacje. Tworzenie Przy wyjaśnianiu znaczenia klas i egzemplarzy często wykorzystuje się metaforę powstawania budynku. Architekt opracowuje wszystkie cechy charakterystyczne budynku, wskazując jego szerokość i wysokość, liczbę okien i ich położenie, a nawet rodzaj materiału używanego do budowy ścian i dachu. W tym momencie architekt niekoniecznie przejmuje się miejscem postawienia budynku czy liczbą budynków, które zostaną zbudowane na podstawie danego projektu. Ponadto architekt nie zajmuje się zawartością projektowanego budynku — meblami, malowaniem ścian, innym wyposażeniem itd. — a jedynie typem struktury budynku. Architektoniczna matryca to zaledwie plan budynku. Ten plan tak naprawdę nie oznacza budynku, do którego można wejść. Do rzeczywistego postawienia budynku potrzebny jest wykonawca — budowniczy. Wykonawca bierze plany i, dokładnie się do nich stosując, buduje zdefiniowany budynek. Można powiedzieć, że przy budowaniu kopiuje się cechy charakterystyczne z planu. Mechanika klas helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 89 0 Po zakończeniu budowy budynek stanowi fizyczną wersję planu — mamy nadzieję, że w praktyce będzie to perfekcyjna kopia. Następnie wykonawca może przejść do kolejnej pustej lokalizacji i ponownie wykonać wszystkie czynności, tym samym tworząc kolejną kopię. Związek między budynkiem i planem jest pośredni. Plan można przeanalizować w celu dokładnego zrozumienia struktury budynku, ponieważ bezpośrednie oględziny budynku mogą się okazać niewystarczające. Po otworzeniu drzwi można wejść do środka budynku — plan zawiera tylko narysowane na kartce papieru linie pokazujące miejsce, w którym powinny się znaleźć drzwi. Klasa jest matrycą. W celu otrzymania rzeczywistego obiektu, z którym następnie można prowadzić interakcje, konieczne jest zbudowanie (inaczej zainicjalizowanie) czegoś na podstawie klasy. Efektem końcowym tego rodzaju „konstrukcji” jest obiekt, zwykle nazywany egzemplarzem. W przygotowanym egzemplarzu można bezpośrednio wywoływać metody oraz uzyskiwać dostęp do wszystkich publicznych danych i właściwości, jeśli zachodzi taka potrzeba. Tego rodzaju obiekt stanowi kopię wszystkich cech charakterystycznych dostarczanych przez klasę. Prawdopodobnie nie oczekujesz, że po wejściu do budynku znajdziesz na ścianie oprawiony w ramkę plan, na podstawie którego powstał dany budynek. Wspomniany plan zwykle znajduje się w biurze, w dokumentacji dotyczącej danego budynku. Podobnie jest z programowaniem. Ogólnie rzecz biorąc, obiekt egzemplarza nie jest używany w celu bezpośredniego dostępu do jego klasy i operowania na niej, ale najczęściej możliwe jest przynajmniej określenie, na podstawie której klasy powstał dany egzemplarz. Rozważmy teraz, jaki jest bezpośredni związek klasy z egzemplarzem obiektu (pomijając pośredni związek między obiektem i klasą), na podstawie której został utworzony. Na rysunku 4.1 pokazałem, że egzemplarz obiektu klasy powstaje na skutek operacji kopiowania. Jak możesz zobaczyć, strzałki są skierowane od lewej do prawej strony oraz od góry do dołu. To pokazuje zachodzącą operację kopiowania — zarówno pod względem koncepcji, jak i fizycznie. 90 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Rysunek 4.1. Schemat dziedziczenia obiektów Konstruktor Egzemplarze klas są konstruowane za pomocą metody specjalnej klasy, zwykle o takiej samej nazwie jak klasa. Tę metodę określamy mianem konstruktora. Zadaniem konstruktora jest zainicjalizowanie wszystkich informacji (stanu) wymaganych przez egzemplarz. Spójrz na przykład na poniższy pseudokod klasy: class CoolGuy { specialTrick = nothing CoolGuy( trick ) { specialTrick = trick } showOff() { output( "Oto moja sztuczka: ", specialTrick ) } } W celu utworzenia egzemplarza CoolGuy konieczne jest wywołanie konstruktora klasy: Janek = new CoolGuy( "skakanka" ) Janek.showOff() // Oto moja sztuczka: skakanka Mechanika klas helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 91 0 Zwróć uwagę na to, że klasa CoolGuy ma konstruktor o nazwie CoolGuy(), który jest wywoływany w instrukcji new CoolGuy(..). W wyniku wywołania konstruktora otrzymujemy obiekt (egzemplarz klasy), a następnie możemy już wywołać metodę showOff(), która wyświetli informacje dotyczące sztuczki wykonywanej przez dany egzemplarz CoolGuy. Konstruktor klasy należy do klasy i prawie zawsze ma taką samą nazwę jak ona. Ponadto konstruktor niemal zawsze będzie wywoływany po użyciu słowa kluczowego new, które informuje silnik języka, że chcesz utworzyć nowy (ang. new) egzemplarz klasy. Dziedziczenie klasy W językach programowania zorientowanego obiektowo można nie tylko zdefiniować klasę przeznaczoną do samodzielnego zainicjowania, ale również definiować inne klasy dziedziczące po tej pierwszej. Pierwszą klasę najczęściej określa się mianem klasy nadrzędnej, podczas gdy drugą — klasą potomną. Te wyrażenia oczywiście są metaforą rodzica i dziecka, choć jest ona nieco naciągana, o czym się wkrótce przekonasz. Kiedy rodzic ma biologiczne dziecko, genetyczne cechy rodzica są przenoszone na dziecko. Oczywiście w większości biologicznych systemów reprodukcji mamy dwoje rodziców, którzy w równym stopniu dostarczają geny. Jednak na potrzeby przytoczonej tutaj metafory przyjmujemy założenie, że istnieje tylko jeden rodzic. Po pojawieniu się dziecka jest ono odrębną jednostką. Wprawdzie dziecko pozostaje pod silnym wpływem rodzica na skutek dziedziczenia po nim, ale jest unikatowe i niezależne. Jeżeli dziecko będzie miało rude włosy, to nie oznacza, że rodzic miał lub będzie miał rude włosy. Podobna sytuacja zachodzi w programowaniu. Po zdefiniowaniu klasy potomnej pozostaje ona odrębna od jej klasy nadrzędnej. Klasa potomna zawiera początkową kopię zachowania z klasy nadrzędnej, ale wszelkie odziedziczone zachowanie może zostać nadpisane, a nawet można tworzyć nowe. Trzeba koniecznie pamiętać, że mówimy tutaj o klasach nadrzędnej i potomnej, które nie są fizycznymi przedmiotami. W tym miejscu metafora rodzica i dziecka może być nieco myląca, ponieważ powinniśmy powiedzieć, że klasa 92 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 nadrzędna jest jak DNA rodzica, natomiast klasa potomna — jak DNA dziecka. Na podstawie DNA rodziców w wyniku różnych procesów biochemicznych powstaje nowy człowiek, z którym będzie można na przykład porozmawiać. Odłóżmy na bok biologicznych rodziców oraz dzieci i posłużmy się innym typowym przykładem: różnych rodzajów pojazdów. Powracamy do rozpoczętej we wcześniejszej części rozdziału analizy dotyczącej klas Vehicle i Car. Spójrz na przedstawiony poniżej pseudokod dla dziedziczonych klas: class Vehicle { engines = 1 ignition() { output( "Włączenie silnika." ); } drive() { ignition(); output( "Sterowanie i poruszanie się do przodu!" ) } } class Car inherits Vehicle { wheels = 4 drive() { inherited:drive() output( "Toczenie się na wszystkich ", wheels, " kołach!" ) } } class SpeedBoat inherits Vehicle { engines = 2 ignition() { output( "Włączenie ", engines, " silników." ) } pilot() { inherited:drive() output( "Poruszanie się z łatwością po wodzie!" ) } } Dziedziczenie klasy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 93 0 W celu zachowania jasności i zwięzłości w powyższym fragmencie kodu pominąłem konstruktory klas. Zdefiniowaliśmy klasę Vehicle, aby dostarczyć silnik, możliwość włączenia zapłonu i rozpoczęcia jazdy. Jednak nigdy nie będziesz tworzyć tylko ogólnego pojazdu — na tym etapie to jedynie abstrakcyjna koncepcja. Dlatego też zdefiniowaliśmy dwa konkretne rodzaje pojazdów: Car i SpeedBoat. Oba wymienione pojazdy dziedziczą ogólne cechy charakterystyczne po Vehicle, ale jednocześnie mają kolejne cechy charakterystyczne dla przedstawianego typu pojazdu. Samochód potrzebuje czterech kół, natomiast szybka łódź dwóch silników, co oznacza konieczność zachowania większej uwagi i pamiętania o włączeniu obu silników. Polimorfizm Klasa Car definiuje własną metodę drive() nadpisującą metodę o tej samej nazwie odziedziczoną po klasie Vehicle. Jednak nawet wtedy metoda drive() klasy Car wywołuje inherited:drive(), co wskazuje, że klasa Car posiada odwołanie do pierwotnej, nienadpisanej i dziedziczonej metody drive() . Metoda pilot() klasy SpeedBoat również zawiera odwołanie do dziedziczonej metody drive(). Ta technika jest nazywana polimorfizmem lub wirtualnym polimorfizmem. Biorąc pod uwagę omawiane tutaj zagadnienia, tę technikę będziemy nazywać względnym polimorfizmem. Polimorfizm to znacznie obszerniejszy temat, niż tutaj przedstawiłem, ale słowo „względny” w tym przypadku oznacza odwołanie się do jednego konkretnego aspektu: możliwości odwołania się z dowolnej metody do innej (o tej samej lub innej nazwie) znajdującej się na wyższym poziomie hierarchii dziedziczenia. Wcześniej użyłem słowa „względna”, ponieważ absolutnie nie definiujemy, na którym poziomie dziedziczenia (inaczej klasy) chcemy uzyskać dostęp. Zamiast tego odwołanie względne oznacza: „szukaj jeden poziom wyżej”. W wielu językach programowania zamiast słowa kluczowego inherited pojawiającego się w powyższym kodzie używane jest słowo kluczowe super, oznaczające superklasę, czyli klasę nadrzędną w stosunku do bieżącej. 94 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Kolejny aspekt polimorfizmu wiąże się z tym, że nazwa metody może mieć wiele definicji na różnych poziomach hierarchii dziedziczenia. Wspomniane definicje są automatycznie odpowiednio wybierane podczas określania metody, która powinna zostać wywołana. W poprzednim przykładzie miałeś okazję zobaczyć dwa wystąpienia omawianego zachowania: metoda drive() jest zdefiniowana w klasach Vehicle i Car, natomiast ignition() w Vehicle i SpeedBoat. W tradycyjnych językach programowania obiektowego za pomocą słowa kluczowego super konstruktor klasy potomnej otrzymuje bezpośrednie odwołanie do konstruktora klasy nadrzędnej. Tak faktycznie jest, ponieważ w prawdziwych klasach konstruktor należy do danej klasy. Jednak w JavaScript mamy odwrotną sytuację — „klasa” należy do konstruktora (odwołania typu Foo.prototype...). Ponieważ w JavaScript związek między elementem potomnym i nadrzędnym istnieje tylko między dwoma obiektami .prototype odpowiednich konstruktorów, same konstruktory nie są bezpośrednio powiązane. Dlatego też nie ma prostego sposobu na zapewnienie względnego odwołania między konstruktorami (zajrzyj do dodatku A, gdzie znajdziesz informacje o wprowadzonym w specyfikacji ES6 słowie kluczowym class, które rozwiązuje problem z super). Interesującą implikację związaną z polimorfizmem można wyraźnie dostrzec w przypadku funkcji ignition(). Wewnątrz metody pilot() tworzymy odwołanie względnego polimorfizmu do (odziedziczonej) po klasie Vehicle wersji metody drive(). Jednak w metodzie drive() do ignition() odwołujemy się po prostu za pomocą nazwy (brak odwołania względnego). Która wersja metody ignition() zostanie użyta przez silnik JavaScript? Czy będzie to wersja pochodząca z klasy Vehicle, czy raczej ze SpeedBoat? Silnik JavaScript skorzysta z metody ignition() zdefiniowanej w klasie SpeedBoat. Jeżeli utworzymy egzemplarz klasy Vehicle, a następnie wywołamy jej metodę drive(), silnik języka JavaScript wykorzysta metodę ignition() zdefiniowaną w klasie Vehicle. Ujmując rzecz jeszcze inaczej, definicja metody ignition() stosuje polimorfizm (zmienia się) w zależności od klasy (poziomu dziedziczenia), do której następuje odwołanie egzemplarza. Dziedziczenie klasy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 95 0 To może się wydawać szczegółem czysto akademickim, jednak jego zrozumienie jest niezbędne, aby zauważyć podobne (choć inne) zachowanie mechanizmu [[Prototype]] w JavaScript. Kiedy klasy są dziedziczone, to właśnie one (a nie egzemplarze utworzone na ich podstawie!) zachowują możliwość względnego odwołania się do klasy nadrzędnej. Takie względne odwołanie jest zwykle nazywane super. Czy pamiętasz wykres przedstawiony we wcześniejszej części rozdziału (rysunek 4.2)? Rysunek 4.2. Schemat dziedziczenia obiektów Zwróć uwagę na utworzone egzemplarze (a1, a2, b1 i b2) oraz dziedziczenie (Bar). Strzałki wskazują na operację kopiowania. Pod względem koncepcyjnym wydaje się, że klasa potomna Bar może uzyskać dostęp do metody w klasie nadrzędnej Foo za pomocą odwołania względnego polimorfizmu (czyli poprzez super). Jednak w rzeczywistości klasa potomna otrzymuje jedynie kopię zachowania odziedziczonego po klasie nadrzędnej. Jeżeli klasa potomna nadpisze dziedziczoną metodę, wówczas zarówno pierwotna, jak i nadpisana wersja metody będą faktycznie obsługiwane, a więc obie pozostaną dostępne. Nie pozwól, aby polimorfizm skłonił Cię do przyjęcia założenia, że klasa potomna jest połączona z jej klasą nadrzędną. Klasa potomna otrzymuje z klasy nadrzędnej kopię tego, co jest jej niezbędne. Dziedziczenie klas oznacza kopiowanie. 96 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Dziedziczenie wielokrotne Czy przypominasz sobie nasze wcześniejsze rozważania dotyczące DNA rodzica i dziecka? Stwierdziłem wówczas, że wymieniona metafora jest nieco naciągana, ponieważ biologicznie dziecko ma zwykle dwoje rodziców. Jeżeli klasa mogłaby dziedziczyć po dwóch innych klasach, wówczas tego rodzaju sytuacja byłaby bliższa metaforze rodziców i dzieci. W pewnych językach programowania obiektowego można wskazać więcej niż tylko jedną klasę nadrzędną, po której będzie dziedziczyła klasa potomna. Wielokrotne dziedziczenie oznacza, że definicje wszystkich klas nadrzędnych są kopiowane do klasy potomnej. Na pierwszy rzut oka wydaje się, że to wspaniałe udogodnienie oferujące możliwość łączenia w klasie wielu różnych funkcjonalności. Jednak z tego rodzaju rozwiązaniem wiążą się pewne komplikacje. Jeżeli obie klasy nadrzędne zawierają metodę o nazwie drive(), do której wersji metody będzie się odwoływać klasa potomna? Czy zawsze trzeba będzie ręcznie podawać, którą metodę drive() mamy na myśli, a tym samym utracimy użyteczność dziedziczenia polimorficznego? Istnieje jeszcze inny wariant przedstawionego problemu, który nosi nazwę problemu diamentu. Odwołuje się on do sytuacji, gdy klasa potomna D dziedziczy po dwóch klasach nadrzędnych (B i C), z których każda dziedziczy po klasie nadrzędnej A. Jeżeli klasa A zawiera metodę drive(), a obie klasy B i C ją nadpisują (polimorfizm), to do której wersji metody drive() — B:drive() czy C:drive() — odwołuje się klasa D (patrz rysunek 4.3)? Rysunek 4.3. Graficzne przedstawienie problemu diamentu Dziedziczenie klasy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 97 0 Problemy mogą być jeszcze poważniejsze, niż tutaj przedstawiłem. W tym miejscu jedynie o nich wspominam, aby podkreślić kontrast ze sposobem działania mechanizmów JavaScript. Język JavaScript jest prostszy: nie oferuje natywnego mechanizmu przeznaczonego dla dziedziczenia wielokrotnego. Wielu programistów uznaje to za zaletę, ponieważ uniknięcie komplikacji jest cenniejsze niż „zredukowana” funkcjonalność. To jednak nie wstrzymuje innych programistów przed próbami symulowania dziedziczenia wielokrotnego na wiele sposobów, o czym przekonasz się w kolejnym podrozdziale. Domieszki Mechanizm obiektowy w JavaScript nie przeprowadza automatycznego kopiowania zachowania podczas dziedziczenia lub tworzenia egzemplarzy klas. Ujmując rzecz najprościej, w języku JavaScript nie ma klas, a jedynie obiekty. Z kolei obiekt nie jest kopiowany do innych, ponieważ obiekty są jedynie łączone ze sobą (więcej informacji na ten temat znajdziesz w rozdziale 5.). Skoro zachowanie zaobserwowane w innych językach programowania wskazuje na kopiowanie, spróbujmy teraz przeanalizować, jak programiści starają się zasymulować brakujące im zachowanie kopiowania klas w JavaScript, używając do tego domieszek. Wyróżniamy dwa rodzaje domieszek: jawne i niejawne. Jawne domieszki Powróćmy jeszcze na chwilę do wcześniejszego przykładu klas Vehicle i Car. Ponieważ JavaScript nie będzie automatycznie kopiować zachowania z Vehicle do Car, możemy samodzielnie utworzyć przeznaczoną do tego funkcję pomocniczą. Tego rodzaju funkcja znajduje się w wielu bibliotekach oraz frameworkach i bardzo często jest nazywana extend(), ale na potrzeby omawianego zagadnienia nadamy jej nazwę mixin(..): // Niezwykle uproszczony przykład domieszki mixin(..): function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { // Kopiowanie klucza tylko wtedy, gdy nie istnieje w obiekcie docelowym. if (!(key in targetObj)) { targetObj[key] = sourceObj[key]; } 98 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 } return targetObj; } var Vehicle = { engines: 1, ignition: function() { console.log( "Włączenie silnika." ); }, drive: function() { this.ignition(); console.log( "Sterowanie i poruszanie się do przodu!" ); } }; var Car = mixin( Vehicle, { wheels: 4, drive: function() { Vehicle.drive.call( this ); console.log( "Toczenie się na wszystkich " + this.wheels + " kołach!" ); } } ); Subtelny, ale jednocześnie ważny szczegół: nie zajmujemy się dłużej klasami, ponieważ w języku JavaScript nie ma klas. Vehicle i Car to po prostu obiekty; pierwszy jest obiektem źródłowym, a drugi utworzoną na jego podstawie kopią. W tym momencie Car ma kopię właściwości i funkcji zdefiniowanych w Vehicle. Pod względem technicznym funkcje tak naprawdę nie są powielane, a zamiast tego następuje skopiowanie odwołań do funkcji. Dlatego też Car ma właściwość o nazwie ignition, będącą skopiowanym odwołaniem do funkcji ignition(), a także właściwość engines wraz ze skopiowaną z Vehicle wartością 1. Obiekt Car miał już właściwość drive (funkcja), a więc odwołanie do właściwości nie zostało nadpisane (spójrz na polecenie if w przedstawionej wcześniej funkcji mixin(..)). Domieszki helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 99 0 Powracamy do polimorfizmu Przeanalizujmy następujące polecenie Vehicle.drive.call( this ). Mamy tutaj do czynienia z czymś, co nazywam wyraźnym pseudopolimorfizmem. Przypomnij sobie wcześniej przedstawiony pseudokod zawierający wiersz inherited:drive(), który nazwałem względnym polimorfizmem. JavaScript nie ma (w specyfikacji wcześniejszej niż ES6, patrz dodatek A) możliwości stosowania względnego polimorfizmu. Ponieważ obiekty Car i Vehicle mają funkcję o takiej samej nazwie, drive(), w celu ich rozróżnienia konieczne jest użycie odwołania absolutnego (nie względnego). Podajemy więc najpierw nazwę obiektu (Vehicle), a następnie jego funkcji (drive()). Jednak w przypadku wywołania Vehicle.drive() wiązaniem this dla podanej funkcji będzie obiekt Vehicle, a nie Car (patrz rozdział 2.), co jest niezgodne z naszymi oczekiwaniami. Dlatego też należy użyć .call( this ) — patrz rozdział 2. — aby zagwarantować, że funkcja drive() zostanie wywołana w kontekście obiektu Car. Jeżeli identyfikator funkcji dla Car.drive() nie nałoży się na (inaczej „przesłoni”, patrz rozdział 5.) Vehicle.drive(), nie mamy do czynienia z metodą polimorfizmu. Dlatego też odwołanie do Vehicle.drive() zostanie skopiowane przez wywołanie mixin(..), dzięki czemu będziemy mieć bezpośredni dostęp do funkcji za pomocą this.drive(). Przesłanianie nakładającego się wybranego identyfikatora to powód, dla którego konieczne jest stosowanie znacznie bardziej skomplikowanego podejścia pseudopolimorfizmu. W językach programowania obiektowego oferujących względny polimorfizm połączenie między obiektami Car i Vehicle jest tworzone tylko jednokrotnie, na początku definicji klasy. W ten sposób mamy tylko jedno miejsce, w którym zajmujemy się obsługą związków między obiektami. Jednak z powodu dziwactw języka JavaScript wyraźny pseudopolimorfizm (z powodu przesłaniania!) powoduje powstanie zawodnego ręcznego i wyraźnego połączenia w każdej funkcji wymagającej tego rodzaju pseudo(polimorfizmu). To oczywiście wiąże się ze zdecydowanie wyższym kosztem obsługi kodu. Co więcej, wprawdzie wyraźny pseudopolimorfizm może symulować zachowanie dziedziczenia wielokrotnego, to jednak z tego względu tylko niepotrzebnie zwiększa stopień skomplikowania i zawodności rozwiązania. 100 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Wynikiem zastosowania powyższego podejścia jest zwykle znacznie bardziej skomplikowany i trudniejszy w odczycie oraz w konserwacji kod. Gdy tylko istnieje możliwość, należy unikać wyraźnego pseudopolimorfizmu, ponieważ jego koszt w większości aspektów przekracza spodziewane korzyści. Domieszka kopiująca Spójrz na przedstawiony wcześniej kod funkcji pomocniczej mixin(..): // Niezwykle uproszczony przykład domieszki mixin(..): function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { // Kopiowanie klucza tylko wtedy, gdy nie istnieje w obiekcie docelowym. if (!(key in targetObj)) { targetObj[key] = sourceObj[key]; } } return targetObj; } Przeanalizujmy teraz sposób działania funkcji mixin(..). Jej zadaniem jest przeprowadzenie iteracji przez obiekt źródłowy (sourceObj, tutaj Vehicle). Jeżeli w obiekcie docelowym (targetObj, tutaj Car) nie zostanie znaleziona dopasowana właściwość, funkcja tworzy jej kopię. Ponieważ tworzenie kopii następuje już po zainicjalizowaniu obiektu, trzeba zachować ostrożność, aby nie nadpisać właściwości istniejącej w obiekcie docelowym. Jeżeli kopia zostanie utworzona na początku, jeszcze przed podaniem zawartości przeznaczonej dla obiektu Car, można zrezygnować z kroku sprawdzenia obiektu docelowego targetObj. Jednak takie rozwiązanie jest tylko nieco mniej niezręczne i mniej efektywne, a więc nie powinno się go stosować: // Alternatywna wersja funkcji, mniej bezpieczna pod względem nadpisywania właściwości. function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { targetObj[key] = sourceObj[key]; } return targetObj; } var Vehicle = { // … Domieszki helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 101 0 }; // Zaczynamy od utworzenia pustego obiektu // wraz ze skopiowanymi elementami obiektu Vehicle. var Car = mixin( Vehicle, { } ); // Teraz do obiektu Car kopiujemy niezbędną zawartość. mixin( { wheels: 4, drive: function() { // … } }, Car ); Niezależnie od przyjętego podejścia do obiektu Car zostaje skopiowana nienadkładająca się zawartość obiektu Vehicle. Nazwa „domieszka” bierze się z alternatywnego sposobu wyjaśnienia zadania: zawartość obiektu Car zostaje zmieszana z zawartością obiektu Vehicle, podobnie jak czekoladę możesz zmieszać z ulubionym deserem. Wynikiem operacji kopiowania jest to, że obiekt Car działa zupełnie oddzielnie od obiektu Vehicle. Po dodaniu właściwości do obiektu Car wprowadzona zmiana nie będzie miała żadnego wpływu na obiekt Vehicle i na odwrót. Pominąłem tutaj kilka mniej ważnych szczegółów. Nadal istnieją pewne subtelne sposoby, na jakie dwa obiekty mogą na siebie wpływać po operacji kopiowania. Przykładem może być współdzielenie odwołania do innego obiektu, takiego jak tablica. Ponieważ dwa obiekty współdzielą również odwołania do funkcji, nawet ręczne skopiowanie funkcji (domieszek) między obiektami tak naprawdę nie zapewni rzeczywistego powielenia, z jakim mamy do czynienia w językach programowania obiektowego. Funkcje JavaScript w rzeczywistości nie mogą być powielane (w standardowy, niezawodny sposób), więc efektem będzie powielone odwołanie do tego samego, współdzielonego obiektu funkcji (obiekty są funkcjami, patrz rozdział 3.). Jeżeli zmodyfikujesz jeden z obiektów funkcji współdzielonych (takich jak ignition()) przez na przykład dodanie właściwości, wówczas przez współdzielone odwołanie wprowadzona zmiana będzie miała wpływ na oba obiekty Vehicle i Car. 102 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Jawne domieszki to dobry mechanizm w języku JavaScript. Jednak jego możliwości są znacznie mniejsze, niż się wydaje. Z rzeczywistego kopiowania właściwości między poszczególnymi obiektami wynika niewiele korzyści, w przeciwieństwie do dwukrotnego zdefiniowania właściwości, po jednym w każdym obiekcie. Brak wyraźnych korzyści w szczególności dotyczy odwołania do obiektu-funkcji, jak wcześniej o tym wspomniałem. Po zmieszaniu dwóch lub większej liczby obiektów w obiekcie docelowym można przynajmniej częściowo emulować dziedziczenie wielokrotne. Nie ma jednak bezpośredniego sposobu obsługi kolizji, gdy dwie właściwości lub metody o tej samej nazwie są kopiowane z więcej niż tylko jednego źródła. Część programistów (co można zaobserwować w niektórych bibliotekach) stosuje techniki późnego wiązania, a także inne niestandardowe rozwiązania. Warto pamiętać, że tego rodzaju sztuczki wymagają zwykle włożenia większego wysiłku (i pogodzenia się z mniejszą wydajnością) niż spodziewane korzyści. Skoncentruj się na użyciu jedynie jawnych domieszek — i to w sytuacji, gdy ich zastosowanie może poprawić czytelność kodu. Staraj się unikać tego wzorca, jeśli okaże się, że utworzony kod stał się trudniejszy do monitorowania lub też powoduje powstanie wielu niepotrzebnych zależności między obiektami. Jeżeli prawidłowe użycie domieszek okaże się trudniejsze, niż sądziłeś, prawdopodobnie powinieneś zrezygnować z ich stosowania. Tak naprawdę gdy korzystasz ze skomplikowanej biblioteki lub narzędzia pomocniczego w celu poradzenia sobie ze wszystkimi przedstawionymi powyżej szczegółami, to może być znak, że prawdopodobnie niepotrzebnie wybrałeś trudniejszą drogę. W rozdziale 6. spróbuję przedstawić prostszy sposób pozwalający na osiągnięcie żądanych efektów przy znacznie mniejszym zamieszaniu. Dziedziczenie pasożytnicze Wariant powyższego wzorca jawnej domieszki, który jest pod pewnymi względami jawny, a pod innymi niejawny, nosi nazwę dziedziczenia pasożytniczego i został spopularyzowany przez Douglasa Crockforda. Oto sposób jego działania. // Tradycyjna klasa JavaScript o nazwie Vehicle. function Vehicle() { this.engines = 1; } Vehicle.prototype.ignition = function() { console.log( "Włączenie silnika." ); Domieszki helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 103 0 }; Vehicle.prototype.drive = function() { this.ignition(); console.log( "Sterowanie i poruszanie się do przodu!" ); }; // Klasa pasożytnicza o nazwie Car. function Car() { // Przede wszystkim 'car' to egzemplarz 'Vehicle'. var car = new Vehicle(); // Teraz modyfikujemy obiekt 'car', aby nadać mu żądane cechy charakterystyczne. car.wheels = 4; // Zachowanie uprzywilejowanego odwołania do Vehicle::drive(). var vehDrive = car.drive; // Nadpisanie Vehicle::drive(). car.drive = function() { vehDrive.call( this ); console.log( "Toczenie się na wszystkich " + this.wheels + " kołach!" ); return car; } var myCar = new Car(); myCar.drive(); // Włączenie silnika. // Sterowanie i poruszanie się do przodu! // Toczenie się na wszystkich 4 kołach! Jak możesz zobaczyć, na początku tworzymy kopię definicji z klasy nadrzędnej (obiektu) Vehicle. Następnie mieszamy ją z definicją klasy potomnej (obiektu), przy czym zachowujemy uprzywilejowane odwołanie do klasy nadrzędnej. Tak przygotowany obiekt car jest przekazywany jako egzemplarz potomny. Podczas wywołania new Car() następuje utworzenie nowego obiektu, do którego można się odwoływać za pomocą słowa kluczowego this (patrz rozdział 2.). Ponieważ nie będziemy używać tego obiektu, a zamiast tego zwracamy własny obiekt car, początkowo utworzony obiekt jest po prostu odrzucany. Dlatego też wywołanie Car() powinno się odbywać bez słowa kluczowego new. Funkcjonalność obu rozwiązań pozostaje identyczna, ale w drugim przypadku nie tworzymy niepotrzebnie obiektu, który następnie jest odrzucany i usuwany przez mechanizm usuwania nieużytków. 104 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Niejawne domieszki Niejawne domieszki są ściśle powiązane z niejawnym polimorfizmem, co już wcześniej wyjaśniłem. Dlatego też w ich przypadku zastosowanie mają te same uwagi i ostrzeżenia. Spójrz na poniższy fragment kodu: var Something = { cool: function() { this.greeting = "Witaj, świecie!"; this.count = this.count ? this.count + 1 : 1; } }; Something.cool(); Something.greeting; // "Witaj, świecie!" Something.count; // 1 var Another = { cool: function() { // Niejawna domieszka powodująca zmieszanie 'Something' z 'Another'. Something.cool.call( this ); } }; Another.cool(); Another.greeting; // "Witaj, świecie!" Another.count; // 1 (stan nie jest współdzielony z 'Something'). W przypadku wywołania Something.cool.call( this ), które będzie zachodziło w wywołaniu konstruktora (w większości przypadków) lub w wywołaniu metody (jak w przedstawionym powyżej kodzie), praktycznie „pożyczamy” funkcję Something.cool() i wywołujemy ją w kontekście Another (za pomocą jej wiązania this, patrz rozdział 2.) zamiast Something. W efekcie przypisanie tworzone przez Something.cool() będzie zastosowane do obiektu Another, a nie Something. Można więc powiedzieć o zmieszaniu funkcji obiektu Something z pochodzącymi z obiektu Another. Wprawdzie przedstawiona technika wydaje się użyteczną funkcjonalnością ponownego wiązania this, jednak wywołanie Something.cool.call( this ) jest zawodne, gdyż nie może być wykonane w odwołaniu względnym (a tym samym bardziej elastycznym), należy więc zachować ostrożność. Ogólnie rzecz biorąc, jeśli jest to możliwe, najlepiej unikać tego rodzaju konstrukcji i zamiast nich zachować przejrzysty oraz łatwy do późniejszej konserwacji kod. Domieszki helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 105 0 Podsumowanie Klasa jest wzorcem projektowym. Wiele języków programowania zawiera składnię pozwalającą w naturalny sposób tworzyć oprogramowanie z użyciem podejścia zorientowanego obiektowo. JavaScript ma podobną składnię, ale jego zachowanie znacznie odbiega od tego, do którego przywykłeś, pracując z klasami w innych językach programowania. Klasa oznacza kopiowanie. Podczas tworzenia tradycyjnej klasy mamy do czynienia ze skopiowaniem zachowania z klasy do nowego egzemplarza. Z kolei w trakcie dziedziczenia również następuje skopiowanie zachowania z obiektu nadrzędnego do potomnego. Polimorfizm (istnienie na wielu poziomach hierarchii dziedziczenia różnych funkcji o takiej samej nazwie) może się wydawać niejawnym odwołaniem względnym z obiektu potomnego do nadrzędnego, ale tak naprawdę jest wynikiem operacji kopiowania. JavaScript nie tworzy automatycznie kopii (jak może na to wskazywać klasa) między obiektami. Wzorzec domieszki (zarówno jawnej, jak i niejawnej) jest często stosowany w charakterze pewnego rodzaju emulacji zachowania kopiowania klasy, choć zwykle prowadzi do powstania brzydkiej i zawodnej składni, takiej jak pseudopolimorfizm (InnyObiekt.nazwaMetody.wywołanie(this, ...)). Skutkiem jest bardziej zagmatwany kod, który na dodatek będzie trudniejszy do późniejszej konserwacji. Jawna domieszka nie jest dokładnie taka sama jak operacja kopiowania klasy, ponieważ obiekty (i funkcje!) mają powielone jedynie współdzielone odwołania, a nie same obiekty bądź funkcje. Jeżeli nie zwrócisz uwagi na tego rodzaju szczegóły, skutkiem może być wiele pułapek. Ogólnie rzecz biorąc, nieprawdziwe klasy w JavaScript często powodują wiele błędów, które ujawniają się podczas tworzenia kodu w przyszłości, a przy tym niezbyt skutecznie rozwiązują obecne, rzeczywiste problemy. 106 Rozdział 4. Mieszanie obiektów „klas” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 5. Prototypy W rozdziałach 3. i 4. kilkakrotnie wspomniałem o łańcuchu [[Prototype]], choć nie wyjaśniłem, o co dokładnie chodzi. W tym rozdziale przeanalizujemy właśnie prototypy. Wszystkie próby emulacji opisanego w rozdziale 4. zachowania klasy (czyli kopiowania) oznaczone jako warianty domieszek całkowicie pomijają zaprezentowany w tym rozdziale mechanizm łańcucha [[Prototype]]. [[Prototype]] Obiekty w JavaScript mają wewnętrzną właściwość oznaczaną w specyfikacji jako [[Prototype]]. Wymieniona właściwość jest po prostu odwołaniem do innego obiektu. Niemal we wszystkich obiektach wartość właściwości [[Prototype]] jest w chwili tworzenia obiektu inna niż null. Uwaga, wkrótce się przekonasz, że jest możliwe, aby obiekt nie miał przypisanej wartości dla właściwości [[Prototype]], jednak tego rodzaju sytuacja rzadko ma miejsce. Spójrz na poniższy fragment kodu: var myObject = { a: 2 }; myObject.a; // 2 107 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Do czego prowadzi odwołanie [[Prototype]]? W rozdziale 3. dowiedziałeś się, że operacja [[Get]] jest wywoływana podczas odwoływania się do właściwości obiektu, na przykład myObject.a. W przypadku domyślnej operacji [[Get]] pierwszym krokiem jest sprawdzenie, czy sam obiekt zawiera wskazaną właściwość a. Jeżeli tak, zostanie ona użyta. Proxy ES6 to zagadnienie wykraczające poza zakres tematyczny książki (powrócę do niego w jednej z kolejnych książek serii). Wszystko to, co powiemy sobie tutaj o operacjach [[Get]] i [[Put]], nie będzie miało zastosowania w przypadku korzystania z proxy. Jednak to, co się dzieje, gdy właściwość a nie znajduje się w myObject, kieruje naszą uwagę w stronę połączenia [[Prototype]] obiektu. Domyślna operacja [[Get]] wykorzysta łącze [[Prototype]] obiektu, jeśli nie będzie mogła znaleźć żądanej właściwości bezpośrednio w obiekcie: var anotherObject = { a: 2 }; // Utworzenie obiektu połączonego z anotherObject. var myObject = Object.create( anotherObject ); myObject.a; // 2 Wkrótce wyjaśnię sposób działania wywołania Object.create(..). Teraz możesz przyjąć założenie, że wymienione wywołanie tworzy obiekt wraz z połączeniem [[Prototype]] do wskazanego obiektu. W tym momencie mamy obiekt myObject, który za pomocą [[Prototype]] został połączony z innym obiektem. Wprawdzie myObject.a tak naprawdę nie istnieje, ale nie ma to znaczenia. Operacja dostępu do właściwości kończy się pomyślnie (właściwość znajduje się w anotherObject), a znalezioną wartością jest 2. Co się stanie w sytuacji, gdy właściwość a nie zostanie znaleziona również w obiekcie anotherObject? W obiekcie anotherObject następuje sprawdzenie łańcucha [[Prototype]] i jeśli nie jest pusty, program podąża za nim. 108 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Proces jest kontynuowany aż do znalezienia szukanej właściwości lub zakończenia łańcucha [[Prototype]]. Jeżeli po przetworzeniu całego łańcucha właściwość w ogóle nie zostanie znaleziona, wartością zwrotną operacji [[Get]] będzie undefined. Podobnie jak w przypadku przedstawionego powyżej procesu wyszukiwania w łańcuchu [[Prototype]], jeżeli użyjesz pętli for in do przeprowadzenia iteracji obiektu, wszystkie właściwości dostępne za pomocą łańcucha (i zdefiniowane jako enumerable — patrz rozdział 3.) zostaną wymienione. W przypadku użycia operatora in do sprawdzenia, czy w obiekcie istnieje wskazana właściwość, operator ten sprawdzi cały łańcuch obiektu (niezależnie od widoczności w typach wyliczeniowych): var anotherObject = { a: 2 }; // Utworzenie obiektu połączonego z anotherObject. var myObject = Object.create( anotherObject ); for (var k in myObject) { console.log("Znaleziono: " + k); } // Znaleziono: a ("a" in myObject); // Prawda. Podczas przeprowadzania operacji wyszukiwania na różne sposoby dochodzi do sprawdzenia łańcucha [[Prototype]], po jednym łączu za każdym razem. Wyszukiwanie zostaje zatrzymane po znalezieniu właściwości lub zakończeniu łańcucha. Object.prototype Mógłbyś w tym miejscu zapytać: gdzie tak dokładnie znajduje się „koniec” łańcucha [[Prototype]]? Górnym końcem każdego normalnego łańcucha [[Prototype]] jest wbudowany obiekt Object.prototype. Wymieniony obiekt zawiera wiele różnych metod pomocniczych używanych w JavaScript, ponieważ wszystkie normalne (wbudowane, a nie charakterystyczne dla hosta) obiekty w tym języku „pochodzą” (czyli mają początek łańcucha [[Prototype]]) właśnie z obiektu Object.prototype. [[Prototype]] helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 109 0 Niektóre przedstawione tutaj metody pomocnicze mogą być znane, na przykład .toString() i .valueOf(). W rozdziale 3. poznałeś też inną: .hasOwnProperty(..). Mamy jeszcze jedną funkcję Object.prototype, która powinna być Ci znana: .isPrototypeOf(..). Do ostatniej z wymienionych funkcji powrócimy w dalszej części rozdziału. Ustawianie i przesłanianie właściwości W rozdziale 3. dowiedziałeś się, że ustawienie właściwości obiektu jest nieco bardziej skomplikowane niż dodanie nowej właściwości do istniejącego obiektu lub też zmiana wartości istniejącej właściwości. Powrócimy teraz do wspomnianej sytuacji i dokładnie ją omówimy: myObject.foo = "bar"; Jeżeli obiekt myObject bezpośrednio zawiera zwykłą właściwość w postaci akcesora danych o nazwie foo, operacja przypisania jest prosta i sprowadza się do zmiany wartości istniejącej właściwości. Natomiast jeżeli foo nie znajduje się bezpośrednio w obiekcie myObject, dochodzi do sprawdzenia łańcucha [[Prototype]], podobnie jak podczas operacji [[Get]]. W przypadku nieznalezienia foo w żadnym miejscu łańcucha właściwość foo będzie dodana bezpośrednio do myObject wraz z podaną wartością, co jest oczekiwanym zachowaniem. Jeśli jednak foo znajduje się gdzieś wyżej w łańcuchu, może dojść do dziwnego (i prawdopodobnie zaskakującego) zachowania w trakcie operacji przypisania myObject.foo = "bar". Wszystko dokładnie wyjaśni się już za moment. Jeżeli właściwość o nazwie foo znajduje się zarówno w samym obiekcie myObject, jak i gdzieś wyżej w łańcuchu [[Prototype]] rozpoczynającym myObject, mamy do czynienia z tak zwanym przesłonięciem (ang. shadowing). Właściwość foo znajdująca się bezpośrednio w obiekcie myObject przesłania każdą właściwość foo pojawiającą się wyżej w łańcuchu, ponieważ operacja wyszukiwania myObject.foo zawsze będzie znajdować tę właściwość foo, która znajduje się najniżej w łańcuchu. Jak już wcześniej wspomniałem, przesłonięcie foo w obiekcie myObject nie jest tak proste, jak może się wydawać. Przeanalizujemy teraz trzy scenariusze dla operacji przypisania myObject.foo = "bar", gdy foo nie znajduje się bezpośrednio w obiekcie myObject , ale jest na wyższym poziomie łańcucha [[Prototype]] dla myObject. 110 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 1. Jeżeli standardowy akcesor danych (patrz rozdział 3.) w postaci właściwości o nazwie foo znajduje się gdziekolwiek wyżej w łańcuchu [[Prototype]] i nie jest oznaczony jako tylko do odczytu (writable:false), dodanie nowej właściwości o nazwie foo bezpośrednio do myObject skutkuje przesłonięciem właściwości. 2. Jeżeli właściwość foo zostanie znaleziona wyżej w łańcuchu [[Prototype]], ale jest oznaczona jako tylko do odczytu (writable:false), ustawienie wartości tej istniejącej właściwości czy utworzenie przesłoniętej właściwości w myObject jest niedozwolone. Gdy kod zostanie uruchomiony w trybie ścisłym, następuje zgłoszenie błędu. W przeciwnym razie ustawienie wartości właściwości zostanie po cichu zignorowane. Niezależnie od wszystkiego nie mamy do czynienia z przesłonięciem właściwości. 3. Jeżeli właściwość foo zostanie znaleziona wyżej w łańcuchu [[Prototype]] i jest setterem (patrz rozdział 3.), zawsze będzie wywoływany setter. Do obiektu myObject nie zostanie dodana właściwość foo (nie dojdzie do przesłonięcia) ani nie będziemy mieli do czynienia z ponownym zdefiniowaniem settera. Większość programistów przyjmuje założenie, że przypisanie właściwości (operacja [[Put]]) zawsze będzie skutkować przesłonięciem, jeśli właściwość już istnieje wyżej w łańcuchu [[Prototype]]. Jak mogłeś zobaczyć, jest tak tylko w jednej z trzech omówionych powyżej sytuacji (w pierwszej z nich). Jeżeli chcesz przesłonić foo w pozostałych dwóch sytuacjach, nie możesz użyć przypisania (=), ale musisz skorzystać z wywołania Object.defineProperty(..) — patrz rozdział 3. — w celu dodania foo do obiektu myObject. Drugi przypadek może być najbardziej zaskakujący ze wszystkich omówionych powyżej. Istnienie właściwości tylko od odczytu uniemożliwia niejawne utworzenie nowej właściwości o tej samej nazwie na niższym poziomie łańcucha [[Prototype]], gdyż doprowadziłoby to do przesłonięcia. Powodem wymienionego ograniczenia jest przede wszystkim podtrzymanie iluzji istnienia właściwości dziedziczonych przez klasę. Jeżeli właściwość foo znajdującą się na wyższym poziomie łańcucha traktujesz jako możliwą do dziedziczenia (skopiowania) przez myObject, wówczas sensowne jest wymuszenie, aby właściwość foo w obiekcie myObject była przeznaczona tylko do odczytu. Jeżeli jednak przekonasz się, że podczas dziedziczenia tak naprawdę nie mamy [[Prototype]] helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 111 0 do czynienia ze wspomnianym kopiowaniem (patrz rozdziały 4. i 5.), uniemożliwienie obiektowi myObject posiadania właściwości foo (ponieważ inny obiekt ma taką właściwość tylko do odczytu) wydaje się nienaturalne. Jeszcze dziwniejsze jest to, że wspomniane ograniczenie ma zastosowanie jedynie do operacji przypisania (=), a nie jest wymuszane w trakcie użycia wywołania Object.defineProperty(..). Przesłanianie metod prowadzi do wyraźnego pseudomorfizmu (patrz rozdział 4.), o ile zachodzi potrzeba zastosowania wzorca delegowania między tymi metodami. Zwykle przesłanianie jest bardzo skomplikowane i wywołuje dużo więcej zamieszania, niż przynosi korzyści, dlatego warto go unikać, gdy tylko istnieje taka możliwość. W rozdziale 6. poznasz alternatywne wzorce projektowe, które zniechęcają do przesłaniania — zamiast tego skłaniają nas do stosowania innych, czytelniejszych rozwiązań. Przesłanianie może występować niejawnie na wiele subtelnych sposobów, dlatego należy zachować ostrożność podczas próby jego uniknięcia. Spójrz na poniższy fragment kodu: var anotherObject = { a: 2 }; var myObject = Object.create( anotherObject ); anotherObject.a; // 2 myObject.a; // 2 anotherObject.hasOwnProperty( "a" ); // Prawda. myObject.hasOwnProperty( "a" ); // Fałsz. myObject.a++; // Ups! Niejawne przesłonięcie! anotherObject.a; // 2 myObject.a; // 3 myObject.hasOwnProperty( "a" ); // Prawda. Wprawdzie może się wydawać, że polecenie myObject.a++ powinno (za pomocą delegowania) wyszukać i po prostu zainkrementować wartość właściwości anotherObject.a znajdującej się w danym miejscu, to tak naprawdę operacja ++ będzie odpowiadała poleceniu myObject.a = myObject.a + 1. W tym momencie zostanie wykonana seria operacji: dochodzi do wyszukania przez operację [[Get]] właściwości a za pomocą [[Prototype]], a następnie 112 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 pobrania wartości bieżącej 2 z anotherObject.a. W dalszej kolejności ma miejsce inkrementacja tej wartości o jeden i na koniec wykonywana jest operacja [[Put]], która przypisuje wartość 3 nowej, przesłoniętej właściwości a w myObject. Ups! Zachowaj ostrożność podczas pracy z delegowanymi właściwościami, które możesz modyfikować. Jeżeli chcesz przeprowadzić inkrementację anotherObject.a, to jedynym poprawnym podejściem jest zastosowanie polecenia anotherObject.a++. „Klasa” Na tym etapie mógłbyś zapytać, dlaczego jeden obiekt miałby być połączony z innym? Czy istnieje jakakolwiek rzeczywista korzyść płynąca z takiego połączenia? To naprawdę dobre pytanie, ale zanim na nie odpowiemy, konieczne jest zrozumienie, czym [[Prototype]] nie jest. Dzięki temu będziesz mógł w pełni pojąć, czym [[Prototype]] jest i na czym polega jego użyteczność. Jak już wyjaśniłem w rozdziale 4., w języku JavaScript nie ma abstrakcyjnych wzorców dla obiektów, które w innych językach programowania obiektowego są nazywane klasami. JavaScript ma jedynie obiekty. W rzeczywistości JavaScript jest niemalże unikatowym językiem i prawdopodobnie jedynym, który zasłużenie używa etykiety „zorientowany obiektowo”. To jeden z krótkiej listy języków, w których obiekt może być utworzony bezpośrednio, w ogóle bez konieczności użycia klasy. W języku JavaScript klasy nie mogą (przecież nie istnieją!) opisywać sposobu zachowania obiektu. Dlatego też obiekt bezpośrednio definiuje własne zachowanie. To po prostu jest obiekt. Funkcje „klasy” Od wielu lat w języku JavaScript niezwykle powszechnie stosowana jest sztuczka polegająca na tworzeniu elementu przypominającego klasę. Teraz szczegółowo omówimy takie podejście. Wspomniana sztuczka polegająca na utworzeniu pewnego rodzaju klasy ma swoje odzwierciedlenie w postaci dziwnej cechy charakterystycznej funkcji: wszystkie funkcje domyślnie otrzymują publiczną, nieuwzględnianą przez Funkcje „klasy” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 113 0 typy wyliczeniowe (patrz rozdział 3.) właściwość o nazwie prototype, która prowadzi do innego dowolnego obiektu: function Foo() { // ... } Foo.prototype; // { } Wspomniany obiekt jest często nazywany prototypem Foo, ponieważ uzyskujemy do niego dostęp za pomocą odwołania właściwości o niefortunnej nazwie Foo.prototype. Terminologia niestety wywołuje pewne zamieszanie, o czym się wkrótce przekonasz. Dlatego też wymienioną właściwość będę określał mianem „obiektu wcześniej znanego jako prototyp Foo”. Żartowałem! Co powiesz na „obiekt określony mianem Foo dot prototype”? Niezależnie od tego, jak go nazwiemy, nadal aktualne pozostaje pytanie, czym dokładnie jest wspomniany obiekt. Oto najlepsze z możliwych wyjaśnień: każdy obiekt tworzony za pomocą wywołania new Foo() — patrz rozdział 2. — będzie zawierał (pod pewnymi względami dowolne) połączenie [[Prototype]] ze wspomnianym wcześniej obiektem Foo dot prototype. Zilustrujmy tę sytuację: function Foo() { // ... } var a = new Foo(); Object.getPrototypeOf( a ) === Foo.prototype; // Prawda. Podczas tworzenia a za pomocą wywołania new Foo() jednym z przeprowadzanych kroków (wszystkie cztery dokładnie omówiłem w rozdziale 2.) jest dostarczenie a wewnętrznego łącza [[Prototype]] do obiektu wskazywanego przez Foo.prototype. Zatrzymaj się na chwilę i zastanów nad znaczeniem powyższego zdania. W językach obiektowych można tworzyć wiele kopii (egzemplarzy) klasy, podobnie jak w przypadku tłoczenia czegoś przy użyciu formy. Jak zobaczyłeś w rozdziale 4., wymienione zachowanie jest możliwe, ponieważ proces tworzenia egzemplarza klasy (lub dziedziczenia po klasie) oznacza „skopiuj zawartość 114 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 tej klasy i umieść w fizycznym obiekcie”. Ta operacja jest powtarzana dla każdego nowego egzemplarza. Jednak w języku JavaScript wspomniane powyżej kopiowanie nie jest przeprowadzane. Nie tworzysz wielu egzemplarzy klas. Można utworzyć wiele obiektów połączonych z pewnym obiektem za pomocą [[Prototype]]. Domyślnie nie mamy do czynienia z kopiowaniem, dlatego też obiekty nie są zupełnie oddzielne, a można wręcz stwierdzić, że pozostają połączone. Wynikiem wywołania new Foo() jest nowy obiekt (nazywamy go a), który jest wewnętrznie połączony z obiektem Foo.prototype za pomocą [[Prototype]]. W efekcie mamy dwa połączone ze sobą obiekty. I to tyle. Nie tworzymy egzemplarza klasy. Na pewno nie dochodzi do skopiowania zachowania „klasy” do konkretnego obiektu. Po prostu spowodowaliśmy, że dwa obiekty są ze sobą połączone. W rzeczywistości sekretem umykającym większości programistów JavaScript jest to, że wywołanie new Foo() ma naprawdę niewiele bezpośrednio wspólnego z procesem tworzenia łącza. To raczej przypadkowy efekt uboczny. Wywołanie new Foo() jest pośrednim, okrężnym sposobem otrzymania interesującego nas efektu: nowego obiektu połączonego z innym obiektem. Czy ten efekt można osiągnąć w bardziej bezpośredni sposób? Tak! Tutaj przydatne będzie wywołanie Object.create(..). Jednak do rozwiązania opartego na wymienionym wywołaniu przejdziemy dopiero za chwilę. Co z nazwą? W JavaScript nie tworzymy kopii jednego obiektu („klasa”) w drugim („egzemplarz”). Zamiast tego tworzymy łącza między obiektami. W przypadku mechanizmu [[Prototype]] na rysunku 5.1 kierunek działania pokazują strzałki, od prawej do lewej i od dołu do góry: Ten mechanizm jest często nazywany dziedziczeniem prototypowym (szczegółowe omówienie kodu przedstawię za chwilę), o którym mówi się, że to wersja klasycznego dziedziczenia w językach dynamicznych. To próba wykorzystania powszechnie stosowanego znaczenia „dziedziczenia” w świecie programowania zorientowanego obiektowo, ale w wersji zmodyfikowanej, aby semantyka została dopasowana do dynamicznego języka skryptowego. Funkcje „klasy” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 115 0 Rysunek 5.1. Graficzne przedstawienie mechanizmu [[Prototype]] Słowo „dziedziczenie” ma bardzo jasno zdefiniowane znaczenie (patrz rozdział 4.), z którym na dodatek wiąże się spora tradycja. Dodanie słowa „prototypowe” w celu nadania nazwy zupełnie przeciwnemu do dziedziczenia zachowaniu w JavaScript nie pomogło w zlikwidowaniu trwającego już od niemal dwóch dekad zamieszania. Mogę stwierdzić, że dodanie słowa „prototypowe” do dziedziczenia w celu całkowitego odwrócenia jego znaczenia przypomina trzymanie pomarańczy w jednej ręce i jabłka w drugiej, a następnie nazwanie jabłka czerwoną pomarańczą. Niezależnie od tego, jak mylącej nazwy tutaj użyjemy, nie zmienia to faktu, że jednym owocem jest pomarańcza, a drugim jabłko. Lepszym podejściem będzie po prostu nazwanie jabłka jabłkiem — wówczas używamy najodpowiedniejszej i bezpośredniej terminologii. W ten sposób łatwiej zrozumieć podobieństwa i różnice, ponieważ wszyscy doskonale wiedzą i rozumieją znaczenie słowa „jabłko”. Z powodu tego zamieszania i łączenia pojęć określenie „dziedziczenie prototypowe” (i nieudolna próba stosowania związanej z tym terminologii programowania zorientowanego obiektowo, czyli na przykład słów takich jak „klasa”, „konstruktor”, „egzemplarz”, „polimorfizm” itd.) wyrządziło więcej szkody, niż przyniosło pożytku w wyjaśnieniu rzeczywistego sposobu działania mechanizmu JavaScript. 116 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Dziedziczenie sugeruje operację kopiowania, ale domyślnie JavaScript nie kopiuje właściwości obiektu. Zamiast tego tworzy połączenie między dwoma obiektami, z których jeden w zasadzie deleguje do innego obiektu dostęp do funkcji i właściwości. Wspomniane delegowanie (patrz rozdział 6.) jest znacznie odpowiedniejszym określeniem dla stosowanego w JavaScript mechanizmu łączenia obiektów ze sobą. Innym pojęciem, czasami pojawiającym się w języku JavaScript, jest dziedziczenie różnicowe. W tym przypadku idea polega na tym, że zachowanie obiektu opisujemy w kategoriach różnic względem ogólnego deskryptora. Na przykład można wyjaśnić, że samochód jest rodzajem pojazdu o czterech kołach, zamiast po prostu podawać wszystkie cechy charakterystyczne ogólnego pojazdu (silnik itd.). Jeżeli spróbujesz potraktować dany obiekt w JavaScript jako połączenie wszystkich funkcji dostępnych dla niego poprzez wzorzec delegowania, a następnie w myślach sprowadzisz te wszystkie funkcje do pojedynczej namacalnej rzeczy, wtedy (z grubsza) możesz zobaczyć, jak przedstawia się dziedziczenie różnicowe. Jednak podobnie jak w przypadku dziedziczenia prototypowego, dziedziczenie różnicowe udaje, że model powstający w Twoich myślach jest znacznie ważniejszy od rzeczywistego sposobu działania języka. Pomijamy fakt, że obiekt B nie został skonstruowany w odmienny sposób, a zamiast tego jest zbudowany z użyciem określonych cech charakterystycznych, obok których istnieją „dziury”, w których nic nie zostało zdefiniowane. W przypadku wspomnianych dziur (lub inaczej braku definicji) może być zastosowany wzorzec delegowania, który po prostu w locie „wypełni je” delegowanymi funkcjami. Domyślnie w trakcie operacji kopiowania nie powstaje pojedynczy obiekt różnicowy, na co może wskazywać model dziedziczenia różnicowego. Dlatego też dziedziczenie różnicowe nie jest najbardziej naturalnym sposobem opisywania rzeczywistego działania mechanizmu [[Prototype]] w JavaScript. Oczywiście możesz się zdecydować na stosowanie terminologii dziedziczenia różnicowego i modelu ułożonego w myślach, ale to nie zmienia faktu, że mamy wówczas do czynienia jedynie z umysłową akrobatyką, a nie z faktycznym zachowaniem języka. Funkcje „klasy” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 117 0 Konstruktory Powracamy do przedstawionego wcześniej fragmentu kodu: function Foo() { // ... } var a = new Foo(); Co dokładnie skłania nas do uznania Foo za „klasę”? Przede wszystkim użycie słowa kluczowego new, podobnie jak ma to miejsce w językach obiektowych podczas tworzenia egzemplarzy klasy. Inna przesłanka to fakt wykonania metody konstruktora klasy, ponieważ Foo() jest faktycznie wywoływaną metodą, podobnie jak konstruktor rzeczywistej klasy jest wywoływany podczas tworzenia egzemplarzy tej klasy. Aby jeszcze bardziej zagmatwać semantykę „konstruktora”, obiekt nazwany Foo.prototype charakteryzuje się jeszcze jedną interesującą cechą. Spójrz na poniższy fragment kodu: function Foo() { // ... } Foo.prototype.constructor === Foo; // Prawda. var a = new Foo(); a.constructor === Foo; // Prawda. Obiekt Foo.prototype domyślnie (w trakcie deklaracji, czyli w pierwszym wierszu kodu!) otrzymuje publiczną, nieuwzględnianą w typach wyliczeniowych (patrz rozdział 3.) właściwość o nazwie .constructor. Wymieniona właściwość jest odwołaniem zwrotnym do funkcji (w tym przypadku Foo) powiązanej z obiektem. Co więcej, można zobaczyć, że obiekt a utworzony przez wywołanie „konstruktora” new Foo() również wydaje się mieć właściwość o nazwie .constructor, wskazującą „funkcję, która utworzyła dany obiekt”. To niekoniecznie prawda. Obiekt a nie ma właściwości .const ructor i choć a.constructor faktycznie wskazuje funkcję Foo, to jednak w rzeczywistości użyte tutaj określenie „konstruktor” nie oznacza „utworzone przez (tutaj podaj nazwę klasy)”, jak mogłoby się wydawać. Do tego dziwnego zachowania jeszcze powrócimy. 118 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 O tak, ponadto… zgodnie z konwencją obowiązującą w świecie JavaScript nazwa „klasy” rozpoczyna się dużą literą, a więc fakt użycia nazwy Foo zamiast foo wyraźnie wskazuje na „klasę”. To całkowicie zrozumiałe, prawda!? Ta konwencja jest na tyle silnie zakorzeniona w świecie JavaScript, że wielu programistów nawet narzeka, gdy spotykają wywołanie new wraz z metodą o nazwie rozpoczynającej się małą literą lub gdy brakuje słowa kluczowego new przed wywołaniem funkcji o nazwie rozpoczynającej się wielką literą. To niewiarygodne, że tak bardzo się trudzimy, by otrzymać (nieprawdziwą) „klasę” bezpośrednio w JavaScript, i tworzymy reguły nakazujące użycie wielkich liter, które tak naprawdę w ogóle nie mają żadnego znaczenia dla silnika JavaScript. Konstruktor czy wywołanie? W poprzednim fragmencie kodu kuszące może być uznanie Foo za konstruktor, ponieważ wywołujemy tę funkcję wraz ze słowem kluczowym new i obserwujemy, jak „konstruuje” obiekt. W rzeczywistości Foo nie ma żadnych specjalnych cech, dzięki którym bardziej od innych funkcji w programie nadaje się do pełnienia roli „konstruktora”. Funkcje same w sobie nie są konstruktorami. Jednak po umieszczeniu słowa kluczowego new przed zwykłym wywołaniem funkcji otrzymujemy tak zwane „wywołanie konstruktora”. W rzeczywistości użycie słowa kluczowego new stanowi pewnego rodzaju przechwycenie zwykłej funkcji i wywołanie jej w sposób pozwalający na konstrukcję obiektu, oczywiście poza wykonaniem innych zadań, do których została przeznaczona dana funkcja. Na przykład: function NothingSpecial() { console.log( "Wszystko mi jedno!" ); } var a = new NothingSpecial(); // "Wszystko mi jedno!" a; // {} NothingSpecial() to zwykła funkcja, ale po wywołaniu wraz ze słowem kluczowym new powoduje skonstruowanie obiektu, co następuje właściwie jako efekt uboczny, a później przypisanie go do zmiennej a. To wywołanie Funkcje „klasy” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 119 0 jest wywołaniem konstruktora, ale funkcja NothingSpecial() w żadnym razie konstruktorem nie jest. Innymi słowy, w świecie JavaScript najbardziej odpowiednie stwierdzenie brzmi następująco: konstruktor to dowolna funkcja wywołana wraz ze słowem kluczowym new. Funkcja nie jest konstruktorem, natomiast wywołanie funkcji może się stać wywołaniem konstruktora tylko i wyłącznie wtedy, gdy odbywa się za pomocą słowa kluczowego new. Mechanika Czy to jedyne powszechnie stosowane wyzwalacze dla feralnych „klas” w języku JavaScript? Nie całkiem. Programiści JavaScript starają się symulować tyle zachowań znanych z programowania obiektowego, ile się tylko da: function Foo(name) { this.name = name; } Foo.prototype.myName = function() { return this.name; }; var a = new Foo( "a" ); var b = new Foo( "b" ); a.myName(); // "a" b.myName(); // "b" W powyższym fragmencie kodu zastosowano jeszcze dwie dodatkowe sztuczki związane z podejściem programowania obiektowego: 1. Polecenie this.name = name powoduje dodanie właściwości .name do każdego obiektu (odpowiednio a i b, więcej informacji o wiązaniu this znajdziesz w rozdziale 2.), podobnie jak egzemplarze klasy hermetyzują wartości danych. 2. Użycie polecenia Foo.prototype.myName = ... to prawdopodobnie najbardziej interesujące podejście, które powoduje dodanie właściwości (funkcji) do obiektu Foo.prototype. Tym samym wywołanie a.myName() działa, co prawdopodobnie będzie zaskoczeniem. Jak to możliwe? 120 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 W powyższym fragmencie kodu niezwykle kuszące może być przyjęcie założenia, że podczas tworzenia a i b właściwości oraz funkcje w obiekcie Foo.prototype zostaną skopiowane do obu obiektów. Nic takiego nie ma jednak miejsca. Na początku rozdziału omówiłem połączenie za pomocą [[Prototype]] i pokazałem, że takie podejście zapewnia awaryjne rozwiązanie podczas wyszukiwania — gdy odwołanie do właściwości nie znajduje się bezpośrednio w obiekcie — ponieważ stanowi część domyślnego algorytmu [[Get]]. Dlatego też ze względu na sposób tworzenia obiekty a i b zawierają wewnętrzne połączenie [[Prototype]] z Foo.prototype. W przypadku nieznalezienia właściwości myName w a lub b, znajdzie się ona (za pomocą wzorca delegowania, patrz rozdział 6.) w obiekcie Foo.prototype. Powrót „konstruktora” Czy pamiętasz wcześniejszą analizę dotyczącą właściwości .constructor i to, jak wydawało się, że a.constructor === true, czyli a miało mieć rzeczywistą właściwość .constructor wskazującą Foo? Tak jednak nie jest. Mamy tutaj do czynienia z niefortunnym nieporozumieniem. W rzeczywistości odwołanie .constructor również zostało delegowane do obiektu Foo.prototype, który domyślnie ma właściwość .constructor wskazującą Foo. Wydaje się, że jest to niezwykle użyteczne, iż obiekt a „skonstruowany przez” Foo ma dostęp do właściwości .constructor wskazującej Foo. Niestety, daje nam to fałszywe poczucie bezpieczeństwa. To tylko szczęśliwy zbieg okoliczności, że a.constructor wskazuje Foo za pomocą domyślnego delegowania [[Prototype]]. Z gruntu fałszywe przekonanie, że .constructor oznacza „został skonstruowany przez (tutaj podaj nazwę klasy)”, może naprawdę na wiele różnych sposobów wypaczyć działanie kodu. Z jednej strony właściwość .constructor w obiekcie Foo.prototype jest umieszczana domyślnie w obiekcie tworzonym podczas deklarowania funkcji Foo. Jeżeli utworzysz nowy obiekt i zastąpisz w funkcji domyślne odwołanie .prototype, nowy obiekt nie otrzyma w magiczny sposób właściwości .constructor. Spójrz na poniższy fragment kodu: function Foo() { /* .. */ } Foo.prototype = { /* .. */ }; // Utworzenie nowego obiektu prototype. Funkcje „klasy” helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 121 0 var a1 = new Foo(); a1.constructor === Foo; // Fałsz! a1.constructor === Object; // Prawda! Wywołanie Object(..) nie spowodowało „skonstruowania” (utworzenia) obiektu a1, prawda? Wydaje się jednak, że wywołanie takie jak Foo() „konstruuje” obiekt a1. Większość programistów uważa, że wywołanie Foo() tworzy obiekt. Jednak to przekonanie okazuje się błędne, gdy znaczenie słowa „konstruktor” odczytujesz jako „został skonstruowany przez (tutaj podaj nazwę klasy)”, ponieważ w takim przypadku właściwość a1.constructor powinna wskazywać Foo, a nie wskazuje! Co się dzieje? Obiekt a1 nie ma właściwości .constructor, a więc stosuje delegowanie w górę łańcucha [[Prototype]] do obiektu Foo.prototype. Jednak ten obiekt również nie ma właściwości .constructor (takiej jak w domyślnym obiekcie Foo.prototype!), a zatem delegowanie trwa dalej. Tym razem docieramy do Object.prototype, czyli na początek łańcucha delegowania. Ten obiekt faktycznie ma właściwość .constructor, która prowadzi do wbudowanej funkcji Object(..). Nieporozumienie: klapa. Oczywiście właściwość .constructor można dodać z powrotem do obiektu Foo.prototype, ale to wymaga ręcznej operacji, zwłaszcza jeśli chcesz otrzymać takie samo zachowanie jak w przypadku natywnej właściwości i zagwarantować, że wymieniona właściwość nie będzie uwzględniana przez typy wyliczeniowe (patrz rozdział 3.). Spójrz na poniższy fragment kodu: function Foo() { /* .. */ } Foo.prototype = { /* .. */ }; // Utworzenie nowego obiektu prototype. // Konieczne jest poprawne usunięcie usterki, jaką jest brak właściwości // '.constructor' w nowym obiekcie dostępnym jako 'Foo.prototype'. // Zajrzyj do rozdziału 3. i poszukaj tam opisu funkcji defineProperty(..). Object.defineProperty( Foo.prototype, "constructor" , { enumerable: false, writable: true, configurable: true, value: Foo // Wskazuje właściwość '.constructor' w obiekcie 'Foo'. } ); 122 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 To całkiem dużo pracy niezbędnej do wykonania, aby naprawić właściwość .constructor. Co więcej, nasze działanie to utrwalanie błędnej koncepcji, że słowo „konstruktor” oznacza „został skonstruowany przez (tutaj podaj nazwę klasy)”. Mamy więc do czynienia z dość kosztowną iluzją. Faktem jest, że właściwość .constructor w obiekcie domyślnie wskazuje funkcję, która ma odwołanie zwrotne do tego obiektu — wspomniane odwołanie nosi nazwę .prototype. Słowa „konstruktor” i „prototyp” mają tutaj luźne znaczenia, które później mogą — choć nie muszą — być prawdziwe. Najlepszym rozwiązaniem jest nieustanne przypominanie sobie, że „konstruktor” nie oznacza „skonstruowany przez (tutaj podaj nazwę klasy)”. Właściwość .constructor nie jest magicznie niemodyfikowalną właściwością. Jak wcześniej wspomniałem (patrz poprzedni fragment kodu), nie jest uwzględniana przez typy wyliczeniowe, ale jej wartość może być zmieniona. Co więcej, istnieje możliwość dodania lub nadpisania (celowo bądź przypadkowo) właściwości o nazwie constructor w dowolnym obiekcie dowolnego łańcucha [[Prototype]]. Ze względu na sposób poruszania się algorytmu [[Get]] po łańcuchu [[Pro totype]] odwołanie do właściwości .constructor znalezionej w dowolnym miejscu wspomnianego łańcucha może zostać rozwiązane zupełnie inaczej, niż tego oczekujesz. Czy już widzisz, jak dowolne może być rzeczywiste znaczenie omawianej właściwości? Wynik? Pewne właściwości, takie jak a1.constructor, nie mogą być bezpiecznie uznane za odwołania do funkcji domyślnych. Co więcej, jak się wkrótce przekonasz, z powodu prostego pominięcia właściwość a1.constructor może prowadzić do zupełnie innego, zaskakującego miejsca. Właściwość a1.constructor okazuje się wyjątkowo zawodna, więc opieranie się w kodzie na tym odwołaniu jest dość niebezpieczne. Ogólnie rzecz biorąc, należy unikać tego rodzaju odwołań, o ile to możliwe. Dziedziczenie (prototypowe) Wcześniej przedstawiłem pewne często stosowane w programach JavaScript sztuczki, których celem jest zapewnienie funkcjonalności klas. Jednak klasy JavaScript pozostałyby puste, gdyby nie istniał mechanizm podobny do dziedziczenia. Dziedziczenie (prototypowe) helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 123 0 Mechanizm powszechnie określany mianem dziedziczenia prototypowego przedstawiłem w działaniu na przykładzie obiektu a, który miał możliwość dziedziczenia po obiekcie Foo.prototype, a tym samym uzyskał dostęp do funkcji myName(). Jednak dziedziczenie jest tradycyjnie traktowane jako związek między dwoma klasami, a nie między klasą i egzemplarzem, jak pokazałem na rysunku 5.2. Rysunek 5.2. Graficzne przedstawienie dziedziczenia prototypowego Powyższy rysunek powinieneś pamiętać z wcześniejszej części rozdziału. Pokazuje nie tylko wzorzec delegowania z obiektu (tutaj „egzemplarza”) a1 do Foo.prototype, ale również z Bar.prototype do Foo.prototype, co czasami przypomina koncepcję dziedziczenia klasy potomnej po nadrzędnej. Przypomina, ale oczywiście za wyjątkiem kierunku operacji, ponieważ tutaj mamy do czynienia z łączami delegowania, a nie z operacją kopiowania. Poniżej przedstawiłem typowy kod wykorzystujący „styl prototypu” odpowiedzialny za utworzenie wspomnianych łączy: function Foo(name) { this.name = name; } Foo.prototype.myName = function() { return this.name; }; 124 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 function Bar(name,label) { Foo.call( this, name ); this.label = label; } // W tym miejscu tworzymy nowy obiekt Bar.prototype // połączony z Foo.prototype. Bar.prototype = Object.create( Foo.prototype ); // Uważaj! Teraz nie ma już Bar.prototype.constructor // i może wystąpić konieczność ręcznego poprawienia, // jeżeli masz w zwyczaju opieranie się na tego rodzaju właściwościach! Bar.prototype.myLabel = function() { return this.label; }; var a = new Bar( "a", "obj a" ); a.myName(); // "a" a.myLabel(); // "obj a" Aby zrozumieć, dlaczego this w powyższym fragmencie kodu prowadzi do a, zajrzyj do rozdziału 2. Najważniejszym poleceniem jest Bar.prototype = Object.create( Foo.prototype ). Wywołanie Object.create(..) powoduje utworzenie „nowego” obiektu oraz połączenie nowego obiektu ze wskazanym za pomocą [[Prototype]] (w omawianym przykładzie to Foo.prototype). Innymi słowy, omawiany powyżej wiersz kodu można odczytać następująco: „utwórz nowy obiekt określany mianem Bar dot prototype, który jest połączony z obiektem nazywanym Foo dot prototype”. Podczas deklarowania funkcji Bar (function Bar() { .. }), podobnie jak każda inna funkcja, otrzymuje ona łącze .prototype prowadzące do obiektu domyślnego. Jednak ten obiekt nie jest połączony z Foo.prototype, jak byśmy tego chcieli. Dlatego też tworzymy nowy obiekt, który będzie połączony w oczekiwany sposób, a tym samym w efekcie pozbywamy się początkowo nieprawidłowo połączonego obiektu. Dziedziczenie (prototypowe) helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 125 0 Bardzo często pojawiającym się tutaj nieporozumieniem jest to, że poniższe podejście również działa, ale nie tak, jak można by oczekiwać: // Poniższe polecenie nie działa tak, jak tego oczekujesz! Bar.prototype = Foo.prototype; // Poniższe polecenie mniej więcej działa w oczekiwany sposób, // ale jednocześnie powoduje efekty uboczne, których nie chcesz :( Bar.prototype = new Foo(); Polecenie Bar.prototype = Foo.prototype nie powoduje utworzenia nowego obiektu, z którym zostanie połączony Bar.prototype. Wymienione polecenie po prostu oznacza, że Bar.prototype stanie się kolejnym odwołaniem do Foo.prototype, co efektywnie łączy Bar bezpośrednio z tym samym obiektem, do którego prowadzi Foo: Foo.prototype. Oznacza to, że po rozpoczęciu stosowania przypisań, takich jak Bar.prototype.myLabel = ..., przeprowadzasz modyfikację nie oddzielnego obiektu, ale współdzielonego Foo.prototype, co może mieć wpływ na obiekty połączone z Foo.prototype. Prawie nigdy nie oczekujesz właśnie takiego efektu. Jeżeli zaś chcesz otrzymać taki wynik, to praktycznie w ogóle nie potrzebujesz Bar — powinieneś używać tylko Foo i dzięki temu uprościć kod. Polecenie Bar.prototype = new Foo() w rzeczywistości tworzy nowy obiekt, który zgodnie z oczekiwaniami jest połączony z Foo.prototype. Do utworzenia obiektu wykorzystaliśmy wywołanie konstruktora. Jeżeli wymieniona funkcja powoduje jakiekolwiek efekty uboczne (na przykład rejestrację danych, zmianę stanu, rejestrację innych obiektów, dodanie właściwości danych do this itd.), wystąpią one w trakcie łączenia (prawdopodobnie z nieprawidłowym obiektem!), a nie tylko podczas tworzenia elementów potomnych Bar(), jak można by tego oczekiwać. Dlatego też pozostało nam jedynie użycie wywołania Object.create(..) w celu utworzenia nowego obiektu, który będzie prawidłowo połączony z innym. W ten sposób unikniemy efektów ubocznych występujących podczas wywoływania Foo(..). Małym minusem tego rozwiązania jest konieczność utworzenia nowego obiektu poprzez pozbycie się starego, a nie po prostu modyfikację istniejącego. Byłoby dobrze, gdyby istniał standardowy i niezawodny sposób modyfikacji połączenia z istniejącym obiektem. W specyfikacji wcześniejszej niż ES6 mamy niestandardowy i nieobsługiwany we wszystkich przeglądarkach internetowych 126 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 sposób bazujący na właściwości .__proto__, której wartość można ustawić. Z kolei w specyfikacji ES6 dodano funkcję pomocniczą Object.setPrototypeOf(..), która wykonuje oczekiwane zadanie w standardowy i przewidywalny sposób. Obie techniki (stosowaną przed wprowadzeniem specyfikacji ES6 i oferowaną przez ES6) porównaj podczas łączenia Bar.prototype z Foo.prototype: // Przed wprowadzeniem specyfikacji ES6. // Pozbycie się domyślnego, istniejącego obiektu Bar.prototype. Bar.prototype = Object.create( Foo.prototype ); // Specyfikacja ES6+. // Modyfikacja istniejącego obiektu Bar.prototype. Object.setPrototypeOf( Bar.prototype, Foo.prototype ); Jeśli zignorujemy niewielki spadek wydajności (pozbycie się obiektu, który później będzie usunięty przez mechanizm usuwania nieużytków) związany z użyciem wywołania Object.create(..), to to podejście jest lepsze, gdyż jest nieco krótsze i prawdopodobnie łatwiejsze w odczycie niż podejście wprowadzone w specyfikacji ES6+. Jednak skutek zastosowania obu rozwiązań jest taki sam. Analiza związków „klasy” Co można zrobić w sytuacji, gdy masz obiekt taki jak a i chcesz ustalić, do jakiego (o ile w ogóle) obiektu się odwołuje (deleguje)? Analizę egzemplarza (czyli po prostu obiektu w JavaScript) i sprawdzenie jego hierarchii dziedziczenia (połączeń mechanizmu delegowania w JavaScript) w tradycyjnych środowiskach programowania obiektowego często określamy mianem introspekcji (lub refleksji). Spójrz na poniższy fragment kodu: function Foo() { // ... } Foo.prototype.blah = ...; var a = new Foo(); W jaki sposób można przeprowadzić introspekcję obiektu a, aby poznać jego przodków (łącza wzorca delegowania). Pierwsze podejście jest związane z nieporozumieniem dotyczącym „klasy”: a instanceof Foo; // Prawda. Dziedziczenie (prototypowe) helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 127 0 Operator instanceof pobiera zwykły obiekt jako lewy operand oraz funkcję jako jego prawy operand. Pytanie, na które udziela odpowiedzi operator instanceof, brzmi: czy w całym łańcuchu [[Prototype]] dla a istnieje obiekt wskazujący na Foo.prototype? Niestety oznacza to możliwość sprawdzenia przodków jedynie pewnego obiektu (a) tylko wtedy, jeśli mamy funkcję (tutaj Foo wraz z dołączonym odwołaniem .prototype) do przetestowania. Jeżeli masz dwa dowolne obiekty — powiedzmy a i b — i chcesz dowiedzieć się, czy są one powiązane ze sobą za pomocą łańcucha [[Prototype]], operator instanceof na nic Ci się nie przyda. Jeżeli za pomocą wbudowanej funkcji pomocniczej .bind() tworzysz funkcję, która ma na stałe zdefiniowane wywołanie funkcji początkowej wraz z kontekstem this (patrz rozdział 2.), wówczas ta nowa funkcja nie będzie miała właściwości .prototype. W takim przypadku operator instanceof wraz z tego rodzaju funkcją po prostu zastąpi .prototype funkcją docelową, która została utworzona na podstawie na stałe dołączonej funkcji. Wcale nie tak rzadko zdarza się sytuacja, kiedy to na stałe zdefiniowane wywołanie funkcji początkowej wraz z kontekstem this jest używane w charakterze „wywołania konstruktora”. Jednak w takim przypadku wspomniana funkcja będzie się zachowywała, jakby nastąpiło wywołanie początkowej funkcji docelowej. Oznacza to, że użycie operatora instanceof wraz z na stałe dołączoną funkcją również daje efekt jak w przypadku wywołania funkcji pierwotnej. Poniższy fragment kodu pokazuje śmieszność próby uzasadnienia związku między dwoma obiektami za pomocą semantyki „klasy” i operatora instanceof: // Funkcja pomocnicza pozwalająca sprawdzić, czy obiekt 'o1' // jest w jakikolwiek sposób powiązany (delegowany) z 'o2'. function isRelatedTo(o1, o2) { function F(){} F.prototype = o2; return o1 instanceof F; } var a = {}; var b = Object.create( a ); isRelatedTo( b, a ); // Prawda. 128 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Wewnątrz wywołania isRelatedTo(..) pożyczamy funkcję F, ponownie przypisujemy wartość jej właściwości .prototype, wskazując pewien obiekt o2, a następnie sprawdzamy, czy o1 jest egzemplarzem F. Oczywiście obiekt o1 w rzeczywistości nie dziedziczy po F, nie jest obiektem pochodnym F ani nawet nie został skonstruowany na podstawie F. Dlatego też powinno być jasne i oczywiste, że tego rodzaju ćwiczenie jest bezsensowne i mylące. Problem sprowadza się do niewygody związanej z wymuszeniem stosowania semantyki klasy w JavaScript, w tym przypadku jako pośredniej semantyki operatora instanceof. Drugie i znacznie prostsze podejście do refleksji [[Prototype]] przedstawia się następująco: Foo.prototype.isPrototypeOf( a ); // Prawda. Zwróć uwagę, że w tym przypadku nie przejmujemy się (a nawet nie potrzebujemy) Foo, potrzebny jest jedynie obiekt (w omawianym przykładzie nazwany Foo.prototype) do przetestowania z innym obiektem. Pytanie, na które udziela odpowiedzi wywołanie isPrototypeOf(..), brzmi: czy w całym łańcuchu [[Prototype]] dla a istnieje obiekt wskazujący na Foo.prototype? Mamy więc dokładnie takie samo pytanie jak wcześniej i dokładnie tę samą odpowiedź. Jednak w omawianym tutaj podejściu tak naprawdę nie potrzebujemy pośredniego odwołania się do funkcji (Foo), której właściwość .prototype będzie automatycznie sprawdzana. Potrzebne są po prostu dwa obiekty, abyśmy mogli sprawdzić, czy zachodzi między nimi jakikolwiek związek. Na przykład: // Sprawdzamy, czy 'b' pojawia się w jakimkolwiek // miejscu łańcucha [[Prototype]] obiektu 'c'. b.isPrototypeOf( c ); Zwróć uwagę, że to podejście w ogóle nie wymaga funkcji („klasy”). Używa jedynie bezpośrednio odwołań do obiektów b i c, a następnie sprawdza, czy zachodzi między nimi związek. Innymi słowy, przedstawiona wcześniej funkcja pomocnicza isRelatedTo(..) jest wbudowana w język i nosi nazwę isPrototypeOf(..). Istnieje również możliwość bezpośredniego pobrania łańcucha [[Prototype]] obiektu. W przypadku specyfikacji ES5 standardowym sposobem wykonania takiego zadania jest wywołanie: Object.getPrototypeOf( a ); Dziedziczenie (prototypowe) helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 129 0 Jak możesz zobaczyć, odwołanie do obiektu jest zgodne z oczekiwaniami: Object.getPrototypeOf( a ) === Foo.prototype; // Prawda. Większość przeglądarek internetowych (choć nie wszystkie!) od dłuższego czasu obsługuje niestandardowy, alternatywny sposób uzyskania dostępu do [[Prototype]]: a.__proto__ === Foo.prototype; // Prawda. Dziwna właściwość .__proto__ (nieustandaryzowana aż do chwili wydania ES6!) w „magiczny” sposób pobiera wewnętrzny łańcuch [[Prototype]] obiektu jako odwołanie. To niezwykle użyteczne, gdy zachodzi potrzeba bezpośredniego przeanalizowania łańcucha .__proto__.__proto__... lub nawet poruszania się po nim. Podobnie jak widziałeś wcześniej w przypadku właściwości .constructor, .__proto__ tak naprawdę nie istnieje w analizowanym obiekcie (tutaj to a). Natomiast w rzeczywistości istnieje (choć właściwość ta nie jest uwzględniana przez typy wyliczeniowe, patrz rozdział 2.) we wbudowanym obiekcie Object.prototype wraz z innymi często używanymi funkcjami pomocniczymi, takimi jak .toString(), .isPrototypeOf(..) itd. Co więcej, .__proto__ wygląda jak właściwość, choć tak naprawdę lepszym określeniem będzie getter lub setter (patrz rozdział 3.). Implementacja .__proto__ może się z grubsza przedstawiać następująco (w rozdziale 3. znajdziesz definicję właściwości obiektu): Object.defineProperty( Object.prototype, "__proto__", { get: function() { return Object.getPrototypeOf( this ); }, set: function(o) { // Wywołanie setPrototypeOf(..) w specyfikacji ES6. Object.setPrototypeOf( this, o ); return o; } } ); Kiedy próbujemy uzyskać dostęp do właściwości a.__proto__ (inaczej: pobrać jej wartość), przypomina to wywołanie a.__proto__(), czyli wywołanie funkcji typu getter. To wywołanie funkcji ma a jako wiązanie this, mimo że funkcja typu getter istnieje w obiekcie Object.prototype (reguły wiązania this omówiłem w rozdziale 2.). Dlatego też wymienione wywołanie odpowiada Object.getPrototypeOf( a ). 130 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Istnieje również możliwość przypisania wartości właściwości .__proto__, podobnie jak w pokazanym wcześniej przypadku użycia wprowadzonego w specyfikacji ES6 wywołania Object.setPrototypeOf(..). Ogólnie rzecz biorąc, nie powinieneś zmieniać [[Prototype]] istniejącego obiektu. Dostępne są pewne bardzo skomplikowane i zaawansowane techniki, używane głęboko we frameworkach, pozwalające na zastosowanie sztuczek, takich jak tworzenie podklas tablicy (Array), ale korzystanie z nich nie jest zalecane, ponieważ zwykle kończy się to powstaniem kodu znacznie trudniejszego do odczytu i obsługi. W specyfikacji ES6 słowo kluczowe class pozwala na osiągnięcie efektu podobnego do tworzenia podklas dla wbudowanych typów danych, takich jak Array. Analiza składni słowa kluczowego class wprowadzonego w specyfikacji ES6 znajduje się w dodatku A. Jedynym wyjątkiem (jak wcześniej wspomniałem) będzie takie ustawienie [[Prototype]] dla domyślnego obiektu .prototype funkcji, aby wskazywał on inny obiekt (poza Object.prototype). Tym samym unikniemy całkowitego zastąpienia obiektu domyślnego nowym, połączonym obiektem. Poza tym połączenie [[Prototype]] najlepiej traktować jako cechę charakterystyczną tylko do odczytu, ułatwiającą później odczyt kodu. Społeczność JavaScript wypracowała nieoficjalne wyrażenie dunder w odniesieniu do dwóch znaków podkreślenia stosowanych we właściwościach takich jak __proto__. Dlatego też programiści JavaScript będą wymawiali __proto__ jako „dunder proto”. Łącza obiektu Jak teraz zobaczysz, mechanizm [[Prototype]] jest wewnętrznym łączem, które istnieje w obiekcie odwołującym się do pewnego innego obiektu. To połączenie jest wykorzystywane przede wszystkim podczas wykonywania odwołania właściwości lub metody do pierwszego obiektu, a wskazana właściwość lub metoda nie istnieją. W takim przypadku połączenie [[Prototype]] nakazuje silnikowi wyszukanie tej właściwości lub metody w obiekcie połączonym z bieżącym. Jeżeli także połączony obiekt nie może spełnić wyszukiwania, następuje wykorzystanie jego połączenia [[Prototype]] itd. Taka seria połączeń między obiektami tworzy tak zwany łańcuch prototypu. Łącza obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 131 0 Tworzenie łączy za pomocą create() Wcześniej z grubsza wyjaśniłem, dlaczego oferowany przez JavaScript mechanizm [[Prototype]] nie przypomina klas. Dowiedziałeś się również, że tworzy połączenia między prawidłowymi obiektami. Mógłbyś w tym miejscu zapytać, jakie jest przeznaczenie mechanizmu [[Pro totype]]. Dlaczego programiści JavaScript tak często ponoszą tak duży wysiłek, emulując klasy w kodzie, aby powiązać ze sobą wspomniane połączenia? Czy pamiętasz, jak wcześniej w tym rozdziale stwierdziłem, że przydatne będzie wywołanie Object.create(..)? Teraz możesz wreszcie zobaczyć dlaczego: var foo = { something: function() { console.log( "Powiedz mi coś dobrego..." ); } }; var bar = Object.create( foo ); bar.something(); // Powiedz mi coś dobrego... Wywołanie Object.create(..) tworzy nowy obiekt (bar) połączony z podanym obiektem (foo), co daje nam dostęp do potężnych możliwości mechanizmu [[Prototype]]. Odbywa się to bez żadnych niepotrzebnych komplikacji funkcji new działających jako klasy i bez wywołania konstruktora, mylących odwołań .prototype i .constructor oraz wszelkich innych problemów. Wywołanie Object.create(null) tworzy obiekt wraz z pustym (null) połączeniem [[Prototype]], co oznacza, że obiekt nie może być delegowany. Ponieważ tego rodzaju obiekt nie ma łańcucha prototypu, operator instanceof (omówiony wcześniej w rozdziale) nie ma nic do sprawdzenia, a więc zawsze zwraca wartość false. Takie obiekty wraz z pustymi łączami [[Prototype]] są często nazywane słownikami, ponieważ najczęściej są używane wyłącznie do przechowywania danych we właściwościach. Wynika to z faktu, że nie sprawiają żadnych problemów podczas obsługi delegowanych właściwości lub funkcji w łańcuchu [[Prototype]], a tym samym są jednorodnym magazynem danych. 132 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Nie potrzebujemy klas do tworzenia mających znaczenie związków między dwoma obiektami. Tak naprawdę powinniśmy się przejmować jedynie łączeniem obiektów w celu zastosowania wzorca delegowania. Wywołanie Object.create(..) umożliwia utworzenie połączenia bez całego bałaganu związanego z klasami. Skrypt typu polyfill dla Object.create() Wywołanie Object.create(..) zostało wprowadzone w specyfikacji ES5. Być może będziesz musiał zapewnić obsługę środowiska zgodnego z ES5 (na przykład starsze wersje przeglądarek IE). Dlatego też warto spojrzeć na prosty częściowy skrypt typu polyfill dla wywołania Object.create(..). Dzięki niemu funkcjonalność wprowadzoną w nowych specyfikacjach ES będzie można stosować nawet w starszych wersjach środowisk JavaScript: if (!Object.create) { Object.create = function(o) { function F(){} F.prototype = o; return new F(); }; } Powyższy skrypt typu polyfill działa przez wykorzystanie funkcji F: nadpisujemy jej właściwość .prototype w taki sposób, aby prowadziła do obiektu, z którym chcemy powiązać bieżący obiekt. Kolejnym krokiem jest użycie wywołania new F() do utworzenia nowego obiektu zawierającego wskazane połączenie. Z takim sposobem użycia wywołania Object.create(..) można się spotkać najczęściej, ponieważ to jedno z wielu podejść, które może być obsłużone za pomocą skryptów typu polyfill. Wbudowane w ES5 wywołanie Object.create(..) oferuje jeszcze inną funkcjonalność, która jednak nie może być wykorzystana w środowiskach wcześniejszych niż ES5. Dlatego też te możliwości są już rzadziej stosowane. Aby przedstawione wyjaśnienie było pełne, warto spojrzeć także na wspomnianą dodatkową funkcjonalność: var anotherObject = { a: 2 }; var myObject = Object.create( anotherObject, { b: { enumerable: false, Łącza obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 133 0 writable: true, configurable: false, value: 3 }, c: { enumerable: true, writable: false, configurable: false, value: 4 } } ); myObject.hasOwnProperty( "a" ); // Fałsz. myObject.hasOwnProperty( "b" ); // Prawda. myObject.hasOwnProperty( "c" ); // Prawda. myObject.a; // 2 myObject.b; // 3 myObject.c; // 4 Drugi argument wywołania Object.create(..) wskazuje nazwy właściwości, które mają być dodane do nowo utworzonego obiektu za pomocą zadeklarowania deskryptora właściwości (patrz rozdział 3.) dla każdej nowej właściwości. Ponieważ nie ma możliwości użycia skryptu typu polyfill do obsługi deskryptorów właściwości w środowisku wcześniejszym niż ES5, dodatkowa funkcjonalność wywołania Object.create(..) nie będzie obsługiwana za pomocą takiego skryptu. W większości przypadków użycia wywołania Object.create(..) stosowany jest bezpieczny dla skryptów polyfill podzbiór funkcjonalności, dzięki czemu programiści nie mają problemu ze stosowaniem częściowych skryptów typu polyfill w środowiskach wcześniejszych niż ES5. Część programistów stosuje znacznie bardziej restrykcyjne rozwiązanie, bazujące na podejściu, zgodnie z którym żadna funkcja nie powinna być obsługiwana przez skrypty typu polyfill, o ile nie będzie w pełni obsłużona. A że wywołanie Object.create(..) jest jednym z tych obsługiwanych częściowo, to jeśli w środowiskach wcześniejszych niż ES5 potrzebujesz jakiejkolwiek funkcjonalności Object.create(..), zamiast skryptów typu polyfill zastosuj samodzielnie opracowane funkcje pomocnicze i unikaj rozwiązań opierających się na Object.create(..). Możesz przygotować własną funkcję pomocniczą, na przykład podobną do poniższej: 134 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 function createAndLinkObject(o) { function F(){} F.prototype = o; return new F(); } var anotherObject = { a: 2 }; var myObject = createAndLinkObject( anotherObject ); myObject.a; // 2 Nie jestem zwolennikiem tego rodzaju restrykcyjnego podejścia. W pełni popieram zastosowanie przedstawionego wcześniej częściowego skryptu typu polyfill dla wywołania Object.create(..) i użycie go w kodzie uruchamianym nawet w środowiskach wcześniejszych niż ES5. Wybór należy do Ciebie. Łącza jako rozwiązanie awaryjne? Być może kusi Cię, by wspomniane tutaj łącza między obiektami potraktować przede wszystkim jako pewnego rodzaju rozwiązanie awaryjne ze względu na brak właściwości lub metod. Wprawdzie tego rodzaju podejście może przynieść pewne korzyści, ale nie jest to poprawny sposób wykorzystywania mechanizmu [[Prototype]]. Spójrz na poniższy fragment kodu: var anotherObject = { cool: function() { console.log( "Świetnie!" ); } }; var myObject = Object.create( anotherObject ); myObject.cool(); // "Świetnie!" Powyższy fragment kodu działa ze względu na sposób funkcjonowania [[Pro totype]]. Jeśli jednak utworzysz go w taki sposób, że anotherObject będzie rozwiązaniem awaryjnym, gdy obiekt myObject nie może obsłużyć pewnej właściwości lub metody, która może być wywoływana przez innego programistę, wówczas istnieje duże prawdopodobieństwo, że tworzone przez Ciebie oprogramowanie będzie trudniejsze do zrozumienia i obsługi. Łącza obiektu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 135 0 To oczywiście nie oznacza, że nie zdarzają się sytuacje, gdy tego rodzaju rozwiązanie jest odpowiednim wzorcem projektowym. W języku JavaScript nie stosuje się go jednak zbyt często. Dlatego też jeśli korzystasz z niego w kodzie, zrób przerwę, wykonaj krok wstecz i zastanów się, czy naprawdę wybrałeś odpowiedni i rozsądny projekt. W specyfikacji ES6 wprowadzono zaawansowaną funkcjonalność o nazwie proxy, która może dostarczyć pewnego rodzaju zachowanie typu „metoda nie została znaleziona”. Omówienie proxy wykracza poza zakres tematyczny książki, ale tą funkcjonalnością zajmę się szczegółowo w jednej z kolejnych książek serii. Nie przegap tutaj ważnego punktu. Jeżeli projektujesz oprogramowanie przeznaczone dla innych programistów i na przykład opracujesz wywołanie myObject.cool(), które działa nawet w przypadku braku metody cool() w obiekcie myObject, wprowadzasz pewien element zaskoczenia w projekcie API. Przyjęte rozwiązanie może być zaskoczeniem dla programistów, którzy będą się w przyszłości zajmować obsługą utworzonego przez Ciebie oprogramowania. Możesz jednak zaprojektować API w taki sposób, aby wyeliminować wspomniane zaskoczenie, a przy tym nadal wykorzystać potężne możliwości drzemiące w połączeniu za pomocą [[Prototype]]: var anotherObject = { cool: function() { console.log( "Świetnie!" ); } }; var myObject = Object.create( anotherObject ); myObject.doCool = function() { this.cool(); // Wewnętrzne delegowanie! }; myObject.doCool(); // "Świetnie!" W powyższym fragmencie kodu mamy wywołanie myObject.doCool(), które jest metodą faktycznie istniejącą w obiekcie myObject. Dlatego też nasz projekt API będzie jasno i wyraźnie sprecyzowany. Wewnętrznie nasza implementacja stosuje wzorzec projektu delegowania (patrz rozdział 6.) i wykorzystuje zalety delegowania [[Prototype]] do anotherObject.cool(). 136 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Innymi słowy, zastosowanie wzorca delegowania powoduje, że działanie oprogramowania okazuje się mniej zaskakujące i mylące, o ile wspomniany wzorzec będzie szczegółem wewnętrznej implementacji, a nie zostanie po prostu udostępniony w projekcie interfejsu API. Szczegółowe omówienie wzorca delegowania znajdziesz w kolejnym rozdziale. Podsumowanie Próba uzyskania dostępu do nieistniejącej w obiekcie właściwości powoduje, że wewnętrzne połączenie [[Prototype]] w obiekcie definiuje miejsce, w którym następnie powinna działać operacja [[Get]] (patrz rozdział 3.). Tego rodzaju kaskadowe połączenie obiektów w praktyce definiuje łańcuch prototypu (pod pewnymi względami podobny do łańcucha zagnieżdżonego zakresu) obiektów analizowanych w celu znalezienia wskazanej właściwości. Wszystkie zwykłe obiekty na początku swojego łańcucha prototypu (może to na przykład być zakres globalny w przypadku operacji wyszukiwania elementu w danym zakresie) mają wbudowaną właściwość Object.prototype, na której zatrzyma się operacja wyszukiwania, jeśli nigdzie wcześniej w łańcuchu nie zostanie znaleziony szukany element. Obiekt Object.prototype zawiera toString(), valueOf() oraz wiele innych funkcji pomocniczych, co wyjaśnia, dlaczego wszystkie obiekty w języku mogą uzyskać do nich dostęp. Najczęściej w celu połączenia dwóch obiektów ze sobą wykorzystuje się słowo kluczowe new wraz z wywołaniem funkcji. Wspomniane wywołanie jest jednym z czterech kroków (patrz rozdział 2.) procedury odpowiedzialnej za utworzenie nowego obiektu połączonego z innym obiektem. Taki „inny obiekt” połączony z nowym obiektem jest wskazywany przez właściwość o nazwie .prototype funkcji wywoływanej wraz ze słowem kluczowym new. Funkcje wywoływane wraz z new są często nazywane konstruktorami, mimo że w rzeczywistości nie tworzą egzemplarza klasy, jak ma to miejsce w przypadku konstruktorów w tradycyjnych językach obiektowych. Mimo że te mechanizmy JavaScript przypominają tworzenie egzemplarza klasy i dziedziczenie klasy, znane z tradycyjnych języków programowania obiektowego, to nie można zapominać tutaj o podstawowej różnicy między nimi: w JavaScript nie mamy do czynienia z tworzeniem kopii obiektu. Zamiast tego obiekty są ze sobą łączone za pomocą wewnętrznego łańcucha [[Prototype]]. Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 137 0 Z wielu różnych powodów, nie tylko związanych z terminologią, wyrażenia takie jak „dziedziczenie” i „dziedziczenie prototypowe” oraz pozostałe pojęcia programowania obiektowego nie mają większego sensu, jeśli wziąć pod uwagę rzeczywisty sposób działania JavaScript (a nie jedynie modele układane w myślach przez programistę). Zamiast tego „wzorzec delegowania” to znacznie odpowiedniejsze pojęcie, ponieważ wspomniane związki nie są kopiami, ale łączami wzorca delegowania. 138 Rozdział 5. Prototypy helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ROZDZIAŁ 6. Delegowanie W rozdziale 5. dokładnie omówiłem mechanizm [[Prototype]] — dowiedziałeś się, dlaczego pomimo podejmowanych przez niemal dwie dekady niezliczonych prób opisywanie go w kontekście klasy lub dziedziczenia jest mylące. Mozolnie przebrnęliśmy nie tylko przez całkiem rozwlekłą składnię (porozrzucane po całym kodzie .prototype), ale także przez różne pułapki (na przykład zaskakujące rozwiązanie właściwości .constructor lub brzydka, pseudopolimorficzna składnia). Przeanalizowaliśmy również warianty podejścia opartego na domieszkach, które jest przez wielu programistów stosowane w celu ułatwienia pracy. W tym miejscu możesz się zastanawiać, jaki jest sens stosowania tak skomplikowanego rozwiązania do wykonania — jak się wydaje — prostego zadania. Skoro poznałeś wszystkie nieciekawe szczegóły wspomnianego rozwiązania, nie powinno być zaskoczeniem, że większość programistów JavaScript nie decyduje się na jego stosowanie. Zamiast tego obsługę wszelkich szczegółów wybranego podejścia przerzuca na bibliotekę „klas”. Mam nadzieję, że nie wystarczy Ci powierzchowna wiedza i nie zamierzasz ukryć wszystkich szczegółów w „czarnym pudełku” biblioteki. Przejdźmy dalej, aby zobaczyć, jak możemy i jak powinniśmy traktować mechanizm [[Prototype]] w JavaScript w znacznie prostszy i bardziej bezpośredni sposób niż w oparciu o klasy. W podsumowaniu rozdziału 5. stwierdziłem, że mechanizm [[Prototype]] jest wewnętrznym połączeniem istniejącym w obiekcie, który odwołuje się do innego obiektu. 139 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Wspomniane połączenie jest wykorzystywane podczas odwoływania się właściwości lub metody do pierwszego obiektu, a wskazana właściwość lub metoda nie istnieją. W takim przypadku połączenie [[Prototype]] nakazuje silnikowi wyszukanie tej właściwości lub metody w obiekcie połączonym z bieżącym. A jeżeli również ten połączony obiekt nie może spełnić wyszukiwania, wykorzystywane jest jego połączenie [[Prototype]] itd. Taka seria połączeń między obiektami tworzy tak zwany łańcuch prototypu. Innymi słowy, rzeczywisty mechanizm to obiekty połączone z innymi obiektami. Daje nam on dostęp do bardzo ważnej funkcjonalności oferowanej przez JavaScript. To stwierdzenie jest fundamentalne i ma krytyczne znaczenie dla zrozumienia materiału przedstawionego w pozostałej części rozdziału! Projekt oparty na delegowaniu Aby skoncentrować się na użyciu [[Prototype]] w najbardziej bezpośredni sposób, konieczne jest zrozumienie, że ten mechanizm przedstawia zupełnie inny wzorzec projektowy niż klasa (patrz rozdział 4.). Pewne reguły programowania obiektowego nadal zachowują ważność, nie odrzucaj więc zdobytej dotąd wiedzy (a jedynie jej większość!). Na przykład hermetyzacja to technika o całkiem potężnych możliwościach, a na dodatek jest ona zgodna ze wzorcem delegowania (choć niezbyt powszechnie stosowana). Musisz podjąć próbę zmiany sposobu myślenia; zamiast wzorca opartego na klasach i dziedziczeniu lepiej skorzystać ze wzorca bazującego na delegowaniu. Jeżeli do tej pory w pracy programisty wykorzystywałeś przede wszystkim klasy, wspomniana zmiana może się okazać niekomfortowa i nienaturalna. Nieuniknione, że będzie musiało upłynąć nieco czasu, zanim przestawisz się na nowy sposób myślenia. Zacznę od przedstawienia pewnych ćwiczeń teoretycznych, a dopiero później przejdziemy do konkretnych przykładów przedstawiających praktyczny kontekst tworzenia kodu. 140 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Teoria klasy Przyjmujemy założenie, że mamy wiele podobnych zadań (XYZ, ABC itd.), które mają być wykonane przez oprogramowanie. W przypadku podejścia bazującego na klasach taki projekt może się przedstawiać następująco: definiujemy ogólną klasę nadrzędną (bazową), na przykład Task, w której będzie zdefiniowane współdzielone zachowanie dla wszystkich tego rodzaju zadań. Następnie przygotowujemy klasy potomne: XYZ i ABC, dziedziczące po Task, z których każda zawiera specjalizowane zachowanie przeznaczone do obsługi odpowiedniego zadania. Co ważniejsze, projekt oparty na wzorcu klas zachęca do zastosowania możliwości przeciążania metod (i polimorfizmu), aby w maksymalnym stopniu wykorzystać dziedziczenie. Dzięki temu w klasach potomnych (na przykład XYZ) będzie można nadpisywać definicje pewnych ogólnych metod klasy Task, prawdopodobnie wykorzystując wywołanie super w celu odwołania się do wersji bazowej danej metody podczas dodawania kolejnego zachowania do klasy. Ponadto istnieje duże prawdopodobieństwo, że znajdziesz kilka miejsc, w których można zmienić ogólne zachowanie klasy nadrzędnej i dostosować je do własnych potrzeb w klasach potomnych. Poniżej przedstawiłem pewien luźny pseudokod dla powyższej sytuacji: class Task { id; // Konstruktor Task(). Task(ID) { id = ID; } outputTask() { output( id ); } } class XYZ inherits Task { label; // Konstruktor XYZ(). XYZ(ID,Label) { super( ID ); label = Label; } outputTask() { super(); output( label ); } } class ABC inherits Task { // … } Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 141 0 Teraz można utworzyć jedną lub większą liczbę kopii klasy potomnej XYZ, a następnie wykorzystać te egzemplarze do wykonania zadania XYZ. Wspomniane egzemplarze będą miały skopiowane zachowanie zarówno ogólnej klasy Task, jak i konkretnej XYZ. Podobna sytuacja zachodzi w przypadku egzemplarzy klasy ABC, które będą zawierały kopie zachowania ogólnej klasy Task i konkretnej ABC. Po utworzeniu egzemplarzy klas XYZ i ABC, ogólnie rzecz biorąc, będziesz prowadził interakcje jedynie z tymi egzemplarzami (a nie klasami), ponieważ każdy z nich zawiera skopiowane wszystkie funkcje, jakie są niezbędne do wykonania danego zadania. Teoria delegowania Spróbujemy teraz rozważyć ten sam problem, ale opierając się na delegowaniu, a nie na klasach. Przede wszystkim zaczynamy od zdefiniowania obiektu (a nie, jak sądzi większość programistów, klasy czy funkcji) o nazwie Task, zawierającego między innymi różne metody pomocnicze, które później będą mogły być używane do wykonywania różnych zadań (czytaj: będzie można stosować delegowanie do nich!). Następnie dla każdego zadania (XYZ, ABC) definiujemy obiekt przechowujący dane i zachowanie typowe dla danego zadania. Obiekt lub obiekty charakterystyczne dla danego zadania łączymy z obiektem narzędziowym Task, umożliwiając tym samym ich delegowanie, gdy zachodzi potrzeba. Zasadniczo możemy uznać za konieczne wykorzystanie zachowania z dwóch bliźniaczych obiektów (XYZ i Task) do wykonania zadania XYZ. Jednak zamiast łączyć je razem za pomocą kopii klas, zachowujemy oddzielne obiekty i pozwalamy obiektowi XYZ na delegowanie do Task, gdy zachodzi taka potrzeba. Poniżej przedstawiłem prosty fragment kodu, sugerujący, jak można wykonać omówione powyżej zadanie: Task = { setID: function(ID) { this.id = ID; }, outputID: function() { console.log( this.id ); } }; // Obiekt XYZ deleguje do obiektu Task. XYZ = Object.create( Task ); XYZ.prepareTask = function(ID,Label) { this.setID( ID ); 142 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 this.label = Label; }; XYZ.outputTaskDetails = function() { this.outputID(); console.log( this.label ); }; // ABC = Object.create( Task ); // ABC… = … W powyższym fragmencie kodu Task i XYZ nie są klasami (ani funkcjami) — to po prostu obiekty. Obiekt XYZ został skonfigurowany za pomocą Object.create(..) jako delegat [[Prototype]] do obiektu Task (patrz rozdział 5.). W porównaniu do podejścia opartego na klasach (inaczej zorientowanego obiektowo) ten styl nazywam kodem OLOO (ang. object linked to other objects — obiekty połączone z innymi obiektami). Wszystko, co tak naprawdę nas interesuje, to fakt, że obiekt XYZ deleguje do obiektu Task (podobnie czyni także obiekt ABC). W języku JavaScript mechanizm [[Prototype]] łączy obiekty z innymi obiektami. Nie ma mechanizmu abstrakcji takiego jak „klasy”, niezależnie od tego, jak bardzo próbujesz przekonać samego siebie, że jest dokładnie na odwrót. Tę sytuację można porównać do płynięcia łodzią pod prąd: wprawdzie można to zrobić, ale oznacza to pogodzenie się z tym, że będzie znacznie trudniej dotrzeć do miejsca docelowego. Poniżej przedstawiłem inne różnice dotyczące kodu w stylu OLOO, o których warto wiedzieć: 1. Elementy składowe id i label z przedstawionego wcześniej przykładu opartego na klasach są właściwościami danych bezpośrednio w XYZ (nie istnieją w klasie Task). Ogólnie rzecz biorąc, w przypadku delegowania za pomocą [[Prototype]] oczekujemy przechowywania informacji o stanie za pomocą wzorca delegatora (XZY, ABC), a nie w delegacie (Task). 2. W przypadku projektu opartego na klasach celowo użyliśmy nazwy outputTask w klasie nadrzędnej (Task) i potomnej (XYZ), aby można było wykorzystać zalety płynące z przeciążania (polimorfizm). W przypadku delegowania postępujemy odwrotnie: jeżeli to możliwe, unikamy nadawania tych samych nazw na różnych poziomach łańcucha [[Prototype]] Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 143 0 (tak zwane przesłanianie — patrz rozdział 5.), ponieważ tego rodzaju kolizje nazw prowadzą do niewygodnej i zawodnej składni, utrudniającej rozróżnianie odwołań (patrz rozdział 4.). Dlatego też staramy się unikać takiej sytuacji, o ile to możliwe. Ten wzorzec projektowy zniechęca do używania ogólnych nazw metod, które są podatne na przeciążenie. Zamiast tego zalecane jest stosowanie znacznie bardziej opisowych nazw metod, konkretnych dla rodzaju zachowania oferowanego przez dany obiekt. Efektem będzie powstanie kodu łatwiejszego do zrozumienia i późniejszej obsługi, ponieważ nazwy metod (nie tylko w miejscu ich zdefiniowania, ale także rozrzucone po całym kodzie) są znacznie bardziej oczywiste i pomagają w samodokumentacji kodu źródłowego. 3. Polecenie this.setID(ID); wewnątrz metody w obiekcie XYZ najpierw sprawdza XYZ dla setID(..), ale ponieważ nie znajduje w XYZ metody o podanej nazwie, delegowanie [[Prototype]] oznacza możliwość przejścia do Task i przeprowadzenia w wymienionym obiekcie operacji wyszukiwania dla setID(..), która zakończy się powodzeniem. Co więcej, ze względu na reguły wiązania this (patrz rozdział 2.) podczas wykonywania funkcji setID(..) wiązaniem this dla funkcji będzie XYZ, tak jak tego oczekujemy i chcemy. Dzieje się tak mimo znalezienia w obiekcie Task szukanej metody. Taka sama sytuacja występuje także w dalszej części listingu, a dokładnie w this.outputID(). Innymi słowy, ogólne metody narzędziowe istniejące w obiekcie Task są dostępne do użycia podczas interakcji z XYZ, ponieważ obiekt XYZ ma możliwość delegowania do Task. Zachowanie bazujące na delegowaniu oznacza pozwolenie pewnemu obiektowi (XYZ) na delegowanie (do Task) w celu uzyskania dostępu do właściwości lub metody, jeśli nie zostaną one znalezione w obiekcie (XYZ). Ten wzorzec projektowy oferuje niezwykle potężne możliwości i jest zupełnie odmienny od wzorca klas, dziedziczenia, polimorfizmu itd. Zamiast układać w myślach klasy pionowo, począwszy od nadrzędnej do potomnych, wyobraź sobie, że obiekty są ułożone obok siebie i zawierają między sobą niezbędne łącza delegowania w dowolnych kierunkach. 144 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Znacznie poprawniejsze będzie użycie delegowania w charakterze wewnętrznych szczegółów implementacji, a nie jego udostępnienie bezpośrednio w projekcie interfejsu API. W poprzednim przykładzie intencją niekoniecznie było umożliwienie programistom wywoływania XYZ.setID() w naszym projekcie API (choć oczywiście można się na to zgodzić!). W pewien sposób ukryliśmy delegowanie jako wewnętrzny szczegół implementacji API, w której wywołanie XYZ.prepareTask(..) jest delegowane do Task.setID(..). Więcej informacji na ten temat znajdziesz w rozdziale 5., a dokładnie w sekcji „Łącza jako rozwiązanie awaryjne?”. Wzajemne delegowanie (zabronione) Nie można utworzyć cyklu, w którym dwa obiekty lub większa ich liczba są do siebie wzajemnie delegowane (dwukierunkowo). Jeżeli obiekt B połączysz z A, a następnie spróbujesz połączyć A z B, otrzymasz błąd. Nie powinno być zaskoczeniem, choć jednocześnie jest to nieco irytujące, że wspomniane wzajemne delegowanie jest zabronione. Jeżeli wykonasz odwołanie do właściwości lub metody nieistniejącej w żadnym ze sprawdzanych miejsc, wówczas powstanie trwająca w nieskończoność rekurencja pętli [[Prototype]]. Jeżeli wszystkie odwołania będą istniały, wówczas B może delegować do A i na odwrót, a tego rodzaju rozwiązanie mogłoby funkcjonować. W ten sposób dowolny obiekt można delegować do innego w celu wykonywania różnych zadań. Jest jednak tylko kilka sytuacji, w których tego rodzaju podejście byłoby użyteczne. Wzajemne delegowanie jest zabronione, ponieważ programiści pracujący nad implementacjami silników JavaScript zauważyli, że większą wydajność ma jednokrotne wykonywanie operacji sprawdzenia pod kątem trwającego w nieskończoność odwołania cyklicznego (i jego odrzucenie!) niż przeprowadzanie sprawdzania w trakcie każdej operacji wyszukiwania właściwości w obiekcie. Debugowanie Pokrótce zajmiemy się drobnym szczegółem, który może wprowadzić nieco zamieszania wśród programistów. Ogólnie rzecz biorąc, specyfikacja JavaScript nie kontroluje sposobu, w jaki narzędzia programistyczne wbudowane w przeglądarkę internetową powinny przedstawiać programistom określone Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 145 0 wartości lub struktury. Dlatego też interpretacja w tym zakresie może się różnić w poszczególnych przeglądarkach i silnikach, a stąd częste rozbieżności między nimi. Przeanalizujemy teraz zachowanie zaobserwowane jedynie w narzędziach programistycznych przeglądarki Chrome. Spójrz na konstruktor klasy w tradycyjnym stylu kodu JavaScript, jaki zostanie wyświetlony w konsoli narzędzi programistycznych przeglądarki internetowej Chrome: function Foo() {} var a1 = new Foo(); a1; // Foo {} Przyjrzyj się ostatniemu wierszowi w powyższym fragmencie kodu: dane wyjściowe po oszacowaniu wyrażenia a1 to Foo {}. Jeżeli ten sam kod wypróbujesz w przeglądarce internetowej Firefox, prawdopodobnie zobaczysz Object {}. Na czym polega różnica? Co oznaczają te dane wyjściowe? Zachowanie przeglądarki Chrome można wyjaśnić następująco: „{} to pusty obiekt, który został skonstruowany przez funkcję o nazwie Foo”. Z kolei wyjaśnienie zachowania przeglądarki Firefox jest następujące: „{} to pusty obiekt ogólnej konstrukcji Object”. Subtelna różnica polega na tym, że przeglądarka Chrome aktywnie monitoruje — za pomocą wewnętrznej właściwości — nazwę rzeczywistej funkcji, która skonstruowała obiekt, podczas gdy inne przeglądarki nie śledzą tych dodatkowych informacji. Kusząca może być próba wyjaśnienia tego za pomocą mechanizmów JavaScript: function Foo() {} var a1 = new Foo(); a1.constructor; // Foo(){} a1.constructor.name; // "Foo" Czy przeglądarka internetowa Chrome wyświetla Foo tylko z powodu sprawdzenia właściwości .constructor.name obiektu? Zaskakujące, ale odpowiedź to jednocześnie i tak, i nie. Spójrz na poniższy fragment kodu: function Foo() {} var a1 = new Foo(); 146 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Foo.prototype.constructor = function Gotcha(){}; a1.constructor; // Gotcha(){} a1.constructor.name; // "Gotcha" a1; // Foo {} Wprawdzie zmieniamy a1.constructor.name na wciąż prawidłową nazwę (Gotcha), ale w konsoli przeglądarki Chrome nadal wyświetlana jest nazwa Foo. A zatem wydaje się, że odpowiedź na zadane powyżej pytanie (czy używana jest właściwość .constructor.name) brzmi nie — przeglądarka musi sprawdzić nazwę obiektu gdzieś indziej, wewnętrznie. Nie tak szybko! Przekonajmy się jeszcze, jak tego rodzaju zachowanie sprawdza się w przypadku kodu w stylu OLOO: var Foo = {}; var a1 = Object.create( Foo ); a1; // Object {} Object.defineProperty( Foo, "constructor", { enumerable: false, value: function Gotcha(){} }); a1; // Gotcha {} W omawianym przykładzie konsola przeglądarki internetowej Chrome znalazła właściwość .constructor.name i skorzystała z niej. Tak naprawdę w trakcie powstawania książki to konkretne zachowanie zostało uznane za błąd w Chrome, więc kiedy czytasz te słowa, błąd może już być usunięty. Dlatego też wynikiem może być a1; // Object {}. Pomijając już kwestię błędu, wewnętrzne monitorowanie (w rzeczywistości jedynie w celu debugowania danych wyjściowych) nazwy konstruktora przez Chrome (jak pokazałem we wcześniejszym fragmencie kodu) jest przeznaczonym tylko dla tej przeglądarki celowym rozszerzeniem zachowania wykraczającego poza te zdefiniowane w specyfikacji JavaScript. Jeżeli do utworzenia obiektów nie używasz konstruktora, do czego zniechęcam w tym rozdziale w przypadku stosowania kodu w stylu OLOO, otrzymasz obiekty, których nazwa konstruktora nie jest wewnętrznie monitorowana przez Chrome. Tego rodzaju obiekty zostaną wyświetlone prawidłowo jako Object {}, co oznacza „obiekt wygenerowany na podstawie konstrukcji Object()”. Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 147 0 Nawet nie myśl, że można to uznać za wadę tworzenia kodu w stylu OLOO. Podczas przygotowywania kodu w stylu OLOO opartego na wzorcu projektowym delegowania to, kto „skonstruował” (to znaczy, która funkcja została wywołana wraz ze słowem kluczowym new) pewien obiekt, jest nieistotnym szczegółem. Charakterystyczne dla przeglądarki internetowej Chrome wewnętrzne monitorowanie nazwy konstruktora okazuje się naprawdę użyteczne tylko wtedy, gdy w pełni korzystasz ze stylu programowania z użyciem klas. Jednak monitorowanie to staje się dyskusyjne, gdy zamiast klas stosujesz styl delegowania i OLOO. Porównanie modeli mentalnych Teraz, skoro przynajmniej teoretycznie możesz dostrzec różnicę między wzorcami projektowymi „klasy” i „delegowania”, spójrzmy na implikacje zastosowania tych wzorców projektowych w przypadku modeli mentalnych używanych przez nas do uzasadnienia tworzonego kodu. Przeanalizujemy pewien bardziej teoretyczny (Foo i Bar) kod i porównamy oba podejścia (OOP kontra OLOO) w zakresie implementacji kodu. Pierwszy fragment przedstawia przykład użycia klasycznego („prototypowego”) stylu programowania obiektowego: function Foo(who) { this.me = who; } Foo.prototype.identify = function() { return "Jestem " + this.me; }; function Bar(who) { Foo.call( this, who ); } Bar.prototype = Object.create( Foo.prototype ); Bar.prototype.speak = function() { alert( "Witaj, " + this.identify() + "!" ); }; var b1 = new Bar( "b1" ); var b2 = new Bar( "b2" ); b1.speak(); b2.speak(); 148 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Klasa nadrzędna Foo jest dziedziczona przez klasę potomną Bar, która następnie została użyta do utworzenia dwóch egzemplarzy b1 i b2. Mamy więc do czynienia z delegowaniem b1 do obiektu Bar.prototype, który z kolei deleguje do Foo.prototype. Na tym etapie rozwiązanie powinno wyglądać znajomo. Nie znajdziesz w nim żadnych nowych elementów. Przechodzimy teraz do implementacji dokładnie tej samej funkcjonalności za pomocą kodu w stylu OLOO: Foo = { init: function(who) { this.me = who; }, identify: function() { return "Jestem " + this.me; } }; Bar = Object.create( Foo ); Bar.speak = function() { alert( "Witaj, " + this.identify() + "!" ); }; var b1 = b1.init( var b2 = b2.init( Object.create( Bar ); "b1" ); Object.create( Bar ); "b2" ); b1.speak(); b2.speak(); Wykorzystujemy tę samą zaletę delegowania [[Prototype]] z b1 do Bar i później do Foo jak w poprzednim fragmencie kodu między b1, Bar.prototype i Foo.prototype. Wciąż mamy te same trzy obiekty powiązane ze sobą. Jednak, co ważniejsze, znacznie uprościliśmy rozwiązanie, ponieważ teraz jedynie konfigurujemy połączone ze sobą obiekty. Unikamy powstawania niepotrzebnych elementów i ogólnie całego zamieszania związanego z elementami przypominającymi klasy, konstruktory, prototypy i wywołania new (ale nie zachowującymi się jak one!). Zadaj sobie pytanie: czy za pomocą kodu w stylu OLOO mogę uzyskać tę samą funkcjonalność, jaką mam dzięki kodowi w stylu programowania obiektowego? Skoro podejście oparte na OLOO jest prostsze i wymaga zajęcia się mniejszą liczbą szczegółów, to czy nie będzie lepsze? Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 149 0 Przeanalizujmy teraz modele zastosowane w obu przedstawionych powyżej fragmentach kodu. Na początek fragment kodu w stylu programowania zorientowanego obiektowo wymusza zastosowanie modelu mentalnego encji i związków zachodzących między nimi, jak pokazałem na rysunku 6.1. Rysunek 6.1. Graficzne przedstawienie kodu w stylu programowania zorientowanego obiektowo W rzeczywistości to nieco nie w porządku i mylące, ponieważ kod zawiera wiele dodatkowych szczegółów, których technicznie nie musisz znać (choć powinieneś je zrozumieć!). Mamy tutaj do czynienia z całkiem skomplikowaną serią relacji. Jeżeli poświęcisz nieco czasu na śledzenie relacji wskazywanych przez strzałki, będziesz zaskoczony wewnętrzną spójnością w mechanizmach JavaScript. 150 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Na przykład funkcja JavaScript może uzyskać dostęp do call(..), apply(..) i bind(..) (patrz rozdział 2.), ponieważ funkcje są obiektami i mają połączenie [[Prototype]] z obiektem Function.prototype definiującym wymienione metody domyślne. Następnie do nich może być delegowana dowolna funkcjaobiekt. Skoro JavaScript może to zrobić, możesz i Ty! Dobrze, spójrzmy teraz (patrz rysunek 6.2) na uproszczoną wersję diagramu, która pozwala na nieco bardziej „uczciwe” porównanie — zawiera jedynie niezbędne encje i relacje. Rysunek 6.2. Uproszczona wersja wykresu przedstawiającego kod w stylu programowania obiektowego Projekt oparty na delegowaniu helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 151 0 Nadal mamy całkiem skomplikowany wykres, prawda? Przerywane linie pokazują implikowane relacje podczas konfiguracji dziedziczenia między Foo. prototype i Bar.prototype, przy czym nie uwzględniono poprawionego brakującego odwołania .constructor (patrz sekcja „Powrót konstruktora” w rozdziale 5.). Nawet po usunięciu wspomnianych przerywanych linii model wciąż pozostaje skomplikowany, co oznacza dla nas więcej pracy podczas korzystania z połączonych obiektów. Teraz spójrz na pokazany na rysunku 6.3 model przeznaczony dla kodu w stylu OLOO. Rysunek 6.3. Graficzne przedstawienie kodu w stylu OLOO 152 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Jak możesz zobaczyć, porównując przedstawione modele, w przypadku stylu OLOO nie musisz się przejmować wieloma szczegółami. W stylu OLOO wykorzystujemy fakt, że jedyną interesującą nas zawsze kwestią są obiekty połączone z innymi obiektami. Wszelkie pozostałe szczegóły związane z „klasami” są mylące i niepotrzebnie komplikują rozwiązanie, które daje taki sam efekt jak w stylu OLOO. Pozbądź się wspomnianych elementów, a wszystko stanie się znacznie prostsze (bez utraty jakiejkolwiek funkcjonalności). Klasy kontra obiekty Przedstawiłem już różne teoretyczne i mentalne modele dla rozwiązań opartych na „klasach” oraz „wzorcu delegowania”. Warto teraz spojrzeć na konkretne scenariusze kodu, aby poznać praktyczne przykłady użycia omówionych koncepcji. Na początek zabieramy się za typowy scenariusz programisty sieciowego, czyli utworzenie widżetów interfejsu użytkownika (przyciski, rozwijane menu itd.). „Klasy” widżetów Ponieważ prawdopodobnie wciąż jesteś przywiązany do wzorca projektowego programowania zorientowanego obiektowo, więc przygotowując rozwiązanie danego problemu, zwykle opierasz je na klasie nadrzędnej (na przykład o nazwie Widget), definiującej całą podstawową funkcjonalność widżetu, i klasach potomnych (na przykład Button) przeznaczonych dla poszczególnych typów widżetów. W tym miejscu do przeprowadzenia operacji zarówno na modelu DOM, jak i tych związanych z CSS wykorzystamy bibliotekę jQuery, ale tylko dlatego, że są to szczegóły zupełnie nieistotne dla przedstawianego tutaj wyjaśnienia. Tego rodzaju kod nie ma żadnego związku z frameworkami JS (jQuery, Dojo, YUI itd.), które możesz wykorzystać do wykonania tak przyziemnych zadań. Zaczynamy od analizy sposobu implementacji projektu „klasy” w klasycznym stylu JavaScript bez użycia jakiejkolwiek składni lub biblioteki pomocniczej „klas”: Klasy kontra obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 153 0 // Klasa nadrzędna. function Widget(width,height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } Widget.prototype.render = function($where){ if (this.$elem) { this.$elem.css( { width: this.width + "px", height: this.height + "px" } ).appendTo( $where ); } }; // Klasa potomna. function Button(width,height,label) { // Wywołanie konstruktora "super". Widget.call( this, width, height ); this.label = label || "Domyślny"; this.$elem = $( "<button>" ).text( this.label ); } // Klasa 'Button' "dziedziczy" po klasie 'Widget'. Button.prototype = Object.create( Widget.prototype ); // Nadpisanie bazowej "odziedziczonej" metody render(..). Button.prototype.render = function($where) { // Wywołanie "super". Widget.prototype.render.call( this, $where ); this.$elem.click( this.onClick.bind( this ) ); }; Button.prototype.onClick = function(evt) { console.log( "Przycisk '" + this.label + "' został kliknięty!" ); }; $( document ).ready( function(){ var $body = $( document.body ); var btn1 = new Button( 125, 30, "Witaj," ); var btn2 = new Button( 150, 40, "świecie" ); btn1.render( $body ); btn2.render( $body ); } ); Wzorce projektowe programowania obiektowego nakazują zadeklarowanie metody bazowej render(..) w klasie nadrzędnej, a następnie nadpisanie jej w klasie potomnej. Nie mamy tutaj do czynienia z całkowitym zastąpieniem metody, 154 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 ale raczej ze zmianą jej podstawowej funkcjonalności i dopasowaniem do zachowania charakterystycznego dla przycisku. W omawianym kodzie zwróć uwagę na wyraźny pseudopolimorfizm (patrz rozdział 4.) w postaci odwołań Widget.call i Widget.prototype.render.call, zastosowany w celu oszukania wywołań „super” z poziomu metod „klasy” potomnej do metod bazowych „klasy nadrzędnej”. Fuj! Składnia class w ES6 Dokładne omówienie składni słowa kluczowego class wprowadzonego w specyfikacji ES6 znajdziesz w dodatku A. Tutaj chcę jedynie pokrótce pokazać, jak ten sam kod można zaimplementować właśnie z użyciem class: class Widget { constructor(width,height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } render($where){ if (this.$elem) { this.$elem.css( { width: this.width + "px", height: this.height + "px" } ).appendTo( $where ); } } } class Button extends Widget { constructor(width,height,label) { super( width, height ); this.label = label || "Domyślny"; this.$elem = $( "<button>" ).text( this.label ); } render($where) { super( $where ); this.$elem.click( this.onClick.bind( this ) ); } onClick(evt) { console.log( "Przycisk '" + this.label + "' został kliknięty!" ); } } $( document ).ready( function(){ var $body = $( document.body ); Klasy kontra obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 155 0 var btn1 = new Button( 125, 30, "Witaj," ); var btn2 = new Button( 150, 40, "świecie" ); btn1.render( $body ); btn2.render( $body ); } ); Nie ulega wątpliwości, że pewne brzydkie fragmenty składni widoczne w poprzednim podejściu klasycznym zostały wyeliminowane dzięki użyciu słowa kluczowego class. Obecność wywołania super(..) wydaje się szczególnie użyteczna (choć kiedy się dokładniej nad tym zastanowisz, nie wszystko jest usłane różami!). Pomimo syntaktycznych usprawnień nie są to rzeczywiste klasy, ponieważ ich działanie wciąż bazuje na mechanizmie [[Prototype]]. Problemem nadal są te same niedopasowania, które wyjaśniłem w rozdziałach 4. i 5. oraz w bieżącym. W dodatku A dokładnie omówię wprowadzoną w specyfikacji ES6 składnię słowa kluczowego class i związane z nim implikacje. Dowiesz się, dlaczego rozwiązanie drobnych problemów ze składnią nie oznacza uniknięcia zamieszania dotyczącego klas w JavaScript, mimo wysiłków programistów! Niezależnie od tego, czy używasz klasycznej składni prototypów, czy nowej, wprowadzonej przez ES6, nadal dokonujesz wyboru modelowania problemu (widżety interfejsu użytkownika) za pomocą „klas”. Jak to próbowałem pokazać w kilku poprzednich rozdziałach, dokonanie tego wyboru w JavaScript wiąże się z dodatkowymi problemami do rozwiązania. Delegowanie obiektów widżetów Poniżej przedstawiłem przygotowany w stylu OLOO kod prostszej wersji przykładu Widget/Button, wykorzystujący delegowanie: var Widget = { init: function(width,height){ this.width = width || 50; this.height = height || 50; this.$elem = null; }, insert: function($where){ if (this.$elem) { this.$elem.css( { width: this.width + "px", height: this.height + "px" } ).appendTo( $where ); 156 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 } } }; var Button = Object.create( Widget ); Button.setup = function(width,height,label){ // Delegowane wywołanie. this.init( width, height ); this.label = label || "Domyślny"; this.$elem = $( "<button>" ).text( this.label ); }; Button.build = function($where) { // Delegowane wywołanie. this.insert( $where ); this.$elem.click( this.onClick.bind( this ) ); }; Button.onClick = function(evt) { console.log( "Przycisk '" + this.label + "' został kliknięty!" ); }; $( document ).ready( function(){ var $body = $( document.body ); var btn1 = Object.create( Button ); btn1.setup( 125, 30, "Witaj," ); var btn2 = Object.create( Button ); btn2.setup( 150, 40, "świecie" ); btn1.build( $body ); btn2.build( $body ); } ); W przypadku powyższego kodu w stylu OLOO nie traktujemy Widget jako obiektu nadrzędnego, natomiast Button jako potomnego. Zamiast tego Widget jest po prostu obiektem i pewnego rodzaju kolekcją pomocniczą, do której może być delegowany dowolnego typu widżet. Button to również oddzielny obiekt, ale oczywiście wraz z łączem delegowania prowadzącym do Widget. Z perspektywy wzorca projektowego w obu obiektach nie współdzielimy tej samej metody o nazwie render(..), jak można by sądzić na podstawie kodu klasy, ale decydujemy się na wybór różnych nazw (tutaj insert(..) i build(..)), które znacznie dokładniej opisują przeznaczenie poszczególnych zadań. Metody inicjalizacyjne są nazwane odpowiednio init(..) i setup(..), z tych samych powodów. Klasy kontra obiekty helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 157 0 Przedstawiony wzorzec delegowania nie tylko sugeruje użycie różnych i bardziej opisowych nazw (zamiast współdzielonych i ogólnych), ale podejście w stylu OLOO pozwala jednocześnie na uniknięcie wyraźnych wywołań pseudopolimorfizmu (Widget.call i Widget.prototype.render.call). W kodzie możesz natomiast dostrzec proste, względne, delegowane wywołania this.init(..) i this.insert(..). Pod względem syntaktycznym nie mamy również żadnych konstruktorów i tak naprawdę .prototype i new okazują się po prostu zbędne. Jeżeli dokładnie przyjrzysz się przedstawionemu kodowi, zauważysz, że to, co wcześniej było pojedynczym wywołaniem (var btn1 = new Button(..)), teraz zostało podzielone na dwa (var btn1 = Object.create(Button) i btn1.setup(..)). Początkowo takie podejście może się wydawać wadą (większa ilość kodu). Jednak nawet takie rozwiązanie w kodzie utworzonym w stylu OLOO można uznać za plus w porównaniu do klasycznego stylu prototypu. Jak to możliwe? W przypadku konstruktorów klas jesteś zmuszony (a przynajmniej silnie zachęcany) do przeprowadzenia operacji tworzenia i inicjalizacji obiektu w tym samym kroku. Jednak bardzo często wykonanie wymienionych zadań w dwóch krokach (jak ma to miejsce w stylu OLOO) jest znacznie elastyczniejsze. Przyjmijmy na przykład założenie, że tworzymy wszystkie egzemplarze w puli na początku programu, natomiast wstrzymujemy się z inicjalizacją aż do chwili pobrania danego egzemplarza z puli i jego użycia. W kodzie pokazałem, że oba wywołania mają miejsce jedno po drugim, ale oczywiście mogą wystąpić w różnym czasie i w odmiennych fragmentach kodu, jeśli zachodzi taka potrzeba. Styl OLOO lepiej obsługuje zasadę podziału obowiązków, kiedy operacje tworzenia i inicjalizacji niekoniecznie muszą być przeprowadzane jednocześnie. Prostszy projekt Poza tym, że styl OLOO charakteryzuje się pozornie prostszym (i znacznie elastyczniejszym!) kodem, delegowanie jako wzorzec może faktycznie prowadzić do prostszej architektury kodu. Przeanalizujemy teraz ostatni przykład, pokazujący, jak styl OLOO upraszcza projekt. 158 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 W omawianym projekcie znajdują się dwa obiekty kontrolera. Jeden z nich jest przeznaczony do obsługi strony internetowej wyświetlającej formularz logowania, podczas gdy drugi odpowiada za faktyczne przeprowadzenie uwierzytelnienia (komunikacji) w serwerze. Potrzebujemy funkcji pomocniczej, która będzie odpowiadała za komunikację z serwerem za pomocą żądań Ajax. Decydujemy się na użycie biblioteki jQuery (choć tutaj sprawdzi się dowolny framework), ponieważ nie tylko zapewnia obsługę żądań Ajax, ale również zwraca odpowiedź w postaci obietnicy. W kodzie wywołującym żądanie możemy więc nasłuchiwać odpowiedzi za pomocą .then(..). Wprawdzie tutaj nie przedstawię obietnic, ale ich dokładne omówienie znajdziesz w książce Asynchroniczność i wydajność z tej samej serii. Stosując typowy wzorzec projektu opartego na klasach, podstawową funkcjonalność zadania umieszczamy w klasie o nazwie Controller, a następnie tworzymy dwie klasy potomne LoginController i AuthController dziedziczące po klasie Controller i nadpisujące niektóre z jej funkcji podstawowych: // Klasa nadrzędna. function Controller() { this.errors = []; } Controller.prototype.showDialog = function(title,msg) { // Wyświetlenie użytkownikowi tytułu i wiadomości w oknie dialogowym. }; Controller.prototype.success = function(msg) { this.showDialog( "Sukces", msg ); }; Controller.prototype.failure = function(err) { this.errors.push( err ); this.showDialog( "Błąd", err ); }; // Klasa potomna. function LoginController() { Controller.call( this ); } // Połączenie klasy potomnej z nadrzędną. LoginController.prototype = Object.create( Controller.prototype ); LoginController.prototype.getUser = function() { return document.getElementById( "login_username" ).value; Prostszy projekt helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 159 0 }; LoginController.prototype.getPassword = function() { return document.getElementById( "login_password" ).value; }; LoginController.prototype.validateEntry = function(user,pw) { user = user || this.getUser(); pw = pw || this.getPassword(); if (!(user && pw)) { return this.failure( "Proszę podać nazwę użytkownika i hasło!" ); } else if (pw.length < 5) { return this.failure( "Hasło musi zawierać przynajmniej 5 znaków!" ); } // Dotarłeś tutaj? Zostałeś uwierzytelniony! return true; }; // Nadpisanie w celu rozszerzenia metody bazowej failure(). LoginController.prototype.failure = function(err) { // Wywołanie "super". Controller.prototype.failure.call( this, "Logowanie nieprawidłowe: " + err ); }; // Klasa potomna. function AuthController(login) { Controller.call( this ); // Poza dziedziczeniem potrzebujemy również kompozycji. this.login = login; } // Połączenie klasy potomnej z nadrzędną. AuthController.prototype = Object.create( Controller.prototype ); AuthController.prototype.server = function(url,data) { return $.ajax( { url: url, data: data } ); }; AuthController.prototype.checkAuth = function() { var user = this.login.getUser(); 160 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 var pw = this.login.getPassword(); if (this.login.validateEntry( user, pw )) { this.server( "/check-auth",{ user: user, pw: pw } ) .then( this.success.bind( this ) ) .fail( this.failure.bind( this ) ); } }; // Nadpisanie w celu rozszerzenia metody bazowej success(). AuthController.prototype.success = function() { // Wywołanie "super". Controller.prototype.success.call( this, "Uwierzytelniony!" ); }; // Nadpisanie w celu rozszerzenia metody bazowej failure(). AuthController.prototype.failure = function(err) { // Wywołanie "super". Controller.prototype.failure.call( this, "Uwierzytelnienie nieprawidłowe: " + err ); }; var auth = new AuthController( // Poza dziedziczeniem potrzebujemy również kompozycji. new LoginController() ); auth.checkAuth(); W powyższym kodzie mamy funkcje podstawowe współdzielone przez wszystkie kontrolery. Do wspomnianego zachowania zaliczamy funkcje success(..), failure(..) i showDialog(..). Nasze klasy potomne LoginController i Auth Controller nadpisują metody failure(..) i success(..) w celu zmiany domyślnego zachowania klasy bazowej. Ponadto zwróć uwagę, że AuthController wymaga egzemplarza LoginController do pracy z formularzem logowania, a więc staje się właściwością danych składowych. Inną kwestią, na którą trzeba zwrócić uwagę, jest fakt, że zdecydowaliśmy się na zastosowanie pewnej kompozycji na bazie dziedziczenia. Kontroler AuthController musi wiedzieć o istnieniu LoginController, a więc tworzymy go (polecenie new LoginController()) i zachowujemy właściwość składowej klasy o nazwie this.login jako odwołania, aby kontroler AuthController mógł wywoływać zachowanie LoginController. Prostszy projekt helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 161 0 Może pojawić się pokusa, aby kontroler AuthController dziedziczył po LoginController lub na odwrót, jakby istniała wirtualna kompozycja przygotowana za pomocą łańcucha dziedziczenia. Jednak to czytelny przykład pokazujący, że zastosowanie dziedziczenia klas do rozwiązania tego problemu jest niewłaściwe, ponieważ ani AuthController, ani LoginController nie nadpisują funkcji bazowych. Dlatego też dziedziczenie między wymienionymi kontrolerami nie ma sensu za wyjątkiem sytuacji, gdy klasy są jedynym wzorcem projektowym. Zamiast tego ułożyliśmy je w pewną prostą kompozycję, umożliwiając im tym samym kooperację, przy czym obie wciąż korzystają z zalet dziedziczenia po bazowej klasie nadrzędnej Controller. Jeżeli programowanie w stylu zorientowanym obiektowo jest Ci znane, przedstawione tutaj informacje nie powinny być zaskoczeniem. Pozbywamy się klas Z pewnością zadajesz sobie teraz pytanie, czy do modelowania problemu naprawdę potrzebujemy klasy nadrzędnej, dwóch klas potomnych i pewnej kompozycji. Czy istnieje możliwość wykorzystania delegowania w kodzie w stylu OLOO i przygotowania znacznie prostszego projektu? Tak! var LoginController = { errors: [], getUser: function() { return document.getElementById( "login_username" ).value; }, getPassword: function() { return document.getElementById( "login_password" ).value; }, validateEntry: function(user,pw) { user = user || this.getUser(); pw = pw || this.getPassword(); if (!(user && pw)) { return this.failure( "Proszę podać nazwę użytkownika i hasło!" ); 162 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 } else if (pw.length < 5) { return this.failure( "Hasło musi zawierać przynajmniej 5 znaków!" ); } // Dotarłeś tutaj? Zostałeś uwierzytelniony! return true; }, showDialog: function(title,msg) { // Wyświetlenie użytkownikowi komunikatu sukcesu w oknie dialogowym }, failure: function(err) { this.errors.push( err ); this.showDialog( "Błąd", "Logowanie nieprawidłowe: " + err ); } }; // Obiekt AuthController zostaje połączony z LoginController i deleguje do niego. var AuthController = Object.create( LoginController ); AuthController.errors = []; AuthController.checkAuth = function() { var user = this.getUser(); var pw = this.getPassword(); if (this.validateEntry( user, pw )) { this.server( "/check-auth",{ user: user, pw: pw } ) .then( this.accepted.bind( this ) ) .fail( this.rejected.bind( this ) ); } }; AuthController.server = function(url,data) { return $.ajax( { url: url, data: data } ); }; AuthController.accepted = function() { this.showDialog( "Sukces", "Uwierzytelniony!" ) }; AuthController.rejected = function(err) { this.failure( "Uwierzytelnienie nieprawidłowe: " + err ); }; Prostszy projekt helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 163 0 Ponieważ AuthController jest po prostu obiektem (podobnie jak LoginController), nie musimy tworzyć egzemplarza (na przykład za pomocą new AuthController()) w celu wykonania zadania. Konieczne jest jedynie wydanie polecenia: AuthController.checkAuth(); Oczywiście w przypadku stylu OLOO, jeśli zachodzi potrzeba utworzenia jednego lub większej liczby obiektów dodatkowych w łańcuchu delegowania, można to zrobić bardzo łatwo, a przy tym nadal nie ma konieczności wykonania operacji takiej jak tworzenie egzemplarzy klasy: var controller1 = Object.create( AuthController ); var controller2 = Object.create( AuthController ); W przypadku podejścia opartego na delegowaniu AuthController i LoginController to po prostu obiekty, ułożone poziomo obok siebie, a nie zorganizowane w postaci obiektów nadrzędnych i potomnych, jak w rozwiązaniach bazujących na klasach. Mamy dowolność w definiowaniu delegowania. Zdecydowaliśmy, że AuthController deleguje do LoginController, ale delegowanie równie dobrze mogłoby zostać zdefiniowane w przeciwnym kierunku. W omawianym tutaj fragmencie kodu mamy jedynie dwie encje (LoginController i AuthController), a nie trzy, jak było wcześniej. Nie potrzebujemy klasy bazowej Controller, która będzie „współdzieliła” zachowanie między dwoma wcześniej wymienionymi kontrolerami, ponieważ delegowanie to mechanizm o potężnych możliwościach i oferuje nam całą niezbędną funkcjonalność. Jak wcześniej wspomniałem, nie ma potrzeby tworzenia egzemplarzy klas, aby pracować z encjami, ponieważ nie mamy klas, a jedynie obiekty. Co więcej, unikamy konieczności przygotowania kompozycji, gdyż we wzorcu delegowania dwa obiekty mogą współpracować odmiennie, jeśli zachodzi taka potrzeba. Udało nam się uniknąć pułapek związanych z polimorfizmem w projekcie zorientowanym obiektowo, ponieważ nie mamy metod success(..) i failure(..) w obu obiektach, co wymagałoby zastosowania wyraźnego pseudopolimorfizmu. Zamiast tego w obiekcie AuthController nadajemy metodom nazwy accepted() i rejected(), które znacznie dokładniej opisują wykonywane przez nie zadania. Ostatecznie mamy tę samą funkcjonalność, ale przy użyciu (zdecydowanie) prostszego projektu. To jest właśnie potęga kodu w stylu OLOO oraz wzorca projektowego delegowania. 164 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Ładniejsza składnia Jedną z miłych cech powodujących, że wprowadzone w specyfikacji ES6 słowo kluczowe class wydaje się niezwykle atrakcyjne (zajrzyj do dodatku A, aby dowiedzieć się, dlaczego należy go unikać!), jest skrócona składnia przeznaczona do deklarowania metod klasy: class Foo { methodName() { /* .. */ } } Zyskujemy możliwość pozbycia się słowa kluczowego function z deklaracji funkcji, co niewątpliwie cieszy programistów JavaScript! W poprzednim kodzie w stylu OLOO prawdopodobnie zauważyłeś (a nawet mogłeś być sfrustrowany z tego powodu), że mieliśmy wiele wystąpień słowa kluczowego function, co raczej mija się z celem, jakim jest uproszczona składnia oferowana przez styl OLOO. Tak jednak nie musi być! Począwszy od wydania ES6, możemy używać zwięzłych deklaracji metod w dowolnym literale obiektu, a więc obiekt w stylu OLOO może zostać zadeklarowany następująco (skrót podobny jak w przypadku składni z użyciem class): var LoginController = { errors: [], getUser() { // Zauważ brak słowa kluczowego 'function'! // … }, getPassword() { // … } // … }; Praktycznie jedyna różnica polega na tym, że literały obiektów nadal wymagają użycia między elementami separatorów w postaci przecinka (,), podczas gdy składnia class tego nie wymaga. To całkiem niewielkie ustępstwo w całym rozwiązaniu. Co więcej, począwszy od wydania ES6, cała dotychczasowa składnia (na przykład definicja AuthController), w której właściwości są przypisywane pojedynczo i nie wykorzystujemy literału obiektu, może być zastąpiona literałem obiektu. To pozwala na użycie zwięzłych metod i po prostu modyfikację [[Prototype]] obiektu za pomocą Object.setPrototypeOf(..), jak pokazałem poniżej: Ładniejsza składnia helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 165 0 // Użycie ładniejszej składni literału obiektu wraz ze zwięzłymi metodami! var AuthController = { errors: [], checkAuth() { // … }, server(url,data) { // … } // … }; // TERAZ łączymy AuthController z LoginController. Object.setPrototypeOf( AuthController, LoginController ); Styl OLOO w wydaniu ES6 wraz ze zwięzłymi metodami stał się znacznie przyjaźniejszy niż wcześniej (choć nawet wcześniej był dużo prostszy i bardziej elegancki niż klasyczny kod w stylu prototypu). Nie musisz decydować się na użycie klas (poziom skomplikowania), aby otrzymać dostęp do ładnej, przejrzystej składni obiektu! Nieleksykalny Istnieje pewna wada metod zwięzłych, która jest subtelna, choć niezwykle ważna. Spójrz na poniższy fragment kodu: var Foo = { bar() { /*..*/ }, baz: function baz() { /*..*/ } }; Poniżej pokazałem, w jaki sposób ten kod będzie działał: var Foo = { bar: function() { /*..*/ }, baz: function baz() { /*..*/ } }; Czy widzisz różnicę? Skrót w postaci bar() stał się wyrażeniem funkcji anonimowej (function(..)) dołączonej do właściwości bar, ponieważ sam obiekt funkcji nie ma identyfikatora w postaci nazwy. Porównaj to z ręcznie podanym wyrażeniem funkcji nazwanej (function baz()..), które ma leksykalny identyfikator nazwy (tutaj baz) i jest dołączony do właściwości .baz. I co teraz? W innej książce z tej serii (Zakresy i domknięcia) szczegółowo przedstawiłem trzy największe wady wyrażeń funkcji anonimowych. Teraz jedynie je tutaj wymienię, aby umożliwić porównanie ze skrótem w postaci zwięzłych metod. 166 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Brak identyfikatora name w funkcji anonimowej: 1. Oznacza trudniejsze debugowanie stosu wywołań. 2. Oznacza trudniejsze odwoływanie się funkcji do samej siebie (rekurencja, dołączanie zdarzeń itd.). 3. Oznacza powstanie kodu (nieco) trudniejszego do zrozumienia. Punkty 1. i 3. nie mają zastosowania dla metod zwięzłych. Nawet w przypadku wyrażeń funkcji anonimowej, która nie ma nazwy w stosie wywołań, metody zwięzłe są przygotowane do odpowiedniego ustawienia wartości wewnętrznej właściwości name obiektu funkcji. Dlatego też stos wywołań powinien być w stanie używać tych nazw (wszystko jednak zależy od implementacji i nie masz w tym zakresie żadnych gwarancji). Punkt 2. niestety jest wciąż wadą metod zwięzłych, ponieważ nie będą miały one leksykalnego identyfikatora pozwalającego na odwołanie się do samej siebie. Spójrz na poniższy fragment kodu: var Foo = { bar: function(x) { if (x < 10) { return Foo.bar( x * 2 ); } return x; }, baz: function baz(x) { if (x < 10) { return baz( x * 2 ); } return x; } }; W powyższym przykładzie ręczne odwołanie Foo.bar(x*2) okazuje się wystarczające, ale istnieje wiele przypadków, w których funkcja niekoniecznie będzie mogła działać w taki właśnie sposób. Przykładem może być sytuacja, gdy funkcja jest współdzielona w delegowaniu między różnymi obiektami za pomocą wiązania this itd. Konieczne jest użycie rzeczywistego odwołania do samej siebie, a identyfikator name obiektu funkcji będzie najlepszym sposobem wykonania takiego zadania. Powinieneś mieć świadomość istnienia przedstawionej wady metod zwięzłych. Jeżeli napotkasz jakiekolwiek problemy związane z brakiem odwołania Ładniejsza składnia helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 167 0 do samej siebie, wówczas zrezygnuj ze składni metody zwięzłej dla tej konkretnej deklaracji i zamiast niej zastosuj ręczną deklarację wyrażenia funkcji nazwanej w postaci: baz: function baz(){..}. Introspekcja Jeżeli poświęciłeś dużo czasu na programowanie zorientowane obiektowo (w JavaScript lub innych językach programowania), prawdopodobnie znasz już koncepcję introspekcji typu: sprawdzenia egzemplarza w celu ustalenia, jakiego rodzaju jest to obiekt. Podstawowym celem introspekcji typu w egzemplarzach klas jest znalezienie uzasadnienia dla struktury i możliwości obiektu na podstawie sposobu jego utworzenia. Spójrz na przedstawiony poniżej kod przedstawiający użycie operatora instanceof (patrz rozdział 5.) przeznaczonego do introspekcji obiektu a1 w celu ustalenia jego możliwości. function Foo() { // … } Foo.prototype.something = function(){ // … } var a1 = new Foo(); // Później. if (a1 instanceof Foo) { a1.something(); } Ponieważ Foo.prototype (nie Foo!) znajduje się w łańcuchu [[Prototype]] (patrz rozdział 5.) obiektu a1, operator instanceof próbuje nam powiedzieć, że a1 jest egzemplarzem „klasy” Foo (co jest mylącym zabiegiem). Mając tę wiedzę, przyjmujemy założenie, że obiekt a1 ma możliwości opisane przez „klasę” Foo. Oczywiście klasa Foo nie istnieje — mamy jedynie zwykłą funkcję Foo zawierającą odwołanie do innego obiektu (Foo.prototype), do którego a1 zostaje oddelegowany. Z powodu stosowanej składni operator instanceof udaje, że sprawdza związek między a1 i Foo, choć tak naprawdę informuje nas, czy a1 i Foo.prototype (dowolny obiekt, do którego się odwołuje a1) są ze sobą powiązane. 168 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Semantyka i pośredniość składni instanceof sprawiają, że w celu użycia opartej na tym operatorze introspekcji do sprawdzenia, czy obiekt a1 jest powiązany ze wskazanym obiektem, konieczne będzie przygotowanie funkcji zawierającej odwołanie do drugiego obiektu. Nie ma możliwości bezpośredniego sprawdzenia, czy dwa obiekty są ze sobą powiązane. Przypomnij sobie przykład Foo/Bar/b1 przedstawiony we wcześniejszej części rozdziału. Poniżej zamieściłem jego skróconą wersję: function Foo() { /* .. */ } Foo.prototype... function Bar() { /* .. */ } Bar.prototype = Object.create( Foo.prototype ); var b1 = new Bar( "b1" ); Na potrzeby introspekcji typu encji wykorzystanych w przykładzie używamy semantyki instanceof i .prototype w celu przeprowadzenia różnych operacji sprawdzenia, które będą musiały być wykonane: // Powiązanie obiektów 'Foo' i 'Bar' ze sobą. Bar.prototype instanceof Foo; // Prawda. Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // Prawda. Foo.prototype.isPrototypeOf( Bar.prototype ); // Prawda. // Powiązanie 'b'1 zarówno z 'Foo', jak i z 'Bar'. b1 instanceof Foo; // Prawda. b1 instanceof Bar; // Prawda. Object.getPrototypeOf( b1 ) === Bar.prototype; // Prawda. Foo.prototype.isPrototypeOf( b1 ); // Prawda. Bar.prototype.isPrototypeOf( b1 ); // Prawda. Można śmiało powiedzieć, że takie rozwiązanie jest niedobre. Na przykład intuicyjnie (z zastosowaniem klas) można użyć Bar instanceof Foo (ponieważ łatwo zmienić znaczenie słowa „egzemplarz”, aby oznaczało także „dziedziczenie”), ale to nie będzie rozsądne porównanie w JavaScript. Zamiast tego konieczne jest użycie Bar.prototype instanceof Foo. Innym często spotykanym, choć mniej niezawodnym wzorcem dla introspekcji typu, preferowanym przez wielu programistów zamiast operatora instanceof, jest tak zwane „kacze typowanie”. To pojęcie bierze się z następującego porzekadła: „Jeżeli coś wygląda jak kaczka i wydaje dźwięki jak kaczka, musi być kaczką”. Introspekcja helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 169 0 Na przykład: if (a1.something) { a1.something(); } Zamiast przeprowadzać introspekcję pod kątem związku między a1 i obiektem przechowującym delegowaną funkcję something(), przyjmujemy założenie, że zaliczenie testu a1.something oznacza, iż a1 ma możliwość wywołania .something() (niezależnie od tego, czy metoda zostanie znaleziona bezpośrednio w a1, czy po delegowaniu do pewnego innego obiektu). Szczerze mówiąc, tego rodzaju założenie jest zbyt ryzykowne. Jednak kacze typowanie jest często rozszerzane o inne założenia dotyczące możliwości obiektu, nie tylko te testowane, co oczywiście wprowadza jeszcze większe ryzyko (i bardziej zawodną konstrukcję) do testu. Jeden z wartych tutaj wymienienia przykładów kaczego typowania wiąże się z wprowadzonymi w specyfikacji ES6 obietnicami (które jak już wcześniej wspomniałem, nie zostaną omówione w książce). Z wielu różnych powodów może wystąpić konieczność ustalenia, czy dany obiekt jest odwołaniem do obietnicy. Jednak sposób przeprowadzania testu opiera się na sprawdzeniu, czy dany obiekt zawiera funkcję then(). Innymi słowy, jeśli obiekt zawiera funkcję then(), wówczas wprowadzony w ES6 mechanizm obietnic przyjmuje bezwarunkowe założenie, że dany obiekt jest „thenable”. To pociąga za sobą oczekiwanie, że jego zachowanie będzie zgodne ze standardowym zachowaniem obietnic. Jeżeli masz obiekt inny niż obietnica, który zawiera z jakiegokolwiek powodu metodę then(), wówczas najlepiej zrezygnuj z użycia mechanizmu obietnic ES6, aby w ten sposób uniknąć tego rodzaju nieprawidłowych założeń. Przedstawiony przykład wyraźnie pokazuje niebezpieczeństwa wiążące się z kaczym typowaniem. Z tego rodzaju podejścia powinieneś korzystać jedynie sporadycznie oraz w kontrolowanych sytuacjach. Jeżeli ponownie spojrzysz na kod w stylu OLOO przedstawiony w rozdziale, to okaże się, że introspekcja typu nie jest taka trudna. Poniżej przedstawiam skróconą wersję przykładu Foo/Bar/b1 w stylu OLOO, omówionego we wcześniejszej części rozdziału: 170 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 var Foo = { /* .. */ }; var Bar = Object.create( Foo ); Bar... var b1 = Object.create( Bar ); Dzięki użyciu podejścia w stylu OLOO mamy zwykłe obiekty powiązane ze sobą za pomocą delegowania [[Prototype]]. Dlatego też możemy użyć całkiem uproszczonej wersji introspekcji typu: // Powiązanie obiektów 'Foo' i 'Bar' ze sobą. Foo.isPrototypeOf( Bar ); // Prawda. Object.getPrototypeOf( Bar ) === Foo; // Prawda. // Powiązanie 'b1' zarówno z 'Foo', jak i z 'Bar'. Foo.isPrototypeOf( b1 ); // Prawda. Bar.isPrototypeOf( b1 ); // Prawda. Object.getPrototypeOf( b1 ) === Bar; // Prawda. Nie używamy dłużej operatora instanceof, ponieważ błędnie wskazuje na rozwiązanie mające coś wspólnego z klasami. Teraz po prostu zadajemy pytanie: „Czy jesteś prototypem mnie?”. Nie mamy żadnych pośrednich mechanizmów, takich jak Foo.prototype lub boleśnie rozwlekły Foo.prototype.is PrototypeOf(..). Jestem przekonany, że teraz można powiedzieć o znacznym uproszczeniu wcześniejszej wersji operacji introspekcji typu. Ponownie przekonujemy się, że kod w stylu OLOO okazuje się prostszy (choć oferuje dokładnie tę samą funkcjonalność) niż klasyczny styl tworzenia kodu w JavaScript. Podsumowanie Klasy w połączeniu z dziedziczeniem to wzorzec projektowy, na stosowanie którego możesz — choć nie musisz — się zdecydować podczas tworzenia oprogramowania. Większość programistów jest przekonana, że klasy to jedyny (prawidłowy) sposób organizacji kodu. Jak mogłeś zobaczyć w rozdziale, istnieje jeszcze inny, rzadziej omawiany wzorzec oferujący potężne możliwości: delegowanie. Delegowanie sugeruje umieszczanie obiektów obok siebie wraz z łączami delegowania między nimi, a nie powstawanie związków typu obiekt nadrzędny i potomny. Oferowany przez JavaScript mechanizm [[Prototype]] Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 171 0 jest doskonale zaprojektowanym wzorcem delegowania. W ten sposób masz wybór: zmaganie się z implementacją klas w JavaScript (patrz rozdziały 4. i 5.) lub też wykorzystanie natywnego mechanizmu delegowania opartego na [[Prototype]]. Kiedy projektujesz kod jedynie z użyciem obiektów, nie tylko upraszczasz używaną składnię, ale jednocześnie otrzymujesz prostszy projekt architektury kodu. OLOO (obiekty połączone z innymi obiektami) to styl programowania polegający na bezpośrednim tworzeniu obiektów i łączeniu ich ze sobą bez użycia abstrakcji w postaci klas. Styl OLOO całkiem naturalnie implementuje bazujący na [[Prototype]] mechanizm delegowania. 172 Rozdział 6. Delegowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 DODATEK A ES6 class Jeżeli druga część książki (rozdziały od 4. do 6.) ma jakiekolwiek przesłanie, to można je streścić następująco: klasy są opcjonalnym wzorcem projektowym (niekoniecznie danym), często niełatwym do zaimplementowania w opartym na mechanizmie [[Prototype]] języku takim jak JavaScript. Niedogodność nie wynika jedynie ze składni, choć ona na pewno stanowi ogromną część problemu. W rozdziałach 4. i 5. przedstawiłem całkiem sporą ilość brzydkiego kodu, począwszy od rozwlekłych odwołań .prototype zaśmiecających kod, a skończywszy na wyraźnym pseudopolimorfizmie (patrz rozdział 4.), gdy metody mają te same nazwy na różnych poziomach łańcucha i następuje próba implementacji polimorficznego odwołania z metody znajdującej się na niższym poziomie do metody na wyższym poziomie. Właściwość .constructor jest błędnie interpretowana jako „został skonstruowany przez”. Co więcej, wymieniona definicja jest nie tylko błędna, ale stanowi także kolejny przykład syntaktycznej brzydoty. Jednak problemy związane z projektem klas sięgają znacznie głębiej. W rozdziale 4. dowiedziałeś się, że klasy w tradycyjnych językach programowania obiektowego w rzeczywistości tworzą w obiekcie potomnym kopię akcji z obiektu nadrzędnego, podczas gdy w mechanizmie [[Prototype]] akcja nie jest kopiowana, a mamy wręcz sytuację odwrotną — łącze delegacji. W porównaniu do prostoty kodu w stylu OLOO i mechanizmu delegowania (patrz rozdział 6.), wykorzystującego mechanizm [[Prototype]] zamiast go ukrywać, klasy w JavaScript wydają się utrapieniem. 173 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Słowo kluczowe class Jednak nie musimy znów do tego powracać. Powyższe kwestie pokrótce przypomniałem, aby o nich nie zapomnieć, gdy naszą uwagę skierujemy na wprowadzony w specyfikacji ES6 mechanizm class. Przedstawię sposób jego działania i wyjaśnię, czy w jakikolwiek sposób ma wpływ na przedstawione wcześniej obawy dotyczące „klas”. Poniżej przedstawiam przykład Widget/Button zaprezentowany w rozdziale 6.: class Widget { constructor(width,height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } render($where){ if (this.$elem) { this.$elem.css( { width: this.width + "px", height: this.height + "px" } ).appendTo( $where ); } } } class Button extends Widget { constructor(width,height,label) { super( width, height ); this.label = label || "Domyślny"; this.$elem = $( "<button>" ).text( this.label ); } render($where) { super( $where ); this.$elem.click( this.onClick.bind( this ) ); } onClick(evt) { console.log( "Przycisk '" + this.label + "' został kliknięty!" ); } } Poza tym, że powyższa składnia wygląda przyjemniej, czy rozwiązuje jakiekolwiek problemy? 174 Dodatek A ES6 class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 1. Kod nie zawiera już zaśmiecających go odwołań do .prototype. 2. Obiekt Button został zadeklarowany bezpośrednio do „dziedziczenia po” (co odpowiada użyciu słowa kluczowego extends) obiekcie Widget. Eliminujemy tym samym konieczność użycia wywołania Object. create(..) do zastąpienia połączonego obiektu .prototype lub też ustawienia wspomnianego obiektu za pomocą .__proto__ bądź Object. setPrototypeOf(..). 3. Wywołanie super(..) oferuje teraz bardzo użyteczny względny polimorfizm, a więc metoda na jednym poziomie łańcucha może względnie odwoływać się do metody o tej samej nazwie istniejącej na wyższym poziomie łańcucha. Tym samym mamy wyjaśnienie kwestii (przedstawionej w rozdziale 4.) dotyczącej dziwnego rozwiązania, gdy konstruktory nie należą do klas i są niepowiązane — wywołanie super() działa wewnątrz konstruktorów dokładnie tak, jak oczekujesz. 4. Literalna składnia class nie pozwala na określanie właściwości, a jedynie metody. Dla niektórych to może się wydawać ograniczeniem. Jednak w większości przypadków oczekuje się, że jeżeli właściwość (informacje o stanie) istnieje w innym miejscu niż „egzemplarz” kończący łańcuch, to zwykle mamy do czynienia z zaskakującym błędem (podobnie jak w przypadku niejawnego „współdzielenia” informacji o stanie między wszystkimi „egzemplarzami”). Dlatego można powiedzieć, że składnia class chroni przed tego rodzaju błędami. 5. Słowo kluczowe extends pozwala na rozszerzenie w bardzo naturalny sposób obiektów nawet wbudowanych (pod)typów, takich jak Array i RegExp. Wykonanie takiego zadania bez użycia class .. extends jest przesadnie skomplikowanym i frustrującym zadaniem. Dlatego też tylko najbardziej doświadczeni autorzy frameworków byli w stanie prawidłowo to zrobić. Teraz zadanie rozszerzenia zostało znacznie uproszczone! Trzeba przyznać, że to jedynie częściowe rozwiązania wielu najbardziej oczywistych (syntaktycznych) problemów napotykanych przez programistów tworzących kod w klasycznym stylu prototypu. Słowo kluczowe class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 175 0 Pułapki związane z class Jednak nie wszystko jest usłane różami. Nadal istnieją pewne głęboko zakorzenione problemy związane z użyciem „klas” jako wzorca projektowego w języku JavaScript. Przede wszystkim składnia class może zasugerować dodanie nowego mechanizmu „klas” do języka JavaScript, zgodnego ze specyfikacją ES6. Nic z tego. Słowo kluczowe class to jedynie syntaktyczne rozwiązanie opracowane na bazie istniejącego mechanizmu [[Prototype]] (delegowanie!). Oznacza to, że class nie powoduje rzeczywistego skopiowania statycznych definicji w trakcie deklaracji, jak ma to miejsce w tradycyjnych językach programowania obiektowego. Jeżeli zmienisz lub zastąpisz metodę (celowo bądź przypadkowo) w „klasie” nadrzędnej, wówczas będzie to miało wpływ na „klasy” i (lub) egzemplarze potomne — ponieważ nie otrzymują kopii w trakcie ich deklarowania, nadal korzystają z modelu delegowania opartego na [[Prototype]]: class C { constructor() { this.num = Math.random(); } rand() { console.log( "Losowa liczba: " + this.num ); } } var c1 = new C(); c1.rand(); // "Losowa liczba: 0.4324299…" C.prototype.rand = function() { console.log( "Losowa liczba: " + Math.round( this.num * 1000 )); }; var c2 = new C(); c2.rand(); // "Losowa liczba: 867" c1.rand(); // "Losowa liczba: 432" -- Ups!!! To wydaje się rozsądnym zachowaniem tylko wtedy, gdy dobrze znasz naturę delegowania i nie oczekujesz otrzymania kopii „rzeczywistych klas”. Dlatego też pytanie, które powinieneś sobie zadać, brzmi: dlaczego wybieram składnię class do osiągnięcia zupełnie innego celu niż utworzenie klasy? 176 Dodatek A ES6 class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Czy wprowadzenie w wydaniu ES6 składni class nie utrudniło dostrzeżenia i poznania różnic między tradycyjnymi klasami i delegowanymi obiektami? Składnia class nie oferuje sposobu na zadeklarowanie właściwości elementu składowego (a jedynie metody). Dlatego też jeśli musisz monitorować stan współdzielony między egzemplarzami, możesz skorzystać jedynie z brzydkiej składni z użyciem .prototype, na przykład: class C { constructor() { // Upewniamy się co do modyfikacji współdzielonego stanu, // a nie co do ustawienia przesłaniającej właściwości // w egzemplarzach! C.prototype.count++; // W tym miejscu 'this.count' działa zgodnie z oczekiwaniami // za pomocą delegowania. console.log( "Witaj: " + this.count ); } } // Dodanie właściwości dla współdzielonego stanu // bezpośrednio do obiektu prototypu. C.prototype.count = 0; var c1 = new C(); // Witaj: 1 var c2 = new C(); // Witaj: 2 c1.count === 2; // Prawda. c1.count === c2.count; // Prawda. Największym problemem jest w tym przypadku fakt, że składnia class udostępnia (tutaj to oznacza wyciek!) .prototype jako szczegół implementacji. Wciąż może być zaskakujące to, że polecenie this.count++ będzie niejawnie tworzyło oddzielną, przesłoniętą właściwość .count zarówno w obiekcie c1, jak i c2, zamiast uaktualnić współdzielony stan. Słowo kluczowe class nie oferuje rozwiązania tego problemu poza (przedwczesnym) zasugerowaniem, że brak syntaktycznej obsługi oznacza zalecenie, aby w ogóle unikać tego rodzaju poleceń. Pułapki związane z class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 177 0 Co więcej, nadal istnieje niebezpieczeństwo wystąpienia przypadkowego przesłonięcia: class C { constructor(id) { // Ups, pułapka! Przesłaniamy metodę id() // wartością właściwości w egzemplarzu. this.id = id; } id() { console.log( "Id: " + id ); } } var c1 = new C( "c1" ); c1.id(); // TypeError -- 'c1.id' to teraz ciąg tekstowy "c1". Istnieją jeszcze pewne drobne kwestie związane ze sposobem działania super. Możesz przyjąć założenie, że dołączenie super następuje w analogiczny sposób jak wiązanie this (patrz rozdział 2.), czyli super zawsze będzie dołączone na poziomie o jeden wyższym niż położenie bieżącej metody w łańcuchu [[Prototype]]. Jednak z powodu wydajności (wiązanie this zalicza się do kosztownych) nie mamy dynamicznego wiązania super. To rodzaj dołączania „statycznego” w trakcie deklarowania obiektu. To nie wydaje się dużym problemem, prawda? Eh, może tak, a może nie. Jeżeli podobnie jak większość programistów JavaScript zaczniesz przypisywać funkcje odmiennym obiektom (pochodzącym z definicji class) na różne sposoby, to prawdopodobnie nie masz świadomości, że w tych wszystkich przypadkach mechanizm super w tle musi być dołączany za każdym razem. Ponadto w zależności od syntaktycznego podejścia zastosowanego w trakcie wspomnianych operacji przypisania mogą wystąpić sytuacje, gdy prawidłowe wiązanie super okaże się niemożliwe (przynajmniej nie tam, gdzie oczekujesz). Dlatego też może zaistnieć konieczność ręcznego dołączenia super za pomocą toMethod(..), co przypomina użycie bind(..) dla wiązania this — patrz rozdział 2. (Muszę w tym miejscu dodać, że w trakcie powstawania tej książki w komitecie TC39 nadal trwała dyskusja poświęcona temu zagadnieniu). 178 Dodatek A ES6 class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Jesteś przyzwyczajony do możliwości przypisywania metod różnym obiektom w celu automatycznego wykorzystania zalet dynamiczności mechanizmu wiązania this za pomocą reguły wiązania niejawnego (patrz rozdział 2.). To jednak nie sprawdza się w przypadku metod używających super. Zastanów się i spróbuj odpowiedzieć, jakie powinno być działanie super w poniższym fragmencie kodu (względem D i E): class P { foo() { console.log( "P.foo" ); } } class C extends P { foo() { super(); } } var c1 = new C(); c1.foo(); // "P.foo" var D = { foo: function() { console.log( "D.foo" ); } }; var E = { foo: C.prototype.foo }; // Łącze 'E' do 'D' zapewniające obsługę delegowania. Object.setPrototypeOf( E, D ); E.foo(); // "P.foo" Jeżeli uważasz (całkiem rozsądnie!), że dołączenie super nastąpi dynamicznie w trakcie wywołania, to możesz oczekiwać automatycznego rozpoznania przez super() faktu delegowania E do D. Dlatego też E.foo(), używając super(), powinno wywołać D.foo(). Tak nie jest. Z pragmatycznych powodów wydajności super nie jest późno wiązane (inaczej: dynamicznie dołączane), podobnie jak ma to miejsce dla this. Zamiast tego w chwili wywołania wywodzi się z [[HomeObject]].[[Prototype]], gdzie [[HomeObject]] jest statycznie dołączane w trakcie tworzenia. W omawianym tutaj przykładzie super() nadal odwołuje się do P.foo(), ponieważ metodą [[HomeObject]] wciąż jest C, a C.[[Prototype]] wynosi P. Pułapki związane z class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 179 0 Prawdopodobnie istnieją sposoby na ręczne rozwiązanie wymienionych kwestii. Użycie toMethod(..) w celu pierwszego lub ponownego dołączenia metod [[HomeObject]] (wraz z ustawieniem [[Prototype]] dla tego obiektu!) wydaje się sprawdzać w poniższym scenariuszu: var D = { foo: function() { console.log( "D.foo" ); } }; // Łącze 'E' do 'D' zapewniające obsługę delegowania. var E = Object.create( D ); // Ręczne dołączenie [[HomeObject]] dla 'foo' jako // 'E', E.[[Prototype]] wynosi 'D', więc // super() to D.foo(). E.foo = C.prototype.foo.toMethod( E, "foo" ); E.foo(); // "D.foo" Funkcja toMethod(..) klonuje metodę i pobiera homeObject jako jej pierwszy parametr (dlatego przekazujemy E), a drugi parametr (opcjonalnie) ustawia nazwę dla nowej metody (tutaj zachowaliśmy foo ). Czas pokaże, czy są jakiekolwiek inne pułapki czyhające na programistów stosujących powyższe rozwiązanie. Niezależnie od tego musisz być sumienny i wiedzieć, w których miejscach silnik automatycznie określa super za Ciebie, a w których musisz się tym zająć osobiście. Fuj! Statyczny > dynamiczny? Jednak największy problem ze wszystkich związanych ze słowem kluczowym class w ES6 wynika z przekonania, że (podobnie jak w przypadku tradycyjnych klas) klasa utworzona za pomocą słowa kluczowego class pozostaje statyczna po jej zadeklarowaniu (w trakcie późniejszych operacji tworzenia egzemplarzy). Całkowicie tracisz z pola widzenia fakt, że C jest obiektem, konkretną rzeczą, z którą można prowadzić bezpośrednią interakcję. W tradycyjnych językach programowania obiektowego po utworzeniu klasy później nigdy nie zmieniamy jej zachowania, więc wzorzec projektu klasy nawet nie sugeruje istnienia tego rodzaju możliwości. Jednak jedną z największych zalet języka JavaScript jest jego dynamiczność, a definicje dowolnych obiektów (o ile nie będą niemodyfikowalne) pozostają płynne i możliwe do zmiany. 180 Dodatek A ES6 class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Mechanizm class wydaje się sugerować, że nie powinieneś myśleć o modyfikacjach. Odbywa się to przez wymuszenie stosowania brzydkiej składni .prototype, uwzględniania wszystkich pułapek związanych z super itd. Ponadto oferuje wyjątkowo niewielką pomoc w rozwiązaniu wszelkich kwestii, które mogą być związane ze wspomnianym dynamizmem. Innymi słowy, mechanizm class mówi: „Zapewnienie dynamizmu jest zbyt trudne, a więc prawdopodobnie to nie jest dobre rozwiązanie. Tutaj masz składnię wyglądającą jak statyczna, a więc twórz kod statyczny”. Co za smutne podsumowanie dotyczące języka JavaScript: zapewnienie dynamizmu jest zbyt trudne, więc udajemy, że mamy do czynienia z kodem statycznym (choć to w rzeczywistości nieprawda!). Istnieją pewne powody, dla których wprowadzone w specyfikacji ES6 słowo kluczowe class udaje eleganckie rozwiązanie dla syntaktycznych problemów, choć tak naprawdę jeszcze bardziej komplikuje sprawy i utrudnia zrozumienie kodu w JavaScript. Jeżeli używasz funkcji pomocniczej .bind(..) w celu utworzenia na stałe dołączonej funkcji (patrz rozdział 2.), to powinieneś wiedzieć, że tak przygotowana funkcja nie umożliwia tworzenia podklas za pomocą wprowadzonego w specyfikacji ES6 słowa kluczowego extends, jak ma to miejsce w przypadku zwykłych funkcji. Podsumowanie Słowo kluczowe class doskonale udaje, że rozwiązuje problemy związane ze wzorcem klas i dziedziczeniem w języku JavaScript. Jednak tak naprawdę ma zupełnie przeciwne działanie: ukrywa wiele problemów i przy okazji wprowadza kolejne — wprawdzie subtelne, ale niebezpieczne. Istnienia słowa kluczowego class ma swój wkład w trwające niemalże od dwóch dekad zamieszanie dotyczące „klas” w języku JavaScript. Pod pewnymi względami stawia więcej pytań, niż udziela odpowiedzi, i wydaje się bardzo nienaturalnym rozwiązaniem zbudowanym na eleganckiej prostocie mechanizmu [[Prototype]]. Podsumowanie helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 181 0 Ostatecznie jeśli wprowadzone w specyfikacji ES6 słowo kluczowe class utrudnia niezawodne wykorzystanie mechanizmu [[Prototype]] i ukrywa najważniejszą naturę mechanizmu obiektowego w JavaScript — odbywające się na żywo delegowanie między obiektami — to czy wobec tego nie powinniśmy postrzegać go jako wprowadzającego więcej problemów niż rozwiązań, a tym samym uznać za antywzorzec? Naprawdę nie potrafię odpowiedzieć za Ciebie na to pytanie. Mam jednak nadzieję, że ta książka w pełni naświetliła problem, i to w znacznie dokładniejszy sposób niż gdziekolwiek indziej, oraz dostarczyła Ci informacji niezbędnych do samodzielnego udzielenia odpowiedzi na wcześniejsze pytanie. 182 Dodatek A ES6 class helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 DODATEK B Podziękowania Zarówno ta książka, jak i pozostałe z tej serii powstały dzięki pomocy wielu osób, którym pragnę podziękować. Przede wszystkim dziękuję mojej żonie, Christen Simpson, oraz dwójce naszych dzieci — Ethanowi i Emily, za cierpliwe znoszenie widoku taty nieustannie stukającego w klawiaturę komputera. Nawet jeśli nie piszę książek, moje obsesyjne zainteresowanie językiem JavaScript sprawia, że wgapiam się w ekran monitora znacznie częściej, niż powinienem. Tylko dlatego, że mam tak wyrozumiałą rodzinę, mogły powstać książki w pełni i dogłębnie wyjaśniające JavaScript. Wszystko zawdzięczam mojej rodzinie. Dziękuję moim redaktorom w wydawnictwie O’Reilly, w szczególności Simonowi St. Laurentowi i Brianowi MacDonaldowi, a także pozostałym redaktorom i pracownikom działu marketingu. Fantastycznie się z nimi pracuje — okazali się niezwykle pomocni podczas eksperymentu, jakim było napisanie, edycja i przygotowanie książki open source. Dziękuję wielu osobom, które uczestniczyły w opracowaniu serii książek poprzez przekazywanie sugestii i przeprowadzenie korekty — na podziękowania szczególnie zasłużyli: Shelley Powers, Tim Ferro, Evan Borden, Forrest L. Norvell, Jennifer Davis, Jesse Harlin, Nick Berardi i wiele innych osób. Dziękuję niezliczonej liczbie członków społeczności, zwłaszcza składowi komitetu TC39, którzy dzielili się swoją wiedzą, a moje ciągłe pytania i eksperymenty znosili z niezwykłą cierpliwością, udzielając przy tym wyczerpujących odpowiedzi. Są to: John-David Dalton, Juriy „kangax” Zaytsev, Mathias Bynens, Rick Waldron, Axel Rauschmayer, Nicholas Zakas, Angus Croll, Jordan Harband, Reginald Braithwaite, Dave Herman, Brendan Eich, Allen Wirfs-Brock, 183 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Bradley Meck, Domenic Denicola, David Walsh, Tim Disney, Kris Kowal, Peter van der Zee, Andrea Giammarchi, Kit Cambridge — to Wam i jeszcze wielu innym osobom serdecznie dziękuję. Powstanie serii Tajniki języka JavaScript było możliwe dzięki projektowi Kickstarter, a przede wszystkim jego (prawie) 500 hojnym uczestnikom, bez których wsparcia finansowego ta seria nigdy by nie powstała. Są to: Jan Szpila, nokiko, Murali Krishnamoorthy, Ryan Joy, Craig Patchett, pdqtrader, Dale Fukami, ray hatfield, R0drigo Perez [Mx], Dan Petitt, Jack Franklin, Andrew Berry, Brian Grinstead, Rob Sutherland, Sergi Meseguer, Phillip Gourley, Mark Watson, Jeff Carouth, Alfredo Sumaran, Martin Sachse, Marcio Barrios, Dan, AimelyneM, Matt Sullivan, Delnatte Pierre-Antoine, Jake Smith, Eugen Tudorancea, Iris, David Trinh, simonstl, Ray Daly, Uros Gruber, Justin Myers, Shai Zonis, Mom & Dad, Devin Clark, Dennis Palmer, Brian Panahi Johnson, Josh Marshall, Marshall, Dennis Kerr, Matt Steele, Erik Slagter, Sacah, Justin Rainbow, Christian Nilsson, Delapouite, D. Pereira, Nicolas Hoizey, George V. Reilly, Dan Reeves, Bruno Laturner, Chad Jennings, Shane King, Jeremiah Lee Cohick, od3n, Stan Yamane, Marko Vucinic, Jim B, Stephen Collins, Ægir Þorsteinsson, Eric Pederson, Owain, Nathan Smith, Jeanetteurphy, Alexandre ELISÉ, Chris Peterson, Rik Watson, Luke Matthews, Justin Lowery, Morten Nielsen, Vernon Kesner, Chetan Shenoy, Paul Tregoing, Marc Grabanski, Dion Almaer, Andrew Sullivan, Keith Elsass, Tom Burke, Brian Ashenfelter, David Stuart, Karl Swedberg, Graeme, Brandon Hays, John Christopher, Gior, manoj reddy, Chad Smith, Jared Harbour, Minoru TODA, Chris Wigley, Daniel Mee, Mike, Handyface, Alex Jahraus, Carl Furrow, Rob Foulkrod, Max Shishkin, Leigh Penny Jr., Robert Ferguson, Mike van Hoenselaar, Hasse Schougaard, rajan venkataguru, Jeff Adams, Trae Robbins, Rolf Langenhuijzen, Jorge Antunes, Alex Koloskov, Hugh Greenish, Tim Jones, Jose Ochoa, Michael Brennan-White, Naga Harish Muvva, Barkóczi Dávid, Kitt Hodsden, Paul McGraw, Sascha Goldhofer, Andrew Metcalf, Markus Krogh, Michael Mathews, Matt Jared, Juanfran, Georgie Kirschner, Kenny Lee, Ted Zhang, Amit Pahwa, Inbal Sinai, Dan Raine, Schabse Laks, Michael Tervoort, Alexandre Abreu, Alan Joseph Williams, NicolasD, Cindy Wong, Reg Braithwaite, LocalPCGuy, Jon Friskics, Chris Merriman, John Pena, Jacob Katz, Sue Lockwood, Magnus Johansson, Jeremy Crapsey, Grzegorz Pawłowski, nico nuzzaci, Christine Wilks, Hans Bergren, charles montgomery, Ariel לבב- ברFogel, Ivan Kolev, Daniel Campos, Hugh Wood, Christian Bradford, Frédéric Harper, Ionuţ Dan Popa, Jeff Trimble, Rupert Wood, Trey Carrico, Pancho Lopez, Joël kuijten, Tom A Marra, Jeff Jewiss, Jacob Rios, Paolo Di Stefano, Soledad Penades, Chris Gerber, Andrey Dolganov, Wil Moore III, Thomas Martineau, Kareem, Ben Thouret, Udi Nir, Morgan Laupies, jory carsonburson, Nathan L Smith, Eric Damon Walters, 184 Dodatek B Podziękowania helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Derry Lozano-Hoyland, Geoffrey Wiseman, mkeehner, KatieK, Scott MacFarlane, Brian LaShomb, Adrien Mas, christopher ross, Ian Littman, Dan Atkinson, Elliot Jobe, Nick Dozier, Peter Wooley, John Hoover, dan, Martin A. Jackson, Héctor Fernando Hurtado, andy ennamorato, Paul Seltmann, Melissa Gore, Dave Pollard, Jack Smith, Philip Da Silva, Guy Israeli, @megalithic, Damian Crawford, Felix Gliesche, April Carter Grant, Heidi, jim tierney, Andrea Giammarchi, Nico Vignola, Don Jones, Chris Hartjes, Alex Howes, john gibbon, David J. Groom, BBox, Yu Dilys Sun, Nate Steiner, Brandon Satrom, Brian Wyant, Wesley Hales, Ian Pouncey, Timothy Kevin Oxley, George Terezakis, sanjay raj, Jordan Harband, Marko McLion, Wolfgang Kaufmann, Pascal Peuckert, Dave Nugent, Markus Liebelt, Welling Guzman, Nick Cooley, Daniel Mesquita, Robert Syvarth, Chris Coyier, Rémy Bach, Adam Dougal, Alistair Duggin, David Loidolt, Ed Richer, Brian Chenault, GoldFire Studios, Carles Andrés, Carlos Cabo, Yuya Saito, roberto ricardo, Barnett Klane, Mike Moore, Kevin Marx, Justin Love, Joe Taylor, Paul Dijou, Michael Kohler, Rob Cassie, Mike Tierney, Cody Leroy Lindley, tofuji, Shimon Schwartz, Raymond, Luc De Brouwer, David Hayes, Rhys Brett-Bowen, Dmitry, Aziz Khoury, Dean, Scott Tolinski — Level Up, Clement Boirie, Djordje Lukic, Anton Kotenko, Rafael Corral, Philip Hurwitz, Jonathan Pidgeon, Jason Campbell, Joseph C., SwiftOne, Jan Hohner, Derick Bailey, getify, Daniel Cousineau, Chris Charlton, Eric Turner, David Turner, Joël Galeran, Dharma Vagabond, adam, Dirk van Bergen, dave ♥♫★ furf, Vedran Zakanj, Ryan McAllen, Natalie Patrice Tucker, Eric J. Bivona, Adam Spooner, Aaron Cavano, Kelly Packer, Eric J, Martin Drenovac, Emilis, Michael Pelikan, Scott F. Walter, Josh Freeman, Brandon Hudgeons, vijay chennupati, Bill Glennon, Robin R., Troy Forster, otaku_coder, Brad, Scott, Frederick Ostrander, Adam Brill, Seb Flippence, Michael Anderson, Jacob, Adam Randlett, Standard, Joshua Clanton, Sebastian Kouba, Chris Deck, SwordFire, Hannes Papenberg, Richard Woeber, hnzz, Rob Crowther, Jedidiah Broadbent, Sergey Chernyshev, Jay-Ar Jamon, Ben Combee, luciano bonachela, Mark Tomlinson, Kit Cambridge, Michael Melgares, Jacob Adams, Adrian Bruinhout, Bev Wieber, Scott Puleo, Thomas Herzog, April Leone, Daniel Mizieliński, Kees van Ginkel, Jon Abrams, Erwin Heiser, Avi Laviad, David newell, Jean-Francois Turcot, Niko Roberts, Erik Dana, Charles Neill, Aaron Holmes, Grzegorz Ziółkowski, Nathan Youngman, Timothy, Jacob Mather, Michael Allan, Mohit Seth, Ryan Ewing, Benjamin Van Treese, Marcelo Santos, Denis Wolf, Phil Keys, Chris Yung, Timo Tijhof, Martin Lekvall, Agendine, Greg Whitworth, Helen Humphrey, Dougal Campbell, Johannes Harth, Bruno Girin, Brian Hough, Darren Newton, Craig McPheat, Olivier Tille, Dennis Roethig, Mathias Bynens, Brendan Stromberger, sundeep, John Meyer, Ron Male, John F Croston III, gigante, Carl Bergenhem, B.J. May, Rebekah Tyler, Ted Foxberry, Jordan Reese, Terry Suitor, afeliz, Tom Kiefer, Darragh Duffy, Kevin Vanderbeken, Andy Pearson, Simon Mac Donald, Abid Din, Chris Joel, Podziękowania helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 185 0 Tomas Theunissen, David Dick, Paul Grock, Brandon Wood, John Weis, dgrebb, Nick Jenkins, Chuck Lane, Johnny Megahan, marzsman, Tatu Tamminen, Geoffrey Knauth, Alexander Tarmolov, Jeremy Tymes, Chad Auld, Sean Parmelee, Rob Staenke, Dan Bender, Yannick derwa, Joshua Jones, Geert Plaisier, Tom LeZotte, Christen Simpson, Stefan Bruvik, Justin Falcone, Carlos Santana, Michael Weiss, Pablo Villoslada, Peter deHaan, Dimitris Iliopoulos, seyDoggy, Adam Jordens, Noah Kantrowitz, Amol M, Matthew Winnard, Dirk Ginader, Phinam Bui, David Rapson, Andrew Baxter, Florian Bougel, Michael George, Alban Escalier, Daniel Sellers, Sasha Rudan, John Green, Robert Kowalski, David I. Teixeira (@ditma), Charles Carpenter, Justin Yost, Sam S, Denis Ciccale, Kevin Sheurs, Yannick Croissant, Pau Fracés, Stephen McGowan, Shawn Searcy, Chris Ruppel, Kevin Lamping, Jessica Campbell, Christopher Schmitt, Sablons, Jonathan Reisdorf, Bunni Gek, Teddy Huff, Michael Mullany, Michael Fürstenberg, Carl Henderson, Rick Yoesting, Scott Nichols, Hernán Ciudad, Andrew Maier, Mike Stapp, Jesse Shawl, Sérgio Lopes, jsulak, Shawn Price, Joel Clermont, Chris Ridmann, Sean Timm, Jason Finch, Aiden Montgomery, Elijah Manor, Derek Gathright, Jesse Harlin, Dillon Curry, Courtney Myers, Diego Cadenas, Arne de Bree, João Paulo Dubas, James Taylor, Philipp Kraeutli, Mihai Păun, Sam Gharegozlou, joshjs, Matt Murchison, Eric Windham, Timo Behrmann, Andrew Hall, joshua price i Théophile Villard. Niniejsza seria książek to projekty open source, włącznie z etapami edycji i produkcji. Dziękuję serwisowi GitHub za umożliwienie realizacji tego rodzaju przedsięwzięcia. Jeszcze raz dziękuję wszystkim niezliczonym osobom, którym bardzo wiele zawdzięczam, a nie jestem im w stanie imiennie podziękować. Niech ta seria stanie się własnością nas wszystkich i przyczyni się do zwiększenia świadomości i zrozumienia języka JavaScript z korzyścią dla wszystkich obecnych i przyszłych członków społeczności. 186 Dodatek B Podziękowania helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 Skorowidz A E egzemplarz klasy, 86 ES6 class, 173 analiza związków klasy, 127 API, 33 F B format JSON, 62 funkcja @@iterator, 79 bind(), 39 create(), 132 foo(), 16 forEach(), 78 funkcje anonimowe, 167 klasy, 113 pomocnicze, 65 bezpieczniejsze this, 42 błąd TypeError, 65, 69 D debugowanie, 145 delegowanie, 117, 139, 145, 171 obiektów widżetów, 156 deskryptory właściwości, 63 dodawanie właściwości, 69 domieszki, 98 jawne, 98 kopiujące, 101 niejawne, 105 dynamiczność, 180 dziedziczenie, 111, 123 klasy, 92 pasożytnicze, 103 prototypowe, 124 wielokrotne, 97 G Get, 70 getter, 72 I indeksy liczbowe, 60 informacje o stanie, 15 introspekcja, 127 typu, 168 iteracja, 77 187 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 J N JavaScript, 9 nawias kwadratowy, 57 nazwy obliczanych właściwości, 57 new, 34, 118 niejawna utrata, 28 niejawne wiązanie, 58 niemodyfikowalność, 67 K klasa, 85, 113, 141, 153 Controller, 164 klasy JavaScript, 88 nadrzędne, 162 potomne, 162 widżetów, 153 kod OLOO, 143 konstruktor, 35, 91, 118–121 kontekst, 14 wywołań, 33 kontroler AuthController, 162 kopia głęboka, 61 obiektu, 115 płytka, 61 O obiekt, 15, 51, 153 docelowy, 62 kontekstu, 14 składnia literalna, 51 skonstruowany, 51 wbudowany, 53 źródłowy, 62 Object.create(), 133 Object.prototype, 109 odwołania pośrednie, 44 OLOO, 143, 158 OOP, object-oriented programming, 85, 148 operator delete, 66 in, 75 instanceof, 128, 168 new, 34 osłabienie wiązania, 44 L leksykalne this, 46 Ł łącza, 115, 135 obiektu, 131 P M pętla for-in, 77 pobieranie wartości właściwości, 74 polimorfizm, 87, 94, 100, 164 porównanie modeli, 148 powielanie obiektów, 61 programowanie funkcjonalne, 87 zorientowane obiektowo, 85, 150 Prototype, 116 mechanika, 120 klas, 89 mechanizm class, 181 Prototype, 107, 116, 135 this, 13–22 metoda, 57 modele mentalne, 148 188 Skorowidz helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 typy, 52 proste, 52 wyliczeniowe, 75 prototypy, 107 przesłanianie metod, 112 właściwości, 110 Put, 71 U ustalenie this, 40 ustawianie właściwości, 110 R rekurencja, 15 W S wartość NaN, 17 wiązanie domyślne, 25 jawne, 30 new, 34, 36 niejawne, 27 this, 13–22 twarde, 31 widżet, 153 właściwość, 57 Configurable, 65 count, 17 dostępu, 70 enumerable, 67 writable, 64 wyjątki dotyczące wiązań, 41 wykonanie funkcji, 35 wyrażenie funkcji anonimowej, 166 wywołanie funkcji, 23, 119 hasOwnProperty(), 75 konstruktora, 35 Object.create(), 132 Object.defineProperty(), 81 Object.freeze(), 69 Object.seal(), 69 wyzwalacz, 120 wzajemne delegowanie, 145 wzorzec projektowy klasy, 87 schemat dziedziczenia obiektów, 91 setter, 72 składnia, 165 class, 155, 176 obiektu, 51 słowo kluczowe class, 131, 165, 174, 180 function, 165 new, 34, 39, 118 super, 178 this, 13–22 stała obiektu, 68 stos wywołań, 23 styl OLOO, 152 T tablica, 60 teoria delegowania, 142 klas, 85, 141 this, 13–22 bezpieczniejsze, 42 leksykalne, 46 ustalenie, 40 zignorowane, 41 tworzenie klas, 89 łącza, 115, 132 Skorowidz helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 189 0 Z Ź zakres funkcji, 20 zawartość obiektu, 55 zignorowane this, 41 zmienna globalna, 17, 25 190 źródło wywołania funkcji, 23 Skorowidz helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 O autorze Kyle Simpson jest pochodzącym z Austin w Teksasie propagatorem Open Web i wielkim pasjonatem wszystkiego, co jest związane z językiem JavaScript. Pisze książki, prowadzi warsztaty, występuje na konferencjach o tematyce technicznej oraz pozostaje aktywnym członkiem społeczności OSS. helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0 helion kopia dla: Dawid Karwot [email protected] 006e9c6d9c8c46149a5704179b57cf99 0