Наследование — это свойство системы, позволяющее описать новый класс на основе уже существующего с частично или полностью заимствующейся функциональностью.
Класс (объект), от которого производится наследование, называется базовым, родительским или суперклассом (объектом).
Новый класс (объект) — потомком, наследником, дочерним или производным классом (объектом).
Наследование позволяет сокращать код, на каждом иерархическом шаге учитывая только изменения, не дублируя всё остальное, учтённое на предыдущих шагах.
JavaScript вместо наследования использует прототипирование: если искомого свойства или вызванного метода в самом объекте нет, то запрос передаётся объекту-прототипу (свойство prototype всех объектов JavaScript). При этом объект, от которого произошло наследование называется прототипом, и унаследованные свойства могут быть найдены в объекте prototype конструктора. Поведение всех объектов класса можно поменять, заменив один из методов прототипа (например, добавив метода .toBASE64 для класса String).
В классическом наследовании объекты являются абстракциями «вещей» реального мира, но мы можем ссылаться на объекты только через классы. Классы — в данном случае это обобщение объекта, и при обобщении мы наследуем один класс от другого.
Процесс классического наследования должен создавать уровень абстракции, т.е. создается вертикальная иерархия.
При каждом наследовании, каждый дочерний класс должен снижать уровень абстракции, тем самым снижая уровень обобщения, например:
"ТУФЛИ" => "МУЖСКИЕ ТУФЛИ" => "МУЖСКИЕ ЛЕТНИЕ ТУФЛИ" => "МУЖСКИЕ ЛЕТНИЕ БЕЛЫЕ ТУФЛИ"
В классических объектно-ориентированных языках программирования классы являются обобщениями и при каждом наследовании у них должен снижаться уровень абстракции. В этом смысле уровень абстракции аналогичен шкале, варьирующейся от более специфических сущностей до более общих сущностей.
Объекты в классических объектно-ориентированных языках программирования могут быть созданы только путем создания экземпляров классов.
Задача программиста при использовании парадигмы классического наследования создать иерархию сущностей от максимальной общей к максимально конкретной.
При использование парадигмы прототипного наследования программист имеет дело только с объектами и при этом у него есть возможность создавать сущности в одном (горизонтальном) уровне абстракции.
В JavaScript выделяют:
- функциональное наследование (через функции-конструкторы и классы class - с особенностями);
- прототипное наследование.
Классовое наследование
Создание класса с использованием ключевого слова class фактически является формой функции-конструктора, поэтому классовое наследование может как использовать, так и не использовать ключевое слово class из ES6.
Класс похож на шаблон – описание объекта который будет создан. Экземпляры классов обычно создаются через функции-конструкторы с помощью ключевого слова new.
Классы наследуются от классов и создают дочерние связи: иерархическую классовую таксономию - самую сильную связанность доступную в объектной-ориентированной архитектуре.
Эти таксономии практически невозможно спроектировать правильно для всех новых сценариев. Широкое распространение (использование) базового класса приводит к проблеме "хрупкого базового класса" (сложность внесения правок, когда он начинает работать неправильно). Классовое наследование служит причиной многих проблем в ОО проектировании:
- проблема сильной связанности (классовое наследование является самым сильным связанным доступным в ОО проектировании);
- проблема хрупкого базового класса (неэластичная иерархия, в конечном итоге, все развивающиеся иерархии не будут работать для новых сценариев);
- дублирование кода (в связи с неэластичными иерархиями новые сценарии часто вклинены в нескольких местах, дублируя код);
- проблема "гориллы и банана" (вы хотели банан, но получили гориллу, ждущую банан, и целые джунгли в дополнение).
Прототипное наследование
Прототип - это экземпляр некоторого объекта, от которого наследуются напрямую другие объекты. Экземпляры могут быть скомпонованы из большого количества разных объектов, позволяя легко проводить точечное наследование и строить простую [[Prototype]] иерархию.
Функциональное наследование в JS
Функциональное наследование использует вызов родительской функции-конструктора внутри функции-конструктора дочернего класса с одновременной передачей ей в качестве контекста this текущего объекта:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function User(firstname, lastName) { this.firstname = firstname; this.lastName = lastName; this.getFullName = function () { return `${firstname} ${lastName}`; }; } function Klient() { User.apply(this, arguments); // функциональное наследование this.getLastName = function () { return this.lastName; }; } let user = new User("Alex", "NAV"); let klient = new Klient("Alex_Klient", "NAV_Klient"); console.log(user); // User {firstname: "Alex", lastName: "NAV", getFullName: ƒ} console.log(klient); // Klient {firstname: "Alex_Klient", lastName: "NAV_Klient", getFullName: ƒ} console.log(klient.getFullName()); // Alex_Klient NAV_Klient console.log(klient.getLastName()); // NAV_Klient |
Наследование реализовано вызовом User.apply(this, arguments) в начале конструктора Klient(). Метод apply(this, arguments) вызывает функцию User, передавая ей в качестве контекста this текущий объект. Конструктор User в процессе выполнения записывает в this свойства firstname, lastName и метод getFullName.
Использование метода apply() вместо call() гарантирует передачу всех аргументов не в виде списка, а в виде массива (псевдомассива) аргументов: если их количество увеличится, то код не надо будет переписывать.
Далее конструктор Klient продолжает выполнение и может добавить свои свойства и методы, например, getLastName(). В результате мы получаем объект klient, который включает в себя методы из User и Klient.
Обратите внимание, наследник не имеет доступа к приватным свойствам родителя, например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function User(firstname, lastName) { let id = null; // приватное (защищенное) свойство this.firstname = firstname; this.lastName = lastName; } function Klient() { User.apply(this, arguments); //функциональное наследование } let user = new User("Alex", "NAV"); let klient = new Klient("Alex_Klient", "NAV_Klient"); console.log(user.id); // undefined console.log(klient.id); // undefined |
Чтобы наследник имел доступ к свойству родителя, оно должно быть записано в this, например, this._id = null; . При этом, чтобы обозначить что свойство является внутренним, его имя начинают с нижнего подчёркивания _.
Нижнее подчёркивание в начале именования свойства – общепринятый знак что свойство является внутренним (защищенным по соглашению, но доступным технически), предназначенным лишь для доступа из самого объекта и его наследников.
ВАЖНО! Методы родительского класса, вынесенные (определенные) в свойстве prototype, не наследуются!
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 |
function Person(first, last, age, gender) { this.name = { first, last, }; this.age = age; this.gender = gender; } 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 (метод не унаследован) |
Для решения данной проблемы необходимо:
- в 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; |
Переопределение методов при функциональном наследовании
Бывает так, что реализация конкретного метода в наследнике имеет свои особенности, при этом, как правило, необходимо не заменить, а расширить метод родителя, добавить к нему какую-то функциональность.
Для этого метод родителя предварительно копируют в переменную, и затем вызывают внутри метода дочернего класса (объекта) – там, где считают нужным:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function User(firstname, lastName) { this.firstname = firstname; this.lastName = lastName; this.getFullName = function () { return `${firstname} ${lastName}`; }; } function Klient() { User.apply(this, arguments); //функциональное наследование let newGetFullName = this.getFullName; // сохраняем родительский метод this.getName = function () { console.log('Дополнительная функциональность может быть вместо этих строк') return newGetFullName.apply(this, arguments); // используем родительский метод }; } console.log(klient.getFullName()); // Alex_Klient NAV_Klient console.log(klient.getName()); // Дополнительная функциональность может быть вместо этих строк // Alex_Klient NAV_Klient |
Функциональное наследование в настоящее время используется нечасто, но знать и понимать его необходимо, так как во многих библиотеках классы написаны в функциональном стиле, и расширять (наследовать) их (от них) можно только так.
Классовое наследование
Классовое наследование в JavaScript можно рассматривать как функциональное наследование с расширенными возможностями: создание класса с использованием ключевого слова class фактически является формой функции-конструктора, поэтому классовое наследование может как использовать ключевое слово class из ES6, так и не использовать его (функциональное наследование через функции-конструкторы).
Класс похож на шаблон – описание объекта, который будет создан. Экземпляры классов обычно создаются с помощью ключевого слова new.
Особенности классового наследования:
- для создания дочернего класса в объявлении класса (или в выражениях класса) применяется ключевое слово extends, использующее прототипы (prototype);
- для вызова функций, принадлежащих родителю объекта, используется ключевое слово super.
Ключевое слово extends позволяет наследовать методы родительского класса, вынесенные в prototype:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// Класс Person содержит только свойства class Person { constructor(first, last, age, gender) { this.name = { first, last }; this.age = age; } } // Методы класса Person вынесены в прототип Person.prototype.greeting = function () { return "Hi! I'm " + this.name.first + "."; }; // Создадим наследник класса Person class Teacher extends Person {} let teacher = new Teacher("Serg",'VAN'); console.log(teacher); // Teacher {name: {first: "Serg", last: "VAN"}, age: undefined} console.log(teacher.__proto__); // Person {constructor: ƒ} console.log(teacher.greeting()); // Hi! I'm Serg. (метод родительского класса доступен) |
Ключевое слово super используется для вызова функций, принадлежащих родителю объекта. В конструкторе ключевое слово super() используется как функция, вызывающая родительский конструктор: её необходимо вызвать до первого обращения к ключевому слову this в теле конструктора:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Polygon { constructor(height, width) { this.name = "Polygon"; this.height = height; this.width = width; } } class Square extends Polygon { // ключевое слово extends использовано для создания дочернего класса constructor(height, width) { // this.height; // ReferenceError, super должен быть вызван первым super(height, width); // Вызов метода конструктора родительского класса с длинами, указанными для ширины и высоты класса Polygon this.name = "Square"; } get area() { return this.height * this.width; } } let s = new Square(300,200); console.log(s); // Square {name: "Square", height: 300, width: 200} console.log(s.area); // 60000 |