Как стать автором
Обновить

Основы и заблуждения насчет JavaScript

Время на прочтение9 мин
Количество просмотров55K

Объекты, классы, конструкторы

ECMAScript, будучи высоко-абстрактным объектно-ориентированным языком программирования, оперирует объектами. Существуют также и примитивы, но и они, когда требуется, также преобразуются в объекты. Объект — это коллекция свойств, имеющая также связанный с ней объект-прототип. Прототипом является либо также объект, или же значение null.
В JavaScript нет привычных классов, но есть функции-конструкторы, порождающие объекты по определенным алгоритмам (см. Оператор new).

Прототипное делегирующее наследование


Классическое наследование очень похоже на то, как люди наследуют гены своих предков. Есть какие-то базовые особенности: люди могут ходить, говорить… И есть характерные черты для для каждого человека. Люди не в состоянии изменить себя — свой класс (но могут поменять собственные свойства) и бабушки, дедушки, мамы и папы не могут динамически повлиять на гены детей и внуков. Все очень по земному.

Теперь представим другую планету, на которой не такое как на Земле генное наследование. Там обитают мутанты с «телепатическим наследованием», которые способны изменять гены своих потомков.
Разберем пример. Отец наследует гены от Дедушки, а Сын наследует гены от Отца, который наследует от Дедушки. Каждый мутант может свободно мутировать, и может менять гены своих потомков. Например у Дедушки был зеленый цвет кожи, Отец цвет унаследовал, Сын тоже унаследовал цвет. И вдруг Дед решил: «надоело мне ходить зеленым — хочу стать сними», смутировал (изменил прототип своего класса) и «телепатически» распространил эту мутацию Отцу и Сыну, вобщем посинели все. Тут Отец подумал: «Дед на старости лет совсем двинулся» и поменял свой цвет в генах обратно на зеленый(изменил прототип своего класса), и распространил «телепатически» свой цвет сыну. Отец и Сын зеленые, Дед синий. Теперь как бы дед ни старался Отец и сын цвет не поменяют, т.к сейчас Отец в своем прототипе прописал цвет, а Сын в первую очередь унаследует от Прототипа Отца. Теперь Сын решает: «Поменяю ка я свой цвет на черный, а моё потомство пусть наследует цвет от Отца» и прописал собственное свойство, которое не влияет на потомство. И так далее.

Опишем все в коде:
var Grandfather = function () {}; // Конструктор Grandfather 
Grandfather.prototype.color = 'green';

var Father = function () {}; // Конструктор Father 
Father.prototype = new Grandfather(); // Это простой, но не самый лучший вариант протитипного наследования

var Son = function () {}; // Конструктор Son 
Son.prototype = new Father(); // Аналогично

var u = new Grandfather(); // Экземпляр "класса" Grandfather
var f = new Father(); // Экземпляр "класса" Father
var s = new Son(); // Экземпляр "класса" Son

// Изначально все зеленые
console.log([u.color, f.color, s.color]); // ["green", "green", "green"]

// Дед решил поменять свой цвет и цвет потомства
Grandfather.prototype.color = 'blue';
console.log([u.color, f.color, s.color]); // ["blue", "blue", "blue"]

// Отец решил все вернуть для себя и своего потомства
Father.prototype.color = 'green';
// Хотя мог исделать и так:
// Grandfather.prototype.color = 'green';
console.log([u.color, f.color, s.color]); // ["blue", "green", "green"]

// Смысла нет
Grandfather.prototype.color = 'blue';
console.log([u.color, f.color, s.color]); // ["blue", "green", "green"]

// Сын решил не брать пример с Деда и поменял только собственное свойство
s.color = 'black'; // Меняем собственное свойство, которое не затрагивает цепочку прототипов
console.log([u.color, f.color, s.color]); // ["blue", "green", "black"]

var SonsSon = function () {}; // Конструктор SonsSon
SonsSon.prototype = new Son(); // Аналогично

