Простое и понятное прототипное наследование

Сергей Ищенко, Март 2012

По этой статье я делал доклад в апреле 2012 на "Coffee & Code" в г. Донецке. Спустя несколько месяцев я написал библиотеку TruePrototyping.js, реализующую описанную в статье идею и пригодную для практического использования.


Предисловие

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

Без сомнения, javascript - самый распространённый язык, в котором используется прототипное наследование. Но, к сожалению, "традиционный" инструментарий наследования в javascript несколько запутанный. Я хочу рассмотреть более простой и понятный способ.

Вот пара статей, довольно внятно объясняющих, как реализовано наследование в javascript:

Вот статья и пост, обличающие некоторую путаницу в ООП-инструментарии языка javascript:

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

Основная суть этого "альтернативного подхода", изложена (с примерами кода на javascript) даже в википедии, в статье о прототипном наследовании - там это называется "true prototypal inheritance style" - не я это так назвал :).

Так о чём тогда я собираюсь говорить - всё ведь уже сказано?!

Во-первых, я до этого дошёл сам ("изобрёл это колесо", если хотите), и только потом, уже чётко понимая, до чего я дошёл, нагуглил внятное и недвусмысленное изложение этой идеи. Пока я доходил, я обзавёлся собственными "переживаниями" на этот счёт. Я хочу поделиться с вами своей собственной аргументацией того, зачем ЭТО нужно - может, для кого-то эта аргументация окажется достаточно веской или более доступно изложенной.

Во-вторых, возможно кто-то из вас этого не знал или знал, но не обратил на это внимания. Лично я год тому назад этого не знал. Рассматриваемвй подход не особо популярен. Возможно, вскоре ситуация изменится (забегая вперёд, скажу, что последний стандарт ECMAScript [на момент написания статьи это был ES5] к этому располагает).

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

Javascript - мощное может быть простым

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

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

Я считаю язык javascript неплохим примером того, как мощные по функциональности вещи могут выглядеть достаточно просто. Как это ни странно, это обстоятельство играет злую шутку с самим javascript - некоторым людям кажется, что он слишком прост для серьёзных задач. Я много раз слышал что-то наподобие "javascript - это язык сценариев" или "javascript - это простенький язык для написания обработчиков событий в браузере".

Я часто слышу от коллег, что javascript "многословен"... Пожалуй, он длиннословен, но не многословен! Да, вездесущее слово "function" могло бы быть и покороче. Зато оно интуитивно понятно (согласитесь - понятнее, чем "def" и даже чем "->"). Но в javascript очень многие вещи делаются с помощью довольно короткого "словаря терминов", заметно более короткого, чем в больштнстве объектно-ориентированных языков. Я утверждаю, что javascript - один из лучших образцов достаточно лаконичного языка програмиирования, который при этом является вполне читаемым, а не похож на шифровки Юстаса Алексу (как, например, Perl).

Например, многие действия по манипуляции с объектами возможно выполнить при помощи двух операторов: "." (или "[]" как динамической разновидности точки) и "=", тогда как в других языках требуется прибегать к использованию тьмы ключевых слов, операторов и прочих приёмов.

Приведу несколько простых примеров - я не знаю, в каком языке они выглядят лаконичнее и интуитивно понятнее:

1) Имеется объект obj, нужно (на этапе выполнения) добавить в него свойство age со значением 5:
obj.age = 5;                  // не надо никакого "instance_variable_set", "Emit", и, уж тем более, перекомпиляции
2) Имеется объект obj, нужно зачитать значение его свойства с именем, содержащимся в строковой переменной propName:
var value = obj[propName];    // не надо никакого "send" и, уж тем более, "InvokeMember" с десятком параметров
3) У объекта obj имеется метод m. требуется переопределить его так, чтобы в новой реализации использовать старую:
obj.oldM = obj.m;             // не надо никакого "unbind" или "method_alias_chain"
obj.m = function(){           // не надо никакого "bind" или "define_method"
  ...
  this.oldM();
  ...
};

// или даже так:

var oldM = obj.m;             // локальная переменная
obj.m = function(){
  ...
  oldM();                     // замыкание на локальную переменную
  ...
};

Конечно, этому способствуют такие свойства javascript, как:

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

Что плохого в классическом наследовании?

Что такое классическое наследование при поверхностном рассмотрении? Есть две сущности: КЛАСС и ОБЪЕКТ. КЛАСС - абстрактная сущность, описывающая структуру и поведение и существующая только в исходном коде. На этапе выполнения программы класс может быть многократно инстанцирован в объект, имеющий эту структуру и реализующий это поведение. Например, если в программе определён класс Man и создан его экземпляр Vasya, то на этапе выполнения существует только объект Vasya как воплощение класса Man. То есть, класс и экземпляр соотносятся как чертёж детали и собственно деталь, изготовленная по этому чертежу.

