Объектно-ориентированный подход в JavaScript сильно отличается от того, что можно наблюдать в таких языках как PHP, Ruby или Python. В последних объекты создаются на основе класса — чертежа будущего объекта. Допустим, у нас есть объект myCar, созданный из класса Car. Когда мы вызываем на объекте метод honk(), интерпретатор языка ищет его в классе Car, из которого был создан объект. Все просто и понятно, переходим к JavaScript.

Классы? Не, не слышал

Понятия класса в JavaScript нет (хотя class является зарезервированным ключевым словом). Вместо классовой модели, где объекты связаны со своими классами, в JavaScript используется прототипная модель, где у каждого объекта может быть объект-прототип. Это значит, что когда мы на нашем объекте myCar вызываем метод honk(), и такой метод отсутствует у самого объекта, интерпретатор ищет его у объекта-прототипа.

Итак, как же в JavaScript создаются объекты? В самом простом случае мы можем создать объект и все его методы вручную. Выглядит это так:

 
var myCar = {};

myCar.honk = function() {
    console.log('би-би');
}

myCar.drive = function() {
    console.log('ррррр…');
}

Код выше дает нам объект с именем myCar и методами honk() и drive():

 
myCar.honk(); // выведет «би би»
myCar.drive(); // выведет «ррррр…»

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

 
Car myCar = new Car(); // создаст объект myCar на основе класса Car

Но в JavaScript задача по созданию такого механизма ложится на наши плечи. Кроме того, JavaScript дает нам очень широкие возможности в этой области.

Самый простой вариант — написать простую функцию, которая будет штамповать объекты с одинаковыми свойствами и методами. Такие объекты никак не будут связаны между собой.

Другой вариант — написать особую функцию, которая будет не только создавать объекты, но и связывать их друг с другом. Всем таким объектам будут доступен один набор методов. Если впоследствии изменить его, например, добавить в него новый метод, то этот метод появится не только в будущих объектах, но и во всех уже созданных.

Давайте рассмотрим оба варианта более детально.

Создание объектов с помощью простой функции

В самом первом примере мы создавали объект myCar и добавляли ему несколько методов. Поместим этот код в функцию:

 
function makeCar() {
    var newCar = {};

    newCar.honk = function() {
        console.log('би-би');
    }
    // для краткости я убрал метод drive()
    return newCar;
}

Теперь мы можем использовать эту функцию для штампования объектов:

 
var myCar1 = makeCar();
var myCar2 = makeCar();
var myCar3 = makeCar();

Один из недостатков такого подхода заключается в его неэффективности. Создание каждого объекта myCar требует создания одной копии метода honk(). Если мы создадим 100 объектов, JavaScript выделит память под 100 копий одного и того же метода.

Кроме того, наши объекты никак не связаны между собой. Эта ситуация очень напоминает реальные автомобили с реального автозавода: они все выглядят одинаково, но как только они покидают конвейер, они полностью независимы друг от друга. Если в середине производства в модели будет обнаружен дефект, то для его устранения производителю придется возвращать все автомобили обратно на автозавод.

Но в виртуальном мире нам незачем ограничивать себя подобным образом. Мы можем создавать объекты так, чтобы иметь возможность менять их все одновременно.

Создание объектов с помощью конструктора

Традиционно конструктором называют метод класса, который автоматически запускается после создания экземпляра этого класса и принимает параметры, переданные при инициализации класса. Но в JavaScript конструктор — это функция. Давайте создадим конструктор для объектов myCar. Назовем его Car, с большой буквы — именно так в JavaScript принято обозначать функции-конструкторы.

Для начала повторим решение с простой функцией, которая штампует независимые объекты, но уже в форме конструктора:

 
function Car() {
    this.honk = function() {
        console.log('би-би');
    }
}

Теперь мы можем вызвать эту функцию с помощью оператора new, а именно:

 
var myCar = new Car();

Оператор new создает новый объект и вызывает функцию-конструктор как метод этого объекта (то есть со ссылкой на объект в переменной this), после чего возвращает созданный объект. Упрощенно работу оператора new можно проиллюстрировать следующим псевдокодом:

 
// Псевдокод, только для иллюстрации!
function Car(this) {
    this.honk = function() {
        console.log('би-би');
    }
    return this;
}

var newObject = {};
var myCar = Car(newObject);

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

 
function Car() {
    this.honk = function() {
        console.log('би-би');
    }
}

var myCar1 = new Car();
var myCar2 = new Car();

console.log(myCar1.constructor); // выведет [Function: Car]
console.log(myCar2.constructor); // выведет [Function: Car]

Все созданные объекты myCar связаны с конструктором Car. Именно это отличает их от случайного набора объектов с одинаковыми методами и похожими именами.