var ss = new SonsSon(); // Экземпляр "класса" SonsSon
// Сын сына унаследовал от Отца
console.log([u.color, f.color, s.color, ss.color]); // ["blue", "green", "black", "green"]

Почитать:
ООП в Javascript: наследование
Разбираемся с prototype, __proto__, constructor и их цепочками в картинках

Цепочка прототипов, получение свойства с заданными именем


Цепь прототипов (prototype chain) — это конечная цепь объектов, которая используется для организации наследования и разделяемых (shared) свойств.

В JavaScript каждый объект имеет собственные свойства (Own Properties) и ссылку на объект-прототип, в свою очередь прототип тоже имеет собственные свойства и ссылку на прототип, прототип прототипа тоже имеет собственные свойства и ссылку на прототип ну и так далее, пока ссылка на прототип не будет null — эта структура называется цепочка прототипов.
При попытке обратиться к свойству объекта (через точку или скобки) выполняется поиск указателя по имени: сперва проверяется есть ли указатель с таком-то именем с списке собственных свойств (если есть, то возвращается), если его нет, то идет поиск в собственном прототипе (если есть, то возвращается), если его нет, то идет поиск в прототипе прототипа и так далее, пока прототип прототипа не станет null в этом случае возвращается undefined.

Некоторые реализации JavaScript используют свойство __proto__ для представления следующего объекта в цепочке прототипов.

Поиск свойства на чтение можно описать следующей функцией:
function getProperty(obj, prop) {
  if (obj.hasOwnProperty(prop))
    return obj[prop]
 
  else if (obj.__proto__ !== null)
    return getProperty(obj.__proto__, prop)
 
  else
    return undefined
}

Для примера рассмотри простой класс Point 2D, содержащий 2 свойства (x, y) и метод print. Используя, определения выше — построим объект.
var Point = {
    x: 0,
    y: 0,
    print: function () { 
        console.log(this.x, this.y); 
    }
};
 
var p = {x: 10, __proto__: Point};

// Свойство 'x' нашлось в свобственных свойствах:
/* p.x */ getProperty(p, 'x'); // 10

// Свойство 'y' нашлось по ссылке __proto__ в объекте-прототипе Point  
/* p.y */ getProperty(p, 'y'); // 0

// Метод print нашелся по ссылке __proto__ в объекте-прототипе Point  
/* p.print() */ getProperty(p, 'print').call(p); // 10 0
Почему я использовал call, а не вызвал полученную функции напрямую, описано ниже.

На самом деле Point имеет ещё одно свйоство, да это наша ссылка на прототип родителя __proto__, которая в случае Point указывает на Object.prototype.
Например, вот так будет выглядеть вся цепочка пртотипов в самом первом примере:
            /* SonsSon <- Son <---- Father <- Grandfather <-- Object <-- null */
console.log(ss.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__ === null); 

__proto__, prototype, оператор new


Выше был «низкоуровневый код», теперь посмотрим как все работает в жизни:
function Point(x, y) { // Конструктор Point
    this.x = x;
    this.y = y;
}
Point.prototype = { // Прототип конструктора
    print: function () { console.log(this.x, this.y); }
};
 
var p = new Point(10, 20); // Создаем новый объект
p.print(); // 10 20

Если в предыдущем коде мы хотя бы знали, что куда ссылается, в этом все как-то запутанно.

Вся «магия» кроется в операторе new. Brendan Eich (создатель JavaScript) захотел, чтобы JavaScript был похож на традиционные ОО языки, такие как C++, Java, поэтому был добавлен оператор new. Посмотрим как же он работает.

Оператор new получает в свое распоряжение функцию и аргументы функции (new F(arguments...)) и выполняет следующие действия:

1. Создает пустой объект с единственным свойством __proto__, которое ссылается на F.prototype
2. Выполняет конструктор F в котором this — созданный ранее объект
3. Возвращает объект
Создадим функцию New, эмулирующую поведение оператора new:
function New (F, args) {
/*1*/  var n = {'__proto__': F.prototype};
/*2*/  F.apply(n, args);
/*3*/  return n;
}

