Klasy w ES6

Jak wiesz w JS tworzenie klas nie istnieje, a cały mechanizm posługuje się konstruktoramii działa na prototypach.
Wielu programistów którzy przychodzili do Javascript z innych języków nie było w stanie zrozumieć zasady działania obiektóww tym języku. W ES6 została wprowadzona nakładka na ten cały mechanizm, który naśladuje tworzenie klas w innych językach. Niech to cie jednak nie zwiedzie - cały nowy mechanizm jest tylko tak zwanym sugar code. Nic nie zmienia, a jest tylko nakładką, która umożliwia nieco inne pisanie.

Zanim przejdziemy do klas, zróbmy powtórkę z dziedziczenia obiektów :)

Stwórzmy sobie prostą klasę Animal z jedną metodą w prototypie:


function Animal(name) {
    this.type = "animal";
    this.name = name;
}
Animal.prototype.eat = function() {
    return "I eat food";
}

Następnie stwórzmy kolejna klasę Bird, która będzie dziedziczyćpo Animalu oraz będzie dodawać jedną metodę:


function Bird(name) {
    Animal.call(this);
    this.type = "bird";
}

//tworzymy obiekt prototypu na bazie prototypu Animal
Bird.prototype = Object.create(Animal.prototype);

//poprawiamy konstruktor, bo powyższa funkcja nam go przestawiła na Animal
Bird.constructor = Bird;

Bird.prototype.eat = function() {
    //pobieramy kod tamtej funkcji
    const text = Animal.prototype.eat.call(this);

    //i go rozszerzamy
    return text + " - exactly seed!";
}

Bird.prototype.fly = function() {
    return "I can fly";
}

new obBird = new Bird("ptako");

Class

Jak powiedzieliśmy we wstępie, w ES6 pojawiła się nakładka (sugar code), która pozwala na zapisanie powyższej konstrukcji w bardziej przystępny sposób.

W większości języków klasy obiektów tworzy się nie za pomocą konstruktorów, a za pomocą słowa kluczowego class.

Każda taka klasa po stworzeniu obiektu pierwsze co robi, to odpala metodę zwącą się constructor, która zawiera kod inicjujący dany obiekt. Ten konstruktor to tak naprawdę to samo co nasz powyższy konstruktor pisany z dużej litery.

Nowa nakładka umożliwia całkiem podobne działanie.

Zacznijmy od zdefiniowania klasy Animal i dopięcia do jego prototypu metody eat:


class Animal {
    constructor(name) {
        this.type = "animal";
        this.name = name;
    }
    eat() {
        return "I eat food";
    }
}

const obAnim = new Animal("Żyraf");

Jak widzisz pojawiła nam się konstrukcja class, za pomocą której definiujemy klasę obiektów.

Dodatkowo pojawiła nam się funkcja constructor, która zostanie odpalona automatycznie przy tworzeniu obiektu na bazie tej klasy. Robi ona to samo co nasz wcześniejszy konstruktor. Dodatkowo już nie musimy wychodzić poza ciało class by dodać do prototypu nowe metody. Powyższa metoda eat() automatycznie trafi do prototypu obiektów Animal.

No dobrze, ale jeżeli to tylko nakładka, to czy dało by radę pomieszać to ze starym zapisem? Jak najbardziej:


class Animal {
    constructor(name) {
        this.type = "animal";
        this.name = name;
    }
    eat() {
        return "I eat food";
    }
}
Animal.prototype.sleep = function() {
    return this.name + " sleep";
}

Użycie teraz kodu:


const obAnim = new Animal("Żyraf");
console.dir(obAnim);

Da w rezultacie:

prototyp

Klasa abstrakcyjna

Podobnie jak w klasycznym JS w przypadku klas możemy zapobiec, by ktoś tworzył obiekty na bazei klasy abstrakcyjnej:


class Animal {
    constructor(name) {
        if (this.constructor === Animal) {
            throw new Error("Nie możesz tworzyć obiektów z klasy abstrakcyjnej!");
        }
        this.type = "animal";
        this.name = name;
    }
    eat() {
        return "I eat food";
    }
}


//lub
class Animal {
    constructor(name) {
        if (new.target === Animal) {
            throw new Error("Nie możesz tworzyć obiektów z klasy abstrakcyjnej!");
        }
        this.type = "animal";
        this.name = name;
    }
    eat() {
        return "I eat food";
    }
}

Dziedziczenie

Kolejną rzeczą jaką chcemy zrobić to przepisać na nowy zapis wcześniej zdefiniowaną klasę Bird, która dziedziczy po Animal:


class Bird extends Animal {
    constructor(name) {
        super(name);
        this.type = "bird";
    }
    eat() {
        const text = super.eat();
        return text + " - exactly seed!";
    }
    fly() {
        return "I can fly";
    }
}

Pojawiła nam się kolejna nowa instrukcja - extends, która mówi nam, że dana klasa rozszerza inną klasę.

Nasza klasa rozszerza zarówno konstruktor jak i metodę eat. Zauważ, że w obydwu tych funkcjach pojawiła nam się instrukcja super()

Służy ona do odwoływania się do kodu z klasy, którą dziedziczymy (coś jak powyżej nasze call).
Jeżeli chcemy rozszerzać konstruktor klasy (w naszym przypadku chcemy, bo dodajemy this.type), pierwszą rzeczą, jaką musimy zrobić to użyć super() w nawiasach podając argumenty, jakie przyjmował konstruktor z klasy rozszerzanej:

class Shape {
    constructor(x, y) { ... }
    area() { ... }
}

class Rectangle extends Shape {
    constructor(x, y, side) {
        super(x, y);
        this.side = side;
    }
}

Jeżeli tego nie zrobimy, wtedy wyskoczy nam błąd


class Rectangle extends Shape {
    constructor(x, y, side) {
        this.side = side;
        super(x, y); //błąd - super musi zawsze przed pierwszym odwołaniem się do this
    }
}

Jeżeli nie będziemy musieli w ogóle rozszerzać konstruktora, wtedy w ogóle go nie piszemy:


class Shape {
    constructor(x, y) { ... }
    area() { ... }
}

class Triangle extends Shape {
    //nie potrzebujemy rozszerzać konstruktora, więc go nie piszemy
    area() {
        ...
    }
}

Zasada z początkowym super() nie obowiązuje przy rozszerzaniu poszczególnych metod:


class Shape {
    constructor(x, y) { ... }
    area() { ... }
}

class Rectangle extends Shape {
    constructor(x, y, side) {
        super(x, y);
        this.side = side;
    }
    area() {
        const a = this.side;
        super.area(); //tutaj już nie przeszkadza bycie drugą
    }
}

Jak widzisz na powyższym przykładzie, zaczynają nam się pojawiać powtórki parametrów dla konstruktorów. Zawsze możemy tutaj zastosować rest i spread:


class Shape {
    constructor(x, y) { ... }
    area() { ... }
}

class Rectangle extends Shape {
    constructor(...param) {//x, y, side
        super(...param);
        this.side = side;
    }
}

var obRect = new Rectangle(10, 20, 30);

Metody statyczne

Jeżeli stworzymy metody klasy tak jak powyżej, trafią one do prototypu obiektów tworzonych na bazie tej klasy:


class Human {
    constructor(name) {
        this.name = name;
    }
    say() {
        console.log("Jestem człowiek");
    }
}

Human.say(); //błąd, bo say jest w prototypie
Human.prototype.say(); //jestem człowiek

const ob = new Human("Marcin");
ob.say(); //Jestem człowiek

Jak wiesz, dane metody możemy przypisywać nie tylko do prototypu obiektów, ale także bezpośrednio do instancji obiektu:


const ob = new Human("Marcin");
ob.say(); //Jestem człowiek
ob.eat = function() {
    console.log("Jem śniadanie");
}
ob.prototype.eat(); //nie ma, bo tylko powyższa instancja ma tą metodę

W JS możemy też przypisać metody statyczne, które są przypięte do danej klasy, i które odpalamy za pośrednictwem tej klasy.
Metody te nie są dostępne dla instancji, a tylko dla samych klas:


//w ES5
function Human {
    this.name = name;
}
Human.prototype.say = function() {
    console.log("Jestem człowiek");
}
Human.create = function() {
   console.log("Jem śniadanie");
}

const ob = new Human("Marcin");
ob.create(); //błąd - to klasa Human ma dostęp do create(), nie instancja ob
Human.create(); //ok

//w ES6
class Human {
    constructor(name) {
        this.name = name;
    }
    say() {
        console.log("Jestem człowiek");
    }
    static create() {
        console.log("Jem śniadanie");
    }
}

const ob = new Human("Marcin");
ob.create(); //błąd - to klasa Human ma dostęp do create(), nie instancja ob
Human.create(); //ok

Najczęściej wykorzystywane są do tworzenia użytecznych metod dla danej klasy (które to metod nie dla konkretnych instancji obiektów). Można dzięki temu pogrupować funkcjonalności dotyczące podobnych obiektów:


class User {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    static compareByName(a, b) {
        if(a.name < b.name) return -1;
        if(a.name > b.name) return 1;
        return 0;
    }

    static compareByAge(a, b) {
        return a.age - b.age;
    }
}

const users = [
    new User("Tomek", 10),
    new User("Ania", 35),
    new User("Beata", 20),
    new User("Monika", 20),
    new User("Karol", 22)
];

const users1 = users.slice().sort( User.compareByName );
console.log( users1, users1[0].name ); // Ania

const users2 = users.slice().sort( User.compareByAge );
console.log( users2, users2[0].name ); // Tomek

Trening czyni mistrza

Poniżej zamieszczam kilka zadań, które w ramach ćwiczenia możesz wykonać:

  1. Stwórz klasę Brick, która będzie opisywać pojedynczy klocek w Arkanoid. Niech ma:
    - właściwości x, y
    - właściwość graphic
    - właściwości width, height
    - właściwość type
    - właściwość live
    - metodę print, która wypisze powyższe detale.
    - metodę init, która wypisze w konsoli "Dodano na planszę"

    Stwórz klasy BrickRed, BrickBlue, BrickGreen, które będą dziedziczyć po klasie Brick.
    Klasy powinny mieć:

    BrickBlue powinien mieć:
    - graphic ustawiony na "blue.png"
    - live ustawione na 10

    BrickRed powinien mieć:
    - graphic ustawiony na "red.png"
    - live ustawione na 15

    BrickGreen powinien mieć:
    - graphic ustawiony na "green.png"
    - live ustawione na 20

    Dodatkowo stwórz klasę BrickAnim, która będzie dziedziczyć po klasie Brick. Klasa ta powinna mieć metodę moveHorizontal, która będzie wypisywać w konsoli "poruszam się poziomo z szybkością ...". W miejsce kropek wstaw właściwość speed, którą będzie miała ta klasa.

    Stwórz kilka obiektów na bazie powyższych klas. Zainicjuj im metody init() i print().