Нужны ли в JavaScript классы?

Содержание

Нужны ли в JavaScript классы?JavaScript принято считать прототип-ориентированным языком программирования. Но, как ни странно, этим подходом практически никто не пользуется: большинство популярных JS-фреймворков явно или неявно оперируют классами.
В этой статье я хочу рассказать об альтернативном способе программирования на JavaScript, без использования классов и конструкторов — чистым прототип-ориентированным ООП и особенностях его реализации на ECMA Script 5.

ООП можно разделить на две группы: класс-ориентированное (классическое) и прототип-ориентированное. Классический подход отражает взгляд Аристотеля на мир, в котором всё описывается идеальными понятиями. Прототипное ООП ближе к философии Людвига Витгенштейна, которая не полагается на строгую категоризацию и классификацию всего и вся, а пытается представить понятия предметной области материальными и интуитивно понятными (насколько это возможно). Типичным аргументом в пользу прототипирования является то, что обычно намного проще сначала разобраться в конкретных примерах, а только потом, изучая и обобщая их, выделить некоторые абстрактные принципы и впоследствии их применять.

JavaScript, согласно этой классификации, находится где-то посередине: с одной стороны, в нем присутствуют прототипы, с другой — классы и оператор new, как средство создания новых объектов, что не свойственно прототип-ориентированному подходу.

Классы

В JavaScript нет классов, скажете вы. Я бы не стал так утверждать.
Под классами в JS я подразумеваю функции-конструкторы: функции, вызываемой при создании экземпляра (выполнении оператора new), со ссылкой на прототип — объект, содержащий свойства (данные) и методы (функции) класса.

Как известно, в ЕСМА Script 6 возможно таки введут ключевое слово class:

   class Duck{
        constructor(name){
            this.name = name;
        },
        quack(){
            return this.name +" Duck: Quack-quack!";
        }
    }

    /// Наследование

    class TalkingDuck extends Duck{
        constructor(name){
            super(name);
        },
        quack(){
            return super.quack() + " My name is " + this.name;
        }
    }

    /// Инстанцирование

    var donald = new TalkingDuck("Donald");

Но по сути, ничего существенного (например модификаторов public, private) данное нововведение не принесет. Это нечто иное, как синтаксический сахар для подобной конструкции:

    var Duck = function(name){
    	this.name = name;
    };

    Duck.prototype.quack = function(){
        return this.name +" Duck: Quack-quack!";
    };

    /// Наследование

    var TalkingDuck = function(name){
        Duck.call(this, name);
    }

    TalkingDuck.prototype = Object.create(Duck.prototype);
    TalkingDuck.prototype.constructor = TalkingDuck;

    TalkingDuck.prototype.quack = function(){
        return TalkingDuck.prototype.quack.call(this) + " My name is " + this.name;
    };

    /// Инстанцирование

    var donald = new TalkingDuck("Donald");

Следовательно, классы в текущей версии JS уже есть, только нет удобной синтаксической конструкции для их создания.
В конце-концов, давайте определимся, что же такое класс. Вот определение (из википедии):

Класс — разновидность абстрактного типа данных в ООП, характеризуемый способом своего построения. Суть отличия классов от других абстрактных типов данных состоит в том, что при задании типа данных класс определяет одновременно и интерфейс, и реализацию для всех своих экземпляров, а вызов метода-конструктора обязателен.

Следуя этому определению, функция-конструктор является классом:
Функция-конструктор это абстрактный тип данных? — Да.
Функция-конструктор (вместе с свойствами из прототипа) определяет одновременно и интерфейс, и реализацию? — Да.
Вызов конструктора при создании экземпляра обязателен? — Да.

Прототипы

Прототип отличается от класса тем, что:

  1. Это уже готовый к использованию объект, не нуждающийся в инстанцировании. Он может иметь собственное состояние (state). Можно сказать что прототип является классом и экземпляром объединенными в одну сущность, грубо говоря, Singleton’ом.
  2. Вызов конструктора при создании объекта (клонировании прототипа) не обязателен.

Суть прототипного ООП сама по себе очень простая. Даже проще чем классического. Сложности в JS возникают из-за попытки сделать его похожим на то, как это реализовано в Java: в Java создание новых объектов производится с помощью оператора new, применяемого к классу. В JS — аналогично. Но, т.к. JS вроде как прототипный язык, и классов в нем не должно быть по определению, было введено понятие функция-конструктор. Беда в том, что синтаксиса для нормального описания связки конструктор-прототип в JavaScript’e нет. В итоге имеем море библиотек, исправляющих это досадное упущение.
В прототип-ориентированном подходе нет оператора new, а создание новых объектов производится путем клонирования уже существующих.

Наследование

Итак, суть прототипного (делегирующего) наследования состоит в том, что один объект может ссылаться на другой, что делает его прототипом. Если при обращении к свойству/методу оно не будет найдено в самом объекте, поиск продолжится в прототипе, а далее в прототипе прототипа и т.д.

    var $duck = {
        name: "",
        quack: function(){
            return this.name +" Duck: Quack-quack!";
        }
    };
    var donald = {
        __proto__: $duck,
        name: "Donald"
    };
    var daffy = {
        __proto__: $duck,
        name: "Daffy"
    };

    console.log( donald.quack() ); // Donald Duck: Quack-quack!
    console.log( daffy.quack()  ); // Daffy Duck: Quack-quack!
    console.log( $duck.isPrototypeOf(donald) ); // true

