6. Тайная жизнь объектов
7. Проект: электронная жизнь
8. Поиск и обработка ошибок

6. Тайная жизнь объектов

Проблема объектно-ориентированных языков в том, что они тащат с собой всё своё неявное окружение. Вам нужен был банан – а вы получаете гориллу с бананом, и целые джунгли впридачу.

Джо Армстронг, в интервью Coders at Work

Термин «объект» в программировании сильно перегружен значениями. В моей профессии объекты – стиль жизни, тема священных войн и любимое заклинание, не теряющий своей магической силы.

Стороннему человеку всё это непонятно. Начнём же с краткой истории объектов как концепции в программировании.

История

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

Объект – твёрдая скорлупа, скрывающая липкую сложность внутри, и вместо неё предлагающая нам несколько ручек настройки и контактов (вроде методов), представляющих интерфейс, посредством которого объект нужно использовать. Идея в том, что интерфейс относительно прост, и при работе с ним позволяет игнорировать все сложные процессы, происходящие внутри объекта.

Простой интерфейс может спрятать много сложного.

Для примера представьте объект, обеспечивающий интерфейс к участку экрана. С его помощью можно рисовать фигуры или выводить текст на этот участок, но при этом все детали, касающиеся превращения текста или фигур в пиксели, скрыты. У вас есть набор методов, к примеру drawCircle, и это всё, что вам нужно знать для использования такого объекта.

Такие идеи получили развитие в 70-80 годах, а в 90-х их вынесла на поверхность рекламная волна – революция объектно-ориентированного программирования. Внезапно большой клан людей объявил, что объекты – это правильный способ программирования. А всё, что не имеет объектов, является устаревшей ерундой.

Такой фанатизм всегда приводит к куче бесполезной чуши, и с тех пор идёт что-то вроде контрреволюции. В некоторых кругах объекты вообще имеют крайне плохую репутацию.

Я предпочитаю рассматривать их с практической, а не идеологической точки зрения. Есть несколько полезных идей, в частности инкапсуляция (различие между внутренней сложностью и внешней простотой), которые были популяризованы объектно-ориентированной культурой. Их стоит изучать.

Эта глава описывает довольно эксцентричный подход JavaScript к объектам, и то, как они соотносятся с классическими объектно-ориентированными техниками.

Методы

Методы – свойства, содержащие функции. Простой метод:

var rabbit = {};
rabbit.speak = function(line) {
  console.log("Кролик говорит '" + line + "'");
};

rabbit.speak("Я живой.");
// → Кролик говорит 'Я живой.'

Обычно метод должен что-то сделать с объектом, через который он был вызван. Когда функцию вызывают в виде метода – как свойство объекта, например object.method() – специальная переменная в её теле будет указывать на вызвавший её объект.

function speak(line) {
  console.log("А " + this.type + " кролик говорит '" + line + "'");
}
var whiteRabbit = {type: "белый", speak: speak};
var fatRabbit = {type: "толстый", speak: speak};

whiteRabbit.speak("Ушки мои и усики, " +  "я же наверняка опаздываю!");
// → А белый кролик говорит ' Ушки мои и усики, я же наверняка опаздываю!'
fatRabbit.speak("Мне бы сейчас морковочки.");
// → А толстый кролик говорит ' Мне бы сейчас морковочки.'

Код использует ключевое слово this для вывода типа говорящего кролика.

Вспомните, что методы apply и bind принимают первый аргумент, который можно использовать для эмуляции вызова методов. Этот первый аргумент как раз даёт значение переменной this.

Есть метод, похожий на apply, под названием call. Он тоже вызывает функцию, методом которой является, только принимает аргументы как обычно, а не в виде массива. Как apply и bind, в call можно передать значение this.

speak.apply(fatRabbit, ["Отрыжка!"]);
// → А толстый кролик говорит ' Отрыжка!'
speak.call({type: "старый"}, " О, господи.");
// → А старый кролик говорит 'О, господи.'

 

Прототипы

Следите за руками.

var empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

Я достал свойство пустого объекта. Магия!

Ну, не магия, конечно. Я просто не всё рассказал про то, как работают объекты в JavaScript. В дополнение к набору свойств, почти у всех также есть прототип. Прототип – это ещё один объект, который используется как запасной источник свойств. Когда объект получает запрос на свойство, которого у него нет, это свойство ищется у его прототипа, затем у прототипа прототипа, и т.д.

Ну а кто же прототип пустого объекта? Это великий предок всех объектов, Object.prototype.

