Продвинутый JavaScript 3. Объектно-ориентированное программирование

Прототип объекта

При использовании наследования в функциональном стиле наборы методов копируются в каждый объект и для большого количества методов такие объекты могут занимать слишком много памяти. Решением данной проблемы является использование прототипного наследования.

У каждого объекта есть скрытое свойство [[Prototype]], которое работает следующим образом - при обращении к какому-либо свойству объекта оно сначала ищется в самом объекте, если его там нет - в прототипе объекта, если нет и там - в прототипе прототипа объекта и т.д. до тех пор пока свойство не будет найдено, либо обращение вернет undefined. Это называется цепочкой прототипов. Наверху цепочки прототипов всегла находится null, который не имеет прототипа.

В браузере свойство [[Prototype]] доступно через __proto__, но это нестандартное свойство и оно доступно не во всех браузерах, потому использоть его явно - плохая практика.

Работу цепочки прототипов с использованием __proto__ можно изобразить следующим образом:

var creature = {
    canBreathe: true
};
var animal = {
    head: 1
};
var dog = {
    legs: 4
};

// прототипное наследование через __proto__
animal.__proto__ = creature;
dog.__proto__ = animal;

dog.legs; // 4, собственное свойство
dog.head; // 1, берется из animal
dog.canBreathe; // true, берется из creature
dog.canSwim; // undefined, не найдено ни в объекте, ни в цепочке прототипов

Иногда говорят, что прототип - это "резервное хранилище" свойств и методов объекта.

Свойства из прототипа берутся только для чтения. При попытке записи или удаления будет изменено свойство объекта, а не прототипа. Но в случае, когда свойства являются массивами или объектами, при изменении внутри объекта могут меняться свойства прототипа.

При наличии свойства у объекта и у прототипа с одинаковым именем при обращении к этому свойству будет браться собственное свойство объекта, т.к. в цепочке прототипов оно будет идти раньше свойства прототипа. Такое явление называется "затенением свойства" (property shadowing). Тоже самое происходит с методами — переопределение методов (method overriding).

Использование hasOwnProperty

Одним из недостатков использования прототипов является то, что без использования специального кода невозможно отделить собственные свойства объекта от свойств из прототипа.

for (var prop in dog) {
    console.log(prop);
}
// legs, head, canBreathe

Для проверки принадлежности свойства объекту используют метод hasOwnropery.

dog.hasOwnProperty("legs"); // true
dog.hasOwnProperty("head"); // false
dog.hasOwnProperty("canBreathe"); // false

Использование Object.create

В ECMAScript 5 был добавлен метод для определения прототипов объектов Object.create.

var creature = {
    canBreathe: true
};
// прототипное наследование через Object.create
var animal = Object.create(creature);
animal.head = 1;

var dog = Object.create(animal);
dog.legs = 4;

При наследовании объект-объект следует использовать метод Object.create, а не явное указание __proto__.

Object.create(null)

Т.к. при создании объектов в цепочке прототипов всегда присутствует Object, то каждый объект "получает" все методы этого объекта: toString, valueOf и т.д. Такое поведение не всегда является желательным. Например, мы используем объекты как простые хранилища данных, и нам необходимо проверить наличие произвольных свойств.

var dog = { legs: 4 };

"legs" in dog; // true, ожидаемо
"toString" in dog; // true, но нам это не нужно

Можно добавлять hasOwnProperty для каждой проверки, но можно поступить проще. По сути, нам нужен объект с пустым прототипом и мы можем создать такой объект с помощью Object.create и указанием в качестве объекта-прототипа null:

var dog = Object.create(null);
dog.legs = 4;

"legs" in dog; // true
"toString" in dog; // false

Создание прототипов объекта через конструктор

Для создания прототипа в конструкторе объекта самое простое, что можно сделать — указать __proto__ для this:

var animal = {
    head: 1
};

var Dog = function() {
    this.legs = 4;
    this.__proto__ = animal;
}

var myDog = new Dog();

Но __proto__ — нестандартное свойство и оно доступно не во всех браузерах, потому используется свойство prototype функции-конструктора:

var animal = {
    head: 1
};

var Dog = function() {
    this.legs = 4;
}
Dog.prototype = animal;

var myDog = new Dog();

Свойство prototype имеет смысл только в случае, если функция используется как конструктор.

Свойство constructor

При создании функции в ее prototype создается свойство constructor, которое является ссылкой на текущую функцию:

function Dog() {
    this.legs = 4;
};
Dog.prototype.constructor == Dog; // true

var myDog = new Dog;
var yourDog = new Dog.prototype.constructor;
var hisDog = new myDog.__proto__.constructor;

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

Для сохранения значения этого свойства обычно не перезаписывают весь prototype, а просто добавляют ему необходимые свойства или методы:

function Dog() {
    this.legs = 4;
};
Dog.prototype.head = 1;

var myDog = new Dog;

Свой Object.create

Object.create доступен только для браузеров с поддержкой ES5, потому имеет смысл написать кроссбраузерную функцию прототипного наследования:

function extend(proto) {
    if (Object.create) {
        return Object.create(proto);
    }
    var F = function() {};
    F.prototype = proto;
    var object = new F;
    return object;
}

Встроенные "классы" в JavaScript

Объекты можно создавать как с помощью литеральной записи, так и с помощью new Object. Аналогичное справедливо для массивов, строк, функций и чисел. Таком образом о Object, Array, Function, Number, String можно говорить как о конструкторах или как о "встроенных классах".

Например, следующий код работает потому, что создание объекта через {} аналогично его созданию через new Object и потому новый объект через прототип получаетс свойства и методы от Object

var myDog = {
    legs: 4
};
"legs" in myDog; // true
myDog.hasOwnProperty("legs"); // true
"hasOwnProperty" in myDog; // true
myDog.hasOwnProperty("hasOwnProperty"); // false

"Встроенные классы" связаны между собой прототипами:

console.dir( [1,2,3,4] );
console.dir( {legs: 4} );
console.dir( function() {} );

Таким образом, все объекты наследуют от Object.

При использовании примитивов вместе с методами будут создаваться временные объекты соответствующего типа (Number, String, Boolean), искаться и выполняться нужные методы в цепочке прототипов, а затем временный объект будет уничтожаться. Явно использовать конструкторы Number, String и Boolean не имеет смысла.

Класс через прототип

Для создания класса через прототип создается функция-конструктор, а все методы помещаются в прототип функции-конструктора:

function Dog() {
    this.legs = 4;
    this.head = 1;
};
Dog.prototype.bark = function() {
    alert("Woof1");
};

var myDog = new Dog;

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

Расширение базовых прототипов

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

Например, мы хотим добавить поддержку метода forEach для старых браузеров. Тогда метод просто добавляется в prototype встроенного объекта Array:

var arr = [1,2,3,4];

if(Array.prototype.forEach) {
    Array.prototype.forEach = function(callback) {
        for(var i = 0; i < this.length; i++) {
            callback(this[i]);
        }
    };
}

arr.forEach(console.log);

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

Мы рассмотрели наследование объект->объект и объект->класс. Однако с помощью прототипов можно организовать наследование классов.

function Soldier(name) {
    this.name = name;
    this.ammo = 100;
}
Soldier.prototype.shoot = function() {
    this.ammo--;
};

function Sniper(name) {
    this.name = name;
    this.weapon = "sniper riffle";
}
Sniper.prototype.aim = function() {
    this.takeBreath();
    this.toggleEyes("left", false);
    this.concentrate();
    this.whatEver();
    // ...
};

Чтобы поиск недостающих свойств в объекте, созданном с помощью Sniper, происходил по свойствам Soldier, нам нужно для всех объектов Sniper указать в их прототипе Soldier.prototype

С использованием __proto__ код мог бы выглядеть так:

Sniper.prototype.__proto__ = Soldier.prototype;

Но т.к. у __proto__ ограничена поддержка, то кроссбраузерное наследование классов будет таким:

Sniper.prototype = Object.create(Soldier.prototype);

Если Object.create не поддерживается, то нужно использовать функцию extend из подраздела Свой Object.create

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

function Soldier(name) {
    this.name = name;
    this.ammo = 100;
}
Soldier.prototype.shoot = function() {
    this.ammo--;
};

function Sniper(name) {
    // проброс аргументов функции-конструктору родительского класса
    Soldier.apply(this, arguments);
    this.weapon = "sniper riffle";
}
// "копируем" методы и свойства в прототип Sniper
Sniper.prototype = Object.create(Soldier.prototype);
// восстанавливаем ссылку на constructor
Sniper.prototype.constructor = Sniper;

Sniper.prototype.aim = function() {
    this.takeBreath();
    this.toggleEyes("left", false);
    this.concentrate();
    this.whatEver();
    // ...
};

Файлы занятия