daffy и donald используют один общий метод quack(), который предоставляет им прототип $duck. С прототипной точки зрения donald и daffy являются клонами объекта $duck, а с класс-ориентированной — “экземплярами класса” $duck.
Eсли же добавить/изменить некоторые методы непосредственно в объекте donald (или daffy), тогда его можно будет считать еще и “наследником класса” $duck.

Не забываем, что свойство __proto__ не стандартизировано, и использовать его можно только для дебага. Официально манипулировать свойством __proto__ возможно методами Object.create и Object.getPrototypeOf, появившимися в ECMAScript 5:

    var donald = Object.create($duck, {
        name: {value: "Donald"}
    });
    var daffy = Object.create($duck, {
        name: {value: "Daffy"}
    });

Инициализация

В отличии от класс-ориентированного подхода, наличие конструктора и его вызов при создании объекта на базе прототипа (клонировании) не обязателен.
Как же тогда инициализировать свойства объекта?
Простые, не калькулируемые значения по умолчанию для свойств можно сразу присвоить прототипу:

var proto = {
    name: "Unnamed"
};

А если нужно использовать калькулируемые значения, то вместе с ECMA Script 5 нам на помощь приходит:

Ленивая (отложенная) инициализация

Ленивая инициализация это техника, позволяющая инициализировать свойство при первом к нему обращении:

var obj = {
    get lazy(){
        console.log("Инициализация свойства lazy...");
        // Вычисляем значение:
        var value = "Лениво инициализированное свойство " + this.name;

        // Переопределяем свойство, для того чтобы при следующем
        // обращении к нему, оно не вычислялось заново:
        Object.defineProperty(this, 'lazy', {
            value: value, 
            writable: true, enumerable: true
        });
        console.log("Инициализация окончена.");
        return value;
    },
    // оставляем возможность инициализировать свойство 
    // самостоятельно, в обход функции-инициализатора 
    // (если это не будет влиять на согласованность объекта):
          set lazy(value){
        console.log("Установка свойства lazy...");
        Object.defineProperty(this, 'lazy', {
            value: value, 
            writable: true, enumerable: true
        });
    },
    name: "БезИмени"
};
console.log( obj.lazy );
// Инициализация свойства lazy...
// Лениво инициализированное свойство БезИмени

console.log( obj.lazy );// Инициализатор не запускается снова
// Лениво инициализированное свойство БезИмени

obj.lazy = "Переопределено";// Сеттер не запускается, т.к. свойство уже инициализировано
console.log( obj.lazy );
// Переопределено

К плюсам этой техники можно отнести:

  • Разбиение конструктора на более мелкие методы-аксессоры “автоматически”, как предотвращение появлению длинных конструкторов (см. длинный метод).
  • Прирост в производительности, т.к. не используемые свойства инициализироваться не будут.

Сравнительная таблица

Прототип Класс (ECMA Script 5) Класс (ECMA Script 6)
Описание типа данных («класса»)
var $duck = {
  name: "Unnamed",
  get firstWords(){
    var value = this.quack();
    Object.defineProperty(
      this, 'firstWords',
      {value: value}
    );
    return value;
  },
  quack: function(){
    return this.name
      +" Duck: Quack-quack!";
  }
};
var Duck = function(name){
  this.name = name||"Unnamed";
  this.firstWords = this.quack();
};
Duck.prototype.quack = function(){
  return this.name
    +" Duck: Quack-quack!";
};
class Duck{
  constructor(name){
    this.name = name||"Unnamed";
    this.firstWords = this.quack();
  },
  quack(){
    return this.name
      +" Duck: Quack-quack!";
  }
}
Наследование
var $talkingDuck = Object.create($duck);

$talkingDuck.quack = function(){
  return $duck.quack.call(this)
    + " My name is "
    + this.name;
};
var TalkingDuck = function(name){
  Duck.call(this, name);
}

TalkingDuck.prototype = Object.create(Duck.prototype);

TalkingDuck.prototype.constructor = TalkingDuck;
TalkingDuck.prototype.quack = function(){
  return TalkingDuck.prototype.quack.call(this)
    + " My name is " 
    + this.name;
};
class TalkingDuck extends Duck{
  constructor(name){
    super(name);
  },
  quack(){
    return super.quack()
      + " My name is " 
      + this.name;
  }
}
Создание объектов-экземпляров
var donald = Object.create($talkingDuck);
donald.name = "Donald";
var donald = new TalkingDuck("Donald");
var donald = new TalkingDuck("Donald");

Список использованной литературы:
Dr. Axel Rauschmayer — Myth: JavaScript needs classes
Antero Taivalsaari — Classes vs. prototypes: some philosophical and historical observations [PDF]
Mike Anderson — Advantages of prototype-based OOP over class-based OOP