Если считать, что на этом всё и заканчивается, то всё достаточно просто и понятно.

Первое сомнение закрадывается в связи с понятием "статический член класса". В классе разрешается объявить поле, которое разделяется между всеми экземплярами этого класса. Значит это поле где-то "живёт" на этапе выполненния?! Значит есть где-то в выполняющейся программе некий объект, представляющий этот класс, но который не является экземпляром этого класса.

Эта двойственность становится ещё очевиднее с появлением полноценной рефлексии (Java, C#). Возникает явное (вместо мистического и невидимого, как в C++) понятие "объект класса" с примечанием "не путать с экземпляром". Объект класса является носителем метаинформации о классе (имени класса, его структуре, его методаx), а также носителем значений статических полей класса.

Итак, сущность КЛАСС на этапе выполнения программы раздваивается: она воплощается частично в ЭКЗЕПЛЯР(ы), а частично в отдельный ОБЪЕКТ КЛАССА, каким-то образом соотносящийся со всеми экземплярами этого класса:

Штриховой линией я показал связи между сущностями, существующие только абстрактно. Хорошо, даже если сущности КЛАСС убрать из этой схемы как нечто, существующее только в исходном коде, а на этапе выполнения воплощенное в ОБЪЕКТ КЛАССА и ЭКЗЕМПЛЯРЫ, какая связь между Man class object и Person class object? Первый наследуется от второго? По логике - вроде, да. По факту - нет...

Кроме того, сын и отец - это не просто мужчины, сын от отца хоть что-то да наследует. Например, фамилию (то есть, не только факт наличия поля Surname, а значенние этого поля). Значит придётся создать ещё один класс Pupkin, чтобы сделать его носителем наследуемой фамилии, в конструкторе класса Pupkin придётся сохранить значение "Пупкин" в поле Surname, чтобы все потомки владели им по умолчанию:

К такому подходу можно привыкнуть и жить с этим - в конце-концов, тысячи программистов этим пользуются!

Но нельзя ли как-то проще?

Идея прототипного наследования

Я считаю саму идею прототипного наследования до гениальности простой и понятной заменой классическому наследованию. Прототипное наслелование бывает двух разновидностей: 1) путём делегирования, 2) путём каскадирования (копирования, клонирования). Я буду говорить о первом (именно оно реализовано в ECMAScript). Итак, суть в следующем:

Рассмотренная выше модель при прототипном наследовании:

Я назвал объекты Person и Man с большой буквы, чтобы было нагляднее, где в классическом наследовании использовались бы классы. Если Вам так удобнее мыслить - можете называть их "классами", но father и son ничем технически не отличаются от Man. Обратите внимание на отсутствие объекта Pupkin! Его роль (носителя фамилии) выполняет father - и это ведь логично!?

Мне кажется, что эта модель значительно ближе к естественному человеческому мышлению, чем рассмотренная ранее модель при классическом наследовании. Если случайного человека попросить изобразить графически понятия Человек, Мужчина, Отец и Сын в порядке "от общего к частному", то, скорее всего, он не станет выдумывать какие-то дополнительные сущности.

Критика "традиционных" инструментов управления наследованием в javascript

В javascript используется делегирующее прототипное наследование. Но, к сожалению, его использование организовано так, что это отпугивает или, по крайней мере, конфузит многих программистов. По этой причине многие считают, что прототипное наследование сложнее и запутаннее классического. Я утверждаю, что это не так.

В чём проблема? Дело в том, что изначально предложенный и много лет используемый способ доступа к цепи прототипов и управления ею в javascript, таков, что двойственность, описанная в разделе "Критика классического наследования" продолжает присутствовать.

Один из двух способов создания объекта в javascript - с помощью оператора new, которому в качестве операнда передаётся функция-инициализатор. Свойство prototype этой функции указывает на автоматически создаваемый объект, который будет выступать в роли прототипа для объекта-возврата оператора new:

var Person = function(name){
  this.name = name;
};

var p1 = new Person('Vasya');

p1.prototype; // undefined, жаль - было бы логично, чтоб свойство с именем "prototype" указывало на прототип объекта p1
p1.constructor == Person; // true
p1 instanceof Person; // true
typeof p1; // "object" (мало пользы в такой информации)

Что мы имеем (зелёным цветом показана истинная цепь прототипов, всё остальное - "серый шум"):