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

Комментарии