Obiekty - dziedziczenie

Dziedziczenie

Jak już wiemy z poprzednich rozdziałów, w JavaScript dziedziczenie opiera się o prototypy.
Jeżeli dana instancja obiektu nie ma metody lub właściwości, JavaScript szuka go w jej prototypie, na który wskazuje właściwość __proto__. Jeżeli jej tam nie znajdzie przechodzi do kolejnego prototypu, i tak w koło do czasu, aż znajdzie szukaną metodę, lub dojdzie do najwyższego prototypu obiektów wszelakich.

No tak - dziedziczenie. A co to właściwie jest?

Gdybym zapytał ciebie czym jest dziedziczenie w realnym świecie co byś powiedział?
Dzieci dziedziczą po rodzicach pewne cechy. Może to kolor włosów, może kolor oczu, a może talent do rysowania.
Część takich właściwości sobie pobierają od rodziców, ale i też mają swoje własne.
Tata był przystojny. Syn też jest. Ale syn jest o wiele wyższy od ojca. Córka po mamie odziedziczyła spojrzenie, ale dodała swoje wyjątkowe różowe policzki.
Niektóre właściwości też nadpisują, bo na przykład córka ma włosy rude do rodzicu, ale jakieś takie nie za bardzo.
Co ważne w drugą stronę to nie działa. Rodzice nigdy nie dziedziczą po dzieciach (w realnym świecie czasami tak, ale to temat dla prawników).

Wracamy do Javascript. Wyobraź sobie, że masz konstruktor Animal:


function Animal(name, age) {
    this.age = age;
    this.name = name;
    this.type = 'animal';
}

Animal.prototype.eat = function() {
    return this.name + " właśnie je";
}

Jak widzisz nie za wiele on ma. Ani nie definiuje konkretnego typu zwierzęcia, ani co takie zwierze je, jakie wydaje dźwięki itp.
Teraz chciałbyś stworzyć nowy typ obiektów - np. "Psa", który miałby powyższe właściwości i metodę eat(), ale równocześnie dodawał by coś swojego:


function Dog(name) {
    this.name = name;
    this.type = "dog";
}
Dog.prototype.bark = function() {
    return "Wof! Wof!";
}

const dog = new Dog("Szamson");
console.log(dog.bark()); //Wof! Wof!
console.log(dog.eat()); //błąd - nie ma takiej metody

No tak, błąd. Nasz obiekt Dog nie ma metody eat(), a __proto__ wskazuje na prototyp Object, który też takiej metody nie ma. Jak więc zrobić, by __proto__ konstruktora Dog wskazywało na konstruktor Animal?
Skoro obiekty Animal mają swój prototyp, który definiuje ich wygląd, wystarczyło by, że dla Dog ustawilibyśmy ten sam prototyp:


function Dog(name) {
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Animal.prototype;

Dog.prototype.bark = function() {
    return "Wof! Wof!";
}

const dog = new Dog("Szamson");
console.log(dog.bark()); //Wof! Wof!
console.log(dog.eat()); //"Szamson właśnie je";

Problem tutaj jest taki, że skoro zrównaliśmy do siebie obydwa prototypy, stały się one tym samym obiektem.
Jeżeli dla Dog dodaliśmy teraz metodę bark(), trafiła by ona też do obiektów tworzonych na bazie Animal, a przecież nie wszystkie zwierzęta szczekają.


const horse = new Animal("Rafał");
horse.bark(); //Wof! Wof!

Rozwiązaniem nie jest równanie prototypów, a stworzenie nowego obiektu prototypu na bazie innego innego prototypu.
Wykorzystuje się do tego metodę Object.create(prototyp). Tworzy ona nowy obiekt na bazie jakiegoś prototypu.
Jeżeli teraz my stworzymy nowy obiekt (nowy prototyp) na bazie już istniejącego, to ten nowy prototyp będzie miał wszystkie metody i właściwości ze starego prototypu, ale już nie będzie tym samym prototypem:


function Dog(name) {
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.bark = function() {
    return "Wof! Wof!";
}

const dog = new Dog("dog");
console.log(dog.bark()); //Wof! Wof!
console.log(dog.eat()); //"dog właśnie je";

const animal = new Animal('Pingwin');
animal.bark(); //błąd, bo Animal nie ma metody bark()

Wszystko działa praktycznie idealnie, ale została malutka rzecz. Pamiętasz jak wyglądały "puste" prototypy, które wypisywaliśmy gdy tworzyliśmy nasze pierwsze konstruktory? Znajdowały się w nich 2 rzeczy: __proto__ i constructor. Właściwość constructor wskazywała na funkcję na bazie której są tworzone obiekty.
W powyższym kodzie stworzyliśmy prototyp Dog na bazie prototypu Animal. Tym samym wzięliśmy tamten konstruktor, który wskazuje na Animal. Dla Dog powinien to być Dog. Naprawmy to:


function Dog(name) {
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

...
...

I to wszystko. Nasz typ obiektów Dog dziedziczy po obiektach typu Animal. A Animal już po samym Object.


function Animal(name, age) {
    this.age = age;
    this.name = name;
    this.type = 'animal';
}

Animal.prototype.eat = function() {
    return this.name + " właśnie je";
}


function Dog(name) {
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    return "Wof! Wof!";
}


const a1 = new Animal("Koń Rafał", 10);
a1.eat(); //Koń Rafał właśnie je!
a1.bark(); //błąd

const d1 = new Dog("Pies");
d1.eat(); //Pies właśnie je!
d1.bark(); //Wof! Wof!

Klasa abstrakcyjna

W powyższych przykładach stworzyliśmy klasę Animal i podklasę Dog. Następnie tworzyliśmy obiekty na ich bazie.

Nie zawsze będzie to dobre podejście.
Wyobraź sobie, że tworzysz główną klasę (konstruktor) Bullet.
W klasie tej tworzysz właściwości takie jak speed, power, kierunek ruchu, jak taki pocisk ma się zachowywać gdy opuści planszę, może typ grafiki dla takiego pocisku.

Tworząc instancję takiego obiektu musisz ustawić ją w odpowiednim miejscu - w wylocie lufy karabinu postaci. Dodatkowo musisz ustawić takiemu pociskowi kierunek ruchu i zapewne odpalić poruszanie się.

Następnie tworzysz kilka klas dziedziczących. Dla przykładu BulletFast, BulletBig, BulletNormal.

Każda taka klasa dziedziczy po głównej klasie Bullet podstawowe właściwości.
Poza dziedziczeniem chciałbyś, by każda z tych klas brała sobie te początkowe ustawienia zmiennych, ewentualnie dodawała coś od siebie lub zmieniała (np. speed, power, grafikę, może kierunek ruchu).

Przykład pocisków w grze

W naszej grze nie chcielibyśmy tworzyć instancji nieokreślonej klasy Bullet (bo np. nie ma wyglądu), a tylko klasy pochodne - BulletFast, BulletBig, BulletNormal. Taka główna klasa - foremka dla klas pochodnych - zwie się klasą abstrakcyjną. Nie ma ona żadnych specyficznych cech, a tylko ogólne - będące formą dla podrzędnych klas.
Nie będziemy chcieli tworzyć instancji na bazie takiej klasy. Możemy się przed tym zabezpieczyć np. spradzając konstruktor:


const Bullet = function() {
    if (this.constructor === Bullet) {
        throw new Error("Nie możesz tworzyć obiektów z klasy abstrakcyjnej!");
    }
    ...
}

Odwoływanie się do obiektu ojca

Wracamy do klasy Animal i Dog.
Zauważ, że konstruktor Dog ustawia dla obiektów budowanych na jego bazie this.type i this.name.
W konstruktorze Animal natomiast tych właściwości ustawianych jest ciut więcej (dodatkowe this.age).
Podczas dziedziczenia chcielibyśmy by Dog wziął sobie ustawianie zmiennych z Animal:


...
this.type = 'animal';
this.name = name;
this.age = age;

A następnie tylko ustawił swoje specyficzne właściwości lub nadpisał to co powyżej:


//to z Animal
this.type = 'animal';
this.name = name;
this.age = age;

//to by Dog sobie dodał i ewentualnie nadpisał niektóre
this.type = "dog";
this.name = name;

Jak to zrobić? Wystarczyłoby w naszym konstruktorze Dog odpalić kod tamtej funkcji Animal, zmieniając jej tylko odpowiednio this by wskazywało na Dog...


function Dog(name) {
    //hej - tutaj chce odpalić kod z konstruktora Animal

    this.name = name;
    this.type = "dog";
}

Call i apply

Użyjemy do tego metody call(this*, parametr1*, parametr2*...)

Metoda call, która jest dostępna dla każdej funkcji i służy do wołania danej funkcji.

Powiedzmy, że mamy prostą funkcję:


function myFunc() {
    console.log('Jestem funkcją');
}

Żeby ją teraz wywołać możemy napisać:


myFunc();

//lub

myFunc.call();

Poza wołaniem o funkcję, metoda call daje nam jedną bardzo ważną funkcjonalność.

W pierwszym jej parametrze możemy przekazać obiekt, jaki zostanie podstawiony pod this wewnątrz funkcji:


const ob = {
    name : "Marcin",
    print : function() {
        console.log("Mam na imię " + this.name);
    }
}

ob.print(); //Mam na imię Marcin



const ob2 = {
    name : "Włodzimierz"
}
ob.print.call(ob2); //Mam na imię Włodzimierz

ob.print.call({name : "Patryk"}); //Mam na imię Patryk

Widzisz jak pożyczyliśmy sobie kod funkcji z innego obiektu?

Jeżeli ta funkcja wymagała by jakiś parametrów, możemy je podać jako kolejne parametry metody call().
Jeżeli wiemy, że w funkcji nie jest obsługiwane this, wtedy jako pierwszy parametr możemy przekazać null, undefined, lub inną rzecz - nie ma to znaczenia, bo i tak nie będzie obsługiwane w danej funkcji.


const ob = {
    name : "Marcin",
    print : function(friend1, friend2) {
        console.log("Mam na imię " + this.name);
        console.log("Moi przyjaciele to: " + friend1 + " i " + friend2);
    }
    sayHiToPet : function(pet) {
        console.log("Cześć " + pet + '!') ;
    }
}

ob.print("Karol", "Magda"); //Mam na imię Marcin. Moi przyjaciele to Karol i Magda


const ob2 = {
    name : "Włodzimierz"
}



ob.print.call(ob2, "Monika", "Piotrek"); //Mam na imię Włodzimierz. Moi przyjaciele to Monika i Piotrek
ob.sayHiToPet.call(null, "Świnka"); //Cześć Świnka!

W rozdziale o DOM poznasz technikę, która umożliwia zastosowanie forEach dla kolekcji elementów. Normalnie dla kolekcji taka metoda nie istniała, została dodana dopiero w nowych przeglądarkach.


const divs = document.querySelectorAll('div.module');

[].forEach.call(divs, function(el) {
    //działamy na elementach
    el.style.color = "red";
});

Czyli tłumacząc: zawołaj z obiektu typu Array metodę forEach, przekazując jej pod this kolekcję divów które właśnie pobrałeś.

Metoda forEach normalnie działa tak:


const tab = [1,2,3,4];
tab.forEach(function(el) {
    console.log(el); //wypisze kolejno 1, 2, 3, 4
})

Czyli właściwością this jest dana tablica po której forEach iteruje, a pierwszym parametrem jest funkcja zwrotna, do której przekazane zostaną kolejne elementy tablicy.

Porównajmy to więc do powyższej sztuczki.
Pod this trafiły divy, a pierwszym parametrem stała się funkcja, do której trafią kolejne składowe divs - czyli kolejne divy.

Bardzo podobna w działaniu do call jest metoda apply(this*, arr*). Różni ją od call to, że poza obiektem dla this przyjmuje tylko jeden atrybut - tablicę, która zawiera w sobie parametry. Po wywołaniu funkcji za pomocą apply, składowe tej tablicy są przekazywane jako kolejne parametry danej funkcji:


const max1 = Math.max(1,2,3,4,5,2,4); //5

const max2 = Math.max.call(Math, 1,2,3,4,5,2,4);
const max3 = Math.max.apply(Math, [1,2,3,4,5,2,4]);

function multiply(a, b) {
    console.log(a * b);
}

const multi1 = multiply(2,3);
const multi2 = Multiply.call(undefined, 2, 3);
const multi3 = multiply.apply(null, [2, 3]);

Co kiedy używać? Wszystko zależy od sytuacji. Czasami mamy dane "parametry" zebrane pod postacią tablicy, czasami możemy użyć oddzielnych zmiennych.

Rozszerzamy konstruktor

Po tym bardzo nudnym wywodzie, wracamy do naszych obiektów


function Animal(name, age) {
    this.name = name;
    this.age = age;
    this.type = 'animal';
}
Animal.prototype.eat = function() {
    return this.name + " właśnie je";
}



function Dog(name, age) {
    Animal.call(this, name, age); //powyższy konstruktor wymaga 2 parametrów
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    return "Wof! Wof!";
}

const dog = new Dog("Szamson", 8);
console.log(dog.bark()); //Wof! Wof!
console.log(dog.eat()); //"Szamson właśnie je";

Zauważyłeś, jak w konstruktorze Dog zawołaliśmy o konstruktor Animal? Musieliśmy też dodać parametry, których Animal wymagał - między innymi age. Skoro je dodałem, to przy tworzeniu obiektu na bazie Dog musieliśmy je uzupełnić.

Podobnie chcielibyśmy nadpisać dla Dog funkcję eat() Animala, tak, by początkowo był wykonywany kod z tamtej funkcji, a następnie ten przeznaczony tylko dla Psów:


function Animal(name, age) {
    this.name = name;
    this.age = age;
    this.type = 'animal';
}

Animal.prototype.eat = function() {
    return this.name + " właśnie je";
}



function Dog(name, age) {
    Animal.call(this, name, age);
    this.name = name;
    this.type = "dog";
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.eat = function() {
    const text = Animal.prototype.eat.call(this); //tamta funkcja nie ma parametrów
    return text + " kości";
}

Dog.prototype.bark = function() {
    return "Wof! Wof!";
}

const dog = new Dog("dog", 8);
console.log(dog.bark());
console.log(dog.eat()); //"dog właśnie je kości";
Gdy uczyłem się obiektów, nie do końca czułem te wszystkie zagadnienia. Obiekty, konstruktory, jakieś dziedziczenie itp - teoria, której nie widziałem w zastosowaniu praktycznym.
Dopiero zabawa z GameMaker - programem służącym do tworzenia gier - wyjaśniła mi sens tych rzeczy. Darmowa wersja tego programu jest do ściągnięcia tutaj.

Poniżej zamieszczam screen. Po prawej stronie mamy belkę z obiektami, które tworzymy. Opisujemy jakiej grafiki ma używać, co ma robić gdy zostanie stworzony, gdy wyjdzie poza planszę, jak będzie z nim kolizja itp. Każdy taki obiekt to tak naprawdę konstruktor, na bazie którego tworzymy (przeciągamy) potem pojedyncze instancje na planszy naszej gry. game maker

Jeżeli do tej pory nie czujesz obiektów, spróbuj stworzyć w tym programie jakąś prostą grę - np. Arkanoida. Szybko złapiesz idee użyteczności obiektów, dziedziczenia itp. A może i tworzenie gier ci się spodoba...

Ciekawy tutorial na powyższe tematy znajdziesz pod adresem: https://www.youtube.com/watch?v=MiKdRJc4ooE