Konstruktor - dziedziczenie

Uwaga! Poniższe manewry mogą wydać ci się skomplikowane. Nie przejmuj się tym!
W dzisiejszych czasach takie rzeczy jeżeli będziesz robił, to raczej za pomocą składni Class, którą poznamy w kolejnym rozdziale. Jest tam to wygodniejsze, czytelniejsze i działa zwyczajnie lepiej.

Załóżmy, że robimy wspomnianą w poprzednich rozdziałach grę, gdzie wszyscy przeciwnicy mają mieć podstawowe funkcjonalności:


function Enemy(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    console.log(`Tworzę przeciwnika: ${this.x}x${this.y}`);
}

Enemy.prototype.fly = function() {
    return this.name + " lecę";
}

Chcielibyśmy stworzyć nowy typ obiektów - np. "EnemyShoot", który miałby wszystkie powyższe funkcjonalności, ale równocześnie potrafiłby strzelać.


function EnemyShoot(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.type = "shooter";
}

EnemyShoot.prototype.shoot = function() {
    return this.name + " strzelam";
}

const enemyS = new EnemyShoot("Shooter", 20, 20);
console.log(enemyS.shoot()); //Shooter strzelam
console.log(enemyS.fly()); //błąd - nie ma takiej metody

No tak, błąd. Nasz obiekt nie ma swojej indywidualnej metody fly(), jego prototyp też jej nie ma, a __proto__ w tym prototypie wskazuje na Object, w którym także jej brakuje (i dobrze, bo raczej nie chcemy by każdy obiekt w JavaScript mógł latać).

object-prorotype

Naszym zadaniem jest więc sprawić by __proto__ w prototypie EnemyShoot wskazywało na prototyp Enemy, dzięki czemu obiekty typu EnemyShoot będą mogły korzystać z wszystkich funkcjonalności obiektów Enemy.

Spróbujmy to zrobić przez podstawienie pod prototyp EnemyShoot prototypu Enemy:


function EnemyShoot(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.type = "shooter";
}

EnemyShoot.prototype = Enemy.prototype;

EnemyShoot.prototype.shoot = function() {
    return this.name + " strzelam!";
}

const enemyS = new EnemyShoot("Shooter", 10, 20);
console.log(enemyS.fly()); //Shooter lecę
console.log(enemyS.shoot()); //Shooter strzelam!

Problem z powyższym rozwiązaniem jest taki, że skoro zrównaliśmy do siebie obydwa prototypy, stały się one tym samym obiektem. Jeżeli dla EnemyShoot dodaliśmy metodę shoot(), trafiła ona też do obiektów tworzonych na bazie Enemy, a przecież nie wszyscy przeciwnicy powinni strzelać.


const enemyN = new Enemy("Normalny", 10, 20);
enemyN.shoot(); //Normalny strzelam

const enemyS = new EnemyShoot("Shooter", 10, 20);
enemyS.shoot(); //Shooter strzelam

Takie zachowanie to już nie dziedziczenie, bo dostaliśmy dwa typy obiektów, które są identyczne.

Rozwiązaniem nie jest równanie prototypów, a stworzenie nowego obiektu na bazie innego prototypu. Można to zrobić na kilka sposobów:


EnemyShoot.prototype = Object.create(Enemy.prototype);

//lub
EnemyShoot.prototype = Object.assign({}, Enemy.prototype);

//lub
EnemyShoot.prototype = Object.create(...Enemy.prototype);

function EnemyShoot(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.type = "shooter";
}

EnemyShoot.prototype = Object.create(Enemy.prototype);

EnemyShoot.prototype.shoot = function() {
    return this.name + " sobie skaczę!";
}


const enemyN = new Enemy("Normalny");
console.log(enemyN.fly()); //Normalny latam
console.log(enemyN.shoot()); //błąd - nie ma takiej metody

const enemyS = new EnemyShoot("Shooter");
console.log(enemyS.fly()); //Shooter latam
console.log(enemyS.shoot()); //Shooter strzelam

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 EnemyShoot na bazie prototypu Enemy. Tym samym wzięliśmy tamten konstruktor, który wskazuje na Enemy. Dla EnemyShoot powinien to być EnemyShoot. Naprawmy to:


function EnemyShoot(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.type = "shooter";
}

EnemyShoot.prototype = Object.create(Enemy.prototype);
EnemyShoot.prototype.constructor = EnemyShoot;

Od tej pory typ obiektów EnemyShoot dziedziczy po obiektach typu Enemy.


function Enemy(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    console.log("Tworzę przeciwnika: " + this.name);
}

Enemy.prototype.fly = function() {
    return this.name + " latam";
}

//dziedziczymy prototyp

function EnemyShoot(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.type = "shooter";
}

EnemyShoot.prototype = Object.create(Enemy.prototype);
EnemyShoot.prototype.constructor = EnemyShoot;

EnemyShoot.prototype.shoot = function() {
    return this.name + " strzelam";
}


const enemyN = new Enemy("Normalny");
console.log(enemyN.fly()); //Normalny latam
console.log(enemyN.shoot()); //błąd - nie ma takiej metody

const enemyS = new EnemyShoot("Shooter");
console.log(enemyS.fly()); //Shooter latam
console.log(enemyS.shoot()); //Shooter strzelam

Odwoływanie się do konstruktora ojca

Wyobraź sobie, że w konstruktorze Enemy są przeprowadzane skomplikowane wyliczenia, które chcielibyśmy wykonywać także w konstruktorze EnemyShoot:


function Enemy(name, x, y) {
    this.name = name;
    this.x = x;
    this.y = y;
    this.speed = Math.random() * 3;
    //...tutaj 20 linii skomplikowanych wyliczeń dla naszego przeciwnika...
    console.log("Tworzę przeciwnika: " + this.name);
}

function EnemyShoot(name, x, y) {
    //tutaj chcemy odpalić kod z powyższego konstruktora Enemy
    //plus coś dodatkowego np.:
    this.type = "shooter";
}

Jak to zrobić? Wystarczyłoby w konstruktorze EnemyShoot odpalić tamtą funkcję, zmieniając jej tylko odpowiednio this by wskazywało na EnemyShoot.

Call i apply

Aby to zrobić, użyjemy do tego metody call(). Jest ona dostępna dla każdej funkcji i służy do jej wywołania:


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

//odpalamy przez nazwę
myFunc();

//lub call
myFunc.call();

Poza wywołaniem funkcji, metoda call() daje nam jedną bardzo ważną funkcjonalność. Jako pierwszy jej parametr podajemy wartość, która zostanie podstawiona pod this wewnątrz wywoływanej funkcji:


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

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

const ob2 = {
    name: "Roman"
}

//pożyczam metodę z tamtego obiektu
ob.print.call(ob2); //Mam na imię Roman

//i znowu pożyczam
ob.print.call({name : "Patryk"}); //Mam na imię Patryk

Jak widzisz więc metoda call() idealnie nadaje się do "zapożyczania" funkcji z innych obiektów w sytuacjach, gdy chcemy taką metodę użyć dla konkretnego obiektu, a on jej nie ma.

Jeżeli taka pożyczona funkcja wymagałaby jakiś parametrów, możemy je podać jako kolejne parametry metody call().


const ob = {
    name : "x-wing",
    print(shotCount, speed) {
        console.log(`${this.name} strzela ${shotCount} razy z szybkością ${speed}`);
    }
}

const tie = {
    name : "Tie fighter"
}

ob.print.call(tie, 5, 200); //Tie fighter strzela 5 razy z szybkością 200

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 = {
    sayHiToPet(pet) {
        console.log(`Cześć ${pet} !!!`) ;
    }
}

ob.sayHiToPet.call(null, "Świnka"); //Cześć Świnka!!!

Bardzo podobna w działaniu do call jest metoda apply(). 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 ob = {
    name : "nikt",
    print(pet1, pet2) {
        console.log(`Nazywam się ${this.name} i mam 2 zwierzaki: ${pet1} i ${pet2}`);
    }
}

const user = {
    name : "Marcin"
}

ob.print.apply(user, ["pies", "kot"]); //Nazywam się Marcin i mam dwa zwierzaki: pies i kot

//normalnie
Math.max(1, 2, 3, 4, 5, 2, 4); //5

