JavaScript OOP Course 6: ES6 Classes

From WikiMLT
Revision as of 08:42, 18 June 2022 by Spas (talk | contribs) (Стадий: 6 [Фаза:Утвърждаване, Статус:Утвърден]; Категория:JavaScript)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Ref­er­ences

See al­so:

ES6 Class­es

  • Class­es are a new way to cre­ate Ob­jects and Pro­to­typ­i­cal In­her­i­tance.
  • Class­es in JavaScript are not like class­es in oth­er lan­guages like C#, Ja­va and so on.
  • Class­es in JavaScript are syn­tac­tic sug­ar over Pro­to­typ­i­cal In­her­i­tance. It is im­por­tant to know how the Pro­to­typ­i­cal In­her­i­tance works be­fore learn­ing this new syn­tax which is clean­er and sim­pler.
  • Cre­at­ing new ob­jects by ES6 Class­es en­force the use of the new op­er­a­tor.
  • JavaScript en­gine ex­e­cutes the body of the Class­es in Strict Mode, no mat­ter 'use strict'; is en­gaged or not.

Let's be­gin with the fol­low­ing Con­struc­tor func­tion that will be con­vert­ed to ES6 Class.

function Circle(radius) {
    this.radius = radius;
    
    this.draw = function() {
        console.log('draw');
    };
}

Circle.prototype.area = function() {
    return this.radius * this.radius * Math.PI;
};
const circle1 = new Circle(1);
console.log(circle1);
Circle {radius: 1, draw: ƒ}
    radius: 1
    draw: ƒ ()
    [[Prototype]]: Object
        area: ƒ ()
        constructor: ƒ Circle(radius)
        [[Prototype]]: Object

We can rewrite this code us­ing ES6 Class­es in the fol­low­ing way. Step 1. De­fine the body of the class. In this body we can de­fine prop­er­ties and meth­ods.

class Circle {
}

Step 2. There is a spe­cial method that is called con­struc­tor() and it is used to ini­tial­ize the ob­jects. The con­struc­tor() method is like the Con­struc­tor func­tion shown above. When we de­fine a method in­side the spe­cial con­struc­tor() method this new method will be part of the new ob­ject in­stance.

class Circle {
    constructor(radius) {
        this.radius = radius;
        
        this.draw = function() {
            console.log('draw');
        };
    }
}

Step 3. De­fine the pro­to­type mem­bers – in this case the .area() method. When we de­fine a method out­side the spe­cial con­struc­tor() method this new method will be part of the pro­to­type of new ob­ject. When we defin­ing meth­ods out­side the con­struc­tor() we can use the sim­pli­fied syn­tax shown be­low.

class Circle {
    constructor(radius) {
        this.radius = radius;
        
        this.draw = function() {
            console.log('draw');
        };
    }
    
    area() {
        return this.radius * this.radius * Math.PI;
    }
}

Step 4. Cre­ate a new ob­ject and in­spect it.

const circle2 = new Circle(1);
console.log(circle2);
Circle {radius: 1, draw: ƒ}
    radius: 1
    draw: ƒ ()
    [[Prototype]]: Object
        area: ƒ area()
        constructor: class Circle
        [[Prototype]]: Object

Let's look for the type­of Cir­cle class. Tit is a func­tion – the ES6 Class­es are es­sen­tial­ly func­tions!

typeof Circle
'function'

Hoist­ing

Let's dis­cuss what we have learned about the Func­tions. In JavaScript are avail­able two way to de­fine a func­tion:

  • Func­tion De­c­la­ra­tion Syn­tax. These func­tions are hoist­ed – which means the JavaScript en­gine raise them to the top of the pro­gram when ex­e­cut­ing it. So we can use a func­tion de­fined by Func­tion De­c­la­ra­tion Syn­tax be­fore its de­f­i­n­i­tion.
sayHello();

function sayHello() {
    console.log('Hello'); 
}
Hello
  • Func­tion Ex­pres­sion Syn­tax. These func­tions are not hoist­ed. It is like when we defin­ing a vari­able that con­tains a prim­i­tive – num­ber, string, etc.
sayHello();

const sayHello = function() { 
    console.log('Hello'); 
};  // Should be determined by semicolon
Uncaught ReferenceError: Cannot access 'sayHello' before initialization
    at index.js:1

