- Строим абстракции!
- Механика наследования
- Переопределение методов из цепочки
- Инспектирование связей между объектами
- Связывание объектов
Строим абстракции! ⇡
К этому моменту мы узнали, как работает механизм цепочки прототипов. Теперь нам предстоит узнать, каким образом мы можем строить эти цепочки.
В этой части лекции мы вернемся к нашему примеру про студентов и лекторов. Разберемся, как нам создавать такие объекты.
У нас была выделена сущность студента:
var student = {
age: 20,
name: 'John',
sleep: function () {},
getAge: function () {},
getName: function () {}
}
Чтобы создавать такого рода объекты, нам нужна будет функция, примерно такая:
function createStudent(name, age) {
return {
age: age,
name: name,
sleep: function () { console.log('hrrr...') },
getAge: function () { return this.age; },
getName: function () { return this.name; }
};
}
var john = createStudent('John', 20);
var carl = createStudent('Carl', 19);
console.log(john); // {age: 20, name: "John", sleep: ..., getAge: ..., getName: ...}
console.log(carl); // {age: 19, name: "Carl", sleep: ..., getAge: ..., getName: ...}
Анаогично можно поступить с лекторами:
function createLecturer(name, age) {
return {
age: age,
name: name,
talk: function () { console.log('bla-bla-bla...') },
getAge: function () { return this.age; },
getName: function () { return this.name; }
};
}
var iwan = createStudent('Iwan', 33);
console.log(iwan); // {age: 33, name: "Iwan", talk: ..., getAge: ..., getName: ...}
Проблемы в этом коде налицо, у нас на каждый объект студента и лектора создаются одни и те же функции. Реализацию функций getAge
и getName
мы вообще делаем в двух местах. Вот бы нам выделить три общих сущности:
Person
- какая-то персона, обладающая методамиgetAge
,getName
Student
- какой-то студент, обладающий методомsleep
Lecturer
- какой-то лектор, обладающий методомtalk
Попробуем решить эту задачу через уже знакомый нам механизм цепочек прототипов.
Делегирование ⇡
Как я сказал ранее, классов в нашем распоряжении нет, а также нет и классического наследования. К механизму цепочки прототипов более близко понятие делегирования. Попробуем посмотреть под углом делегирования на следующий пример:
var person = { type: 'none' }; // person --> Object.prototype --> null
var student = { age: 20 }; // student --> Object.prototype --> null
Object.setPrototypeOf(student, person); // student --> person --> Object.prototype --> null
Когда мы обращаемся к свойству age
, объект student
про него знает и возвращает нам его значение. Когда же мы хотим получить type
, объект student
про него ничего не знает и делегирует получение свойства объекту person
.
Аналогичным образом можно рассмотреть и более длинные цепочки. Например, при вызове метода hasOwnProperty
, объект student
ничего не про него не знает и поэтому делегирует вызов объекту person
, который в свою очередь тоже ничего не знает про этот метод, поэтому делегирует его выполнение объекту Object.prototype
. Последний про него в курсе, поэтому выполняет его.
Когда же никто ничего не знает, нам возвращается undefined
.
Свойство prototype
⇡
Внимательный студент скорее всего уже заметил, что объект Object.prototype
имеет схожее название с внутренним свйоством [[Prototype]]
. И это не совпадение!
У каждой функции есть свойство prototype
. Это свойство доступное и мы властны его изменять.
function person() {}
console.log(person.prototype) //Object {}
Мы уже сталкивались с понятием функции-конструктора. Когда к функции применяется оператор new
, JavaScript запускает эту функцию в режиме конструктора, что предполагает следующее. Перед выполнением функции создается пустой объект, а в его внутреннее свойство [[Prototype]]
кладется ссылка на свойство prototype
данной функции. Этот пустой объект становится контекстом исполнения данной функции (устанавливается в this
). Результом выполнения является этот вновь созданный объект.
function Person() {
this.type = 'none';
}
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
console.log(john) //{type:"none"}
Если же из функции вернуть объект, то вернется именно он:
function Person() {
this.type = 'none';
return { type: '---' };
}
var john = new Person(); // john --> Object.prototype --> null
console.log(john) // {type: "---"}
Как видно из примера мы потеряли из цепочки объект Person.prototype
. В данном примере вызов с new
и без него даст идентичный результат. Такое происходит только если возвращать объект. Если вернуть примитив, undefined
или null
, то вернется конктекст исполнения (this
).
Важно понимать, что сами по себе функции-конструкторы от обычных функций не отличаются. Отличается то, как они спроектированы и к какому способу вызова приспособлены. Например, если вызвать функцию Person
без new
, то произойдет страшное. Конктекстом исполнения будет глобальный объект и именно ему присвоится свойство type
. В строгом режиме будет выброшена ошибка TypeError
.
Чтобы исключить такого рода ошибки, в мире JavaScript принято функции-конструкторы называть с большой буквы. Мимикрия под классы :)
function Person() { // Person --> Function.prototype --> Object.prototype --> null
this.type = 'none';
}
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
Второе важное замечание касается того, что функция Person
сама по себе также является объектом и обладает собственной цепочкой прототипов, которая никак не связана с цепочкой john
. Тут как раз и видно разницу между Person.[[Prototype]]
и Person.prototype
.
Вернемся к нашему примеру
function Person() {
this.type = 'none';
}
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
console.log(john) //{type:"none"}
Из него можно сделать два важных вывода!
Во-первых, во время и после исполнения в наших руках объект, свойство [[Prototype]]
которого указывает на Person.prototype
. А мы помним, что этим свойством мы можем управлять. Это значит, что мы можем что-то положить в Person.prototype
и вновь созданным объектам эти свойства будут доступны.
function Person() {}
Person.prototype.type = 'none';
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
console.log(john); // {}
console.log(john.type); // "none"
Мы избавились от необходимости хранить в каждом объекте общую информацию!
Второй важный вывод: внутреннее свойство [[Prototype]]
хранит ссылку на объект Person.prototype
. А значит, если мы после создания объекта начнем править объект Person.prototype
, нам эти изменения будут доступны:
function Person() {}
Person.prototype.type = 'none';
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
console.log(john); // {}
console.log(john.type); // "none"
Person.prototype.type = 'person';
console.log(john.type); // "person"
Тут скрывается одна опасность. Если мы после создания переопределим Person.prototype
, то потеряем связь объекта с Person.prototype
. Потому что после этого Person.prototype
и свойство [[Prototype]]
объекта будут ссылаться на разные объекты!
function Person() {} // Person --> Function.prototype --> Object.prototype --> null
Person.prototype.type = 'none';
var john = new Person(); // john --> Person.prototype --> Object.prototype --> null
console.log(john.type); // "none"
Person.prototype = { type: 'person' };
// john --> {объект, бывший Person.prototype} --> Object.prototype --> null
console.log(john.type); // "none"
console.log(Person.prototype.type); // "person"
При получении свойства type
мы переходим по цепочке прототипов в объект, на который раньше ссылался объект Person.prototype
.
Лучше изменять свойство prototype
до создания объектов и делать это прямо в месте объявления данной функции.
Контекст исполнения в функции-конструкторе ⇡
Как мы выяснили ранее при вызове функции через оператор new
создается новый объект и он же становится контекстом исполнения. Это может пригодиться для инициализации свойств вновь создаваемого объекта:
function Person(name, age) {
this.name = name;
this.age = age;
}
var john = new Person('John', 20);
console.log(john); // {name: "John", age: 20}
Пример это почти то, что нам нужно, но не хватает методов!
function Person(name, age) {
this.name = name;
this.age = age;
this.increaseAge = function () {
this.age++;
}
}
var john = new Person('John', 20);
console.log(john); // {name: "John", age: 20}
john.increaseAge();
console.log(john); // {name: "John", age: 21}
Теперь у нас методы, но осталась одна проблема, мы создаем функции для каждого экземпляра персоны. Ну дак давайте вынесем эту функцию в Person.prototype
:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.increaseAge = function () {
this.age++;
}
var john = new Person('John', 20);
console.log(john); // {name: "John", age: 20}
john.increaseAge();
console.log(john); // {name: "John", age: ??}
Хм! А оно заработает? Ведь в функции используется this. Нам снова нужно вспомнить, как выбирается контекст исполнения. Когда мы вызываем функцию на объекте, контекстом исполнения становится данный объект, а это и именно то, что мы ожидаем. Поэтому пример выше отработает правильно.
К этому моменту мы избавились от дублирования и научились создавать схожие по функциональности объекты!
Свойство constructor
⇡
Вначале рассказа про свойство prototype
я говорил, что это свойство ссылается на пустой объект. Я вас обманул, у этого "пустого" объекта есть неперчисляемое свойство constructor
, которое ссылается на саму функцию:
function Person() {};
console.log(Person.prototype.constructor === person); //true
var john = new Person();
console.log(john.constructor === person); //true (по цепочке)
Это может показаться удобным для того, чтобы узнать, с помощью какой функции создан данный объект. Но здесь таится опасность. Это свойство не является read-only
и может быть легко перезаписано. Более того, весь прототип может быть перезаписан. Поэтому нет никакой гарантии, что constructor
будет содержать правильную ссылку:
function Person() {};
Person.prototype = {}
console.log(Person.prototype.constructor === Person); //false
console.log(Person.prototype.constructor === Object); // true
Теперь конструктор ссылается на Object
. Это произошло из-за того, что у литерала объекта свойство [[Prototype]]
ссылается на Object.prototype
, а свойство constructor
ссылается на Object
.
Механика наследования ⇡
Мы научились объявлять функции-конструкторы, через которые можно создавать схожие объекты. Если вернуться к задаче про студентов и лекторов, мы можем выделить три сущности:
function Person() {}
function Student() {}
function Lecturer() {}
Но нам не хватает связи между сущностью Person
и Student
. Хотелось бы выделить общую функциональность, объединяющую Student
и Lecturer
в сущность Person
, чтобы ее реализацию не дублировать.
На самом деле все знания для этого у нас уже есть, осталось только их применить.
Первая попытка ⇡
Первое, что приходит в голову, это сделать как-то так:
function Person() {}
Person.prototype.increaseAge = function () {
this.age++;
}
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype = Person.prototype;
var john = new Student('John', 20);
console.log(john); // {name: "John", age: 20}
john.increaseAge();
console.log(john); // {name: "John", age: 21}
Вроде работает, но есть одна большая проблема: у нас Student.prototype
и Person.prototype
ссылаются на один объект. Это значит, что если мы начнем менять Student.prototype
, например, чтобы добавить метод sleep()
, он окажется доступным и для Person.prototype
. Если сюда добавить лекторов, то все три сущности будут делить один прототип: лекторы начнут спать, а студенты что-то там читать.
Вторая попытка ⇡
Ну хорошо. А что если нам сделать так:
function Person() {}
Person.prototype.increaseAge = function () {
this.age++;
}
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype = new Person();
var john = new Student('John', 20);
console.log(john); // {name: "John", age: 20}
john.increaseAge();
console.log(john); // {name: "John", age: 21}
Это уже лучше, за одним исключением. В функцию Person
мы можем захотеть положить что-нибудь важное. Например, логирование, или вызов внутренних методов. Мы вовсе не хотим, чтобы все это исполнялось при присвоении прототипа.
Object.create
⇡
В ES5 появился метод Object.create
, который делает то, что нам нужно. А именно создает объект, в свойстве [[Prototype]]
которого лежит объект, переданный первым аргументом.
var person = {}; // person --> Object.prototype --> null
var student = Object.create(person); // student --> person --> Object.prototype --> null
console.log(Object.getPrototypeOf(student) === person); // true
Метод принимает также вторым параметром дескриптор свойств для нового объекта. Дескриптор аналогичен тому, который принимает метод Object.defineProperties
.
Полуполифил ⇡
В средах, где ES5 все еще не поддерживается, можно самим реализовать. Единственное ограничение, мы можем реализовать только функциональность, связанную с первым параметром. Полную реализацию Object.defineProperties
на ES3 сделать невозможно.
if (!Object.create) {
Object.create = function(o) {
function F() {}
F.prototype = o;
return new F();
};
}
Эта реализация очень похожа на нашу вторую попытку, но за одним исключением: мы берем заведомо пустую функцию. Поэтому мы можем не переживать, что в функции-конструкторе будет какая-то важная функциональность.
Object.create(null)
⇡
В рамках рассказа про Object.create
хочу еще немного отвлечся от основной темы и рассказать про очень интересный прием. А именно создание по-настоящему пустого объекта с пустой цепочкой:
var dict = Object.create(null);
console.log(dict.hasOwnProperty); // false
В примере dict
— пустой объект, у которого нет цепочки прототипов, его внутреннее свойство [[Prototype]]
указывает на null
. Такого рода объекты, часто называют словарями, так как их удобно использовать для хранения данных, они лишены особенностей, связанных с делегированием свойств по цепочке прототипов. По ним можно безопасно итерироваться циклом for..in
.
Успешная попытка ⇡
Теперь мы можем воспользоваться функцией Object.create
для создания прототипа.
function Person() {}
Person.prototype.increaseAge = function () {
this.age++;
}
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype = Object.create(Person.prototype); // Student.prototype --> Person.prototype
Student.prototype.constructor = Student; // восстанавливаем конструктор
var john = new Student('John', 20);
// john --> Student.prototype --> Person.prototype --> Object.prototype --> null
Нам пришлось восстановить constructor
, потому что вновь созданный объект через Object.create
ссылается на Person.prototype
. При установке свойства Student.prototype.constructor = Student
создается затеняющее свойство на объекте Student.prototype
, поэтому Person.prototype.constructor
все еще будет ссылаться на Person
Теперь мы можем в полной мере реализовать нашу первичную задачу:
function Person() {}
Person.prototype.getName = function () { return this.name; }
Person.prototype.getAge = function () { return this.age; }
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype = Object.create(Person.prototype); // Student.prototype --> Person.prototype
Student.prototype.constructor = Student; // восстанавливаем конструктор
Student.prototype.sleep = function () {
console.log('hrrrr...');
};
Аналогично с лектором:
function Lecturer(name, age) {
this.name = name;
this.age = age;
}
Lecturer.prototype = Object.create(Person.prototype); // Student.prototype --> Person.prototype
Lecturer.prototype.constructor = Lecturer; // восстанавливаем конструктор
Lecturer.prototype.talk = function () {
console.log('bla-bla-bla...');
};
Проверяем в бою:
var john = new Student('John', 20);
var iwan = new Lecturer('Iwan', 33);
// john --> Student.prototype --> Person.prototype --> Object.prototype --> null
// iwan --> Lecturer.prototype --> Person.prototype --> Object.prototype --> null
console.log(john.getName()); // "John"
console.log(iwan.getName()); // "Iwan"
Подробную визуализацию можно посмотреть тут.
Реиспользуем конструкторы ⇡
В целом мы добились, чего хотели. Избавились от избыточности и повторяющейся реализации. Выделили абстракцию в виде сущности Person
и конкретные сущности в виде Student
, Lecturer
.
На этом примере я хочу показать еще один прием. А именно, что можно сделать с одинаковыми частями кода в функции-конструкторе.
Во-первых, переопределим функцию Person
:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.getName = function () { return this.name; }
Person.prototype.getAge = function () { return this.age; }
А в конструкторе Student
и Lecturer
мы вызовем функцию Person с подмененным контекстом:
function Student(name, age) {
Person.call(this, name, age);
}
Student.prototype = Object.create(Person.prototype); // Student.prototype --> Person.prototype
Student.prototype.constructor = Student; // восстанавливаем конструктор
Student.prototype.sleep = function () {
console.log('hrrrr...');
};
Что произошло? Мы из конструктора вызываем функцию Person
(это всего-навсего обычная функция), но контекстом исполнения делаем this
из Student
. Соответственно, функция Person
устанавливает переданные свойства.
Переопределение методов из цепочки ⇡
Мы выделили общие сущности, построили их в цепочки, доопределили свойства более конкретным способом. Все свойства и методы, которые мы добавляли, отличались от методов и свойств, определенных в базовой сущности. Что же делать, если мы хотим доопределить метод из базовой сущности?
Проблема заключается в том, что созданные на объекте свойства затеняют собой свойства из цепочки прототипов. Поэтому получить метод, который мы затенили на текущем объекте, можно лишь через объект, лежащий у текущего в цепочке.
Первый способ заключается в том, что бы не создавать методы и свойства, затеняющие свойства из цепочки. "– Доктор, у меня болит, когда я делаю так. – Ну не делайте так!".
function Person() {}
Person.prototype.getName = function () { return this.name; };
function Student(name) { this.name = name; }
Student.prototype = Object.create(Person.prototype);
Student.prototype.getStudentName = function () {
return 'Student ' + this.getName();
};
var john = new Student('John');
console.log(john.getStudentName()); // "Student John"
Это хороший способ и часто подходит. Но есть и другой способ. Он заключается в вызове затеняемого свойства объекта из цепочки прототипов с текущим конктекстом исполнения:
function Person() {}
Person.prototype.getName = function () { return this.name; };
function Student(name) { this.name = name; }
Student.prototype = Object.create(Person.prototype);
Student.prototype.getName = function () {
return 'Student ' + Person.prototype.getName.call(this);
};
var john = new Student('John');
console.log(john.getName()); // "Student John"
Инспектирование связей между объектами ⇡
На этом этапе мы научились создавать объекты через функции-конструкторы. Остался один вопрос: каким образом мы можем понять, через какую функцию-конструктор создан наш объект.
Допустим, у нас есть некая система, где нужно разделить права доступа для студентов и лекторов. В некоторые части этой системы есть доступ только у лекторов, поэтому есть функция, проверяющая студент перед ней или лектор:
function checkPerson(person) {}
var john = new Student('John');
var iwan = new Lecturer('Iwan');
checkPerson(john); // should return "false"
checkPerson(iwan); // should return "true"
Как же нам это сделать?
instanceof
⇡
В этом нам может помочь оператор instanceof
. Левый операнд является проверяемым объектом, а правый функцией-конструктором (на самом деле любой функцией). Оператор проверяет есть ли в в цепочке прототипов объекта ссылка на свойство prototype
функции:
function Student() {}
var john = new Student(); // john --> Student.prototype --> Object.prototype --> null
console.log(john instanceof Student); // true
console.log(john instanceof Object); // true
В примере выше, используя оператор instanceof
, мы задали два вопроса:
- "Есть ли в цепочке прототипов объекта
john
ссылка на объектStudent.prototype
?" - "Есть ли в цепочке прототипов объекта
john
ссылка на объектObject.prototype
?"
И получили положительный ответ на оба.
Object.prototype.isPrototypeOf()
⇡
Второй способ — через метод Object.prototype.isPrototypeOf
. Так как он лежит в объекте Object.prototype
, он нам доступен через цепочку прототипов.
function Student() {}
var john = new Student(); // john --> Student.prototype --> Object.prototype --> null
console.log(Student.prototype.isPrototypeOf(john)); // true
console.log(Object.prototype.isPrototypeOf(john)); // true
Данный метод решает аналогичную задачу, что и оператор instanceof
, только его вопросы выглядят более явно. Концептуальная же разница заключается в том, что instanceof
работает исключительно с функциями, так как проверяет их свойство prototype
, а метод isPrototypeOf
работает с объектами, что бывает предпочтительнее в некоторых случаях.
Связывание объектов ⇡
Мы полностью разобрались в механизме цепочки прототипов. Увидели, что этот механизм связан с созданием связей между объектами и делегированием свойств.
Сейчас я хочу взглянуть на реализацию нашей задачи про студентов и лекторов только с точки зрения делегирования. Отбросить функции-конструкторы и мимикрию под классы. Все, что для этого необходимо, это метод Object.create
.
Итак, опишем сущность персоны: объект, который содержит свойства и методы, общие и для студентов, и для лекторов.
var person = {
getName: function () { return this.name; },
getAge: function () { return this.age; }
};
У нас получился объект с выделенной общей функциональностью, чистой функциональностью!
Теперь нам необходимо выделить сущности student
и leсturer
и сделать так, чтобы они делегировали методы getName
и getAge
объекту person
:
var student = Object.create(person); // student --> person
student.sleep = function () { console.log('hrrrr...'); };
var leсturer = Object.create(person); // leсturer --> person
leсturer.talk = function () { console.log('bla-bla-bla...'); };
Далее мы можем создавать конкретных представилей точно таким же способом:
var john = Object.create(student); // john --> student --> person
john.name = 'John';
john.age = 20;
Или даже сделать метод-хелпер для этих целей:
var person = {
create: function (name, age) {
var person = Object.create(this);
person.name = name;
person.age = age;
return person;
},
getName: function () { return this.name; },
getAge: function () { return this.age; }
};
var student = Object.create(person); // student --> person
student.sleep = function () { console.log('hrrrr...'); };
var john = student.create('John', 20); // john --> student --> person
john.sleep(); // hrrrr...
console.log(john.getName()); // "John"
Данное решение является чисто объектным. Одни объекты делегируют свойства и методы другим. Объекты, обладающие более общими свойствами, лежат выше в цепочке делегирования. Более конкретные – ниже.
Переопределение методов делается аналогично подходу с функциями-конструкторами:
var person = {
create: function (name) {
var person = Object.create(this);
person.name = name;
return person;
},
getName: function () { return this.name; }
};
var student = Object.create(person); // student --> person
student.getName = function () { return 'Student ' + person.getName.call(this); };
var john = student.create('John'); // john --> student --> person
console.log(john.getName()); // "Student John"
Только под призмой делегирования это выглядит более явно и логично. В примере выше мы явно делегируем вызов объекту person
. То есть делаем то же самое, что происходит при обычном прототипном делегировании, только руками.
При этом подходе для инспектирования объекта как раз подходит метод isPrototypeOf
и совершенно не подходит оператор instanceof
:
var person = {
create: function (name) {
var person = Object.create(this);
person.name = name;
return person;
},
getName: function () { return this.name; }
};
var student = Object.create(person); // student --> person
var john = student.create('John'); // john --> student --> person
console.log(student.isPrototypeOf(john)); // true
console.log(person.isPrototypeOf(john)); // true
console.log(person.isPrototypeOf(student)); // true
Оба подхода — и через функции-конструкторы, и через Object.create
— хороши, каждый имеет свои плюсы и минусы. Иногда лучше подходит один, иногда другой, но в целом это дело вкуса и зависит от того, как удобнее программисту.