ES6 классы
Нет, ничего не изменилось, прототипы никуда не ушли, а классы лишь приятная обёртка над прототипным наследованием. В классическом понимании классов в JavaScript никогда не существовало и не будет существовать никогда. Многие разработчики, особенно те, которые решили познать мир JavaScript после изучения другого языка программирования, не понимают (или не хотят понять) разницы между классическим и прототипным наследованием, в результате чего они буквально отказываются полностью использовать самый мощный инструмент, которым только может вооружиться JavaScript-разработчик. Поэтому данная статья, в первую очередь, призвана объяснить, почему классы, появившиеся в новом стандарте языка, не то, чем кажутся на первый взгляд.
JavaScript не такой, как все
Для начала, три концепции, которые должен включать в себя любой объектно-ориентированный язык программирования: инкапсуляция, наследование и полиморфизм. Знакомые слова, не так ли? Теперь простыми словами о каждой концепции:
Инкапсуляция
Инкапсуляция есть ни что иное, как реализация приватности. В JavaScript подобная концепция реализуется благодаря функциям и их областям видимости.
// Функция конструктор
var Person = function(name) {
// Приватная функция
var log = function(message) {
console.log(message);
};
// Публичное свойство
this.name = name;
// Публичный метод
this.logger = function(message) {
log('Public ' + message);
};
};
Что не так с инкапсуляцией в JavaScript? Всё просто. В примере приведённом выше отсутствует возможность явно обозначить приватность, как в других языках программирования с помощью ключевых слов private
, protected
и public
(пример с PHP). Естественно, подобная проблема достаточно просто решается с помощью тех же самых функций — создаётся ещё одна обёртка над кодом в виде самовызывающейся анонимной функции, например, при использовании паттерна “модуль”:
var Peron = (function() {
// Приватная функция
var log = function(message) {
console.log(message);
};
var Person = function(name) {
// Публичное свойство
this.name = name;
};
// Публичный метод
Person.prototype.logger = function(message) {
log('Public ' + message);
};
// Экспорт публичной функции
return Person;
})();
С релизом стандарта ES2015 подобная проблема приватности была частично решена добавлением блочных областей видимости для переменных, в результате чего чистые функции, работающие только для данного конкретного конструктора можно вынести в отдельный модуль и запрашивать по необходимости, не опасаясь того, что кто-то их перезапишет. Но чистые функции лишь вершина айсберга и про некоторые способы организации приватности при работе с классами в JavaScript можно прочитать в этой статье.
Наследование
С помощью наследования вы, буквально, говорите: “У меня есть один конструктор/класс и другой конструктор/класс, который точно такой же, как и первый, кроме вот этого и вот этого”. Чаще всего наследование в JavaScript реализуется с помощью функции Object.create()
, позволяющий создать новый объект с заданным прототипом.
// функция конструктор
var Person = function(name) {
this.name = name + ' Doe';
};
// запись метода в прототип
Person.prototype.sayName = function() {
console.log(this.name);
};
// Вызов конструктора родителя внутри дочернего
// конструктора для записи всех свойств
var GreatPerson = function(name, phrase) {
Person.apply(this, arguments);
this.phrase = phrase;
};
// Перезапить прототипа дочернего конструктора
GreatPerson.prototype = Object.create(Person.prototype);
GreatPerson.prototype.sayPhrase = function() {
console.log(this.name + ' says: "' + this.phrase + '"');
};
// создание нового объекта
var john = new Person('John');
var jane = new GreatPerson('Jane', 'Hello, World!');
john.sayName(); // John Doe
jane.sayName(); // Jane Doe
jane.sayPhrase(); // Jane Doe says: "Hello, World!"
И здесь с JavaScript “всё не так”. Тот же самый пример, написанный на PHP:
<?php
class Person {
public $name;
public function __construct($name) {
$this->name = $name;
}
public function say_name() {
echo $this->name;
}
}
class GreatPerson extends Person {
public $name;
public function __construct($name, $phrase) {
parent::__construct($name);
$this->phrase = $phrase;
}
public function say_phrase() {
echo $this->name . " says " . $this->phrase;
}
}
$john = new Person('john');
$john->say_name(); // john
$jane = new GreatPerson('jane', 'Hello World!');
$jane->say_phrase(); // jane says Hello World!
Да в чём вообще кроется разница? Мы же выполнили одни и те же действия, просто с разным синтаксисом! Чтобы понять, почему JavaScript другой представьте себе семейство птичек: дедушка попугай, отец попугай и сын попугай — все попугаи! Совершенно очевидно, что если у деда попугая вырастет ещё одна лапка, то это ни коем образом не повлияет на отца и сына. То есть попугай, родившийся с двумя лапами, так и останется до конца своей жизни с двумя лапами, в независимости от того, что случилось с любым из его предков. Подобным образом можно представить себе классическое наследование.
С прототипным наследование ситуация абсолютно противоположная. Дед-попугай отрастил себе третью лапку, и она автоматически появилась у отца и сына (прототипное наследование против природы). Сложившееся положение вещей крайне не понравилось отцу попугаю, и он решил, что и попугаем-то больше быть не хочет и стал орлом (с тремя лапами). Как вы уже, наверное, догадались сын попугай уже больше не попугай, а настоящий орёл (но опять же с тремя лапами). Три лапы слишком много для сына и он решает отказаться от одной (теперь у нас есть обычный орёл с двумя лапами).
Но и это ещё не всё! Единственный оставшийся попугай (дедушка) решил, что трёх лап мало и приобрёл себе ещё одну, а также решил стать ласточкой. В результаты из обычного семейства попугаев мы получили: деда ласточку с четырьмя лапами, отца орла с тремя лапами, сына орла с двумя лапами. Вот она вся суть прототипного наследования. Похоже на безумие? Да? Тогда перейдём к коду:
// Дед попугай с двумя лапами
var ParrotGrandfather = function() {};
ParrotGrandfather.prototype = {
species: 'Parrot',
paws: 2
};
// Отец попугай унаследовал всё у деда
var ParrotFather = function() {};
ParrotFather.prototype = Object.create(ParrotGrandfather.prototype);
// Сын попугай унаследовал всё у отца
var ParrotSon = function() {};
ParrotSon.prototype = Object.create(ParrotFather.prototype);
var grandfather = new ParrotGrandfather();
var father = new ParrotFather()
var son = new ParrotSon();
console.log(grandfather.species, father.species, son.species);
// Parrot Parrot Parrot - все попугаи!
console.log(grandfather.paws, father.paws, son.paws);
// 2 2 2 - у каждого по 2 лапы
// Дед меняет количество лап
ParrotGrandfather.prototype.paws++;
console.log(grandfather.paws, father.paws, son.paws);
// 3 3 3 - у каждого теперь по 3 лапы
// Отец меняет вид
ParrotFather.prototype.species = 'eagle';
console.log(grandfather.species, father.species, son.species);
// Parrot eagle eagle - дед остался попугаем, отец и сын стали орлами
// Сын уменьшил количество лап
ParrotSon.prototype.paws--;
console.log(grandfather.paws, father.paws, son.paws);
// 3 3 2 - дед и отец остались при своих трёх лапах
// Дед решил стать чайкой
ParrotGrandfather.prototype.species = 'seagull';
console.log(grandfather.species, father.species, son.species);
// seagull eagle eagle - дед чайка, отец и сын орлы
Вывод из всего выше перечисленного: в JavaScript прототипное наследование “динамическое”, можно изменять всё налету, классическое же наследование подобным похвастаться не может: всё, что вы объявили в одном классе останется там навсегда. Грубо говоря, классическое представление наследования предполагает наличие определённой статической схемы, по которой будет строиться каждый объект данного класса. В прототипном наследовании мы имеем дело не со схемой, а с живым, постоянно развивающимся организмом, который со временем изменяется и принимает ту форму, которая нам нужна (и это прекрасно).
Полиморфизм
Полиморфизм проще всего постичь на примере встроенных конструкторов (String
, Array
, Object
…). Вот если вас спросят: “Чем число 42
отличается от массива [4, 2]
и что у них общего?”, чтобы вы ответили? Наверняка, вы были начали рассказывать про примитивы и объекты, чем они отличаются, что можно делать с теми и другими, на вопрос про отличия. Но чем они похожи друг на друга? Абсолютно разные же типы данных! Но, очевидно, что они разделяют определённую часть методов, например, метод toString
, унаследованный от Object
. Это уже полиморфизм? Ещё нет, но мы уже близко. Метод toString
можно весьма успешно переназначить, во-первых, в прототипе функции конструктора, и, во-вторых, сразу же для данного конкретного объекта.
// Наш собственный конструктор
var Person = function(name) {
this.name = name;
};
// Переназначение метода toString для всех объектов,
// созданных с помощью данного конструктора
Person.prototype.toString = function() {
return 'Person ' + this.name;
};
var john = new Person('John');
// Два массива, второй абсолютно обычный,
// для первого переназначен метод toString
var arr1 = [4, 2];
var arr2 = [5, 3];
arr1.toString = function() {
return 'Array ' + this.reduce(function(result, item) {
return result + '' + item;
});
};
// В итоге
console.log(john.toString()); // Person John
console.log(arr1.toString()); // Array 42
console.log(arr2.toString()); // 5,3
Что происходит? При использовании метода toString
на каждом из объектов происходит проверка того, какой метод нужно выбрать. Проверка ведётся следующим образом: изначально проверяется существует ли нужное свойство на самом объекте, если существует, то используется именно оно, если же нет, то проверка продолжается — на наличие свойства проверяется прототип, потом прототип прототипа, потом прототип прототипа прототипа… и так, пока не дойдём до самого конца — null
(null является прототипом для Object
). Таким образом, полиморфизм отвечает за то, чей метод вызвать. Подробнее о том, как это всё работает в других языках программирования можно узнать в этом вопросе на Тостере, а более подробный пример с JavaScript можно посмотреть в статье про прототипы.
Большой вывод, который я хочу, чтобы вы сделали из всего вышеперечисленного: “Javascript не такой, как остальные языки программирования с классическим пониманием ООП”. Не стоит жить в стране предрассудков и пытаться перенести своё понимание ООП из другого языка — вы только потеряете время.
Теперь, когда мы во всём разобрались и сделали нужный вывод (“JavaScript другой”) можно перейти к рассмотрению “классов” в JavaScript.
Классы
Итак, выше вы уже достаточно насмотрелись примеров конструкторов, свойств и методов, которыми мы пользовались до выхода в свет нового стандарта. Не буду вас больше томить ожиданием и сразу перейдём к коду:
class Person {
constructor(name) {
this.name = name;
}
sayName() {
console.log(`Person ${this.name} said his name`);
}
}
const john = new Person('John');
john.sayName(); // Person John said his name
Пример выше можно записать в стиле ES5 следующим образом:
var Person = function(name) {
this.name = name;
};
Person.prototype.sayName = function() {
console.log('Person ' + this.name + ' said his name');
};
var john = new Person('John');
john.sayName(); // Person John said his name
Что нужно знать про классы:
- Создавая класс, вы пользуетесь блоком кода (всё, что находится между
{
и}
), внутри которого объявляете, всё, что хотите видеть в прототипе. - Запись
class Person
означает, что будет создана функция конструкторPerson
(всё точно так же, как и в ES5) - Свойство
constructor
используется для обозначение того, что будет происходить непосредственно в самом конструкторе. - Все методы для класса используют краткую запись, которую мы обсуждали ранее в статье про расширение литерала объектов.
- При перечислении методов не надо использовать запятые (на самом деле, они запрещены)
Важно понимать, что мы до сих пор работаем с обычными функциями. То есть если вы захотите проверить тип класса, то (с удивлением?) обнаружите function
:
typeof Person === 'function'; // true
Как и при работе с функциями конструкторами мы можем записать “класс” (на самом деле, только конструктор) в переменную. Иногда подобная запись может быть чрезвычайно полезной, например, когда нужно записать конструктор, как свойство объекта.
const Person = class P { /* делаем свои дела */ }
const obj = {
Person
};
Но, как и во всех остальных вопросах в JavaScript, здесь есть свои подводные камни. Во-первых функция Person
обязана быть вызвана с оператором new
и, во-вторых, в то время, как функция function Person(name) { this.name = name; }
поднималась наверх (стандартный hoisting) и её можно было использовать в любой части кода, конструктор, созданный с помощью class
не испытывает на себе поднятия.
extends
ES6 классы также обладают синтаксическим сахаром для реализации прототипного наследования. Для подобных целей используется extends
:
class GreatPerson extends Person {
constructor(name, phrase) {
super(name);
this.phrase = phrase;
}
sayPhrase() {
console.log(`${this.name} says: "${this.phrase}"`)
}
}
const jane = new Person('Jane', 'Hello, World!');
jane.sayName(); // Person Jane said his name
jane.sayPhrase(); // Jane says: "Hello, World!"
super
В примере выше мы использовали super
для вызова конструктора-родителя. С помощью подобного вызова мы записали свойство name
для текущего объекта. Другуми словами, всё, что делает super
при вызове внутри конструктора (свойства constructor
) — вызывает конструктор родителя и записывает в текущий объект (то есть в this
) всё, что от него требуется. В ES5 для подобных действий приходилось напрямую обращаться к конструктору:
var GreatPerson = function(name, phrase) {
// Пердача всех аргументов в конструктор родителя
Person.apply(this, arguments);
// или только одного
Person.call(this, name);
// запись новых свойств
this.phrase = phrase;
};
Но это ещё не всё, чем может порадовать super
! Если вы захотите обратиться к любому методу, записанному в прототип родителя внутри метода потомка, то super
и здесь вас сможет выручить.
class Person {
constructor(name) {
this.name = name;
}
speak(phrase) {
return `${this.name} says ${phrase}`;
}
}
class Speaker extends Person {
speak(phrase) {
console.log(`${super.speak(phrase)} very confidently`);
}
}
const bob = new Speaker('Bob');
const john = new Person('John');
console.log(john.speak('I don\'t have a lot of money'));
// John says "I don't have a lot of money"
bob.speak('I have a lot of money');
// Bob says "I have a lot of money" very confidently
Без super
нам бы пришлось напрямую обращаться к прототипу конструктора родителя, чтобы получить перезаписанный нами метод:
var Person = function(name) {
this.name = name;
};
Person.prototype.speak = function(phrase) {
return `${this.name} says "${phrase}"`;
};
var Speaker = function(name) {
Person.call(this, name);
};
Speaker.prototype = Object.create(Person.prototype);
Speaker.prototype.speak = function(phrase) {
// обращаемся к методу speak из функции родителя
var originalSpeak = Person.prototype.speak;
// в добавок ко всему нужно использовать и call
console.log(originalSpeak.call(this, phrase) + ' very confidently');
};
Таким образом, super
“оценивает ситуацию” и в зависимости от того, где вы его решили использовать будет работать по-разному, но при любом использовании он готов достаточно сильно сократить ваш код и избавить от не самого понятного способа вызова конструктора родителя.
Подводный камень super
: при реализации наследования с помощью extends
и работе с дочерним конструктором необходимо вызвать super()
перед добавлением любого нового свойства.
class Pesron {
constructor(name) {
this.name = name;
}
}
// Всё работает хорошо
class GreatPerson extends Person {
constructor(name, phrase) {
// Необходимо вызвать super
super(name);
this.phrase = phrase;
}
}
// А тут ошибка
class GreatPerson extends Person {
constructor(name, phrase) {
// Необходимо вызвать super до записи собственных свойств
this.phrase = phrase;
super(name);
}
}
Классы без конструкторов
Как вы, наверное, заметили в примере выше не было использовано свойство constructor
для класса Speaker
, поскольку в нём просто нет необходимости в данном случае. Но свойство name
всё равно было записано. Магия? Магия! Когда вы не указываете явно, что нужно сделать в конструкторе, то всё решается без вашего участия. Можно представить себе данный процесс следующим образом:
// Эквивалентно созданию класса без конструктора
class GreatPerson extends Person {
constructor() {
super(...arguments);
}
}
static
При работе с конструкторами в ES5 многие привыкли использовать функции, как объекты (они же и есть объекты) и вешать на них служебные функции:
// Конструктор
var Person = function() {};
// Служебное значение
Person.hello = 'World';
// Служебная функция
Person.speak = function() {
console.log('I am alive!');
};
Если вы с подобной реализацией никогда не сталкивались, то обратите внимание на то, что подобные значения не имеют ничего общего с прототипами и доступны только при запросе непосредственно с функции (объекта):
Person.speak(); // I am alive!
var john = new Person();
john.speak(); // Ошибка: нет такого метода!
При работе с классами запись подобных свойств доступна сразу внутри класса с помощью оператора static
. Причем все записанные подобным образом свойства при наследовании благополучно переносятся на конструктор потомка:
class Person {
static sos() {
console.log('I really need help!');
}
}
class Artist extends Pesron {
draw(art) {
console.log(`Artist has just drawn ${art}`);
}
}
const artist = new Artist();
Person.sos(); // I really need help!
Artist.sos(); // I really need help!
artist.sos(); // artist.sos is not a function
Комментарии