We can de­fine ES6 Class­es by al­so us­ing a De­c­la­ra­tion or Ex­pres­sion Syn­tax – but note in both syn­tax­es the Class­es are not hoist­ed!

  • Class De­c­la­ra­tion Syn­tax. This is the sim­pler and clean­er syn­tax.
    class Circle {
        // Class definition
    }
    
  • Class Ex­pres­sion Syn­tax. This is rarely used syn­tax – prob­a­bly you won't see it in the prac­tice.
    const Circle = class {
        // Class definition
    };  // Should be determined by semicolon
    

Sta­t­ic Meth­ods

In clas­si­cal ob­ject ori­ent­ed lan­guages we have two tips of meth­ods:

  • In­stance meth­ods – in JavaScript they can be at the Ob­ject lev­el or at the Pro­to­type lev­el – these meth­ods are avail­able at the in­stance of a Class which is an Ob­ject.
  • Sta­t­ic meth­ods. – these meth­ods are avail­able on the Class it­self (not at the Ob­ject in­stance). We have of­ten used them to cre­ate util­i­ty func­tions that are not spe­cif­ic to a giv­en ob­ject.
class Circle {
    constructor(radius) {
        this.radius = radius;
    }
    
    draw() {                    // Instance method, it will be available at new Circle objects
        console.log('draw');
    }
    
    static create(str) {         // Static method, it will not be available at new Circle objects        
        const radius = JSON.parse(str).radius;
        return new Circle(radius);
    }
}
  • In the above ex­am­ple the ex­pres­sion JSON.parse(str).radius tests whether the in­put ar­gu­ment str is valid JSON for­mat and ex­tract the ra­dius prop­er­ty if it is avail­able.
const circle = new Circle(1);
console.log(circle);
Circle {radius: 1}
    radius: 1
    [[Prototype]]: Object
        draw: ƒ draw()
        constructor: class Circle
        [[Prototype]]: Object
circle.draw();
draw

We can see the .cre­ate() method is not avail­able at the class in­stance – the cir­cle ob­ject.

circle.create('{"radius": 1}');
Uncaught TypeError: circle.parse is not a function
    at index.js:18

But the .cre­ate() method is avail­able at the class Cir­cle it­self.

Circle.create('{"radius": 1}');
Circle {radius: 1}
    radius: 1
    [[Prototype]]: Object
        draw: ƒ draw()
        constructor: class Circle
        [[Prototype]]: Object

As it can be seen above, with this im­ple­men­ta­tion we can cre­ate new in­stances (ob­jects) of the class Cir­cle in the fol­low­ing way.

const c = Circle.create('{"radius": 1}');

One more time:

  • We use sta­t­ic meth­ods to cre­ate util­i­ty func­tions that are not part of par­tic­u­lar ob­ject.

The this Key­word

The this key­word ref­er­ences the Ob­ject that is ex­e­cut­ing the cur­rent Func­tion. For ex­am­ple if a func­tion is method of an Ob­ject this ref­er­ences to that Ob­ject it­self. Oth­er­wise if that func­tion is a reg­u­lar func­tion (which means it is not part of cer­tain Ob­ject) it ref­er­ences to the glob­al ob­ject which is the Win­dow Ob­ject in the bowsers and Glob­al Ob­ject in Nod.js.

this with­in Func­tions and Meth­ods

Let's de­clare a con­struc­tor func­tion (by us­ing Func­tion Ex­pres­sion Syn­tax) and cre­ate an ob­ject in­stance of Cir­cle.

const Circle = function(radius) {
    this.radius = radius;
    this.logThis = function() { console.log(this); };
};
const c = new Circle(1);

If we use the Method Call Syn­tax for the .logTh­is() method, we are go­ing to see the new Cir­cle ob­ject in the con­sole, be­cause this of the .logTh­is() method points to that ob­ject – re­mem­ber the new op­er­a­tor which cre­ates a new ob­ject and set this from the Con­struc­tor to points to that new ob­ject.

c.logThis();                   // this is 'method call syntax'
Circle {radius: 1, draw: ƒ}
    radius: 1
    logThis: ƒ ()
    [[Prototype]]: Object
        constructor: ƒ (radius)
        [[Prototype]]: Object

Now let's get a ref­er­ence to the method in a vari­able.

const logThis = c.logThis;        // by omitting () we just creating a reference to a function and not calling it

Note: By omit­ting () at the end of a func­tion (or method) we just cre­at­ing a ref­er­ence to that func­tion and not call­ing it. To prove that let's log the vari­able to the con­sole – we will see the func­tion it­self not the out­put of its ex­e­cu­tion.

console.log(logThis);
ƒ () { console.log(this); }