//za pomocą call i apply
Math.max.call(Math, 1, 2, 3, 4, 5, 2, 4);
Math.max.apply(Math, [1, 2, 3, 4, 5, 2, 4]);

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

Przy czym w dzisiejszych czasach wydaje się, że wręcz o apply można zapomnieć, ponieważ nawet jak mamy tablicę, bez problemu zamienimy ją na pojedyncze atrybuty za pomocą spread operatora:


    Math.max.call(Math, ...[1, 5, 3, 2, 3]);
    

Rozszerzamy konstruktor

Po tym przydługim odejściu od tematu, wracamy do naszych obiektów:


function Enemy(name, x, y) {
    ...
}

function EnemyShoot(name, x, y) {
    Enemy.call(this, name, x, y); //Enemy wymaga 3 parametrów
    this.type = "shooter";
}

const shooter = new EnemyShoot("Shooter"); //Tworzę przeciwnika: Shooter

Idąc za ciosem, podobnie chcielibyśmy nadpisać dla EnemyShoot funkcję fly(), bo nie wypada strzelającemu przeciwnikowi, by latał jak zwykłe statki:


function EnemyShoot(name, x, y) {
    Enemy.call(this, name, x, y);
    this.type = "shooter";
}

EnemyShoot.prototype = Object.create(Enemy.prototype);
EnemyShoot.prototype.constructor = EnemyShoot;

EnemyShoot.prototype.fly = function() {
    const text = Enemy.prototype.fly.call(this); //tamta funkcja nie ma parametrów
    return text + " i czasami strzelam!!!";
}

const enemyN = new Enemy("Normalny");
console.log(enemyN.fly()); //Normalny latam

const enemyS = new EnemyShoot("Shooter");
console.log(enemyS.fly()); //Shooter latam i czasami strzelam!!!

Nasz super strzelający przeciwnik nie tylko pożyczył sobie kod z funkcji fly() prototypu Enemy, ale także go rozbudował o dodatkową funkcjonalność.

instanceof

Ostatnią rzeczą, jaką poznamy w tym rozdziale, to sprawdzanie, jakiego typu jest konkretny obiekt. Wystarczy użyć tutaj operatora instanceof:


const enemyN = new Enemy("Normalny");
enemyN.fly(); //Normalny latam

const enemyS = new EnemyShoot("Shooter");
console.log(enemyS.fly()); //Shooter latam i czasami strzelam!!!

console.log(enemyN instanceof Enemy); //true
console.log(enemyS instanceof EnemyShoot); //true
console.log(enemyN instanceof Object); //true
console.log(enemyS instanceof Object); //true

const ob = {
    name : "Marcin"
}

console.log(ob instanceof Object); //true
console.log(ob instanceof Enemy); //false
console.log(ob instanceof EnemyShoot); //false

Czemu obiekty simple i shooter są instancjami Object? Wynika to z faktu, że Enemy i EnemyShoot dziedziczą po Object. Ale już nasz obiekt ob nie ma żadnego powiązania z naszymi klasami więc instanceof zwraca false.

Gdybyś chciał sprawdzić czy obiekt shooter jest typu EnemyShoot, wystarczy sprawdzić jego konstruktor:


const enemyS = new EnemyShoot("Shooter");
enemyS.constructor === EnemyShoot;

Możemy też zmienić działanie tej instrukcji poprzez zastosowanie odpowiedniego symbolu.

Podsumowanie

Jak widzisz, trochę kodu musieliśmy napisać. Z jednej strony musimy pamiętać o składni prototypów, z drugiej zmuszeni jesteśmy do używania call() i apply(). Po drugim, trzecim użyciu da się przyzwyczaić...

Jeżeli nie podoba ci się powyższe podejście - nic straconego. W dzisiejszych czasach możemy korzystać z innej - o wiele przyjemniejszej w użyciu składni class, którą nie tylko bardzo upraszcza powyższy zapis, ale i nieco rozbudowuje nasze możliwości.

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

Trening czyni mistrza

Jeżeli chcesz sobie potrenować zdobytą wiedzę z tego działu, to zadania znajdują się w repozytorium pod adresem: https://github.com/kartofelek007/zadania-obiekty

W repozytorium jest branch "solutions". Tam znajdziesz przykładowe rozwiązania.

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.

Menu