Pull to refresh

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

Reading time 6 min
Views 104K
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 Duck.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$ = {// "$" в этом контексте читается как "прототип": duck$ == Duck.prototype
        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$. V8 так и делает, создавая скрытые классы при каждом добавлении свойства.

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

    var donald = Object.create(duck$, {
        name: {value: "Donald"}
    });
    console.log( Object.getPrototypeOf(donald) === duck$ ); // true


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


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

var proto = {
    name: "Unnamed"
};

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

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


Ленивая инициализация это техника, позволяющая инициализировать свойство при первом к нему обращении:
var obj = {
    name: "БезИмени",
    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
        });
    }
};
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="Unnamed"){
    this.name = name;
    this.firstWords = this.quack();
  },
  quack(){
    return this.name
      +" Duck: Quack-quack!";
  }
}
Наследование
var talkingDuck$ = Object.create(duck$, {
  quack: {value: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 Duck.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");


Часть 2 — Производительность: создание классов через __proto__



Список использованной литературы:
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
Tags:
Hubs:
+116
Comments 60
Comments Comments 60

Articles