Прототипное объектно-ориентированное программирование в JavaScript

Еще в момент первой войны браузеров, руководители Netscape наняли умного парня Брендан Эйч для создания языка программирования, который работал бы в браузере. Одно время этот язык носил название LiveScript и в отличие от других, основанных на C++ и Java, он был разработан для реализации модели наследования на основе JavaScript prototype.

К сожалению, этот новый язык по маркетинговым причинам должен был “быть похожим на Java”. Руководители Netscape хотели продать свой блестящий новый язык как “маленького брата Java”. Видимо поэтому его название было изменено на JavaScript. Но система ООП на основе прототипа не была похожа на классы в Java. Чтобы сделать ее похожей, разработчики JavaScript придумали ключевое слово new и новый способ использовать функции конструктора.

Понимание принципов прототипного программирования разрешило большинство проблем, которые у меня с JavaScript.

Прототипное ООП

В класс-ориентированном программировании мы сначала создаем класс в качестве шаблона, а затем на его основе создаем объект. Для построения более конкретных типов объектов мы создаем потомка класса. То есть, мы вносим некоторые изменения в шаблон и используем полученный новый шаблон для построения более конкретных объектов.

В реальном мире, если нужно создать стул, вы сначала создали бы проект на бумаге, а затем изготовили стулья на основе этого проекта. Проект здесь — класс, а стулья являются объектами. Если бы вы захотели сделать кресло-качалку, вы бы взяли проект, внесли некоторые изменения и создали бы кресло-качалку.

Теперь перенесем этот пример в мир JavaScript prototype наследования: здесь вы не создаете проекты или классы, вы просто создаете объект. Вы берете доски и сколачиваете стул. Этот стул — реальный объект, который может функционировать в полной мере как стул, а также служить прототипом для будущих стульев. В мире прототипов вы делаете стул и создаете из него «клонов«. Если хотите построить кресло-качалку, тогда нужно выбрать стул, изготовленный ранее, приложить к нему две дугообразные доски.

JavaScript и прототипное ООП

Ниже приведен пример, который демонстрирует этот вид ООП в JavaScript. Мы начнем с создания объекта animal:

var genericAnimal = Object.create(null);

Object.create(null) создает новый пустой объект. Далее, мы добавим свойства и функции нашему новому объекту:

genericAnimal.name = 'Животное'; 
genericAnimal.gender = 'женский'; 
genericAnimal.description = function() { 
return 'Пол: ' + this.gender + '; Название: ' + this.name; 
};

genericAnimal — объект и соответственно может быть использован:

console.log(genericAnimal.description()); 
//Пол: женский; Название: Животное

Можно создать других животных, используя наш объект в качестве JavaScript object prototype. Думайте об этом как о клонировании объекта:

var cat = Object.create(genericAnimal);

Мы только что создали кошку, как клона обычного животного. Мы можем добавить ей свойства и функции:

cat.purr = function() { 
return 'Мурр!';
 };

Также мы можем использовать нашу кошку в качестве прототипа и создать еще несколько кошек:

var colonel = Object.create(cat); 
colonel.name = 'Полковник Мяу'; 
var puff = Object.create(cat); 
puff.name = 'Паффи';

Заметьте, что родительские свойства/методы были надлежащим образом наследованы с помощью JavaScript prototype:

console.log(puff.description()); 
// Пол: женский; Название: Паффи

Ключевое слово New и функция-конструктор

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

function Person(name) { 
this.name = name;
 this.sayName = function() {
 return "Привет, Я " + this.name; 
}; 
} 
var adam = new Person('Адам');

В JavaScript реализация наследования с помощью метода по умолчанию выглядит сложнее. Определим Ninja как подкласс Person. Ниндзя могут иметь имя, так как они люди, а также могут иметь личное оружие, например сюрикены:

function Ninja(name, weapon) {
 Person.call(this, name); 
this.weapon = weapon; 
}
 Ninja.prototype = Object.create(Person.prototype); 
Ninja.prototype.constructor = Ninja;

Многие считают проблематичным использование шаблона конструктора, который мог бы выглядеть более привлекательно для тех, кто знаком с класс-ориентированным программированием. Реализация JavaScript prototype наследования скрыта от глаз, а функция — конструктор запутывает. Это странный способ реализации ООП на основе класса без реальных классов.

Так как это не совсем класс, важно понять, что делает вызов конструктора. Сначала создается пустой объект, затем устанавливается прототип этого объекта в свойство prototype конструктора, а затем вызывается функция-конструктор с указателем this на вновь созданный объект, и в конце возвращается объект.

Дуглас Крокфорд кратко подытожил проблемы с шаблоном конструктора в JavaScript:

Шаблон конструктора JavaScript не заинтересовал классическую публику и приуменьшил истинную природу прототипов JavaScript. В результате очень мало программистов знает, как эффективно использовать язык.


Самый эффективный способ работы с ООП в JavaScript состоит в понимании прототипного ООП и того, следует ли использовать шаблон конструктора или нет.

Понимание делегирования и реализация прототипов

Прототипное ООП отличается от традиционного ООП тем, что в нем нет никаких классов — только объекты, которые наследуются от других объектов.

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

Некоторые примеры из предыдущего кода могут проиллюстрировать этот момент:

Следующая строка создает новый пустой объект с нулевым __proto__:

var genericAnimal = Object.create(null);

Приведенный ниже код создает новый пустой объект с JavaScript свойством prototype __proto__ объекта genericAnimal, то есть rodent.__proto__ указывает на genericAnimal:

var rodent = Object.create(genericAnimal);
 rodent.size = 'S';

Следующая строка создаст пустой объект с __proto__, указывающим на rodent:

var capybara = Object.create(rodent); 
//capybara.__proto__ указывает на rodent 
//capybara.__proto__.__proto__ указывает на genericAnimal 
//capybara.__proto__.__proto__.__proto__  ни на что не указывает

Как видите, каждый объект содержит ссылку на свой прототип. Рассмотрим функцию Object.create. Кажется, что функция «клонирует» родительский объект, а свойства родителя копируются в дочерний элемент, но это не так. Когда capybara создается из rodent, capybara — это пустой объект со ссылкой на rodent.

Но тогда, если мы вызовем capybara.size сразу после создания, мы получим S размер, который установлен в родительском объекте. Что же это за магия такая? Ведь capybara еще не имеет свойства размера. Тем не менее, когда мы пишем capybara.size, мы так или иначе можем получить свойство size прототипа.

Ответ находится в реализации JavaScript prototype наследования – делегировании. Когда мы вызываем capybara.size, JavaScript сначала ищет это свойство у capybara. Если не находит, продолжает искать это свойство в capybara.__proto__. Если не находит его в capybara.__proto__, продолжает искать внутри capybara.__proto__.__proto__. Это называется цепочкой прототипов.

Если бы мы вызвали capybara.description(), обработчик JavaScript начал бы поиск функции description вверх по цепочке прототипов и обнаружил ее в capybara.__proto__.__proto__, как это было определено в genericAnimal. Затем функция была бы вызвана с указателем this на capybara.

Задание свойства немного отличается. Когда мы устанавливаем capybara.size = ‘XXL’, новое свойств size, создается в capybara объекте. И в следующий раз, когда мы попытаемся получить доступ к capybara.size, мы найдем его непосредственно в объекте со значением ‘XXL‘.

Поскольку свойство прототипа является ссылкой, изменение свойств объекта прототипа повлияет на все объекты, использующие прототип. Например, если после создания rodent и capybara мы перепишем функцию description или добавим новую функцию в genericAnimal, они немедленно станут доступны для использования в rodent и capybara благодаря делегированию.

Создание Object.create

Некоторые браузеры не поддерживают Object.create. По этой причине Дуглас Крокфорд рекомендует включать следующий код в JavaScript-приложения, чтобы при JavaScript prototype наследовании Object.create был создан в случае, если его нет:

if (typeof Object.create !== 'function') {
      Object.create = function (o) {
           function F() {}
           F.prototype = o;
           return new F();
      };
}

Object.create в действии

Если бы вы хотели в JavaScript расширить Math объект, как бы вы это сделали? Предположим, что мы хотим переопределить случайную функцию, не изменяя исходный Math объект, поскольку другие скрипты могут использовать его. Гибкость JavaScript предоставляет множество возможностей, но использовать Object.create легче:

var myMath = Object.create(Math);

Конечно, если хотите, можете написать новый конструктор, установить его прототипом для клона Math, расширить прототип функциями, которые вам нравятся, а затем создать сам объект. Но зачем проходить через все это, чтобы сделать его похожим на класс, когда прототипы настолько просты?

Теперь мы можем переопределить random функцию в нашем myMath объекте. Я написал функцию, которая возвращает целые случайные числа в пределах диапазона, указанного пользователем. В противном случае она вызывает родителя random функции:

myMath.random = function() {
	var uber = Object.getPrototypeOf(this);
if (typeof(arguments[0]) === 'number' && typeof(arguments[1]) === 'number' &&
 arguments[0] < arguments[1]) {
		var rand = uber.random();
		var min = Math.floor(arguments[0]);
		var max = Math.ceil(arguments[1]);
		return this.round(rand * (max - min)) + min;
	}
	return uber.random();

Теперь myMath.random(-5,5) получает случайное целое число от -5 до 5, в то время как myMath.random() получает обычное. А так как myMath имеет Math в качестве своего JavaScript prototype, он имеет все функциональные возможности этого Math объекта, встроенного в него.

ООП на основе классов по сравнению с прототипным ООП

Оба подхода имеют свои плюсы и минусы. Но прототипное ООП проще для понимания, оно более гибкое и динамичное.

Чтобы получить представление о его динамичном характере, взгляните на следующий пример: вы написали код, который использует indexOf функцию в массивах. После его написания и тестирования в хорошем браузере вы неохотно проверяете его в Internet Explorer 8. Как и следовало ожидать, вы сталкиваетесь с проблемами. Так как функция indexOf не определена в IE8.

Так что же делать? В ООП на основе классов можно решить эту проблему, определив функцию, в другом классе «помощнике«, который принимает Array или List в качестве входных данных, и заменяет все вызовы в вашем коде. Или можно разделить List или ArrayList на подклассы, определить функцию в подклассе и использовать новый подкласс вместо ArrayList.

Но ООП на базе JavaScript prototype делает это проще. Каждый массив представляет собой объект и указывает на родительский объект прототипа. Если мы можем определить функцию в прототипе, то наш код будет работать без каких-либо изменений!

if (!Array.prototype.indexOf) {
	Array.prototype.indexOf = function(elem) {
		//Волшебный  код находится здесь.
};
}

Также вы сможете расширить существующие прототипы для добавления новых функциональных возможностей – расширение прототипов, как мы делали это выше. Хорошо известная библиотека Prototype.js добавляет свою магию во встроенные объекты JavaScript. Вы сможете создать все виды схем наследования, например, выборочное наследование от нескольких объектов.

Эмуляция ООП на основе классов, что может пойти не так

Рассмотрим следующий пример, написанный с псевдоклассами:

function Animal(){
 this.offspring=[];
 }
 Animal.prototype.makeBaby = function(){ 
var baby = new Animal(); 
this.offspring.push(baby); 
return baby; 
};
 //создание Cat как подкласса Animal 
function Cat() { 
} 
//наследует Animal 
Cat.prototype = new Animal();
 var puff = new Cat();
 puff.makeBaby(); 
var colonel = new Cat();
 colonel.makeBaby();

Это образец наследования. Тем не менее, кое-что забавное здесь происходит — если вы проверите colonel.offspring и puff.offspring, то заметите, что каждый из них содержит два одинаковых потомка!

JavaScript пытался сделать нашу жизнь проще, делая себя похожим на старый добрый ООП на основе классов. Но как оказалось, это не так просто. Имитация под ООП на основе классов без полного понимания JavaScript prototype наследования может привести к неожиданным результатам. Чтобы понять, почему возникла эта проблема, вы должны понимать что прототипы и конструкторы – это всего лишь один из способов построения объектов из других объектов.

Если вы рассуждаете с точки зрения прототипов, то приведенный выше код ясен. Переменная offspring создается в объекте Cat.prototype, когда вызывается конструктор Animal. Все отдельные объекты, созданные конструктором Cat, используют Cat.prototype в качестве своего прототипа, где находится offspring. Когда мы вызываем makeBaby, обработчик JavaScript ищет свойство offspring в объекте Cat и не может найти его. Затем он находит свойство в Cat.prototype и добавляет нового потомка в совместно используемый объект, от которого наследовались оба отдельных объекта Cat.

Теперь, когда мы понимаем, в чем проблема, благодаря нашим знаниям ООП на основе прототипа, как же мы будем решать эту проблему? Свойство offspring должно быть создано в самом объекте, а не где-то в цепочке прототипов. Есть много способов решить эту проблему. Один из них заключается в том, что makeBaby гарантирует, что объект, в котором вызывается функция, имеет свое собственное JavaScript свойство prototype offspring:

Animal.prototype.makeBaby=function(){
	var baby=new Animal(); 
	if(!this.hasOwnProperty('offspring')){
		this.offspring=[]; }
	this.offspring.push(baby); 
	return baby;
};

Backbone.js работает с подобной ситуацией. В Backbone.js вы создаете представления (Views) путем расширения «класса» Backbone.View. Затем вы реализуете представление, используя шаблон конструктора. Эта модель хороша в эмуляции ООП на основе классов в JavaScript:

//Создание HideableView "подкласса" Backbone.View
var HideableView = Backbone.View.extend({
    el: '#hideable', //представление будет связано с этим селектором
    events : {
        'click .hide': 'hide'
    },
    // эта функцию ссылается на обработчик события click выше 
    hide: function() {
      //сокрытие всего представления
    	$(this.el).hide();
    }
});
var hideable = new HideableView();

Это выглядит как простой класс на основе ООП, а не JavaScript prototype. Мы унаследовали от базового класса Backbone.View, чтобы создать дочерний класс HideableView. Затем мы создали объект типа HideableView.

Поскольку это выглядит как простое ООП на основе класса, мы можем использовать эту функцию для построения иерархии наследования, как показано в следующем примере:

var HideableTableView = HideableView.extend({
// Представление hideable в виде таблицы.
});
var HideableExpandableView = HideableView.extend({
    initialize: function() {
        //Расширяем обработчик события click. Мы не создаем отдельный
        //объект события, так как нам нужно добавить
        //к наследованным событиям.
        this.events['click .expand'] = 'expand';
    },
    expand: function () {
    	//расширенный дескриптор
    }
});
var table = new HideableTableView();
var expandable = new HideableExpandableView();

Все это выглядит хорошо, пока вы думаете классовым ООП. Но если вы попробуете table.events[‘click .expand’] в консоли, то увидите «expand«! Так или иначе, у HideableTableView есть обработчик события expand, хотя он не был определен в этом классе.

Увидеть данную проблему в действии можно здесь.

Представленная выше проблема произошла по той же причине, описанной в предыдущем примере. В Backbone.js нужно работать, пытаясь сделать его похожим на классы, чтобы увидеть цепочку прототипов, скрытую в фоновом режиме. Как только поймете, как структурирована цепочка прототипов, вы сможете найти простое решение этой проблемы.

Заключение

Несмотря на то, что JavaScript prototype наследование лежит в основе одного из самых популярных языков, большинство программистов не знакомы с ним. Возможно, сам JavaScript частично виноват в этом из-за своих попыток маскироваться под ООП на основе классов.

Это необходимо изменить. Для эффективной работы с JavaScript разработчики должны понимать, как и зачем программировать на основе прототипа.

Перевод статьи “Prototypal Object-Oriented Programming using JavaScript” был подготовлен дружной командой проекта Сайтостроение от А до Я.