Now if we use Func­tion Call Syn­tax for the vari­able con­tain­ing the ref­er­ence – run it just as reg­u­lar func­tion, we will see the Win­dow Ob­ject, that is the de­fault con­text of the reg­u­lar func­tions.

logThis();                     // this is 'function call syntax'
Window {window: Window, self: Window, document: document, name: '', location: Location, …}
    ...
    ...

Strict Mode chang­ing be­hav­ior of this with­in Func­tions

When we en­able the Strict Mode, by plac­ing 'use strict'; in the be­gin­ning of our pro­gram, the JavaScript en­gine will be more sen­si­tive, it will do more er­ror check­ing and al­so it will change the be­hav­ior of of the this key­word with­in func­tions. So if en­abling the Strict Mode the out­put of the above func­tion will be un­de­fined in­stead of the Win­dow Ob­ject.

'use strict';
// the rest code of the program...
logThis();
undefined

Note:

  • 'use strict'; can­not be used with­in the con­sole, it should be part of our script file.
  • When we en­able 'use strict'; op­tion this of the func­tions not longer point to the glob­al ob­ject which is the Win­dow Ob­ject in the bowsers and Glob­al Ob­ject in Nod.js The rea­son for this is to pre­vent us from ac­ci­dent­ly mod­i­fy­ing the glob­al ob­ject.

this Key­word and ES6 Class­es

JavaScript en­gine ex­e­cutes the body of the Class­es in Strict Mode, no mat­ter 'use strict'; is en­gaged or not in the be­gin­ning of our pro­gram. Let's de­fine a Cir­cle class with .logTh­is() method and do re­peat the above steps – we will see un­de­fined in the con­sole.

class Circle {
    constructor(radius) {
        this.radius = radius;
    }
    
    logThis() { console.log(this); }
}
const c = new Circle(1);
const logThis = c.logThis;
logThis();
undefined

Ab­strac­tion and Pri­vate Mem­bers

Ab­strac­tion means hid­ing the de­tails and com­plex­i­ty and show­ing on­ly the es­sen­tial parts. This is one of the core prin­ci­ple in OOP. In or­der to im­ple­ment ab­strac­tion we use Pri­vate Prop­er­ties and Meth­ods. So cer­tain mem­bers of an ob­ject wont be ac­ces­si­ble from the out­side.

class Circle {
    constructor(radius) {
        this.radius = radius;
    }
}
const c = new Circle(10);
c.radius;
10


Let's imag­ine we want to make the ra­dius prop­er­ty from the be­low class pri­vate, so it can't be ac­ces­si­ble from out­side. With­in ES6 Class­es there are three ap­proach­es to do that.

Us­ing _​​​Underscore Nam­ing Con­ven­tion

Us­ing un­der­score in the be­gin­ning of the name of a prop­er­ty is not ac­tu­al­ly a way to make the prop­er­ty pri­vate (it will be ac­ces­si­ble from the out­side) it is just nam­ing con­ven­tion used by some pro­gram­mers. This is not ab­strac­tion this is a con­ven­tion for de­vel­op­ers. It doesn't pre­vent an­oth­er de­vel­op­er from writ­ing code against these un­der­scored prop­er­ties.

class Circle {
    constructor(radius) {
        this._radius = radius;
    }
}
const c = new Circle(10);
c._radius;
10

Pri­vate Mem­bers Us­ing ES6 Sym­bols

In ES6 we have a new prim­i­tive type called Sym­bol. Sym­bol() is a ES6 func­tion we called to gen­er­ate a Sym­bol prim­i­tive val­ue which rep­re­sents an an unique iden­ti­fi­er. Note it is not a con­struc­tor (or class) so we don't need to use the new op­er­a­tor, oth­er­wise we will get an er­ror. Every time we call the Sym­bol() func­tion it gen­er­ates an unique val­ue. If we do a com­par­i­son like Sym­bol() === Sym­bol() we will get false.

So let's de­fine a new vari­able of this type (by us­ing the un­der­score nam­ing con­ven­tion) and the unique val­ue gen­er­at­ed by the Sym­bol() func­tion to name a prop­er­ty of an ob­ject by us­ing a Brack­et no­ta­tion.

const _radius = Symbol();
class Circle {
    constructor(radius) {
        this[_radius] = radius;
    }
}
const c = new Circle(10);
console.log(c);
Circle {Symbol(): 10}
    Symbol(): 10
    [[Prototype]]: Object
        constructor: class Circle
        [[Prototype]]: Object

