По этой статье я делал доклад в апреле 2012 на "Coffee & Code" в г. Донецке. Спустя несколько месяцев я написал библиотеку TruePrototyping.js, реализующую описанную в статье идею и пригодную для практического использования.
В этой статье я попытаюсь показать, что прототипное наследование - очень простая идиома, при этом, по мощности своей не уступающая классическому наследованию, и даже превосходящая его.
Без сомнения, javascript - самый распространённый язык, в котором используется прототипное наследование. Но, к сожалению, "традиционный" инструментарий наследования в javascript несколько запутанный. Я хочу рассмотреть более простой и понятный способ.
Вот пара статей, довольно внятно объясняющих, как реализовано наследование в javascript:
Вот статья и пост, обличающие некоторую путаницу в ООП-инструментарии языка javascript:
Вот несколько статей, рассматривающих альтернативный, менее запутанный, подход:
Основная суть этого "альтернативного подхода", изложена (с примерами кода на javascript) даже в википедии, в статье о прототипном наследовании - там это называется "true prototypal inheritance style" - не я это так назвал :).
Так о чём тогда я собираюсь говорить - всё ведь уже сказано?!
Во-первых, я до этого дошёл сам ("изобрёл это колесо", если хотите), и только потом, уже чётко понимая, до чего я дошёл, нагуглил внятное и недвусмысленное изложение этой идеи. Пока я доходил, я обзавёлся собственными "переживаниями" на этот счёт. Я хочу поделиться с вами своей собственной аргументацией того, зачем ЭТО нужно - может, для кого-то эта аргументация окажется достаточно веской или более доступно изложенной.
Во-вторых, возможно кто-то из вас этого не знал или знал, но не обратил на это внимания. Лично я год тому назад этого не знал. Рассматриваемвй подход не особо популярен. Возможно, вскоре ситуация изменится (забегая вперёд, скажу, что последний стандарт ECMAScript [на момент написания статьи это был ES5] к этому располагает).
И наконец, я попытаюсь предложить некоторое улучшение этого альтернативного подхода.
В области разработки ПО постоянно происходит поиск путей борьбы со сложностью. Появляются библиотеки и языки, которые предлагают решать задачи более просто, компактно, делают программу более лёгкой для осознания, создания и сопровождения. Грубо говоря, большинство программистов превращаюися в DSL-юзеров.
Если говорить о языках программирования, то лично для меня язык тем лучше, чем меньшим количеством операторов, ключевых слов и абстракций он позволяет решать задачу (конечно, в пределах разумного - не советуйте мне удовлетворить мою страсть программированием с ипользованием исключительно единиц и нулей ;)).
Я считаю язык javascript неплохим примером того, как мощные по функциональности вещи могут выглядеть достаточно просто. Как это ни странно, это обстоятельство играет злую шутку с самим javascript - некоторым людям кажется, что он слишком прост для серьёзных задач. Я много раз слышал что-то наподобие "javascript - это язык сценариев" или "javascript - это простенький язык для написания обработчиков событий в браузере".
Я часто слышу от коллег, что javascript "многословен"... Пожалуй, он длиннословен, но не многословен! Да, вездесущее слово "function" могло бы быть и покороче. Зато оно интуитивно понятно (согласитесь - понятнее, чем "def" и даже чем "->"). Но в javascript очень многие вещи делаются с помощью довольно короткого "словаря терминов", заметно более короткого, чем в больштнстве объектно-ориентированных языков. Я утверждаю, что javascript - один из лучших образцов достаточно лаконичного языка програмиирования, который при этом является вполне читаемым, а не похож на шифровки Юстаса Алексу (как, например, Perl).
Например, многие действия по манипуляции с объектами возможно выполнить при помощи двух операторов: "." (или "[]" как динамической разновидности точки) и "=", тогда как в других языках требуется прибегать к использованию тьмы ключевых слов, операторов и прочих приёмов.
Приведу несколько простых примеров - я не знаю, в каком языке они выглядят лаконичнее и интуитивно понятнее:
1) Имеется объект obj, нужно (на этапе выполнения) добавить в него свойство age со значением 5:obj.age = 5; // не надо никакого "instance_variable_set", "Emit", и, уж тем более, перекомпиляции
var value = obj[propName]; // не надо никакого "send" и, уж тем более, "InvokeMember" с десятком параметров
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 - с помощью оператора 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" (мало пользы в такой информации)
Что мы имеем (зелёным цветом показана истинная цепь прототипов, всё остальное - "серый шум"):
Мне это кажется противоречивым, и я бы предпочёл не иметь ТАКИХ операторов new и instanceof, и ТАКОГО свойства prototype.
Также есть ещё одна неприятность, связанная со свойством constructor. Из объекта p1 обращение к непосредственному его прототипу возможно через p1.constructor.prototype (кстати, довольно-таки через околицу...). Но и это легко испортить:
var Person = function(name){ this.name = name; }; var Obj = {}; Person.prototype = Obj; // автоматический ПРОТОТИП ЗАМЕНЁН на другой объект var p1 = new Person('person'); p1.prototype; // undefined, по-прежнему очень жаль p1.constructor == Person; // false - потому что Obj.constructor == Object (через Obj.__proto__) p1.__proto__.constructor == Person; // false (по той же причине) p1 instanceof Person; // true typeof p1; // object, по-прежнему пользы мало
Стандартного доступа из объекта p1 к его прототипу больше нет.
Вот полученная иерархия объектов (зелёным цветом показана истинная цепь прототипов, всё остальное - "серый шум"):
Автоматический Person.prototype заменён на Obj - p1.constructor стал указывать на Object... Я бы предпочёл не иметь ТАКОГО свойства constructor.
Чтобы конструкция p1.constructor.prototype продолжала работать по-прежнему, при замене прототипа придётся устанавливать свойство constructor вручную:
var Obj = { }; Person.prototype = Obj; Obj.constructor = Person;
Что я предлагаю? Предлагаю забыть оператор new, свойства constructor и prototype, а также операторы typeof и instanceof.
Я хочу использовать прототипное наследование без выше рассмотренного "серого шума" и "раздвоения личностей".
Существует свойство __proto__. Оно не предусмотрено стандартом ECMAScript (изначально это дело рук разработчиков Mozilla, на данный момент оно работает в Firefox, Safari, Chrome - не знаю, начиная с каких версий), поэтому я не предлагаю его использовать, хотя его использование даёт максимум гибкости и компактности кода. Сейчас я буду использовать это свойство только лишь чтобы продемонстрировать суть решения:
var Person = { name: 'Abstract person', ancestorCount: function(){ return (this.__proto__ && this.__proto__.ancestorCount) ? this.__proto__.ancestorCount() + 1 : 0; } }; var Man = { __proto__: Person, name: 'Abstract man' }; var father = { __proto__: Man, name: 'Father' }; var son = { __proto__: father, name: 'Son' }; // Посмотрим, что получилось Person.test = function(){ console.info('My name is: ', this.name); console.info('ancestorCount: ', this.ancestorCount()); console.info('Person is my ancestor: ', Person.isPrototypeOf(this)); console.info('Man is my ancestor: ', Man.isPrototypeOf(this)); console.info('father is my ancestor: ', father.isPrototypeOf(this)); console.info('My immediate parent:', Object.getPrototypeOf(this)); console.info('***************'); }; Person.test(); Man.test(); father.test(); son.test();
С помощью свойства __proto__ организован однонаправленный связный список - и ничего более не требуется!
Вот полученная иерархия объектов (зелёным цветом показана истинная цепь прототипов, серого шума нет*):Person - это прототип для Man, Man - прототип для father, father - прототип для son, и нет никаких отдельно стоящих, косвенно причастных объектов. Такое положение вещей кажется мне легко понятным и непротиворечивым.
*Если не хотите, чтобы предком Person был Object.prototype, нужно просто присвоить null свойству Person.__proto__:
var Person = { __proto__: null, name: 'Abstract person', ... };
Чем мне нравится такой подход:
Отлично, но свойство __proto__ нестандартизированное. Базировать свою программу на том, чего может не стать без предупреждения, не очень-то хорошо.
В браузерах, реализующих стандарт ECMAScript 5th Edition (JavaScript 1.8.5, далее - ES5), есть метод Object.create. Это единственный стандартный способ присвоить прототип объекту, не используя оператор new. Используя этот способ, можно строить цепь прототипов без побочных сущностей, как это было сделано выше при непосредственном использовании свойтсва __proto__.
Метод Object.create создаёт и возвращает новый объект, делая его прототипом свой первый параметр. Таким образом, присвоить прототип объекту можно только при его создании (как и в случае использования оператора new). Во время жизни объекта нет возможности заменить ему прототип (вдруг превратить сына в брата), о чём, наверное, немногие пожалеют, а кто-то даже порадуется. Но, вообще-то, возможность замены прототипа во время жизни объекта считается важной характеристикой в теории прототипного наследования.
Выше приведенная программа с использованием метода Object.create будет выглядеть вот так:
var Person = { name: 'Abstract person', ancestorCount: function(){ return (this.__proto__ && this.__proto__.ancestorCount) ? this.__proto__.ancestorCount() + 1 : 0; } }; var Man = Object.create(Person); Man.name = 'Abstract man'; var father = Object.create(Man); father.name = 'Father'; var son = Object.create(father); son.name = 'Son'; // Посмотрим, что получилось Person.test = function(){ console.info('My name is: ', this.name); console.info('ancestorCount: ', this.ancestorCount()); console.info('Person is my ancestor: ', Person.isPrototypeOf(this)); console.info('Man is my ancestor: ', Man.isPrototypeOf(this)); console.info('father is my ancestor: ', father.isPrototypeOf(this)); console.info('My immediate parent:', Object.getPrototypeOf(this)); console.info('***************'); }; Person.test(); Man.test(); father.test(); son.test();
Результат тот же (см. схему при использовании свойства __proto__).
В сравнении с "традиционным" способом создания объекта через конструктор, последний вариант программы, во-первых, является более "чистым" семантически (то есть, не порождает лишних сущностей, чётко соответствует идее прототирного наследования), во-вторых, просто компактнее и интуитивно понятнее (я сейчас опускаю все свойства, чтобы максимально упростить программу и сосредоточиться только лишь на коде построения иерархии наследования):
Вариант 1 (создание объектов с помощью оператора new) |
Вариант 2 (создание объектов с помощью метода Object.create) |
---|---|
Person = function(){}; Man = function(){}; Man.prototype = new Person(); Father = function(){}; Father.prototype = new Man(); father = new Father(); Son = function(){}; Son.prototype = new Father(); son = new Son(); |
Person = {}; Man = Object.create(Person); father = Object.create(Man); son = Object.create(father); |
На мой взгляд, Вариант 2 даже внешне выглядит привлекательнее.
Но есть две небольших неприятности.
1) Метод Object.create отсутствует браузерах, не реализующих стандарт ES5. Для решения этой проблемы предлагается использовать вот такой "путь для отступления":
if (!Object.create) { Object.create = function (proto) { if (arguments.length > 1) { throw new Error('Object.create implementation only accepts the first parameter.'); } function F() {} F.prototype = proto; return new F(); }; }
Это упрощённая реализация метода Object.create, она принимает только один параметр - прототип создаваемого объекта. Разумеется, можно реализовать и более полную версию.
Исходный код клиентской программы останется таким же. Использование такого метода Object.create породит "серый шум", от которого я избавлялся на протяжении всей статьи (а именно - объект F), но, всё-таки, Man будет предком объекта father, а не "каким-то странным родственником":
2) В выше приведенном примере программы с использовани Object.create свойства присваиваются объекту императивно после его создания. Такой стиль лично мне не очень нравится, я предпочитаю JSON. Вторым параметром методу Object.create можно передать все необходимые свойства, но ради гибкости структура этого объекта довольно громоздкая. Для того, чтобы создать объект obj и проинициализировать его "привычными" writable и enumerable свойствами (называемыми "ECMAScript 3 property") name, age и weight, нужно написать вот такой код:
obj = Object.create(Man, { name: { value: 'Vasya', writable: true, enumerable: true }, age: { value: 25, writable: true, enumerable: true }, weight: { value: 80, writable: true, enumerable: true } });
Жить можно, но приятного мало. Можно скрыть лишнюю сложность, например, так:
Object.spawn = function(proto, properties){ var o; if(Object.create){ // ES5 o = Object.create(proto); } else { // ES3 function F() {} F.prototype = proto; o = new F(); } for(p in properties){ if(properties.hasOwnProperty(p)){ o[p] = properties[p]; } } return o; };
Теперь прикладной код выглядит проще:
obj = Object.spawn(Man, { name: 'Vasya', age: 25, weight: 80 });
То есть, метод Object.spawn (предлагается как заменитель метода Object.create) - это синтаксический сахар для простых случаев, когда не требуется особого конфигурирования свойств.
А можно ещё слаще:
Object.prototype.spawn = function(properties){ return Object.spawn(this, properties); }; // или, если не хотите трогать Object.prototype (я же предлагал забыть о свойстве prototype совсем): Person.spawn = function(properties){ return Object.spawn(this, properties); };
Теперь прикладной код может выглядеть так:
obj = Man.spawn({ name: 'Vasya', age: 25, weight: 80 });
В общем, бросайте сахара столько ложечек, сколько Вам угодно - javascript позволяет это делать легко и просто.
Итак, цель достигнута:
var Person = { name: 'Abstract person' }; var Man = Person.spawn({ name: 'Abstract man' }); var father = Man.spawn({ name: 'Father' }); var son = father.spawn({ name: 'Son' });
Object.getPrototypeOf(son) == father; // true
Object.prototype.parent = function(){ return Object.getPrototypeOf(this); }; son.parent().parent() == Man; // true
Резюме сути предложенного:
Забыть | Использовать |
---|---|
Оператор new | Метод Object.create |
Свойства constructor и prototype, оператор typeof | Метод Object.getPrototypeOf |
Оператор instanceof | Метод Object.isPrototypeOf |
В предлагаемом подходе наследуются ВСЕ свойства, что, в общем случае, может быть нежелательно. Это логично, что сын наследует от отца фамилию. Но, наверное, нелогично, если он будет наследовать имя или вес.
Я предлагаю решение, суть которого - для всех ненаследуемых свойств обеспечить в потомках присутствие этого свойства со значением null (или undefined?). Об этом будет в другой статье.
Сын является объектом, производным не только от отца, но и от матери. Он наследует фамилию от отца (по умолчанию) в силу патриархата. Но он вполне может наследовать цвет волос от матери. Или же сын, наследуя какие-то физиологические характеристики от отца, в то же время, является программистом, а значит наследует какие-то характеристики, общие для всех программистов. Вы можете придумать сколько угодно куда более удачных примеров, когда множественное наследование (или использование примесей) уместно.
К сожалению, в javascript нет встроенной поддержки множественного наследования. О том, как с этим быть, я попробую порассуждать в другой статье.
Существует много решений, позволяющих писать код примерно такой же компактности, как предложил я. Например: KevLinDev Inheritance, Prototype.Class, base2, Simple JavaScript Inheritance by John Resig, jQuery.Class. Если они и схожи с тем, что предлагаю я, то только по степени компактности. Но по сути своей упомянутые решения эмулируют классическое наследование (их так и называют - "classical-inheritance-simulating techniques" или "class-like systems"), а я предлагаю мыслить исключительно прототипами, избавившись от "серого шума".
Очевидно, я не вещаю непоколебимую истину. Я просто изложил мои представления об ООП без лишней сложности и путаницы.
Отзывы и замечания можно присылать на адрес sergey.ischenko.78@gmail.com
Изображения, представленные в статье, не идеальны, но в них интересно то, что они сделаны с помощью очень простого в использовании онлайн-инструмента для создания UML-диаграмм. Вот пример исходного кода м полученной картинки:
[Person{bg:green}]<-__proto__[Man{bg:green}] [Man]<-__proto__[father{bg:green}] [father]<-__proto__[son{bg:green}]
Сюда, с целью разгрузки основного тела статьи, вынесена информация, имеющая второстепенное значение; статья содержит соответствующие ссылки.
using System; using System.Reflection; class A { public static int test(){ return 5; } public static string f = "asdf"; public static void Main(){ A a = new A(); // a - instance of class A Type c = a.GetType(); // c - "Class object" of class A int i = (int)c.InvokeMember("test", BindingFlags.Default | BindingFlags.InvokeMethod, null, null, new object[] { }); Console.WriteLine(i); // 5 string s = (string)c.InvokeMember("f", BindingFlags.Default | BindingFlags.GetField, null, null, new object[] { }); Console.WriteLine(s); // asdf } }
Оператор instanceof возвращает true, если в цепи прототипов первого операнда есть объект, на который указывает свойство prototype второго операнда. Попробую обмануть instanceof:
var Person = { name: 'Abstract person', prototype: this // чтоб instanceof моего истинного потомка возвращал true }; var p1 = { __proto__: Person, name: 'Test person' }; p1 instanceof Person; // TypeError: invalid 'instanceof' operand Person
В браузерах, не реализующих стандарт ECMAScript 5th Edition, метода Object.getPrototypeOf нет. Эту проблему можно решить следующим образом:
if ( typeof Object.getPrototypeOf !== "function" ) { if ( typeof "test".__proto__ === "object" ) { Object.getPrototypeOf = function(object){ return object.__proto__; }; } else { Object.getPrototypeOf = function(object){ // May break if the constructor has been tampered with return object.constructor.prototype; }; } }