console.log(Object.getPrototypeOf({}) ==  Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

Как и следовало ожидать, функция Object.getPrototypeOf возвращает прототип объекта.

Прототипические отношения в JavaScript выглядят как дерево, а в его корне находится Object.prototype. Он предоставляет несколько методов, которые появляются у всех объектов, типа toString, который преобразует объект в строковый вид.

Прототипом многих объектов служит не непосредственно Object.prototype, а какой-то другой объект, который предоставляет свои свойства по умолчанию. Функции происходят от Function.prototype, массивы – от Array.prototype.

console.log(Object.getPrototypeOf(isNaN) ==  Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true

У таких прототипов будет свой прототип – часто Object.prototype, поэтому он всё равно, хоть и не напрямую, предоставляет им методы типа toString.

Функция Object.getPrototypeOf возвращает прототип объекта. Можно использовать Object.create для создания объектов с заданным прототипом.

var protoRabbit = {
  speak: function(line) {
    console.log("А " + this.type + " кролик говорит '" +  line + "'");
  }
};
var killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "убийственный";
killerRabbit.speak("ХРЯЯЯСЬ!");
// → А убийственный кролик говорит ' ХРЯЯЯСЬ!'

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

Конструкторы

Более удобный способ создания объектов, наследуемых от некоего прототипа – конструктор. В JavaScript вызов функции с предшествующим ключевым словом new приводит к тому, что функция работает как конструктор. У конструктора будет в распоряжении переменная this, привязанная к свежесозданному объекту, и если она не вернёт непосредственно другое значение, содержащее объект, этот новый объект будет возвращён вместо него.

Говорят, что объект, созданный при помощи new, является экземпляром конструктора.

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

function Rabbit(type) {
  this.type = type;
}

var killerRabbit = new Rabbit("убийственный");
var blackRabbit = new Rabbit("чёрный");
console.log(blackRabbit.type);
// → чёрный

Конструкторы (а вообще-то, и все функции) автоматически получают свойство под именем prototype, которое по умолчанию содержит простой и пустой объект, происходящий от Object.prototype. Каждый экземпляр, созданный этим конструктором, будет иметь этот объект в качестве прототипа. Поэтому, чтобы добавить кроликам, созданным конструктором Rabbit, метод speak, мы просто можем сделать так:

Rabbit.prototype.speak = function(line) {
  console.log("А " + this.type + " кролик говорит '" +   line + "'");
};
blackRabbit.speak("Всем капец...");
// → А чёрный кролик говорит ' Всем капец...'

Важно отметить разницу между тем, как прототип связан с конструктором (через свойство prototype) и тем, как у объектов есть прототип (который можно получить через Object.getPrototypeOf). На самом деле прототип конструктора — Function.prototype, поскольку конструкторы – это функции. Его свойство prototype будет прототипом экземпляров, созданных им, но не его прототипом.

Перезагрузка унаследованных свойств

Когда вы добавляете свойство объекту, есть оно в прототипе или нет, оно добавляется непосредственно к самому объекту. Теперь это его свойство. Если в прототипе есть одноимённое свойство, оно больше не влияет на объект. Сам прототип не меняется.

Rabbit.prototype.teeth = "мелкие";
console.log(killerRabbit.teeth);
// → мелкие
killerRabbit.teeth = "длинные, острые и окровавленные ";
console.log(killerRabbit.teeth);
// → длинные, острые и окровавленные
console.log(blackRabbit.teeth);
// → мелкие
console.log(Rabbit.prototype.teeth);
// → мелкие

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

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

Также она используется для назначения функциям и массивам разных методов toString.

console.log(Array.prototype.toString == Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

Вызов toString массива выводит результат, похожий на .join(«,») – получается список, разделённый запятыми. Вызов Object.prototype.toString напрямую для массива приводит к другому результату. Эта функция не знает ничего о массивах:

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

Нежелательное взаимодействие прототипов

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

Rabbit.prototype.dance = function() {
  console.log("А " + this.type + " кролик танцует джигу.");
};
killerRabbit.dance();
// → А убийственный кролик танцует джигу.

Это удобно. Но в некоторых случаях это приводит к проблемам. В предыдущих главах мы использовали объект как способ связать значения с именами – мы создавали свойства для этих имён, и давали им соответствующие значения. Вот пример из 4-й главы:

var map = {};
function storePhi(event, phi) {
  map[event] = phi;
}

storePhi("пицца", 0.069);
storePhi("тронул дерево", -0.081);

Мы можем перебрать все значения фи в объекте через цикл for/in, и проверить наличие в нём имени через оператор in. К сожалению, нам мешается прототип объекта.

Object.prototype.nonsense = "ку";
for (var name in map)
  console.log(name);
// → пицца
// → тронул дерево
// → nonsense
console.log("nonsense" in map);
// → true
console.log("toString" in map);
// → true

// Удалить проблемное свойство
delete Object.prototype.nonsense;

Это же неправильно. Нет события под названием “nonsense”. И тем более нет события под названием “toString”.

Занятно, что toString не вылезло в цикле for/in, хотя оператор in возвращает true на его счёт. Это потому, что JavaScript различает счётные и несчётные свойства.

Все свойства, которые мы создаём, назначая им значение – счётные. Все стандартные свойства в Object.prototype – несчётные, поэтому они не вылезают в циклах for/in.

Мы можем объявить свои несчётные свойства через функцию Object.defineProperty, которая позволяет указывать тип создаваемого свойства.

Object.defineProperty(Object.prototype,