We can see our Cir­cle ob­ject has one prop­er­ty named Sym­bol(). If we set mul­ti­ple prop­er­ties us­ing Sym­bols, the prop­er­ty names all will show up a Sym­bol() but in­ter­nal­ly they are unique. This prop­er­ty is se­mi pri­vate - we can­not ac­cess this prop­er­ty di­rect­ly in our code.

console.log(Object.getOwnPropertyNames(c));
[]

From the above com­mand we see our ob­ject c doesn't have any reg­u­lar prop­er­ty.

console.log(Object.getOwnPropertySymbols(c));
[Symbol()]
    0: Symbol()
    length: 1
    [[Prototype]]: Array(0)

The above com­mand re­turns an ar­ray of Sym­bols() so if we get the first el­e­ment of this ar­ray we can get the ra­dius val­ue by us­ing this as prop­er­ty name of our Cir­cle ob­ject c.

const key = Object.getOwnPropertySymbols(c)[0];
const radius = c[key];
console.log(radius);
10

To make a Method pri­vate by us­ing Sym­bol, we need to de­fine an­oth­er Sym­bol and use ES6 fea­ture called Com­put­ed Prop­er­ty Names.

const _radius = Symbol(); 
const _draw = Symbol();

class Circle {
    constructor(radius) {
        this[_radius] = radius;
    }
    
    [_draw]() {
        console.log('draw');
    }
}
const c = new Circle(10);
console.log(c);
Circle {Symbol(): 10}
    Symbol(): 10
    [[Prototype]]: Object
        Symbol(): ƒ [_draw]()
        constructor: class Circle
        [[Prototype]]: Object

Pri­vate Mem­bers Us­ing ES6 WeakMaps

WeakMap is a new type in ES6 to im­ple­ment Pri­vate Prop­er­ties and Meth­ods in an Ob­ject. A WeakMap is es­sen­tial­ly a dic­tio­nary where keys are ob­jects and val­ues can be any­thing, and the rea­son we call them weak maps is be­cause the keys are weak. So if there are no ref­er­ences to these keys, there will be garbage col­lec­tor.

const _radius = new WeakMap();
class Circle {
    constructor(radius) {
        _radius.set(this, radius);
    }
}
const c = new Circle(10);
console.log(c);
Circle {}
    [[Prototype]]: Object
        constructor: class Circle
        [[Prototype]]: Object

In the above ex­pres­sion _radius.set(this, ra­dius);

  • _​​​radius is our WeakMap,
  • .set() is a method of the WeakMap type,
  • this is the key (the cur­rent ob­ject in our case),
  • ra­dius is the prop­er­ty we set to that key.

We can ac­cess a Prop­er­ty de­fined by a WeakMap in the fol­low­ing way.

const _radius = new WeakMap();
class Circle {
    constructor(radius) {
        _radius.set(this, radius);
    }
    
    getRadius() {
        return _radius.get(this);
    }
}
const c = new Circle(10);
console.log(c.getRadius());
10

In or­der to de­fine a Pri­vate Method with this ap­proach we need an­oth­er WeakMap.

const _radius = new WeakMap();
const _move = new WeakMap();
class Circle {
    constructor(radius) {
        _radius.set(this, radius);
        
        _move.set(this, function() {
           console.log('move', this); // we added 'this' here in order to see what it means in this context
        });
    }
    
    getRadius() {
        return _radius.get(this);
    }
    
    getMove() {
        return _move.get(this)();   // because it will return a function we are calling that function by ()
    }
}
const c = new Circle(10);
c.getMove();
move undefined

We can see the pri­vate ._​​​move() method re­turns un­de­fined for this. This is be­cause the body of the class is ex­e­cut­ed in Strict Mode and the call­back func­tion is just a reg­u­lar func­tion – read the sec­tion above . We can solve this prob­lem by us­ing Ar­row Func­tion (as it is shown be­low) or we could change this of the call­back func­tion in some oth­er way.

const _radius = new WeakMap();
const _move = new WeakMap();
class Circle {
    constructor(radius) {
        _radius.set(this, radius);
        
        _move.set(this, () => {
           console.log('move', this); // we added 'this' here in order to see what it means in this context
        });
    }
    
    getRadius() {
        return _radius.get(this);
    }
    
    getMove() {
        return _move.get(this)();   // because it will return a function we are calling that function by ()
    }
}
const c = new Circle(10);
c.getMove();
move Circle {}

Get­ter and Set­ters with ES6 Class­es

Let's re­mind you, in or­der to de­fine Get­ter and Set­ters when we use Con­struc­tor func­tions, we've used the Object.defineProperty() method like is shown be­low. Al­so we've made some prop­er­ties pri­vate by defin­ing vari­ables in­side the Con­struc­tor.

function Circle(radius) {
    let _radius = radius;
    
    Object.defineProperty(this, 'radius', {
        get: function() {
            return radius;
        },
        
        set: function(value) {
            if (value <= 0) throw new Error('Invalid radus value!');
            radius = value;
        }
    });
}
const c1 = new Circle(10);
c1.radius;
10
c1.radius = 15;
c1.radius;
15

Defin­ing Get­ters and Set­ters with­in ES6 Class­es is much eas­i­er. Note in the fol­low­ing ex­am­ple we have one prop­er­ty called ra­dius, made pri­vate by WeakMap, so it is not ac­ces­si­ble from the out­side.

const _radius = new WeakMap();
class Circle {
    constructor(radius) {
        _radius.set(this, radius);
    }
    
    get radius() {
        return _radius.get(this);
    }
    
    set radius(value) {
        if (value <= 0) throw new Error('Invalid radus value!');
        _radius.set(this, value);
    }
}
const c2 = new Circle(20);
c2.radius;
20
c2.radius = 25;
c2.radius;
25

In­her­i­tance and ES6 Class­es

Let's start with one class called Shape, which has one method called .move().

class Shape {
    move() {
        console.log('move');
    }
}

Now let's cre­ate an­oth­er class called Cir­cle which has its own method called .draw(), but we want to in­her­it al­so the .move() method from the Shape class. With­in ES6 Class­es this op­er­a­tion is much eas­i­er and clean­er than of the ap­proach used with Con­struc­tor func­tions – we do not need to change the Pro­to­type and re­set the Con­struc­tor.

class Circle extends Shape {
    draw() {
        console.log('draw');
    }
}

Now let's cre­ate a Cir­cle ob­ject and in­spect it.

const c1 = new Circle();
console.log(c1);
Circle {}
    [[Prototype]]: Shape
        draw: ƒ draw()
        constructor: class Circle
        [[Prototype]]: Object
            move: ƒ move()
            constructor: class Shape
            [[Prototype]]: Object

Let's imag­ine all of our shapes need a col­or – so we nee to ad such prop­er­ty at the Shape class.

class Shape {
    constructor(color) {
        this.color = color;
    }
    
    move() {
        console.log('move');
    }
}

At this point, if we have a con­struc­tor at the base class (in this ex­am­ple Shape), when we cre­at­ing de­rived class (in this ex­am­ple Cir­cle) which have its own con­struc­tor we mist ini­tial­ize the base class con­struc­tor by us­ing the su­per key­word, oth­er­wise we will get an er­ror.

class Circle extends Shape {
    constructor(color, radius) {
        super(color);
        this.radius = radius;
    }
    
    draw() {
        console.log('draw');
    }
}

Now let's cre­ate a new Cir­cle ob­ject, set val­ues for the col­or and ra­dius prop­er­ties and in­spect it.

const c2 = new Circle('red', 10);
console.log(c2);
Circle {color: 'red', radius: 10}
    color: "red"
    radius: 10
    [[Prototype]]: Shape
        draw: ƒ draw()
        constructor: class Circle
        [[Prototype]]: Object
            move: ƒ move()
            constructor: class Shape
            [[Prototype]]: Object

Method Over­rid­ing and ES6 Class­es

class Shape {
    move() {
        console.log('Shape move');
    }
}
class Circle extends Shape {
    move() {
        console.log('Circle move');
    }
}
const c1 = new Circle();

Now if we call c1.move() we will see the child im­ple­men­ta­tion of the method is used.

c1.move();
Circle move

The rea­son for that goes back to the Pro­to­typ­i­cal In­her­i­tance – so when ac­cess­ing a prop­er­ty or method the JavaScript en­gine walks on the Pro­to­typ­i­cal In­her­i­tance Tree from the child all the way to the par­ent and eval­u­ate the first ac­ces­si­ble prop­er­ty. Let's imag­ine we have sce­nario where we want reuse some of the code that have been im­ple­ment­ed at the par­ent move() method – in that case we can call that by us­ing the su­per key­word.

class Circle extends Shape {
    move() {
        super.move();
        console.log('Circle move');
    }
}
const c2 = new Circle();
c2.move();
Shape move
Circle move