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

Улики способны превращаться в привычку. К примеру, находишь ты на месте преступления деревянную ногу, шелковую балетную тапочку и перышко, и в голове мгновенно выстраивается элегантная теория об одноногом артисте балета и постановке «Куриного озера».

—Терри Пратчетт

В детстве я прочел много книг про частных детективов и почти ни одной про программистов (книга “Понедельник начинается в субботу” — определённо про программистов). Тем не менее, когда я вырос, то стал работать не частным детективом, а программистом, хотя страсть к расследованиям до сих пор со мной. И как оказалось, в программировании всегда найдется работа для частного детектива. Одно из самых захватывающих расследований, в которых мне удалось недавно поучаствовать — мрачная тайна, окружающая тему наследования в JavaScript.

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

Программист во мне говорил — просто ты не умеешь общаться с людьми, частный же детектив шептал — тут что-то нечисто! Они что-то недоговаривают… Здесь есть тайна!

А, раз есть тайна, значит, есть работа для частного детектива! Пора открывать дело №… “Что не так с наследованием?”. Время искать улики и тянуть за все ниточки.

noun_Detective_1098021

Какие же зацепки мы имеем? Время преступления — началось это достаточно давно, лет 20 назад. Место тоже известно — JavaScript. Но JavaScript это слишком обобщенно. Уточним, что же под ним подразумевается?

TypeScript — язык программирования, появился в 2013 году (молод, амбициозен, характер мелкий, мягкий), типизированное надмножество ES6.

ES6 — он же ECMAScript 6, он же ECMAScript 2015, он же JavaScript 6, он же JavaScript 1.6 — значительное расширение ES5.

ES5 — он же ECMAScript 5, он же JavaScript 5, он же JavaScript 1.5. Часто, когда говорят JavaScript, имеет в виду именно его.

Целая семейка и очень подозрительная. Чувствую, что взял правильный след!

Модель наследования была заложена в самых первых версиях языка и она, что бы это не значило, прототипная. А это уже похоже на зацепку — попробуем копнуть поглубже. Классическая модель наследования — это объектная модель на основе классов, когда существует иерархия классов, а может даже и интерфейсов. Классов в ES5 нет, хотя слово class и является зарезервированным для будущего использования. И тут, в нашем расследовании появляется первая улика.

Улика 1: всё в JavaScript — объекты

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

Чтобы создать новый объект (нет смысла говорить слово инстанс так как классов у нас все-равно нет), нужно применить оператор new к какой-нибудь функции. Между прочим, функции, как и все, что есть в javascript — это объекты, но не все объекты функции. И если все объекты созданы из функций, то возникает логический парадокс курицы и яйца — кто же создал первую функцию, если она тоже объект? К счастью, есть нативный способ создать функцию: (function () {}).

У любого объекта может присутствовать свойство __proto__. Вообще-то оно было специфицировано только в ES6, и до этого в разных реализациях называлось по-разному. Чтобы избежать путаницы, это свойство называли ссылкой на прототип и записывают вот так: [[Prototype]], что добавляет еще больше путаницы. Так же, есть прото-независимые методы Object.getPrototypeOf() и Object.setPrototypeOf(), возвращающие и устанавливающие значение свойства [[Prototype]], но это нам сейчас не очень интересно, а интересно нам вот что — цепочка прототипов.

Улика 2: если у объекта задано свойство __proto__, значит, у него имеется прототип

У которого, в свою очередь, тоже может быть прототип, у которого… При вызове искомого (мне нравится это слово) свойства объекта, если искомое свойство отсутствует в самом объекте, оно будет искаться по всей цепочке прототипов (Prototype chain), пока не найдется подходящий объект или пока цепочка не закончится на объекте у которого __proto__ === null.

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

function new(Fn) {
var instance = {};
instance.__proto__ = Fn.prototype;
var result = Fn.apply(instance, arguments);
return result instanceof Object ? result : instance;
}

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

Улика 3: у функций есть свойство prototype

Очень кстати, у нас под рукой есть функцияfunction Fn() { this.answer = 42; }. Эта функция является объектом (спасибо, кэп!) созданным нативным конструктором Function и у нее есть несколько предустановленных свойств. Впрочем, сейчас меня интересует только одно свойство Fn.prototype = {constructor: Fn}. Это объект, который в поле constructor хранит ссылку на саму функцию Fn.

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

Итак, собственно, акт созидания:
function Fn() { this.answer = 42; }
var ob = new Fn();
или, в нашем случае, можно опустить скобки — у конструктора Fn нет параметров:
var ob = new Fn;

Тадам! Мы получили новый объект у которого есть свойство ob.answer === 42, и в его цепочку прототипов был скопирован конструктор: ob.__proto__.constructor === Fn.

Глядя на код оператора new, можно также заметить, что

  1. Если функция-конструктор ничего не возвращает, то будет создан новый объект типа Fn.
  2. Если наша функция-конструктор возвращает не объект, а, например, число, то это значение будет проигнорировано и создан новый объект типа Fn.
  3. Если функция-конструктор возвращает объект, то именно этот объект будет возвращен оператором new и вряд ли он будет типа Fn.

Улика 4: типы объектов и функция-конструктор

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

Той самой, которую мы только что назвали другим объектом, и той самой, которую принято записывать с Большой буквы. Поэтому верны оба утверждения:
ob instanceof Fn; // true
ob instanceof Object; // true

Кажется, стало немного понятнее. А ведь им почти удалось меня запутать меня всеми этими прототипами, прототайпами и разными прочими прото. И, теперь, совсем уж яснее ясного, почему
ob.__proto__.__proto__.__proto__ === null

Первое прото это наш {constructor: Fn}, второе — его прототип Object.prototype, а третье пустое это конец цепочки прототипов.

Есть еще один способ создать объект — литеральная (инициирующая) нотация. Она немного похожа на JSON, но менее строгая.
var ob = {answer: 42};

Новый объект obбудет унаследован от Object.prototype, и, если, это звучит как тавтология, то самое время разобраться, что же такое Object и Object.prototype.

Object() — это функция-конструктор, которую мы уважительно пишем с Большой буквы, и у которой (помним улику 3) есть свойство prototype. Функция Object, вдобавок, содержит множество полезных методов. Внимательный читатель легко вспомнит Object.getPrototypeOf() и Object.setPrototypeOf().

Object.prototype — это то свойство prototype, которое есть у функций (улика 3) и которое при вызове оператора new будет скопировано в instance.__proto__. Именно в Object.prototype находятся методы вроде toString()или hasOwnProperty(), которые новый объект типа Objectполучает “из коробки”.

Практически все объекты в JavaScript относледованы от Object.prototype, хотя это необязательное условие. Можно создать объект без прототипа (__proto__) или с прототипом, который не является instanceof Object. В этом случае и сам объект не будет являться instanceof Object. Улика 1 при этом не отменяется, никто и не обещал что каждый объект будет типа Object.

Говорят, что всех людей можно разделить на три категории: на тех, кто умеет считать, и на тех, кто не умеет. Так и все объекты в JavaScript тоже можно разделить на три категории, что вряд ли может быть просто совпадением. Объекты базового типа (из спецификации ECMAScript, такие как Objectили Function), объекты среды выполнения (браузера или node.js, такие как window или global) и пользовательские объекты (наше всё).

Гм, похоже я взял ложный след, расследование буксует, новых улик не прибавилось. Но раз мы добрались до методов Object, стоит попристальнее взглянуть на метод Object.create(proto[, propertiesObject]) — он создает новый объект с указанными прототипом [и свойствами]. Значит, чтобы создать новый объект, нам больше не нужна функция-конструктор. Это позволяет удобно выстраивать цепочки прототипов.

var car = {wheels: 4};
var hondaCar = Object.create(car);
hondaCar.driveSide = 'right';

На самом деле, внутри Object.create() вызывается функция-конструктор и к ней применяется оператор new, просто снаружи этого не видно.

Отлично! Теперь можно построить удобную иерархию прототипов! Эх, заживем! Вот только…

Улика 5: наследование в ECMAScript 5 не декларативное

Все прототипы мы объявляем в рантайме, а значит на этапе разработки никакая IDE не сможет разобраться, что за иерархию мы выстроили. Работать будет, но сопровождать такой код будет весьма непросто. Код пишется один раз, а читается многократно.

Сложночитаемыйкодчертовскибольшаяпроблема — чтобы позволять себе его писать. На поддержку тратится уйма времени, сил и душевного равновесия. Как частный детектив, я не советую никому пользоваться наследованием в ECMAScript 5, разве только вы пишите очень специфичный код, разрабатываете новый фреймворк или хотите отдать код на ревью вашему злейшему врагу.

Недавно ко мне в гости зашли друзья с шестилетней дочкой. Обычно, мой кот не реагирует на взрослых людей и он никогда раньше не встречался с подростками. Но, похоже, в него уже была заложена программа поведения при встрече с детьми. Сперва он попытался исчезнуть, был обнаружен под диваном, вздыбил шерсть, пошипел, улизнул на балкон, где и просидел все время прикинувшись ветошью. Мне кажется, что такая защитная реакция встроена во всех котов на генетическом уровне. Имеется она и у javascript-разработчиков, когда они слышат про наследование. И, кажется, теперь я знаю почему.

Примерно с 2015 году ситуация изменилась. Вышел ECMAScript 6, в котором сохранились все механизмы прототипного наследования и был добавлен синтаксический сахар — классы. Вроде бы мелочь, подумаешь, сахар! Но он предоставляет возможность для IDE строить иерархию классов на этапе разработки.

Хотя мы то с вами (дважды постучал себя по носу) знаем, что на самом деле это не классы, а та самая функция-конструктор с Большой буквы. Именно поэтому в ES6 конструктор может быть только один на класс.

Вместо заключения

Прототипная модель наследования в ES5 предоставляет универсальные низкоуровневые механизмы для реализации более близких к классической моделей наследования в ES6 и TypeScript. Не стоит использовать эти механизмы напрямую. Модели наследования в ES6 и TypeScript, напротив, хороши и удобны. В них есть полиморфизм, инкапсуляция, data hiding — лучшие друзья ООП. Смело пользуйтесь ими, только не забывайте о правильном выборе абстракции, впрочем это уже совсем другое Дело №…

===

Автор: Антон Карначук