Изменим предыдущий пример с Point:
function Point(x, y) { // Конструктор Point
    this.x = x;
    this.y = y;
}
Point.prototype = { // Прототип конструктора
    print: function () { console.log(this.x, this.y); }
};
 
var p1 = new Point(10, 20);
p1.print(); // 10 20
console.log(p1 instanceof Point); // true

// Это аналогично:
var p2 = New(Point, [10, 20]);
p2.print(); // 10 20
console.log(p2 instanceof Point); // true

Построение цепочки прототипов


В самом первом примере я строил цепочку прототипов, используя вот такую конструкцию Father.prototype = new Grandfather():
var Grandfather = function () {}; // Конструктор Grandfather 
Grandfather.prototype.color = 'green';

var Father = function () {}; // Конструктор Father 
Father.prototype = new Grandfather(); // Это простой, но не самый лучший вариант протитипного наследования

var Son = function () {}; // Конструктор Son 
Son.prototype = new Father(); // Аналогично

Теперь мы знаем поведение оператора new и можем понять, что тут делается — развернем new Grandfather():
Father.prototype = {
    __proto__: { // Прототип Grandfather
        color: 'green', 
        __proto__: Object.prototype
    }
};

Теперь при вызове new Father() мы получим вот такой объект (сразу развернем объект):
Son.prototype = {
    __proto__: { // Прототип Father
        __proto__: { // Прототип Grandfather
            color: 'green', 
            __proto__: Object.prototype
        }
    }
}

Давайте посмотрим, что мы имеем в конце кода в объекте s (экземпляр Son)
{
    color: 'black', // Сын поменял только собственное свойство 
    __proto__: { // Прототип Son
        __proto__: { // Прототип Father
            color: 'green', // Отец решил вернуть цвет
            __proto__: { // Прототип Grandfather
                color: 'blue', // Дед решил поменять на синий 
                __proto__: Object.prototype
            }
        }
    }
}

Почему же Father.prototype = new Grandfather() не самый лучший вариант построения цепочки прототипов?
Потому, что нам приходится вызывать конструктор Grandfather, который может подмешать лишние свойства и вызвать лишние методы, например alert. Для обхода этой проблемы используют подставной конструктор:
function inherit (object, parent) {
    function F(){}; // Подставной конструктор
    F.prototype = parent.prototype; // Подсовываем прототип реального конструктора
    object.prototype = new F(); // Теперь реальный конструктор не будет выполнен
    return object; // Можно и не возвращать
};

Пример использования:
var Grandfather = function () {}; // Конструктор Grandfather 
Grandfather.prototype.color = 'green';

var Father = function () {}; // Конструктор Father
inherit(Father, Grandfather); // Это лучше

Конструктор Grandfather не будет выполнен. Если нам все-такие необходимо выполнить конструктор Grandfather, то вызываем его с помошью call или appy
var Father = function () { // Конструктор Father
    Grandfather.call(this);    
};


Оператор instanceof


if (p instanceof Point) {
    // ...
}

Оператор instanceof очень тесно связан с цепочной прототипов. Он использует именно цепочку прототипов для вынесения вердикта, а не проверяет порожден ли данный объект «p» конструктором «Point». В этом моменте часто бывает путаница.

Оператор instanceof оперирует двумя объектами — obj и constructor: (obj instanceof constructor). Он начиная с constructor.prototype, пробегает по цепочке прототипов и проверяет следующее равенство obj.__proto__ === constructor.prototype, если оно истинное, то возвращает true.

Опишем в коде:
function isInstanceOf(obj, constructor) {
  if (obj.__proto__ === constructor.prototype)
    return true;
 
  else if (obj.__proto__ !== null)
    return isInstanceOf(obj.__proto__, constructor)
 
  else
    return false
}

Рассмотрим пример выше:
function Point(x, y) { // Конструктор Point
    this.x = x;
    this.y = y;
}
Point.prototype = { // Прототип конструктора
    print: function () { console.log(this.x, this.y); }
};
 