Теперь пришло время поговорить о прототипах, о которых я упоминал в начале статьи.

Использование прототипов для создания объектов с общим функционалом

Как я уже писал, в прототипной модели программирования методы, общие для всех объектов, размещаются в объекте-прототипе. Но где же находится объект-прототип для наших объектов myCar, ведь мы его не создавали? Он неявно создается за нас и записывается в свойство Car.prototype.

Функции и методы в JavaScript

Функции в JavaScript — объекты первого класса. Это значит, что они могут быть присвоены переменным в качестве значения, переданы в другие функции в качестве параметров и возвращены из других функций в качестве результата. Методов в том виде, в котором мы привыкли к ним в языках с классовой моделью, в JavaScript нет. Вместо них тут функции, записанные в свойства объектов.

Ключ к созданию объектов с общим функционалом в следующем: каждый раз, когда мы вызываем метод объекта, интерпретатор JavaScript ищет его в этом объекте. Но если метода он там не находит, он ищет этот метод в объекте-прототипе. Значит, мы можем создавать наши объекты myCar вообще без методов, а сами методы добавлять в прототип. Вот как это делается:

function Car() {}

Car.prototype.honk = function() {
    console.log('би-би');
}

var myCar1 = new Car();
var myCar2 = new Car();

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «би би»
myCar2.honk(); // вызовет метод Car.prototype.honk() и выведет «би би»

Теперь наш конструктор пуст, потому что для наших простых объектов myCar не требуется никакой инициализации. А благодаря тому, что объекты myCar созданы с помощью конструктора, у них общий прототип — Car.prototype. Теперь давайте посмотрим, что этот прототип позволяет нам делать. В JavaScript можно менять свойства объектов на лету, в том числе это касается и объектов-прототипов. Вот почему мы можем добавить нашим объектам метод drive() даже после того, как они были созданы:

function Car() {}

Car.prototype.honk = function() {
    console.log('би-би');
}

var myCar1 = new Car();
var myCar2 = new Car();

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»
myCar2.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»

console.log(myCar1.drive); // выведет «undefined» — метод не объявлен
console.log(myCar2.drive); // выведет «undefined» — метод не объявлен

Car.prototype.drive = function() {
    console.log('ррррр…');
}

myCar1.drive(); // вызовет метод Car.prototype.drive() и выведет «ррррр…»
myCar2.drive(); // вызовет метод Car.prototype.drive() и выведет «ррррр…»

Мы также можем заменить уже существующий метод на новый:

function Car() {}

Car.prototype.honk = function() {
    console.log('би-би');
}

var myCar1 = new Car();
var myCar2 = new Car();

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»
myCar2.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»

Car.prototype.honk = function() {
    console.log('ми-ми-ми');
}

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «ми-ми-ми»
myCar2.honk(); // вызовет метод Car.prototype.honk() и выведет «ми-ми-ми»

Мы даже можем изменить метод только одного из наших объектов:

function Car() {}

Car.prototype.honk = function() {
    console.log('би-би');
}

var myCar1 = new Car();
var myCar2 = new Car();

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»
myCar2.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»

myCar2.honk = function() {
    console.log('ми-ми-ми');
}

myCar1.honk(); // вызовет метод Car.prototype.honk() и выведет «би-би»
myCar2.honk(); // вызовет метод myCar2.honk() и выведет «ми-ми-ми»

В последнем примере важно понимать, что происходит за кулисами. Как мы уже знаем, во время вызова метода интерпретатор проходит определенный путь для того чтобы выполнить этот метод. У объекта myCar1 по-прежнему нет собственного определения метода honk() и интерпретатор выполняет метод объекта-прототипа. Но в случае с myCar2 все обстоит иначе, у него теперь есть собственный метод honk(), поэтому интерпретатор больше не обращается за этим методом к прототипу.

Обратите внимание, насколько эффективнее использование прототипа по сравнению с созданием копий методов. Как уже упоминалось, интерпретатору не понятно, что эти методы должны быть идентичны, поэтому он выделяет память для каждого метода каждого созданного нами объекта. Но в случае с прототипом все иначе — память выделяется только один раз независимо от того, сколько объектов мы создадим.

Чтобы не быть голословным, приведу очень простое сравнение. Первый пример создает миллион объектов и каждому из них добавляется копия одного метода:

var C = function() {
    this.f = function(foo) {
        console.log(foo);
    }
}

var a = [];
for (var i = 0; i < 1000000; i++) {
    a.push(new C());
}

В браузере Google Chrome выделяется область памяти размером в 328 Мб. А вот этот же пример, но метод перенесен в прототип:

var C = function() {}

C.prototype.f = function(foo) {
    console.log(foo);
}

var a = [];
for (var i = 0; i < 1000000; i++) {
    a.push(new C());
}

На этот раз размер области памяти всего 17 Мб, это примерно 5% от предыдущего значения.

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

Пока что мы не затрагивали тему наследования в JavaScript, но настало и ее время.

Несомненно, удобно иметь класс объектов с общими методами, но часто нужно создать несколько разных классов объектов с некоторым общим функционалом. Представим, что в нашем чудесном виртуальном мире есть не только автомобили, но и велосипеды. И те, и другие умеют ездить, но вместо клаксона у велосипеда звоночек.

Итак, метод drive() — общий для обоих классов, в то время как методы honk() и ring() относятся каждый к своему классу. Выделим класс Vehicle, от которого будут наследоваться классы Car и Bike:

Схема иерархии классов Схема иерархии классов

Как построить такую иерархию в JavaScript, где вместо классов нам доступны прототипы? Начнем с примера и затем его разберем. Чтобы не перегружать себя кодом, ограничимся определением Vehicle и Car:

function Vehicle() {}

Vehicle.prototype.drive = function() {
    console.log('ррррр…');
}

function Car() {}

Car.prototype = new Vehicle();

Car.prototype.honk = function() {
    console.log('би-би');
}

var myCar = new Car();

myCar.honk(); // выведет «би-би»
myCar.drive(); // выведет «ррррр…»

В JavaScript наследование реализуется с помощью цепи прототипов. В качестве прототипа конструктора Car выступает объект, созданный с помощью конструктора Vehicle. Прототип конструктора Vehicle содержит метод drive(). Вот что происходит, когда мы обращаемся к методу drive() объекта myCar:

  • Интерпретатор ищет метод drive() в самом объекте myCar. Этого метода в объекте не существует;
  • Интерпретатор ищет метод drive() в прототипе конструктора Car, которым является объект, созданный с помощью конструктора Vehicle. Здесь метода также нет;
  • Интерпретатор ищет метод drive() в прототипе конструктора Vehicle, где и находит искомый метод.

Классы? Не, не слышал. Часть 2

Мы только что научились эмулировать традиционное наследование в JavaScript. Теперь можно спокойно забыть про него и принять тот факт, что в JavaScript на самом деле не нужны классы, а, следовательно, и нет необходимости их эмулировать. Кроме того, не многовато ли кода для того, чтобы выразить такую простую мысль: «пойди, погляди на вон тот вот объект, в нем есть метод, который тебе нужен, дружище»?

Дуглас Крокфорд придумал отличное решение, позволяющее объектам наследовать друг друга напрямую, без необходимости создавать конструкторы. Это решение сейчас уже стало частью самого JavaScript, и выглядит оно примерно так:

Object.create = function(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
Дуглас Крокфорд (англ. Douglas Crockford) — американский программист, широко известный благодаря своему участию в разработке языка JavaScript, популяризации формата JSON и разработке разнообразных инструментов для JavaScript, например JSLint и JSMin.

Давайте разберемся, что происходит. Разберем пример:

var vehicle = {};

vehicle.drive = function() {
    console.log('ррррр…');
}

var car = Object.create(vehicle);

car.honk = function() {
    console.log('би-би');
}

var myCar1 = Object.create(car);
var myCar2 = Object.create(car);

myCar1.honk(); // выводит «би-би»
myCar1.drive(); // выводит «ррррр…»

Этот код дает практически такой же результат, как и создание конструкторов, но при этом намного короче. За кулисами метод Object.create() создает временный конструктор, в качестве прототипа которого используется объект, передаваемый нами. После этого метод возвращает нам объект, созданный с помощью временного конструктора.

Но погодите-ка, мы же создали методы drive() и honk() прямо в самих объектах вместо их прототипов, разве это не приведет к росту потребления памяти? В данном случае — нет. Мы создали четыре разных объекта, два из них — это объекты myCar, созданные на основе объекта car. Но при этом сами методы honk() и drive() определены лишь однажды, а, следовательно, и в памяти они существуют лишь однажды. Проведем эксперимент:

var c = {};
c.f = function(foo) {
    console.log(foo);
}

var a = [];
for (var i = 0; i < 1000000; i++) {
    a.push(Object.create(c));
}

Выясняется, что размер выделенной памяти таки увеличился — до 40 Мб. Но в обмен на упрощение кода это позволительная жертва.

О статье

Эта статья — вольный перевод статьи Мануэля Кисслинга. Мануэль Кисслинг (англ. Manuel Kiessling) — веб-разработчик и автор книги «The Node Beginner Book».