Na początku lat 90. w branży programistycznej powstało zamieszanie spowodowane programowaniem obiektowym. Większość zasad tej techniki nie była żadną nowością, ale osiągnęła ona na tyle dużą popularność, że w końcu nabrała pędu i zaczęła robić się modna. Pisano książki na jej temat, przeprowadzano kursy oraz tworzono nowe obiektowe języki programowania. Nagle wszyscy zaczęli wychwalać pod niebiosa zalety obiektowości i z entuzjazmem używać jej do rozwiązywania wszystkich możliwych problemów. Wyglądało to tak, jakby niektórzy byli przekonani, że w końcu odkryli prawidłowy sposób pisania programów.
Jest to dość typowe.. Gdy coś jest bardzo skomplikowane, ludzie zawsze szukają jakiegoś magicznego rozwiązania. I gdy pojawi się cos, co tak wygląda, od razu zyskuje potężną rzeszę oddanych wielbicieli. Do dziś dla wielu programistów obiektowość (albo ich wyobrażenie obiektowości) jest świętością. Dla nich program, który nie jest „prawdziwie obiektowy” (cokolwiek to znaczy), to słaby program.
Niemniej jednak niewiele jest przejściowych mód, które przetrwałyby tak długo. Sukces obiektowości można w dużym stopniu tłumaczyć tym, że jest oparta na solidnych podstawach. W tym rozdziale znajduje się opis właśnie tych podwalin obiektowości oraz ich adaptacja w JavaScripcie. Chcę też podkreślić, że w poprzednich akapitach nie chciałem zdyskredytować obiektowości jako takiej. Moją intencją było tylko ostrzec Cię przed nadmiernym przywiązywaniem się do tej metodologii programowania.
Czym jest programowanie obiektowe
Jak nazwa wskazuje, programowanie obiektowe polega na używaniu obiektów. Do tej pory używaliśmy ich do luźnego grupowania wartości, dodając i usuwając z nich dane, gdy tak się nam podobało. W programowaniu obiektowym obiekty są traktowane jak małe samodzielne światy, a świat zewnętrzny może się z nimi kontaktować tylko poprzez niewielki ściśle zdefiniowany interfejs, będący zestawem metod i własności. Przykładem tego jest lista osiągniętych celów, której używaliśmy na końcu rozdziału 7. Do posługiwania się nią używaliśmy tylko trzech funkcji: makeReachedList
, storeReached
oraz findReached
. Te trzy funkcje stanowią interfejs tego typu obiektów.
To samo dotyczy obiektów Date
, Error
i BinaryHeap
. Zamiast zwykłych funkcji do pracy z obiektami, do dyspozycji mamy słowo kluczowe new
do tworzenia obiektów, które wraz z pewną liczbą metod i własności stanowi ich interfejs.
Metody
Jednym ze sposobów na dodanie metod do obiektów jest po prostu dołączenie do nich funkcji.
var rabbit = {};
rabbit.speak = function(line) {
print("Królik powiedział „", line, "”");
};
rabbit.speak("Teraz Ty pytasz mnie.");
W większości przypadków metoda musi wiedzieć, na czym ma działać. Na przykład, jeśli byłoby kilka królików, metoda speak
musiałaby wskazywać, który królik ma mówić. Do tego służy specjalna zmienna o nazwie this
, która jest zawsze dostępna w wywołaniu funkcji, a jeśli funkcja jest wywoływana jako metoda, wskazuje odpowiedni obiekt. Funkcja nazywa się metodą, gdy należy do obiektu i jest z niego wywoływana, np. object.method()
.
function speak(line) {
print("Pewien ", this.adjective, " królik mówi „", line, "”");
}
var whiteRabbit = {adjective: "biały", speak: speak};
var fatRabbit = {adjective: "gruby", speak: speak};
whiteRabbit.speak("Na moje uszy i wąsy, która to już godzina!");
fatRabbit.speak("Nie pogardziłbym jakąś małą marchewką.");
Teraz mogę wyjaśnić do czego służy ten tajemniczy pierwszy argument metody apply
, któremu zawsze przypisywaliśmy wartość null
w rozdziale 6. Przy jego użyciu można określić obiekt, do którego ma zostać zastosowana funkcja. W przypadku funkcji niebędących metodami argument ten jest niepotrzebny i dlatego nadawaliśmy mu wartość null
.
speak.apply(fatRabbit, ["Pycha."]);
Funkcje również mają metodę call
, która jest podobna do apply
, ale przyjmuje argumenty dla funkcji oddzielnie, zamiast w tablicy:
speak.call(fatRabbit, "Burp.");
Tworzenie obiektów – new
Słowo kluczowe new
umożliwia tworzenie obiektów w wygodny sposób. Gdy przed wywołaniem funkcji wstawi się słowo kluczowe new
, jej zmienna this
wskaże na nowy obiekt, który funkcja automatycznie zwróci (chyba że celowo ma ustawione, aby zwracać coś innego). Funkcje służące do tworzenia nowych obiektów nazywają się konstruktorami. Poniżej znajduje się konstruktor królików:
function Rabbit(adjective) {
this.adjective = adjective;
this.speak = function(line) {
print("Pewien ", this.adjective, " królik mówi „", line, "”");
};
}
var killerRabbit = new Rabbit("zabójczy");
killerRabbit.speak("KRAAAAAAAAACH!");
W programowaniu JavaScript istnieje konwencja, zgodnie z którą nazwy konstruktorów rozpoczyna się wielką literą. Dzięki temu łatwo się je odróżnia od innych funkcji.
Czy słowa kluczowe new
jest tak naprawdę potrzebne? Przecież równie dobrze można by było pisać tak:
function makeRabbit(adjective) {
return {
adjective: adjective,
speak: function(line) {/*itd.*/}
};
}
var blackRabbit = makeRabbit("czarny");
To nie jest dokładnie to samo. Słowo kluczowe new
wykonuje jeszcze kilka dodatkowych działań, tylko tego nie widać. Nasz obiekt killerRabbit
ma własność o nazwie constructor
wskazującą funkcję Rabbit
, która go utworzyła. Obiekt blackRabbit
również ma taką własność, ale wskazującą funkcję Object
.
show(killerRabbit.constructor);
show(blackRabbit.constructor);
Prototypy
Skąd się wzięła własność constructor
? Jest ona częścią prototypu królika. Prototypy są potężną, choć trochę zawiłą, częścią systemu obiektowego języka JavaScript. Każdy obiekt bazuje na jakimś prototypie, z którego dziedziczy różne własności. Proste obiekty, których używaliśmy do tej pory bazują na podstawowym prototypie, który jest związany z konstruktorem Object
. W istocie wyrażenie {}
jest równoważne z new Object()
.
var simpleObject = {};
show(simpleObject.constructor);
show(simpleObject.toString);
Metoda toString
należy do prototypu Object
. Oznacza to, że wszystkie proste obiekty mają metodę toString
, która konwertuje je na łańcuch. Nasze obiekty królików są utworzone na bazie prototypu związanego z konstruktorem Rabbit
. Za pomocą własności prototype
konstruktora można nawet uzyskać dostęp do ich prototypu:
show(Rabbit.prototype);
show(Rabbit.prototype.constructor);
Każdej funkcji automatycznie przypisywana jest własność prototype
, której własność constructor
wskazuje na tę funkcję. Ponieważ prototyp królika sam jest obiektem, bazuje na prototypie Object
i ma jego metodę toString
.
show(killerRabbit.toString == simpleObject.toString);
Obiekty dziedziczą własności swoich prototypów, ale dziedziczenie to jest tylko jednostronne. Własności prototypu mają wpływ na obiekt utworzony na bazie tego prototypu, ale własności tego obiektu nie mają wpływu na prototyp.
Ściśle rzecz biorąc reguła ta brzmi następująco: szukając własności JavaScript najpierw przeszukuje zestaw własności samego obiektu. Jeśli własność o szukanej nazwie zostanie znaleziona, to zostanie użyta. Jeśli własność nie zostanie znaleziona, przeszukiwany jest prototyp obiektu, następnie prototyp prototypu itd. Jeśli nic nie zostanie znalezione, zostaje zwrócona wartość undefined
. Z drugiej strony, gdy ustawiana jest wartość własności, JavaScript nigdy nie przechodzi do prototypu, lecz zawsze ustawia własność w samym obiekcie.
Rabbit.prototype.teeth = "małe";
show(killerRabbit.teeth);
killerRabbit.teeth = "długie, ostre i zakrwawione";
show(killerRabbit.teeth);
show(Rabbit.prototype.teeth);
Oznacza to, że za pomocą prototypu można w dowolnej chwili dodać nowe własności i metody do wszystkich bazujących na nim obiektów. Na przykład w trakcie pracy może się okazać, że nasze króliki muszą umieć tańczyć.
Rabbit.prototype.dance = function() {
print("Pewien ", this.adjective, " królik tańczy gigę.");
};
killerRabbit.dance();
Jak się pewnie domyślasz, prototyp królika jest doskonałym miejscem na dodawanie wartości wspólnych dla wszystkich królików, takich jak metoda speak
. Oto nowa wersja konstruktora Rabbit
:
function Rabbit(adjective) {
this.adjective = adjective;
}
Rabbit.prototype.speak = function(line) {
print("Pewien ", this.adjective, " królik mówi „", line, "”");
};
var hazelRabbit = new Rabbit("brązowy");
hazelRabbit.speak("Dobry Frith!");
Fakt, że wszystkie obiekty mają prototypy i mogą po nich dziedziczyć różne własności może sprawiać problemy. Oznacza to, że użycie obiektu do przechowywania zbioru wartości, jak w przypadku kotów w rozdziale 4, może się nie udać. Gdybyśmy np. chcieli sprawdzić czy istnieje kot o imieniu constructor
, napisalibyśmy taki kod:
var noCatsAtAll = {};
if ("constructor" in noCatsAtAll)
print("Tak, niewątpliwie istnieje kot o imieniu „constructor”.");
Mamy problem. Dodatkowe trudności może sprawiać fakt, że standardowe prototypy, takie jak Object
i Array
, często rozszerza się o nowe przydatne funkcje. Na przykład moglibyśmy wszystkim obiektom dodać metodę o nazwie properties
zwracającą tablicę nazw wszystkich nieukrytych własności obiektów:
Object.prototype.properties = function() {
var result = [];
for (var property in this)
result.push(property);
return result;
};
var test = {x: 10, y: 3};
show(test.properties());
Od razu widać, w czym tkwi problem. Od tej chwili prototyp Object
ma własność o nazwie properties
, w związku z czym w wyniku iteracji przy użyciu pętli for
i in
po własnościach jakiegokolwiek obiektu otrzymamy także tę wspólną własność, czego normalnie byśmy nie chcieli. Interesują nas jedynie własności należące tylko do tego obiektu.
Na szczęście można sprawdzić, czy wybrana własność należy do obiektu, czy do jednego z jego prototypów. Niestety dodatek tego testu sprawia, że kod pętli staje się nieco niezgrabny. Każdy obiekt ma metodę o nazwie hasOwnProperty
, która informuje, czy obiekt ma własność o określonej nazwie. Przy jej użyciu naszą metodę properties
moglibyśmy przepisać następująco:
Object.prototype.properties = function() {
var result = [];
for (var property in this) {
if (this.hasOwnProperty(property))
result.push(property);
}
return result;
};
var test = {"Fat Igor": true, "Fireball": true};
show(test.properties());
I oczywiście możemy ją przepisać abstrakcyjnie jako funkcję wyższego rzędu. Zwróć uwagę, że w wywołaniu funkcji action
przekazywana jest zarówno nazwa własności jak i jej wartość w obiekcie.
function forEachIn(object, action) {
for (var property in object) {
if (object.hasOwnProperty(property))
action(property, object[property]);
}
}
var chimera = {głowa: "lwa", ciało: "kozła", ogon: "węża"};
forEachIn(chimera, function(name, value) {
print(name, " ", value, ".");
});
Ale co będzie, gdy znajdziemy kota o imieniu hasOwnProperty
? (Kto wie, jakie imiona ludzie mogą nadawać swoim kotom). Zostanie ono zapisane w obiekcie i gdy spróbujemy potem przejrzeć kolekcję kotów, wywołanie metody object.hasOwnProperty
nie uda się, ponieważ wartość ta nie będzie już wskazywała wartości funkcyjnej. Można to rozwiązać stosując jeszcze mniej eleganckie rozwiązanie:
function forEachIn(object, action) {
for (var property in object) {
if (Object.prototype.hasOwnProperty.call(object, property))
action(property, object[property]);
}
}
var test = {name: "Mordechai", hasOwnProperty: "Uh-oh"};
forEachIn(test, function(name, value) {
print("Property ", name, " = ", value);
});
(Uwaga: ten przykład nie działa w przeglądarce Internet Explorer 8, która najwyraźniej ma trudności z przesłanianiem wbudowanych własności prototypów).
W tym kodzie zamiast używać metody z obiektu posługujemy się metodą pobraną z prototypu Object
, a następnie stosujemy ją do odpowiedniego obiektu za pomocą funkcji call
. Jeśli nikt nie nabałagani w tej metodzie w Object.prototype
(a nie należy tego robić), to program powinien działać prawidłowo.
Metody hasOwnProperty
można także używać w tych sytuacjach, w których używaliśmy operatora in
, aby dowiedzieć się czy wybrany obiekt ma określoną własność. Jest jednak pewien haczyk. W rozdziale 4 dowiedziałeś się, że niektóre własności, np. toString
, są ukryte i pętle for
–in
ich nie wykrywają. Przeglądarki z rodziny Gecko (przede wszystkim Firefox) każdemu obiektowi przypisują ukrytą własność o nazwie __proto__
wskazującą prototyp tego obiektu. Dla niej metoda hasOwnProperty
również zwróci true
, mimo że nie została dodana bezpośrednio przez program. Dostęp do prototypu obiektu bywa przydatny, ale realizowanie tego w postaci własności nie było najlepszym pomysłem. Niemniej jednak Firefox to bardzo popularna przeglądarka, a więc pisząc aplikację sieciową trzeba o tym pamiętać. Istnieje też metoda o nazwie propertyIsEnumerable
, która zwraca false
dla ukrytych własności i za pomocą której można odfiltrować takie dziwadła, jak __proto__
. Poniższe wyrażenie jest dobrym sposobem na obejście omawianego problemu:
var object = {foo: "bar"};
show(Object.prototype.hasOwnProperty.call(object, "foo") &&
Object.prototype.propertyIsEnumerable.call(object, "foo"));
Proste i eleganckie, prawda? Jest to jedna z tych słabszych stron projektu JavaScriptu. Obiekty pełnią zarówno rolę „wartości z metodami”, dla których prototypy są pożyteczne jak i „zbiorów własności”, którym prototypy tylko przeszkadzają.
Wpisywanie powyższego wyrażenia, za każdym razem gdy trzeba sprawdzić, czy obiekt zawiera jakąś własność jest niewykonalne. Moglibyśmy zdefiniować funkcję, ale jeszcze lepszym rozwiązaniem jest napisanie konstruktora i prototypu specjalnie na okazje, gdy obiekt chcemy traktować jako zestaw własności. Ponieważ można w nim wyszukiwać wartości po nazwach, nazwiemy go Dictionary
(słownik).
function Dictionary(startValues) {
this.values = startValues || {};
}
Dictionary.prototype.store = function(name, value) {
this.values[name] = value;
};
Dictionary.prototype.lookup = function(name) {
return this.values[name];
};
Dictionary.prototype.contains = function(name) {
return Object.prototype.hasOwnProperty.call(this.values, name) &&
Object.prototype.propertyIsEnumerable.call(this.values, name);
};
Dictionary.prototype.each = function(action) {
forEachIn(this.values, action);
};
var colours = new Dictionary({Grover: "niebieski",
Elmo: "pomarańczowy",
Bert: "żółty"});
show(colours.contains("Grover"));
show(colours.contains("constructor"));
colours.each(function(name, colour) {
print(name, " jest ", colour);
});
Cały mechanizm wykorzystania obiektów jako zbiorów własności został zamknięty w wygodnym interfejsie: jeden konstruktor i cztery metody. Zauważ, że własność values
obiektu Dictionary
nie należy do tego interfejsu, tylko jest wewnętrznym szczegółem, którego nie używa się bezpośrednio podczas korzystania z obiektów typu Dictionary
.
Do każdego tworzonego przez siebie interfejsu powinno dodać się krótki komentarz opisujący sposób jego działania i użycia. Dzięki temu, gdy za kilka miesięcy ktoś (może Ty sam) zechce go użyć, będzie mógł szybko przeczytać instrukcję obsługi zamiast studiować kod.
Zazwyczaj krótko po zaprojektowaniu interfejsu odkrywa się jego ograniczenia i usterki, które należy zmienić. Dlatego dla oszczędności czasu zaleca się dokumentowanie interfejsów dopiero po pewnym czasie ich użytkowania, gdy zostanie udowodnione, że są praktyczne. Oczywiście to może być pokusą, aby w ogóle zapomnieć o pisaniu dokumentacji. Sam robienie tego traktuję jako czynność wykończeniową podczas prac nad systemem. Gdy interfejs jest gotowy, po prostu stwierdzam, że czas coś o nim napisać, aby przekonać się, że jego opis w języku ludzkim brzmi równie dobrze, jak w języku JavaScript (lub jakimkolwiek innym języku programowania, jakiego używamy).
Interfejs i implementacja
Rozróżnienie zewnętrznego interfejsu i wewnętrznych szczegółów obiektu jest ważne z dwóch powodów. Po pierwsze dzięki niewielkiemu i ściśle zdefiniowane interfejsowi obiekt jest łatwy w użyciu. Trzeba tylko znać ten interfejs, a resztę kodu obiektu nie musimy się interesować, chyba że chcemy coś w nim zmienić.
Po drugie często zdarza się, że trzeba coś zmienić w wewnętrznej implementacji obiektu, aby był bardziej praktyczny, lepiej działał albo żeby usunąć usterkę. Gdyby w innych częściach programu używane były wszystkie własności i elementy budowy obiektu, nie można by było w nim nic zmienić bez dodatkowego modyfikowania dużych partii kodu w innych miejscach. Jeśli na zewnętrz obiektu używany jest tylko jego niewielki interfejs, można wprowadzać dowolne zmiany w implementacji, pod warunkiem, że nie rusza się tego interfejsu.
Niektórzy traktują to niezwykle poważnie. Osoby takie np. w interfejsach obiektów nigdy nie umieszczają własności, a jedynie metody — jeśli ich typ obiektowy ma długość, to jest ona dostępna poprzez metodę getLength
, a nie własność length
. Dzięki temu, jeśli kiedyś zechcą ze swojego obiektu usunąć własność length
, bo np. od tej pory zawiera on wewnętrzną tablicę, której długość musi zwracać, mogą zmodyfikować funkcję bez zmieniania interfejsu.
Jednak moim zdaniem nie jest to warte zachodu. Dodanie metody o nazwie getLength
, która zawiera tylko instrukcję return this.length;
jest niepotrzebnym mnożeniem kodu. Dla mnie w większości przypadków taki bezsensowny kod jest większym problemem niż konieczność zmiany interfejsu raz na gody.
Bardzo przydatne jest dodawanie nowych metod do istniejących prototypów. W języku JavaScript dodatkowe metody przydałyby się prototypom Array
i String
. Moglibyśmy np. zamienić funkcje forEach
i map
metodami tablic, a funkcję startsWith
napisaną w rozdziale 4 zamienić w metodę łańcuchów.
Jeśli jednak Twój program będzie działał na jednej stronie internetowej z innym programem, w którym programista używa konstrukcji for
–in
naiwnie ― czyli tak, jak my do tej pory ― to dodanie metod do prototypów, zwłaszcza Object
i Array
, na pewno spowoduje problemy, ponieważ pętle te nagle zaczną znajdować nowe własności. Dlatego niektórzy wolą w ogóle nie ruszać tych prototypów. Oczywiście jeśli jesteś ostrożny i nie spodziewasz się, że Twój kod będzie pracował obok jakiegoś źle napisanego programu, dodawanie metod do standardowych prototypów jest jak najbardziej wartościową techniką.
Budowa wirtualnego terrarium
W tym rozdziale zbudujemy wirtualne terrarium, czyli pojemnik zawierający fruwające owady. W programie tym będziemy używać obiektów, co chyba Cię nie dziwi, skoro temat tego rozdziału to programowanie obiektowe w JavaScript. Nie będziemy tworzyć niczego skomplikowanego. Nasze terrarium będzie dwuwymiarową siatką, jak druga mapa w rozdziale 7. Na siatce rozmieszczone są owady. Gdy terrarium jest aktywne, każdy owad może wykonać jakąś czynność, np poruszyć się co pół sekundy.
W związku z tym podzielimy przestrzeń i czas na jednostki o stałym rozmiarze ― kwadraty dla przestrzeni i połówki sekund dla czasu. Zaletą tego jest uproszczenie modelowania w programie, a wadą niska precyzja. Na szczęście w tym symulatorze terrarium nic nie musi być precyzyjne, a więc nie ma problemu.
Terrarium można zdefiniować przy użyciu szablonu będącego tablicą łańcuchów. Moglibyśmy użyć pojedynczego łańcucha, ale ponieważ w JavaScripcie łańcuchy muszą w całości mieścić się w jednym wierszu, byłoby to trudne do zrealizowania.
var thePlan =
["############################",
"# # # o ##",
"# #",
"# ##### #",
"## # # ## #",
"### ## # #",
"# ### # #",
"# #### #",
"# ## o #",
"# o # o ### #",
"# # #",
"############################"];
Znaki #
reprezentują ściany terrarium (i znajdujące się w nim ozdobne kamienie), znaki o
reprezentują owady, a spacje, jak się pewnie domyślasz oznaczają puste miejsce.
Z takiej tablicy można utworzyć obiekt terrarium. W obiekcie tym przechowywane będą kształt i zawartość terrarium oraz będzie on umożliwiał poruszanie się owadom. Obiekt ma cztery metody: Pierwsza to toString
, która konwertuje terrarium na łańcuch podobny do bazowego planu, dzięki czemu można zobaczyć, co się dzieje wewnątrz. Metoda step
pozwala owadom wykonać pojedynczy ruch, jeśli sobie tego życzą. Natomiast metody start
i stop
służą do „włączania” i wyłączania” terrarium. Gdy terrarium jest uruchomione, metoda step
jest wywoływana automatycznie co pół sekundy powodując ruch owadów.
Punkty na siatce także będą reprezentowane jako obiekty. W rozdziale 7 do pracy z punktami używane były trzy funkcje: point
, addPoints
oraz samePoint
. Tym razem użyjemy konstruktora i dwóch metod. Napisz konstruktor Point
pobierający dwa argumenty będące współrzędnymi x i y punktu i tworzący obiekt zawierający własności x
i y
. Prototypowi tego konstruktora dodaj metodę add
pobierającą punkt jako argument i zwracającą nowy punkt, którego współrzędne x
i y
są sumą współrzędnych x
i y
dwóch podanych punktów. Dodatkowo napisz metodę isEqualTo
pobierającą punkt i zwracającą wartość logiczną oznaczającą, czy ten (this
) punkt ma takie same współrzędne, jak podany punkt.
Oprócz wymienionych dwóch metod w skład interfejsu tego typu obiektów wchodzą również własności x
i y
: Kod używający obiektów punktów może dowolnie pobierać i modyfikować własności x
i y
.
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.add = function(other) {
return new Point(this.x + other.x, this.y + other.y);
};
Point.prototype.isEqualTo = function(other) {
return this.x == other.x && this.y == other.y;
};
show((new Point(3, 1)).add(new Point(2, 4)));
Pamiętaj, aby Twoja wersja metody add
pozostawiała punkt this
nietknięty i tworzyła nowy obiekt. Metoda zmieniająca bieżący obiekt działałaby podobnie do operatora +=
, który z kolei działa jak operator +
.
Podczas pisania obiektów do implementacji programu nie zawsze jest jasne, gdzie powinny być zaimplementowane różne funkcje. Niektóre rzeczy najlepiej jest zrealizować jako metody obiektów, inne lepiej wyrazić jako osobne funkcje, a jeszcze inne najlepiej jest zaimplementować poprzez dodanie nowego typu obiektowego. Aby kod był klarowny i dobrze zorganizowany, należy starać się liczbę metod i obowiązków obiektów sprowadzić do minimum. Gdy obiekt wykonuje zbyt wiele zadań, robi się w nim bałagan, który bardzo trudno zrozumieć.
Wcześniej napisałem, że obiekt terrarium będzie odpowiedzialny za przechowywanie zawartości terrarium i możliwość ruchu owadów. Należy podkreślić słowo możliwość, które nie oznacza przymusu. Same owady też będą obiektami i w ich gestii będzie leżeć podejmowanie decyzji, co w danym momencie zrobić. Terrarium umożliwia zaledwie pytanie owadów, czy chcą coś zrobić co pół sekundy i jeśli owad zechce się poruszyć, terrarium zadba o to, aby tak się stało.
Przechowywanie siatki, na której rozmieszczona jest zawartość terrarium może być skomplikowane. Trzeba zdefiniować jakąś reprezentację, sposoby dostępu do tej reprezentacji, sposób inicjacji siatki z tablicowego planu, sposób zapisania zawartości siatki w łańcuchu za pomocą metody toString
oraz ruch owadów na siatce. Dobrze by było przynajmniej część tych obowiązków przenieść na inny obiekt, aby obiekt terrarium nie stał się zbyt rozbudowany.
Zawsze gdy natkniesz się na problem pomieszania reprezentacji danych i kodu implementacyjnego w jednym obiekcie, dobrym pomysłem jest wydzielenie kodu dotyczącego reprezentacji danych do osobnego typu obiektu. W tym przypadku potrzebujemy reprezentacji siatki wartości, a więc napisałem typ o nazwie Grid
obsługujący operacje wymagane przez terrarium.
Wartości na siatce można zapisywać na dwa sposoby: Można użyć tablicy tablic:
var grid = [["0,0", "1,0", "2,0"],
["0,1", "1,1", "2,1"]];
show(grid[1][2]);
Ale można też wszystkie wartości umieścić w jednej tablicy. W tym przypadku element o współrzędnych x
,y
można znaleźć pobierając element znajdujący się w tablicy na pozycji x + y * width
, gdzie width
to szerokość siatki.
var grid = ["0,0", "1,0", "2,0",
"0,1", "1,1", "2,1"];
show(grid[2 + 1 * 3]);
Zdecydowałem się na drugie z przedstawionych rozwiązań, ponieważ o wiele łatwiej jest w nim zainicjować tablicę. Instrukcja new Array(x)
tworzy nową tablicę o długości x
, wypełnioną wartościami undefined
.
function Grid(width, height) {
this.width = width;
this.height = height;
this.cells = new Array(width * height);
}
Grid.prototype.valueAt = function(point) {
return this.cells[point.y * this.width + point.x];
};
Grid.prototype.setValueAt = function(point, value) {
this.cells[point.y * this.width + point.x] = value;
};
Grid.prototype.isInside = function(point) {
return point.x >= 0 && point.y >= 0 &&
point.x < this.width && point.y < this.height;
};
Grid.prototype.moveValue = function(from, to) {
this.setValueAt(to, this.valueAt(from));
this.setValueAt(from, undefined);
};
Będziemy też potrzebować sposobu na przeglądanie wszystkich elementów siatki, aby znaleźć owady, które mają się poruszyć i przekonwertować całość na łańcuch. Najłatwiej będzie napisać funkcję wyższego rzędu pobierającą jako argument akcję. Dodaj metodę each
do prototypu Grid
, która jako argument będzie pobierać funkcję dwóch argumentów. Metoda będzie wywoływać tę funkcję dla każdego punktu na siatce przekazując jej jako pierwszy argument obiekt tego punktu, a jako drugi argument — wartość znajdującą się w tym punkcie na siatce.
Przeglądanie rozpocznij w punkcie 0
,0
i przeglądaj po jednym wierszu, tzn. tak, aby punkt 1
,0
został odwiedzony wcześniej niż 0
,1
. To ułatwi późniejsze napisanie funkcji toString
terrarium. (Podpowiedź: pętlę for
dla współrzędnej x
umieść wewnątrz pętli dla współrzędnej y
.)
Lepiej jest nie kombinować bezpośrednio z własnością cells
obiektu siatki, tylko zamiast tego do wartości dostać się używając valueAt
. Dzięki temu, jeśli postanowimy do zapisywania wartości użyć innej metody, będziemy musieli przepisać tylko valueAt
i setValueAt
, a pozostałe metody pozostawić bez zmian.
Grid.prototype.each = function(action) {
for (var y = 0; y < this.height; y++) {
for (var x = 0; x < this.width; x++) {
var point = new Point(x, y);
action(point, this.valueAt(point));
}
}
};
Przetestujemy siatkę:
var testGrid = new Grid(3, 2);
testGrid.setValueAt(new Point(1, 0), "#");
testGrid.setValueAt(new Point(1, 1), "o");
testGrid.each(function(point, value) {
print(point.x, ",", point.y, ": ", value);
});
Zanim napiszemy konstruktor Terrarium
, musimy skonkretyzować obiekty owadów, które mają w nim żyć. Wcześniej napisałem, że terrarium będzie pytać owady, jaką czynność chcą wykonać. Będzie się to odbywać następująco: każdy obiekt owada będzie miał metodę act
zwracającą „akcję”. Akcja to obiekt zawierający własność type
określającą nazwę typu czynności, jaką owad chce wykonać, np. move
(ruch). Większość akcji zawiera dodatkowe informacje, takie jak kierunek, w jakim owad chce się poruszyć.
Owady są niezwykle krótkowzroczne, przez co widzą tylko kwadraty znajdujące się w ich bezpośrednim sąsiedztwie. Ale to wystarczy, aby wykonać ruch. Przy wywoływaniu metodzie act
będzie przekazywany obiekt zawierający informacje o otoczeniu określonego owada. W obiekcie tym będzie znajdować się własność dla każdego z ośmiu kierunków. Własność wskazująca, co znajduje się powyżej będzie miała nazwę n
(od North — północ), własność kierunku w górę i na prawo będzie się nazywała ne
(od North-East itd.). Kierunki, do których odnoszą się poszczególne nazwy można znaleźć w poniższym obiekcie słownikowym:
var directions = new Dictionary(
{"n": new Point( 0, -1),
"ne": new Point( 1, -1),
"e": new Point( 1, 0),
"se": new Point( 1, 1),
"s": new Point( 0, 1),
"sw": new Point(-1, 1),
"w": new Point(-1, 0),
"nw": new Point(-1, -1)});
show(new Point(4, 4).add(directions.lookup("se")));
Gdy owad postanowi się poruszyć, wskaże interesujący go kierunek nadając powstałemu w wyniku tej decyzji obiektowi akcji własność direction
zawierającą nazwę jednego z kierunków. Możemy też zrobić głupiego owada, który zawsze porusza się w jednym kierunku — do światła:
function StupidBug() {};
StupidBug.prototype.act = function(surroundings) {
return {type: "move", direction: "s"};
};
Teraz może rozpocząć pracę nad obiektem Terrarium
. Zaczniemy od konstruktora, który będzie przyjmował plan (będący tablicą łańcuchów) jako argument i inicjował jego siatkę.
var wall = {};
function Terrarium(plan) {
var grid = new Grid(plan[0].length, plan.length);
for (var y = 0; y < plan.length; y++) {
var line = plan[y];
for (var x = 0; x < line.length; x++) {
grid.setValueAt(new Point(x, y),
elementFromCharacter(line.charAt(x)));
}
}
this.grid = grid;
}
function elementFromCharacter(character) {
if (character == " ")
return undefined;
else if (character == "#")
return wall;
else if (character == "o")
return new StupidBug();
}
wall
to obiekt służący do oznaczania ścian siatki. Jak na ścianę przystało, nic nie robi, tylko stoi w jednym miejscu i nie pozwala przejść.
Najprostszą metodą obiektu jest toString
, która zamienia terrarium w łańcuch. Aby sobie ułatwić, zaznaczymy wall
i prototyp owada StupidBug
własnością character
zawierającą znak reprezentujący owady.
wall.character = "#";
StupidBug.prototype.character = "o";
function characterFromElement(element) {
if (element == undefined)
return " ";
else
return element.character;
}
show(characterFromElement(wall));
Teraz do utworzenia łańcucha możemy użyć metody each
obiektu Grid
. Jednak aby wynik był czytelny, przydałoby się na końcu każdego wiersza dodać znak nowego wiersza. Końce rzędów można znaleźć po współrzędnej x
pozycji na siatce. Dodaj do prototypu Terrarium
metodę toString
, która nie pobiera żadnych argumentów i zwraca łańcuch, który po przekazaniu do funkcji print
prezentuje się jako dwuwymiarowy widok terrarium.
Terrarium.prototype.toString = function() {
var characters = [];
var endOfLine = this.grid.width - 1;
this.grid.each(function(point, value) {
characters.push(characterFromElement(value));
if (point.x == endOfLine)
characters.push("n");
});
return characters.join("");
};
Wypróbuj ten kod…
var terrarium = new Terrarium(thePlan);
print(terrarium.toString());
Niewykluczone, że próbując rozwiązać powyższe zadanie próbowałeś uzyskać dostęp do this.grid
wewnątrz funkcji przekazywanej jako argument do metody each
siatki. To się nie uda. Wywołanie funkcji zawsze powoduje powstanie nowej zmiennej this
wewnątrz tej funkcji, nawet jeśli nie jest ona używana jako metoda. Przez to żadna zmienna this
z poza funkcji nie będzie widoczna.
czasami problem ten można łatwo obejść zapisując potrzebne informacje w zmiennej, np. endOfLine
, która jest widoczna w funkcji wewnętrznej. Jeśli potrzebujesz dostępu do całego obiektu this
, to jego również możesz zapisać w zmiennej. Zmiennej takiej często nadaje się nazwę self
(albo that
).
Jednak w działaniach tych można się w końcu pogubić. Innym dobrym rozwiązaniem jest użycie funkcji podobnej do partial
z rozdziału 6. Zamiast dodawać argumenty do funkcji, ta dodaje obiekt this
używając pierwszego argumentu metody apply
funkcji:
function bind(func, object) {
return function(){
return func.apply(object, arguments);
};
}
var testArray = [];
var pushTest = bind(testArray.push, testArray);
pushTest("A");
pushTest("B");
show(testArray);
W ten sposób można powiązać (bind
) wewnętrzną funkcję z this
i będzie ona miała tę samą zmienną this
, co funkcja zewnętrzna.
W wyrażeniu bind(testArray.push, testArray)
nazwa testArray
występuje dwa razy. Potrafisz zaprojektować funkcję o nazwie method
pozwalającą powiązać obiekt z jedną z jego metod bez podawania nazwy obiektu dwa razy?
Nazwę metody można przekazać jako łańcuch. Dzięki temu funkcja method
może sama znaleźć odpowiednią wartość funkcyjną.
function method(object, name) {
return function() {
object[name].apply(object, arguments);
};
}
var pushTest = method(testArray, "push");
Funkcji bind
(lub method
) będziemy potrzebować przy implementowaniu metody step
naszego terrarium. Metoda ta musi przejrzeć wszystkie owady na siatce, spytać je o zamierzone działanie i wykonać to działanie. Może Cię kusić, aby przejrzeć siatkę za pomocą instrukcji each
i zrobić, co trzeba z każdym napotkanym owadem. Ale wówczas, jeśli owad przemieści się na południe albo wschód, napotkamy go ponownie i znowu pozwolimy mu wykonać ruch.
Dlatego najpierw zbierzemy wszystkie owady do tablicy, a potem je przetworzymy. Poniższa metoda zbiera owady lub inne byty mające metodę act
i zapisuje je w obiektach zawierających dodatkowe informacje o ich bieżącym położeniu:
Terrarium.prototype.listActingCreatures = function() {
var found = [];
this.grid.each(function(point, value) {
if (value != undefined && value.act)
found.push({object: value, point: point});
});
return found;
};
Prosząc owada, aby wykonał jakąś czynność musimy mu przekazać obiekt zawierający informacje o jego aktualnym otoczeniu. W obiekcie tym będą znajdować się własności o nazwach odpowiadających nazwom kierunków, o których była mowa wcześniej (n
, ne
itd.). Każda własność zawiera łańcuch składający się z jednego znaku, zwrócony przez characterFromElement
, wskazujący co owad widzi w danym kierunku.
Dodaj metodę listSurroundings
do prototypu Terrarium
. Metoda ta powinna przyjmować jeden argument będący punktem, w którym aktualnie znajduje się owad i zwracać obiekt z informacją o otoczeniu tego punktu. Gdy punkt znajduje się przy krawędzi siatki, kierunki wykraczające poza siatkę oznaczaj znakiem #
, aby owad nie próbował tam się przemieścić.
Podpowiedź: Nie wypisuj wszystkich kierunków, tylko zastosuj metodę each
na słowniku directions
.
Terrarium.prototype.listSurroundings = function(center) {
var result = {};
var grid = this.grid;
directions.each(function(name, direction) {
var place = center.add(direction);
if (grid.isInside(place))
result[name] = characterFromElement(grid.valueAt(place));
else
result[name] = "#";
});
return result;
};
Zwróć uwagę na użycie zmiennej grid
w celu obejścia problemu z this
.
Żadna z powyższych metod nie wchodzi w skład zewnętrznego interfejsu obiektu Terrarium
— obie są wewnętrznymi szczegółami. W niektórych językach istnieje możliwość jawnego oznaczenia wybranych metod i własności jako „prywatnych” i spowodowanie, że próba ich użycia poza obiektem zakończy się błędem. W języku JavaScript nie jest to możliwe, przez co trzeba opisać interfejs za pomocą komentarzy. Czasami pomocne może być zastosowanie jakiegoś specyficznego nazewnictwa, aby odróżnić własności zewnętrzne od wewnętrznych. Można np. nazwom wszystkich metod wewnętrznych dodać przedrostek w postaci znaku podkreślenia („_
”). Dzięki temu łatwiej będzie zauważyć wszystkie przypadkowe użycia własności nie należących do interfejsu obiektu.
W następnej kolejności zajmiemy się kolejną metodą wewnętrzną, tą która pyta owada o czynność i ją wykonuje. Metoda ta przyjmuje jako argument obiekt z własnościami object
i point
zwrócony przez listActingCreatures
. Na razie znana jest jej tylko czynność move
:
Terrarium.prototype.processCreature = function(creature) {
var surroundings = this.listSurroundings(creature.point);
var action = creature.object.act(surroundings);
if (action.type == "move" && directions.contains(action.direction)) {
var to = creature.point.add(directions.lookup(action.direction));
if (this.grid.isInside(to) && this.grid.valueAt(to) == undefined)
this.grid.moveValue(creature.point, to);
}
else {
throw new Error("Nieobsługiwana czynność: " + action.type);
}
};
Zauważ, że metoda ta sprawdza czy wybrany kierunek prowadzi do miejsca w obrębie siatki i czy to miejsce jest wolne. Jeśli nie jest, to je ignoruje. Dzięki temu owad może prosić o dowolną czynność. Jeśli jej wykonanie jest niemożliwe, to po prostu nic się nie dzieje. Jest to coś w rodzaju warstwy odizolowującej owady od terrarium, która pozwala nam trochę zaniedbać precyzję przy pisaniu metod act
owadów ― np. owad StupidBug
zawsze zmierza na południe, niezależnie od tego, czy na jego drodze stoją jakieś ściany.
Te trzy wewnętrzne metody umożliwiły nam napisanie w końcu metody step
, która wszystkim owadom daje szansę na wykonanie jakiejś czynności (dotyczy to wszystkich elementów mających metodę act
― moglibyśmy też taką metodę zdefiniować dla obiektu wall
, gdybyśmy chcieli mieć ruchome ściany).
Terrarium.prototype.step = function() {
forEach(this.listActingCreatures(),
bind(this.processCreature, this));
};
Teraz możemy utworzyć terrarium, aby zobaczyć czy owady będą się w nim poruszać…
var terrarium = new Terrarium(thePlan);
print(terrarium);
terrarium.step();
print(terrarium);
Chwileczkę, jak to możliwe, że powyższe wywołania print(terrarium)
powodują wyświetlenie wyniku naszej metody toString
? Funkcja print
zamienia swoje argumenty na łańcuchy za pomocą funkcji String
. Obiekty zamienia się w łańcuchy wywołując ich metodę toString
, a więc zdefiniowanie metody toString
dla własnych typów obiektowych jest dobrym sposobem na sprawienie, aby były czytelne po wydrukowaniu.
Point.prototype.toString = function() {
return "(" + this.x + "," + this.y + ")";
};
print(new Point(5, 5));
Zgodnie z obietnicą obiekty Terrarium
otrzymają także metody start
i stop
do uruchamiania i wyłączania symulacji. Do ich budowy użyjemy dwóch funkcji dostarczanych przez przeglądarkę: setInterval
i clearInterval
. Pierwsza z nich przyjmuje dwa argumenty. Pierwszy z nich określa kod (funkcję albo łańcuch zawierający kod JavaScript), który ma być przez tę metodę cyklicznie wywoływany. Natomiast drugi określa liczbę milisekund (1/1000 sekundy) między wywołaniami. Zwracana jest wartość, którą możne przekazać do metody clearInterval
, aby zatrzymać wykonywanie.
var annoy = setInterval(function() {print("Co?");}, 400);
I…
clearInterval(annoy);
Istnieją też podobne metody do jednorazowych czynności. Metoda setTimeout
powoduje wykonanie funkcji lub łańcucha po upływie określonej liczby milisekund, a clearTimeout
anuluje tę czynność.
Terrarium.prototype.start = function() {
if (!this.running)
this.running = setInterval(bind(this.step, this), 500);
};
Terrarium.prototype.stop = function() {
if (this.running) {
clearInterval(this.running);
this.running = null;
}
};
Mamy już terrarium z kilkoma średnio bystrymi owadami i możemy je nawet uruchomić. Ale żeby zobaczyć, co się dzieje, musimy ciągle wywoływać funkcję print(terrarium)
. Nie jest to praktyczne. Lepiej by było, gdyby terrarium było drukowane automatycznie. Ponadto lepszy efekt uzyskamy, jeśli zamiast drukować tysiące terrariów jedno pod drugim będziemy aktualizować jeden wydruk. Jeśli chodzi o drugi z opisanych problemów, to w tej książce dostępna jest pomocnicza funkcja o nazwie inPlacePrinter
. Zwraca funkcję podobną do print
, która zamiast dodawać wynik do aktualnego wydruku, zastępuje go.
var printHere = inPlacePrinter();
printHere("Teraz widzisz.");
setTimeout(partial(printHere, "A teraz nie."), 1000);
Aby terrarium było ponownie drukowane po każdej zmianie, możemy zmodyfikować metodę step
:
Terrarium.prototype.step = function() {
forEach(this.listActingCreatures(),
bind(this.processCreature, this));
if (this.onStep)
this.onStep();
};
Do terrarium została dodana własność onStep
, która będzie wywoływana w każdym kroku.
var terrarium = new Terrarium(thePlan);
terrarium.onStep = partial(inPlacePrinter(), terrarium);
terrarium.start();
Zwróć uwagę na użycie funkcji partial
― tworzy miejscową drukarkę stosowaną do terrarium. Drukarka taka przyjmuje tylko jeden argument, a więc po jej częściowym zastosowaniu nie pozostają żadne argumenty i staje się funkcją zera argumentów. Dokładnie tego potrzeba nam dla własności onStep
.
Pamiętaj, że terrarium należy wyłączyć gdy nie jest już interesujące (co powinno nastąpić dosyć szybko), aby nie zużywało zasobów komputera:
terrarium.stop();
Ale komu potrzebne jest terrarium z tylko jednym owadem i to głupim? Na pewno nie mnie. Fajnie by było, gdybyśmy mogli dodać jeszcze inne rodzaje owadów. Na szczęście jedyne, co w tym celu musimy zrobić, to uogólnić funkcję elementFromCharacter
. Obecnie zawiera ona trzy przypadki, które są w niej bezpośrednio zakodowane:
function elementFromCharacter(character) {
if (character == " ")
return undefined;
else if (character == "#")
return wall;
else if (character == "o")
return new StupidBug();
}
Dwa pierwszy przypadki może pozostawić, ale trzeci jest o wiele za bardzo specyficzny. Lepszym rozwiązaniem byłoby zapisanie znaków i odpowiadających im konstruktorów owadów w słowniku i pobieranie ich stamtąd:
var creatureTypes = new Dictionary();
creatureTypes.register = function(constructor) {
this.store(constructor.prototype.character, constructor);
};
function elementFromCharacter(character) {
if (character == " ")
return undefined;
else if (character == "#")
return wall;
else if (creatureTypes.contains(character))
return new (creatureTypes.lookup(character))();
else
throw new Error("Nieznany znak: " + character);
}
Zwróć uwagę na sposób dodania metody register
do obiektu creatureTypes
― to, że jest to obiekt słownikowy nie znaczy, że nie może on obsługiwać dodatkowej metody. Metoda ta znajduje znak związany z konstruktorem i zapisuje go w słowniku. Powinna być wywoływana wyłącznie na konstruktorach, których prototypy zawierają własność character
.
Teraz metoda elementFromCharacter
szuka znaku podanego jej w creatureTypes
i zgłasza wyjątek jeśli otrzyma nieznany znak.
Poniżej znajduje się definicja nowego typu owada i wywołanie rejestrujące jego znak w creatureTypes
:
function BouncingBug() {
this.direction = "ne";
}
BouncingBug.prototype.act = function(surroundings) {
if (surroundings[this.direction] != " ")
this.direction = (this.direction == "ne" ? "sw" : "ne");
return {type: "move", direction: this.direction};
};
BouncingBug.prototype.character = "%";
creatureTypes.register(BouncingBug);
Rozumiesz jak to działa?
Utwórz typ owada o nazwie DrunkBug
, który w każdej kolejce próbuje wykonać ruch w losowym kierunku nie zważając na ściany. Przypomnij sobie sztuczkę z Math.random
z rozdziału 7.
Aby wybrać losowy kierunek, potrzebna nam jest tablica nazw kierunków. Oczywiście moglibyśmy po prostu napisać ["n", "ne", ...]
, ale to oznaczałoby duplikowanie informacji, a powielanie danych mnie złości. Moglibyśmy też do budowy tej tablicy użyć instrukcji each
w obiekcie directions
, co byłoby już lepszym rozwiązaniem.
Jednak w tym przypadku jest możliwość zastosowania uogólnienia. Możliwość utworzenia listy nazw własności znajdujących się w słowniku wydaje się bardzo przydatna, a więc dodamy takie narzędzie do prototypu Dictionary
.
Dictionary.prototype.names = function() {
var names = [];
this.each(function(name, value) {names.push(name);});
return names;
};
show(directions.names());
Neurotyk od razu dodałby jeszcze dla równowagi metodę values
zwracającą listę wartości zapisanych w słowniku. Myślę jednak, że to może poczekać, aż będzie potrzebne.
Oto sposób pobrania losowego elementu z tablicy:
function randomElement(array) {
if (array.length == 0)
throw new Error("Tablica jest pusta.");
return array[Math.floor(Math.random() * array.length)];
}
show(randomElement(["heads", "tails"]));
A to jest owad we własnej osobie:
function DrunkBug() {};
DrunkBug.prototype.act = function(surroundings) {
return {type: "move",
direction: randomElement(directions.names())};
};
DrunkBug.prototype.character = "~";
creatureTypes.register(DrunkBug);
Przetestujmy nasze nowe owady:
var newPlan =
["############################",
"# #####",
"# ## ####",
"# #### ~ ~ ##",
"# ## ~ #",
"# #",
"# ### #",
"# ##### #",
"# ### #",
"# % ### % #",
"# ####### #",
"############################"];
var terrarium = new Terrarium(newPlan);
terrarium.onStep = partial(inPlacePrinter(), terrarium);
terrarium.start();
Widzisz, jak teraz pijane owady obijają się po całej scenie? Czysta komedia. Gdy nacieszysz już oko tym fascynującym przedstawieniem, wyłącz je:
terrarium.stop();
Mamy już dwa rodzaje obiektów zawierających metodę act
i własność character
. Dzięki temu, że mają wspólne te cechy, terrarium może z nimi postępować w taki sam sposób. A to oznacza, że możemy utworzyć dowolną liczbę owadów nie zmieniając niczego w kodzie terrarium. Technika ta to polimorfizm. Jest to chyba najpotężniejsze narzędzie programowania obiektowego.
Mówiąc najprościej w polimorfizmie chodzi o to, że gdy zostanie napisany moduł kodu przystosowany do współpracy z obiektami mającymi określony interfejs, to można do niego podłączyć obiekt dowolnego typu, który ten interfejs obsługuje. Widzieliśmy już proste przykłady zastosowania tego, np. metodę toString
obiektów. Wszystkie obiekty mające zdefiniowaną w sensowny sposób metodę toString
można przekazać do funkcji print
oraz innych funkcji konwertujących wartości na łańcuchy i zostanie utworzony prawidłowy łańcuch bez względu na to, jak ich metoda toString
go zbuduje.
Analogicznie funkcja forEach
działa zarówno na prawdziwych tablicach, jak i pseudotablicach znajdujących się w zmiennej arguments
, ponieważ potrzebna jest jej tylko własność length
oraz własności o nazwach 0
, 1
itd. elementów tablicy.
Aby trochę urozmaicić życie w terrarium, dodamy do niego pojęcia pożywienia i rozmnażania. Każdemu stworzeniu w terrarium dodamy nową własność o nazwie energy
, której wartość będzie się zmniejszała w wyniku wykonywanych czynności i zwiększała w wyniku zjadania pożywienia. Gdy żyjątko będzie miało wystarczająco dużo energii, będzie mogło się rozmnożyć2, czyli wygenerować nowe stworzenie tego samego gatunku.
Jeśli w terrarium będą tylko owady marnujące energię na poruszanie się i zjadanie się nawzajem, szybko pogrąży się ono w entropii, skończy się energia i zostanie tylko martwa pustynia. Aby temu zapobiec (a przynajmniej, żeby nie nastąpiło to zbyt szybko), dodamy do terrarium porosty. Porosty nie ruszają się, a jedynie gromadzą energię dzięki fotosyntezie i rozmnażają się.
Aby to działało, potrzebujemy terrarium z inną metodą processCreature
. Moglibyśmy zmienić metodę prototypu Terrarium
, ale zbytnio przywiązaliśmy się do symulacji pijanych owadów i nie chcielibyśmy niszczyć starego terrarium.
W związku z tym możemy utworzyć nowy konstruktor, np. o nazwie LifeLikeTerrarium
, którego prototyp będzie oparty na prototypie Terrarium
, ale który będzie miał inną metodę processCreature
.
Pomysł ten można zrealizować na kilka sposobów. Można przejrzeć własności prototypu Terrarium.prototype
i dodać je jedna po drugiej do prototypu LifeLikeTerrarium.prototype
. Wykonanie tego jest łatwe i w niektórych sytuacjach jest to najlepsze rozwiązanie, ale w tym przypadku jest lepszy sposób. Jeśli stary obiekt prototypowy uczynimy prototypem nowego obiektu prototypowego (możliwe, że będziesz musiał kilka razy przeczytać tę część zdania), to ten nowy obiekt automatycznie otrzyma wszystkie własności starego.
Niestety w języku JavaScript nie da się w łatwy sposób utworzyć obiektu, którego prototypem jest wybrany inny obiekt. Można jednak napisać funkcję, która to zrobi. Trzeba tylko zastosować następującą sztuczkę:
function clone(object) {
function OneShotConstructor(){}
OneShotConstructor.prototype = object;
return new OneShotConstructor();
}
W funkcji tej użyty jest pusty jednorazowy konstruktor, którego prototypem jest podany obiekt. Jeśli do tego konstruktora zastosuje się operator new
, utworzy on nowy obiekt na bazie podanego obiektu.
function LifeLikeTerrarium(plan) {
Terrarium.call(this, plan);
}
LifeLikeTerrarium.prototype = clone(Terrarium.prototype);
LifeLikeTerrarium.prototype.constructor = LifeLikeTerrarium;
Nowy konstruktor nie musi robić czegokolwiek innego niż stary, a więc tylko wywołuje stary konstruktor na obiekcie this
. Musimy też odtworzyć własność constructor
w nowym prototypie, bo jeśli tego nie zrobimy, będzie „twierdził”, że jego konstruktorem jest Terrarium
(to oczywiście sprawiałoby problem, gdybyśmy używali tej własności, a tutaj tego nie robimy).
Teraz można wymienić niektóre metody obiektu LifeLikeTerrarium
albo dodać nowe. Utworzyliśmy nowy typ obiektu na bazie innego, dzięki czemu uniknęliśmy przepisywania wszystkich metod, które w Terrarium
i LifeLikeTerrarium
są takie same. Technika ta nazywa się dziedziczenie. Nowy typ dziedziczy własności po starym typie. W większości przypadków nowy typ obsługuje także interfejs starego typu, ale może mieć dodatkowo inne metody nie obsługiwane przez stary typ. Dzięki temu obiektów nowego typu można używać wszędzie tam, gdzie można używać obiektów starego typu. To się nazywa polimorfizm.
W większości „typowo” obiektowych języków programowania dziedziczenie jest jednym z fundamentów i korzystanie z niego jest bardzo łatwe. W JavaScripcie jednak nie ma specjalnego mechanizmu, który by to umożliwiał. Z tego też powodu programiści używający JavaScriptu opracowali wiele własnych technik realizacji dziedziczenia. Niestety każda z nich ma jakieś wady. Z drugiej strony jest ich tak dużo, że zawsze da się znaleźć odpowiednią, a ponadto można stosować sztuczki, które w innych językach są niemożliwe.
Na zakończenie rozdziału pokażę Ci kilka innych technik realizacji dziedziczenia oraz opiszę ich wady.
Poniżej znajduje się kod nowej metody o nazwie processCreature
. Metoda ta jest dość duża.
LifeLikeTerrarium.prototype.processCreature = function(creature) {
var surroundings = this.listSurroundings(creature.point);
var action = creature.object.act(surroundings);
var target = undefined;
var valueAtTarget = undefined;
if (action.direction && directions.contains(action.direction)) {
var direction = directions.lookup(action.direction);
var maybe = creature.point.add(direction);
if (this.grid.isInside(maybe)) {
target = maybe;
valueAtTarget = this.grid.valueAt(target);
}
}
if (action.type == "move") {
if (target && !valueAtTarget) {
this.grid.moveValue(creature.point, target);
creature.point = target;
creature.object.energy -= 1;
}
}
else if (action.type == "eat") {
if (valueAtTarget && valueAtTarget.energy) {
this.grid.setValueAt(target, undefined);
creature.object.energy += valueAtTarget.energy;
}
}
else if (action.type == "photosynthese") {
creature.object.energy += 1;
}
else if (action.type == "reproduce") {
if (target && !valueAtTarget) {
var species = characterFromElement(creature.object);
var baby = elementFromCharacter(species);
creature.object.energy -= baby.energy * 2;
if (creature.object.energy > 0)
this.grid.setValueAt(target, baby);
}
}
else if (action.type == "wait") {
creature.object.energy -= 0.2;
}
else {
throw new Error("Nieobsługiwana czynność: " + action.type);
}
if (creature.object.energy <= 0)
this.grid.setValueAt(creature.point, undefined);
};
Funkcja nadal rozpoczyna działanie od spytania stworzenia, co chce zrobić. Jeśli wybrana czynność ma własność direction
(kierunek), oblicza który punkt na siatce ten kierunek wskazuje i jaka wartość aktualnie się w nim znajduje. Informacja ta jest potrzebna trzem z pięcie obsługiwanych akcji i gdyby każda z nich obliczenia wykonywała osobno, kod byłby jeszcze bardziej niezgrabny. Jeśli nie ma własności direction
albo jest niepoprawna, zmienne target
i valueAtTarget
pozostają niezdefiniowane.
Następnie funkcja przechodzi przez akcje. Niektóre akcje przed wykonaniem wymagają dodatkowych testów, które są realizowane w osobnej instrukcji if
, dzięki czemu jeśli jakieś stworzenie spróbuje np. przejść przez ścianę nie generujemy wyjątku "Nieobsługiwana czynność"
.
Zwróć uwagę, że w akcji reproduce
stworzenie będące rodzicem traci dwa razy tyle energii, co otrzymuje nowonarodzone stworzenie (rodzenie dzieci nie jest łatwe) i potomek pojawia się na siatce tylko, jeśli rodzic ma wystarczająco dużo energii.
Gdy czynność zostanie wykonana sprawdzamy, czy stworzeniu nie wyczerpała się energia. Jeśli tak, stworzenie umiera i usuwamy je.
Porosty nie są skomplikowanymi organizmami. Na planszy będą reprezentowane przez znak *
. Upewnij się, że masz zdefiniowaną funkcję randomElement
z ćwiczenia 8.6, ponieważ będzie nam tu potrzebna.
function Lichen() {
this.energy = 5;
}
Lichen.prototype.act = function(surroundings) {
var emptySpace = findDirections(surroundings, " ");
if (this.energy >= 13 && emptySpace.length > 0)
return {type: "reproduce", direction: randomElement(emptySpace)};
else if (this.energy < 20)
return {type: "photosynthese"};
else
return {type: "wait"};
};
Lichen.prototype.character = "*";
creatureTypes.register(Lichen);
function findDirections(surroundings, wanted) {
var found = [];
directions.each(function(name) {
if (surroundings[name] == wanted)
found.push(name);
});
return found;
}
Maksymalny poziom energii porostów wynosi 20. Gdyby mogły rosnąć większe, to tworzyłyby gigantyczne skupiska i nie byłoby miejsca na rozmnażanie.
Utwórz stworzenie LichenEater
(zjadacz porostów). Początkowo niech ma 10
jednostek energii i niech zachowuje się następująco:
- gdy ma nie mniej niż 30 jednostek energii i jest wystarczająco dużo miejsca, rozmnaża się.
- W przeciwnym przypadku, jeśli w pobliżu są jakieś porosty, niech je losowo zjada.
- Jeśli nie ma porostów w pobliżu, ale jest puste miejsce, niech się przemieszcza w losowo wybranym kierunku.
- Jeśli nie ma wolnych miejsc, niech czeka.
Do sprawdzania otoczenia i wybierania kierunku użyj metod findDirections
i randomElement
. Stworom tym przypisz literę c
na planszy (jak pac-man).
function LichenEater() {
this.energy = 10;
}
LichenEater.prototype.act = function(surroundings) {
var emptySpace = findDirections(surroundings, " ");
var lichen = findDirections(surroundings, "*");
if (this.energy >= 30 && emptySpace.length > 0)
return {type: "reproduce", direction: randomElement(emptySpace)};
else if (lichen.length > 0)
return {type: "eat", direction: randomElement(lichen)};
else if (emptySpace.length > 0)
return {type: "move", direction: randomElement(emptySpace)};
else
return {type: "wait"};
};
LichenEater.prototype.character = "c";
creatureTypes.register(LichenEater);
Wypróbuj to.
var lichenPlan =
["############################",
"# ######",
"# *** **##",
"# *##** ** c *##",
"# *** c ##** *#",
"# c ##*** *#",
"# ##** *#",
"# c #* *#",
"#* #** c *#",
"#*** ##** c **#",
"#***** ###*** *###",
"############################"];
var terrarium = new LifeLikeTerrarium(lichenPlan);
terrarium.onStep = partial(inPlacePrinter(), terrarium);
terrarium.start();
Najprawdopodobniej najpierw porosty szybko rozrosną się i zajmą dużą część terrarium, po czym duża ilość pożywienia sprawi, że zaczną mnożyć się w dużych ilościach zjadacze porostów, które wytępią porosty i przy okazji samych siebie. Cóż, taka już jest natura.
terrarium.stop();
Śmierć wszystkich mieszkańców naszego terrarium w ciągu kilku minut nie jest dla nas miła. Aby temu zapobiec, musimy nauczyć zjadaczy porostów długofalowego zarządzania pożywieniem. Jeśli będą zjadać porosty tylko wtedy, gdy w pobliżu widzą przynajmniej dwa krzaki (bez względu na to jak są głodne), to nigdy nie wytępią wszystkich porostów. Do tego potrzebna jest dyscyplina, ale w ten sposób powstanie biotop, który nie będzie niszczył samego siebie. Poniżej znajduje się nowy kod metody act
― jedyna zmiana polega na tym, że jedzenie jest wykonywane tylko wtedy, gdy własność lichen.length
ma wartość nie mniejszą od dwóch.
LichenEater.prototype.act = function(surroundings) {
var emptySpace = findDirections(surroundings, " ");
var lichen = findDirections(surroundings, "*");
if (this.energy >= 30 && emptySpace.length > 0)
return {type: "reproduce", direction: randomElement(emptySpace)};
else if (lichen.length > 1)
return {type: "eat", direction: randomElement(lichen)};
else if (emptySpace.length > 0)
return {type: "move", direction: randomElement(emptySpace)};
else
return {type: "wait"};
};
Uruchom ponownie terrarium lichenPlan
i zobacz, co się dzieje. Po pewnym czasie zjadacze porostów prawdopodobnie wyginą, ponieważ podczas masowego głodu będą poruszać się bezcelowo w przód i w tył, zamiast znajdować porosty znajdujące się tuż obok.
Zmodyfikuj obiekt LichenEater
, aby miał większą szansę na przetrwanie. Nie oszukuj, tzn. this.energy += 100
jest niedozwolone. Jeśli od nowa napiszesz konstruktor, nie zapomnij go zarejestrować w słowniku creatureTypes
albo terrarium nadal będzie używać starego.
Jednym z rozwiązań może być rezygnacja z losowego wybierania kierunków ruchu. gdy kierunki są wybierane losowo, stwory często poruszają się w tę i z powrotem ostatecznie nigdzie nie docierając. Jeśli stworzenie będzie pamiętać kierunek ostatniego ruchu i preferować jego kontynuację, zmarnuje mniej czasu i szybciej dotrze do pożywienia.
function CleverLichenEater() {
this.energy = 10;
this.direction = "ne";
}
CleverLichenEater.prototype.act = function(surroundings) {
var emptySpace = findDirections(surroundings, " ");
var lichen = findDirections(surroundings, "*");
if (this.energy >= 30 && emptySpace.length > 0) {
return {type: "reproduce",
direction: randomElement(emptySpace)};
}
else if (lichen.length > 1) {
return {type: "eat",
direction: randomElement(lichen)};
}
else if (emptySpace.length > 0) {
if (surroundings[this.direction] != " ")
this.direction = randomElement(emptySpace);
return {type: "move",
direction: this.direction};
}
else {
return {type: "wait"};
}
};
CleverLichenEater.prototype.character = "c";
creatureTypes.register(CleverLichenEater);
Wypróbuj to na poprzedniej planszy terrarium.
Łańcuch pokarmowy zawierający tylko jedno ogniwo to wciąż uboga opcja. Czy potrafisz napisać nowego stwora, LichenEaterEater
(znak @
), który aby żyć musi zjadać zjadaczy porostów? Spróbuj tak go dopasować do ekosystemu, aby zbyt szybko nie wymarł. Dodaj kilka takich stworzeń do tablicy lichenPlan
i wypróbuj je.
Rozwiązanie tego problemu musisz znaleźć sam. Mnie nie udało się znaleźć dobrego sposobu na to, aby zapobiec wyginięciu tych stworzeń natychmiast albo zaraz po wytępieniu wszystkich zjadaczy porostów. Sztuczka ze zjadaniem osobników tylko wtedy, gdy dwa z nich znajdują się obok siebie tutaj nie działa, ponieważ osobniki te się ruszają i trudno napotkać je, gdy są obok siebie. Obiecującym rozwiązaniem jest danie zjadaczom zjadaczy dużo energii, dzięki której mogą przetrwać czas, gdy jest mało zjadaczy porostów oraz powolne rozmnażanie, dzięki czemu zasoby pożywienia nie są zbyt szybko zużywane.
Życiem porostów i zjadaczy rządzi pewien cykl — raz jest dużo porostów, co powoduje, że rodzi się dużo zjadaczy, co z kolei powoduje, że robi się mało porostów, z którego to powodu zjadacze zaczynają umierać z głodu, co sprawia że porosty znowu się rozrastają itd. Można też spróbować hibernacji zjadaczy zjadaczy porostów (przy użyciu akcji wait
) na pewien czas, gdy nie uda im się znaleźć nic do jedzenia przez kilka kolejek. Dobre efekty może przynieść wybudzanie stworów z hibernacji po odpowiedniej liczbie kolejek albo gdy wyczują w pobliżu jedzenie.
Na tym zakończymy pracę nad naszym terrarium. W dalszej części rozdziału bardziej dogłębnie zajmiemy się kwestią dziedziczenia i związanymi z tym problemami w JavaScripcie.
Zaczniemy od odrobiny teorii. Studenci uczący się programowania obiektowego często dyskutują na temat prawidłowych i nieprawidłowych sposobów wykorzystania technik dziedziczenia. Dlatego ważne jest, aby zdawać sobie sprawę, że dziedziczenie to tak naprawdę tylko sztuczka pozwalająca leniwym3 programistom uniknąć pisania części kodu. W związku z tym wszelkie dyskusje dotyczące poprawności stosowania dziedziczenia sprowadzają się do rozstrzygnięcia, czy otrzymany kod działa poprawnie i nie zawiera niepotrzebnych powtórzeń. Z drugiej strony zasady, o których toczone są wspomniane dyskusje mogą być dobrym wstępem do dziedziczenia.
Dziedziczenie to technika tworzenia nowych typów obiektów, tzw. podtypów, na bazie istniejących typów, tzw. nadtypów. Podtyp dziedziczy po nadtypie wszystkie własności i metody, a następnie może je modyfikować i ewentualnie dodawać nowe. Dziedziczenie najlepiej jest stosować wtedy, gdy obiekt, którego modelem jest podtyp może być określony, jako obiekt nadtypu.
Na przykład typ Fortepian
może być podtypem typu Instrument
, ponieważ fortepian jest instrumentem. Ponieważ fortepian ma szereg klawiszy, niektórych może kusić uczynienie typu Fortepian
podtypem typu Array
, ale fortepian nie jest rodzajem tablicy i jego implementowanie w ten sposób na pewno spowoduje powstanie wielu nonsensów. Fortepian ma też pedały. Można spytać dlaczego element piano[0]
reprezentuje pierwszy klawisz, a nie pedał? W tej sytuacji, jako że każdy fortepian ma klawisze, o wiele lepiej byłoby utworzyć obiekt mający własności klawisze
i pedaly
zawierające tablice.
Każdy podtyp może być nadtypem innego podtypu. Niektóre problemy nawet najlepiej się rozwiązuje poprzez budowę skomplikowanych drzew rodzinnych typów. Należy tylko uważać, żeby z tym nie przesadzić. Nadużywanie dziedziczenia jest prostą drogą do zamienienia programu w jeden wielki bałagan.
Sposób działania słowa kluczowego new
i własności prototype
konstruktorów narzucają określony sposób używania obiektów. W przypadku prostych obiektów, jak stworzenia w terrarium jest to wystarczające. Jeśli jednak chcemy w programie intensywnie wykorzystywać dziedziczenie, taki sposób obsługi obiektów szybko stanie się niezgrabny. Można sobie ułatwić pracę pisząc funkcje do wykonywania niektórych często wykonywanych zadań. Na przykład wielu programistów definiuje obiektom metody inherit
i method
.
Object.prototype.inherit = function(baseConstructor) {
this.prototype = clone(baseConstructor.prototype);
this.prototype.constructor = this;
};
Object.prototype.method = function(name, func) {
this.prototype[name] = func;
};
function StrangeArray(){}
StrangeArray.inherit(Array);
StrangeArray.method("push", function(value) {
Array.prototype.push.call(this, value);
Array.prototype.push.call(this, value);
});
var strange = new StrangeArray();
strange.push(4);
show(strange);
Jeśli poszukasz w internecie informacji na tematy „JavaScript” i dziedziczenie (ang. inheritance), to znajdziesz wiele różnych zdań na ten temat, z których część jest o wiele bardziej skomplikowana i sprytna od przedstawionego przeze mnie.
Zwróć uwagę na sposób, w jaki napisana tu metoda push
wykorzystuje metodę push
z prototypu swojego typu nadrzędnego. Jest to często spotykany sposób działania, jeśli chodzi o dziedziczenie — metoda w podtypie wewnętrznie używa metody nadtypu, ale jakoś go rozszerza.
Największym problemem z tym prostym podejściem jest dualizm między konstruktorami i prototypami. Konstruktory grają centralną rolę, ponieważ od nich typ obiektowy wywodzi swoją nazwę, a gdy potrzebny jest prototyp, trzeba pobrać własność prototype
konstruktora.
To nie tylko wymaga dużo pisania (słowo prototype
składa się z 9 liter), ale i jest mylące. We wcześniejszym przykładzie musieliśmy napisać pusty i bezużyteczny konstruktor dla typu StrangeArray
. Sam nie raz omyłkowo dodałem metody do konstruktora zamiast jego prototypu albo próbowałem wywołać Array.slice
, gdy w rzeczywistości chciałem Array.prototype.slice
. Moim zdaniem prototyp jest najważniejszym aspektem typu obiektowego, a konstruktor jest tylko rozszerzeniem, specjalną metodą.
Dodając kilka prostych metod pomocniczych do prototypu Object.prototype
można utworzyć alternatywne podejście do obiektów i dziedziczenia. W podejściu tym typ jest reprezentowany przez swój prototyp i do przechowywania prototypów używa się zmiennych o nazwach pisanych wielkimi literami. Gdy trzeba coś „skonstruować”, należy użyć metody o nazwie construct
. Dodamy metodę o nazwie create
do prototypu Object
, która będzie używana w miejsce słowa kluczowego new
. Metoda ta będzie klonować obiekt i wywoływać jego metodę construct
, jeśli taka istnieje, przekazując jej argumenty, które zostały do niej (create
) przekazane.
Object.prototype.create = function() {
var object = clone(this);
if (typeof object.construct == "function")
object.construct.apply(object, arguments);
return object;
};
Dziedziczenie można zrealizować poprzez sklonowanie obiektu prototypowego i dodanie lub zmodyfikowanie własności. Do tego również napiszemy metodę pomocniczą, o nazwie extend
, która będzie klonować obiekt, do którego zostanie zastosowana i dodawać do klonu własności obiektu, który otrzymała w argumencie.
Object.prototype.extend = function(properties) {
var result = clone(this);
forEachIn(properties, function(name, value) {
result[name] = value;
});
return result;
};
W przypadkach gdy kombinowanie z prototypem Object
nie jest bezpieczne zamiast metod można utworzyć zwykłe funkcje.
Na przykład, jeśli jesteś dość stary, to możliwe, że kiedyś grałeś w grę typu „tekstowa przygoda”, w której chodzi się po świecie używając specjalnych poleceń i otrzymuje się opisy znajdujących się w otoczeniu rzeczy oraz wykonywanych działań. Kiedyś to były gry!
Poniżej znajduje się przykładowy prototyp przedmiotu w takiej.
var Item = {
construct: function(name) {
this.name = name;
},
inspect: function() {
print("To jest ", this.name, ".");
},
kick: function() {
print("klunk!");
},
take: function() {
print("Nie możesz podnieść ", this.name, ".");
}
};
var lantern = Item.create("brązowa latarnia");
lantern.kick();
A oto sposób dziedziczenia po nim…
var DetailedItem = Item.extend({
construct: function(name, details) {
Item.construct.call(this, name);
this.details = details;
},
inspect: function() {
print("Widzisz ", this.name, ", ", this.details, ".");
}
});
var giantSloth = DetailedItem.create(
"wielkiego leniwca",
"wisi sobie na drzewie i żuje liście");
giantSloth.inspect();
Pozbycie się obowiązkowej części prototype
sprawia, że wywołania typu Item.construct
z konstruktora DetailedItem
są nieco prostsze. Zwróć uwagę, że this.name = name
w DetailedItem.construct
byłoby złym pomysłem. Byłoby to zduplikowanie wiersza. Oczywiście powielenie jednego wiersza jest lepsze niż wywołanie funkcji Item.construct
, ale jeśli później zechcemy cos dodać do tego konstruktora, to będziemy musieli zrobić to w dwóch miejscach.
W większości przypadków konstruktor podtypu powinien zaczynać działanie od wywołania konstruktora nadtypu. Dzięki temu pracę rozpoczyna od poprawnego obiektu nadtypu, który następnie rozszerza. W tym podejściu do prototypów typy nie wymagające konstruktora mogą go opuścić. Odziedziczą go automatycznie po nadtypie.
var SmallItem = Item.extend({
kick: function() {
print(this.name, " fruwa po pokoju.");
},
take: function() {
// (wyobraź sobie tutaj kod wkładający przedmiot do Twojej kieszeni)
print("Bierzesz ", this.name, ".");
}
});
var pencil = SmallItem.create("czerwony ołówek");
pencil.take();
Mimo że typ SmallItem
nie definiuje własnego konstruktora, można go tworzyć przy użyciu argumentu name
, ponieważ dziedziczył konstruktor po prototypie Item
.
W języku JavaScript znajduje się operator o nazwie instanceof
, za pomocą którego można sprawdzić czy obiekt jest utworzony na bazie określonego prototypu. Po lewej stronie podaje się obiekt, a po prawej konstruktor. Zwracana jest wartość logiczna: true
jeśli własność prototype
konstruktora jest bezpośrednim lub pośrednim prototypem obiektu lub false
w przeciwnym przypadku.
Jeśli nie są używane zwykłe konstruktory, używanie tego operatora jest trochę nieporęczne — jego drugim argumentem powinna być funkcja konstrukcyjna, a my mamy tylko prototypy. Problem ten można rozwiązać stosując sztuczkę podobną do tej z funkcją clone
: Operatorowi instanceof
przekazujemy „fałszywy” konstruktor.
Object.prototype.hasPrototype = function(prototype) {
function DummyConstructor() {}
DummyConstructor.prototype = prototype;
return this instanceof DummyConstructor;
};
show(pencil.hasPrototype(Item));
show(pencil.hasPrototype(DetailedItem));
Następnie chcemy utworzyć mały przedmiot, który ma szczegółowy opis. Wydaje się że przedmiot ten powinien dziedziczyć zarówno po DetailedItem
jak i SmallItem
. W JavaScripcie obiekt nie może mieć kilku prototypów, a nawet gdyby mógł, problem i tak nie byłby łatwy do rozwiązania. Na przykład, gdyby SmallItem
z jakiegoś powodu zawierał definicję metody inspect
, której metody inspect
używałby nowy prototyp?
Derywacja typu obiektu z więcej niż jednego typu nadrzędnego nazywa się wielodziedziczeniem. W niektórych językach jest to całkowicie zabronione, a w innych opracowano skomplikowane zasady, aby to działało i było praktyczne. W języku JavaScript można zaimplementować porządny schemat wielodziedziczenia. Oczywiście, jak to zwykle bywa, można to zrobić na kilka sposobów. Jest to jednak zbyt skomplikowane, aby to tutaj omawiać. Dlatego przedstawiam tylko proste rozwiązanie, które powinno wystarczyć w większości przypadków.
Domieszka (ang. mix-in) to specjalny rodzaj prototypu, który można „wmieszać” w inne prototypy. W ten sposób można potraktować prototyp SmallItem
. Kopiując jego metody kick
i take
do innego prototypu dodamy do niego domieszkę.
function mixInto(object, mixIn) {
forEachIn(mixIn, function(name, value) {
object[name] = value;
});
};
var SmallDetailedItem = clone(DetailedItem);
mixInto(SmallDetailedItem, SmallItem);
var deadMouse = SmallDetailedItem.create(
"Mysz Fred",
"on jest martwy");
deadMouse.inspect();
deadMouse.kick();
Pamiętaj, że forEachIn
przegląda tylko własności należące do obiektu, a więc skopiuje metody kick
i take
, ale nie skopiuje konstruktora odziedziczonego przez SmallItem
po Item
.
Mieszanie prototypów staje się o wiele bardziej skomplikowane, gdy domieszka ma konstruktor lub gdy niektóre jej metody kolidują nazwami z metodami prototypu, do którego są dodawane. Czasami da się wykonać „domieszkowanie ręczne”. Powiedzmy, że mamy prototyp Monster
, który ma swój własny konstruktor, i chcemy go zmieszać z DetailedItem
.
var Monster = Item.extend({
construct: function(name, dangerous) {
Item.construct.call(this, name);
this.dangerous = dangerous;
},
kick: function() {
if (this.dangerous)
print(this.name, " odgryza Ci głowę.");
else
print(this.name, " ucieka, szlochając.");
}
});
var DetailedMonster = DetailedItem.extend({
construct: function(name, description, dangerous) {
DetailedItem.construct.call(this, name, description);
Monster.construct.call(this, name, dangerous);
},
kick: Monster.kick
});
var giantSloth = DetailedMonster.create(
"Wielki leniwiec",
"wisi sobie na drzewie i żuje liście",
true);
giantSloth.kick();
Zauważ jednak, że konstruktor Item
przy tworzeniu DetailedMonster
jest wywoływany dwukrotnie ― raz poprzez konstruktor DetailedItem
, a drugi raz poprzez konstruktor Monster
. W tym przypadku nie powoduje to wielkich szkód, ale w innych może być poważnym problemem.
Mimo tych komplikacji nie zniechęcaj się do dziedziczenia. Wielodziedziczenie, mimo że czasami bardzo przydatne, w większości przypadków można sobie darować. Dlatego właśnie w takich językach jak Java jest ono zabronione. A jeśli kiedyś będziesz go naprawdę potrzebować, możesz poszukać informacji w internecie, zrobić rozeznanie i znaleźć rozwiązanie idealne dla siebie.
Tak mi teraz przyszło do głowy, że JavaScript byłby doskonałym językiem do napisania tekstowej gry przygodowej. Bardzo w tym pomaga możliwość zmieniania zachowań obiektów, którą mamy dzięki prototypowemu dziedziczeniu. Jeśli masz obiekt hedgehog
, który ma niezwykłą zdolność toczenia się, gdy zostanie kopnięty, możesz tylko zmienić jego metodę kick
.
Niestety tekstowe przygodówki podzieliły losy płyt winylowych i mimo że kiedyś były bardzo popularne, dziś gra w nie tylko garstka zapaleńców.
Przypisy
- Takie typy w innych językach zazwyczaj nazywają się klasami.
- Dla uproszczenia stworzenia w naszym terrarium rozmnażają się bezpłciowo, same z siebie.
- Lenistwo w przypadku programistów niekoniecznie oznacza coś złego. Osoby, które lubią wielokrotnie powtarzać te same czynności są dobrymi robotnikami przy taśmach montażowych i słabymi programistami.
Obszerny materiał o_O
chyba z tydzień zajmie mi przerabianie tego rozdziału
ale motywacja jest, więc do boju… 😀