var p = new Point(10, 20); // Создаем новый объект

/* {} instanceof Object */ console.log(isInstanceOf({}, Object)); // true
/* p instanceof Point */   console.log(isInstanceOf(p, Point)); // true
/* p instanceof Object */  console.log(isInstanceOf(p, Object)); // true потому, что Object есть в цепи прототипов (Point.__proto__ === Object.prototype)
/* p instanceof Array */   console.log(isInstanceOf(p, Array)); // false потому, что Array нет в цепочке прототипов

Свойство this


this это одно большое заблуждение.

Многие привыкли, что ключевое слово this в языках программирования тесно связано с объектно-ориентированным программированием, а именно, указывает на текущий порождаемый конструктором объект. В ECMAScript this не ограничивается лишь определением порождаемого объекта.
В JavaScript значение this определяется вызывающей стороной по форме вызова. Правило по которому определяется то, что будет в this такое (объясню по-простому):
1. Если метод вызывается напрямую (без new, call, apply, bind, with, try catch), то значением this будет тот объект, который стоит перед точкой, слева от имени метода.
2. Если точки нет (функция вызывается напрямую), то this будет приравнен к undefined, null или window(global), в зависимости от среды и «use strict».
3. Если выражение представляет из себя не ссылку, а значение, то применяется пункт 2

Пример:
var foo = {
    bar: function () {
        console.log(this);
    }
};

var bar = foo.bar;

bar(); // this === global (2)

foo.bar();   // this === foo (1)
(foo.bar)(); // this === foo скобки ничего не меняют (1)
 
 // Все выражения слева от скобок вызова - значения
(foo.bar = foo.bar)(); // this === global (3)
(false || foo.bar)();  // this === global (3)
(foo.bar, foo.bar)();  // this === global (3)

function foo() {
   function bar() {
       console.log(this);
   }
   
   bar(); // this === global (2)
}

Вспомним пример с getProperty(p, 'print').call(p) именно из-за этого правила я вручную указал значение this. Иначе функция print получила бы в качестве this — window.

Вот эти операторы и методы способны управлять значением this: new, call, apply, bind, with, try catch (с ними более-менее все понятно, не буду затрагивать).

Подробнее о this:
Тонкости ECMA-262-3. Часть 3: This

undefined, null, void


null — примитивное значение, представляющее нулевую, пустую, не существующую ссылку
undefined — примитивное значение, которое получает каждая перемененная по умолчанию (когда переменная не имеет значение)
void — это оператор (т.е. при вызове его скобки не нужны), выполняющий выражение и всегда возвращающий undefined

Заключение


1. В JavaScript нет классов — есть конструкторы
2. Цепь прототипов — база на которую опирается все наследование в JavaScript
3. Свойство объекта получается с использованием цепи прототипов
4. __proto__ — это ссылка на прототип конструктора(prototype)
5. Оператор new создает пустой объект с единственным свойством __proto__, которое ссылается на F.prototype, выполняет конструктор F в котором this — созданный ранее объект и возвращает объект
6. Оператор instanceof не проверяет порожден ли данный объект «Object» конструктором «ObjectsConstoructor», для своего вердикта он использует цепь прототипов
7. В JavaScript значение this определяется вызывающей стороной по форме вызова
8. void — это оператор, а не функция. undefined, null — примитивные значения

Важно В некоторых реализациях JavaScript нельзя напрямую менять __proto__, к тому же это свойство не стандартное и уже устаревшее. Для получения ссылки на прототип следует использовать Object.getPrototypeOf. В статье я применял его (__proto__) для демонстрации «внутренностей» ECMAScript.

В статье были использованы материалы


Статьи из блога Дмитрия Сошинкова dsCode
How Prototypal Inheritance really works (Christopher Chedeau)
Теги:
Хабы:
Всего голосов 174: ↑161 и ↓13+148
Комментарии96

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань