В отличие от классического наследования, прототипное наследование не обязательно имеет дело с увеличивающимися уровнями абстракции, а именно:
- объект может быть как абстракцией реальной вещи (как и в классическом наследовании), так и прямой копией другого объекта (прототипа (Prototype));
- объекты могут быть созданы сами по себе, или они могут быть созданы из других объектов.
Если взять пример из классического наследования (например, "ТУФЛИ" => "МУЖСКИЕ ТУФЛИ" => "МУЖСКИЕ ЛЕТНИЕ ТУФЛИ"), то вряд ли удастся избежать иерархии абстракций:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let shoes = { a: 1 }; let mensShoes = Object.create(shoes); let mensSummerShoes = Object.create(mensShoes); // все созданные объекты - независимые: console.log(shoes === mensShoes); // false console.log(mensShoes.__proto__); // {a: 1} console.log(mensShoes.a); // 1 shoes.a = 2; mensShoes.a = 3; console.log(mensShoes.__proto__); // {a: 2} console.log(mensShoes.a); // 3 |
Главное отличие указанного выше способа наследования от классического способа: shoes, mensShoes, mensSummerShoes - независимые объекты (просто одни объекты созданы от других, одни объекты наследуют свойства от других объектов). А при классическом наследование обобщения являются абстракциями абстракций вплоть до самого последнего потомка.
При использование парадигмы прототипного наследования программист имеет дело только с объектами и при этом у него есть возможность создавать сущности в одном (горизонтальном) уровне абстракции.
JavaScript позволяет комбинировать обе формы наследования для достижения очень гибкой системы повторного использования кода, что собственно почти всегда и происходит в реальном коде JavaScript.
В общем случае, в JavaScript новые объекты могут создаваться следующими способами:
- пустой объект - с помощью литеральной нотации {} или методом Object.create(null);
- с помощью функции-конструктора (в т.ч. class, и далее - расширение через функциональное наследование);
- клонировать существующий объект и расширить его через прототипное наследование:
- делегированием через прототипы (объект ссылается или делегирует другому объекту):
- с помощью Object.create();
- с использованием constructor();
- конкатенацией объектов (объект формируется путем добавления новых свойств к объекту из других существующих объектов методом Object.assign()).
- делегированием через прототипы (объект ссылается или делегирует другому объекту):
Делегирование через прототипы в JavaScript
Делегирование - это когда объект ссылается на другой объект или делегирует функциональность другому объекту. Например, прототипы JavaScript также являются делегатами: экземпляры массива перенаправляют встроенные методы массива на Array.prototype, объекты - на Object.prototype и т. д.
Делегирование с помощью Object.create()
Делегирование с помощью Object.create() позволяет наследовать всю функциональность родительского объекта:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// Функция-конструктор Person содержит только свойства function Person(first, last, age, gender) { this.name = {first,last}; this.age = age; this.gender = gender; } // Методы функции-конструктора Person вынесены в прототип Person.prototype.greeting = function () { return ("Hi! I'm " + this.name.first + "."); }; // Создадим экземпляр класса Person let newPerson = new Person("Alex", "NAV", 30, "male"); console.log(newPerson); // Person {name: {…}, age: 30, gender: "male"} // С помощью метода Object.create() создадим объект-наследник let objCrtPerson = Object.create(newPerson); console.log(objCrtPerson); // Person {} (пустой объект) console.log(objCrtPerson.age); // 30 (значение свойства родительского объекта) console.log(objCrtPerson.greeting()); // Hi! I'm Alex. (сработал метод родительского объекта) |
Таким образом, дочерний объект objCrtPerson полностью ссылается на родительский объект newPerson и получает значения его свойств и методов (newPerson делегирует свою функциональность objCrtPerson).
Переработанный материал отсюда...
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let circle = { radius: 5, }; circle.circumference = function () { return (2 * Math.PI * this.radius).toFixed(1); }; let circle_2 = Object.create(circle); // делегирование с помощью Object.create() circle_2.radius = 10; circle_2.circumference(); // 62.8 circle.circumference(); //31.4 |
Фактически, создание объекта можно автоматизировать, объединив все это в один литерал объекта:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
let circle = { radius: 5, create(radius) { // вводим метод, создающий объект-наследник circle = Object.create(circle); circle.radius = radius; return circle; }, area() { let radius = this.radius; return (Math.PI * radius * radius).toFixed(1); }, circumference() { return (2 * Math.PI * this.radius).toFixed(1); }, }; console.log(circle); // {radius: 5, create: ƒ, area: ƒ, circumference: ƒ} console.log(circle.circumference()); //31.4 let circle_2 = circle.create(10); console.log(circle_2); // {radius: 10} (остальные методы - в прототипе) console.log(circle_2.circumference()); // 62.8 |
Делегирование с использованием constructor()
Делегирование с использованием constructor() используется при функциональном наследовании в связи с тем, что методы родительского класса, вынесенные (определенные) в свойстве prototype, при функциональном наследовании не наследуются.
Делегирование с использованием constructor() включает в себя два этапа:
- функциональное наследование;
- перезапись прототипа и восстановление constructor() дочернего объекта.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Родительский класс function Person(first, last, age, gender) { this.name = { first, last, }; this.age = age; this.gender = gender; } // Метод родительского класса, определенный в prototype Person.prototype.greeting = function () { return ("Hi! I'm " + this.name.first + "."); }; function Teacher(first, last, age, gender, subject) { Person.apply(this, arguments); // функциональное наследование от Person (метод, вынесенный в прототип, не наследуется) // добавляем специфическую функциональность Teacher (свойства и/или методы) this.subject = subject; } let newPerson = new Person("Alex", "NAV", 30, "male"); console.log(newPerson); // Person {name: {…}, age: 30, gender: "male"} console.log(newPerson.greeting()); // Hi! I'm Alex. let newTeacher = new Teacher("Mary", "VAN", 25, "female", 'english'); console.log(newTeacher); // Teacher {name: {…}, age: 25, gender: "female", subject: "english"} console.log(newTeacher.greeting()); // TypeError: newTeacher.greeting is not a function (метод не унаcледован) |
Для решения данной проблемы необходимо:
- в prototype дочернего класса записать prototype родительского класса, используя метод Object.create();
- воcстановить значение constructor() дочернего класса, потерянное при перезаписи prototype.
Таким образом, код функции-конструктора дочернего класса будет следующим:
1 2 3 4 5 6 7 |
function Teacher(first, last, age, gender, subject) { Person.apply(this, arguments); // функциональное наследование от Person // добавляем специфическую функциональность Teacher (свойства и/или методы) this.subject = subject; } Teacher.prototype = Object.create(Person.prototype); Teacher.prototype.constructor = Teacher; |
Расширение объектов конкатенацией
Конкатенативное наследование - это процесс наследования компонентов напрямую из одного объект в другой через копирование свойств. В JavaScript прототипы обычно создаются через примеси и в ES6 эту возможность предоставляет метод Object.assign().
Наследование с помощью конкатенации:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// задаём исходные объекты и методы (компоненты объектов-наследников) const cockpit = { cockpit: "yes" }; let fuselage = { fuselage: "yes" }; const tailUnit = { tailUnit: "yes" }; const wing = { wing: "yes" }; const bombs = { bombs: "yes" }; const missile = { missile: "yes" }; let launchMissile = { launch() { return "Launch Missile"; }, }; // функция создания с помощью конкатенации объекта "самолет" let plane = () => { return Object.assign({}, cockpit, fuselage, tailUnit, wing); }; // функция создания с помощью конкатенации объекта "военный самолет" (добавляем свойство arms, возможность собирать объект из нужных компонентов) let militaryAircraft = (...props) => { let arms = Object.assign({}, ...props); return Object.assign({}, plane(), { arms }, launchMissile); }; console.log(plane()); // {cockpit: "yes", fuselage: "yes", tailUnit: "yes", wing: "yes"} console.log( militaryAircraft(bombs, missile) ); // {cockpit: "yes", fuselage: "yes", tailUnit: "yes", arms: {…}, launch: ƒ} console.log(militaryAircraft().launch()); // Launch Missile |
Наследование через классы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// Класс "самолет" - базовый class Plane { constructor( cockpit = "yes", fuselage = "yes", tailUnit = "yes", wing = "yes" ) { this.fuselage = fuselage; this.tailUnit = tailUnit; this.wing = wing; this.cockpit = cockpit; } } // Класс "военный самолет" наследует всю функциональность класса "самолет" через вызов super class MilitaryAircraft extends Plane { constructor() { super(); this.arms = { bombs: "yes", missile: "yes", }, this.launch = function() { return "Launch Missile"; } } } let plane = new Plane(); let militaryAircraft = new MilitaryAircraft(); console.log(plane); // Plane {fuselage: "yes", tailUnit: "yes", wing: "yes", cockpit: "yes"} console.log(militaryAircraft); // MilitaryAircraft {fuselage: "yes", tailUnit: "yes", wing: "yes", cockpit: "yes", arms: {…}, …} console.log(militaryAircraft.launch()); // Launch Missile |
Благодаря конкатенации можно более конкретно отбирать те свойства и методы, которые мы хотим передать новому объекту (объекту-наследнику). Классовое наследование передает всё, даже если вы не хотите этого.
Конкатенация решает проблему "хрупкого базового класса".
См. Eric Elliott - "Master the JavaScript Interview: What’s the Difference Between Class & Prototypal Inheritance"?
A - это базовый класс;
B - унаследованный от A;
C наследуется от B;
D наследуется от B.
C вызывает super, который запускает код в B. В свою очередь, B вызывает super, который запускает код в A.
A и B содержат несвязанные функции, необходимые как C, так и D.
D - это новый вариант функциональности, и для него требуется несколько иное поведение в коде инициализации A, чем требуется C.
C и D , возможно, не требуют использования всех функций классов A и B, а просто хотят унаследовать некоторые свойства или методы, которые уже определены в A и B. Но, наследуя с помощью вызова super, вы не можете избирательно подходить к тому, что вы наследуете. Вы наследуете все: «… проблема объектно-ориентированных языков в том, что у них есть вся эта неявная среда, которую они носят с собой. Вы хотели банан, но получили гориллу, держащую банан и все джунгли» (Joe Armstrong — “Coders at Work”).
Наследуя с помощью конкатенации можно обойти эту проблему.
Представьте, что у вас есть функции вместо классов: feat1, feat2, feat3, feat4.
C нужны feat1 и feat3 , а D нужны feat1 , feat2 , feat4:
1 2 |
const C = compose (feat1, feat3); const D = compose (feat1, feat2, feat4); |
Теперь представьте, что вы обнаруживаете, что D требует немного другого поведения, чем feat1 .
Так как при использовании наследования классов вы получаете всю существующую таксономию классов, то если вы хотите немного адаптироваться для нового варианта использования, вам придется либо дублировать части существующей таксономии (проблема дублирования по необходимости), либо реорганизовать всё, что зависит от существующей таксономии, чтобы адаптировать таксономию к новому использованию.
При наследовании с помощью конкатенации на самом деле нет необходимости изменять feat1, вместо этого вы можете создать собственную версию feat1 и использовать ее. Вы по-прежнему можете наследовать существующее поведение от feat2 и feat4 без изменений:
1 |
const D = compose (custom1, feat2, feat4); |
И C остается неизменным.