Skip to content

Latest commit

 

History

History
477 lines (300 loc) · 61.4 KB

File metadata and controls

477 lines (300 loc) · 61.4 KB

Вы не знаете JS: This и Прототипы Объектов

Глава 4: Смешивая объекты "классов"

Продолжая исследование объектов, начатое в предыдущей главе, вполне естественно, что теперь мы обратим внимание на «объектно-ориентированное (OO) программирование», с «классами». Мы рассмотрим «класс-оринтированность» в качестве шаблона проектирования, прежде чем изучать механику «классов»: «создание экземпляров», «наследование» и «(относительный) полиморфизм».

Мы увидим, что эти понятия на самом деле не очень хорошо соотносятся с механизмами работы с объектами в JS (mixins и т. д.), и многие разработчики JavaScript идут на преодоление подобных вызовов.

Примечание: В этой главе уделяется довольно много внимания (первая половина!) тяжеловесной теории «объективно-ориентированного программирования». В конце концов мы свяжем эти идеи с реальным JavaScript кодом во второй половине, где мы поговорим о «миксинах (mixins)». Но будет рассмотрено много концепций и псевдокода, чтобы продвинуться вперед, поэтому не теряйтесь - просто потерпите!

Теория классов

"Классовое наследование" описывает определенный подход к архитектуре и организации кода - способ моделирования реальных проблемных областей в нашем программном обеспечении.

ОО или класс-ориентированное программирование делает акцент на том, что данные в действительности имеют схожее поведение (конечно, разное в зависимости от типа и природы данных!), влияющее на них, поэтому соответствующий дизайн должен упаковать (ака, инкапсулировать) данные и их поведение вместе. В формальной информатике это иногда называют "структурами данных".

Например, некая последовательность символов, представляющая сообой слово или фразу, обычно называется "строка". Данными здесь являются символы. Но вам почти никогда не интересны данные, обычно Вы хотите с этими данными что-то делать, поэтому все операции, которые могут применяться к этим данным (вычисление длины, добавление данных, поиск и т. д.) разработаны как методы класса 'String'.

Любая данная строка просто является экземпляром этого класса, что означает, что это аккуратно собранная упаковка как символьных данных, так и функциональности, которую мы можем к ним применить.

Классы также несут в себе способ классификации определенной структуры данных. То, как мы это делаем, - это воспринимать любую данную структуру как конкретную вариацию более общего базового определения.

Давайте рассмотрим этот процесс "классификации" на часто используемом примере: автомобиль можно описать как некую частную реализацию более общего "класса" предметов, называемого транспортные средства.

В программном обеспечении мы моделируем данную связь с помощью классов, определяя класс транспортные средства и класс автомобиль.

Определение транспортные средства может включать в себя такие понятия, как силовая установка (ДВС и т. д.), способность перевозить людей, и так далее, все это будет неким поведением класса. Все, что мы определяем в "транспортном средстве", - это принципы, являющиеся общими для всех (или большинства) возможных типов транспортных средств ("самолеты, поезда и автомобили").

Возможно, нет смысла снова и снова переопределять в нашей программе базовую сущность "способности перевозить людей" для каждого типа транспортного средства. Вместо этого мы определим данную возможность один раз в "транспортном средстве", а далее, описывая "автомобиль", мы просто укажем, что он "наследует" (или "расширяет") базовое определение от "транспортного средства". Определение "автомобиля", как говорят, уточняет, общее определение "транспортного средства".

В то время как транспортное средство и автомобиль определяют поведение посредством методов, данными экземпляра будут такие вещи, как уникальный VIN конкретного автомобиля и т. д.

Таким образом возникают классы, наследование и создание экземпляров.

Другим ключевым понятием касательно классов является "полиморфизм", который описывает идею о том, что общее поведение, описанное в родительском классе может быть переопределено в дочернем классе, чтобы придать ему больше конкретики. Фактически, относительный полиморфизм позволяет нам ссылаться на базовое поведение из переопределенного.

Теория классов предполагает, что родительский и дочерний классы разделяют название методов для описания определенного поведения, так что потомок переопределеят родительскую реализацию. Как мы увидим позже, подобные вещи в вашем JavaScript коде могут привести к разочарованию и хрупкости кода.

Шаблон проектирования "Класс"

Возможно, вы никогда не смотрели на классы как на "шаблон проектирования", так как чаще всего обсуждаются популярные "OO шаблоны проектирования", такие как "итератор", "наблюдатель", "фабрика", "синглтон" и т.д. При этом сразу предполагается, что OO классы являются механизмами более низкого уровня, с помощью которых мы реализуем все шаблоны проектирования (более высокого уровня), как будто OO является единственно-верной основой для всего (правильного) кода.

В зависимости от вашего уровня формального образования в области программирования, вы, возможно, слышали о "процедурном программировании" как способе описания кода, который состоит только из процедур (ака, функций), вызывающих другие функции, без каких-либо более высокоуровневых абстракций. Возможно, вас учили, что классы были правильным способом преобразования процедурного "спагетти-кода" в хорошо оформленный организованный код.

Конечно, если у вас есть опыт в "функциональном программировании" (монады и др.), вы хорошо знаете, что классы являются лишь одним из нескольких распространенных шаблонов проектирования. Но другие, возможно, впервые спросят себя, а действительно ли классы являются фундаментальной основой для написания кода, или они являются необязательной абстракцией.

Некоторые языки (например, Java) не оставляют вам выбора, поэтому это восе не опционально - все это класс. Другие языки, такие как C/C++ или PHP, предоставляют вам как процедурные, так и классовые синтаксисы, и больше зависит от выбора разработчика, какой стиль или смесь стилей ему подойдет.

"Классы" JavaScript

Где JavaScript начинает иметь к этому всему отношение? JS имеет некоторые синтаксические элементы, подобные классу (например, new и instanceof) довольно давно, а в последнее время в ES6 появились некоторые дополнения, такие как ключевое слово class (см. приложение A).

Но значит ли это, что в JavaScript действительно есть классы? Строго и однозначно: Нет.

Поскольку классы являются шаблоном проектирования, вы можете, приложив немало усилий (как мы увидим далее в этой главе), реализовать некоторое приближение для большей части классической функциональности класса. JS пытается удовлетворить чрезвычайно распространенное желание проектировать с классами, предоставляя, казалось бы, похожий на классы синтаксис.

Хотя у нас и может быть синтаксис, похожий на классы, это больше похоже на то, что механика JavaScript борется против того, чтобы вы использовали шаблон проектирования class. Так как под капотом механизмы, которые вы строите, работают совсем по-другому. Синтаксический сахар и (очень широко используемые) JS библиотеки для работы с "классами" проходят долгий путь скрывая эту реальность от вас, но рано или поздно вы столкнетесь с тем, что классы которые у вас есть в других языках не похожи на фейковые "классы", которые мы создаем себе в JS.

Все это сводится к тому, что классы не являются обязательным шаблоном при разработке программного обеспечения, и у вас есть выбор, использовать их в JavaScript или нет. Поскольку многие разработчики имеют сильную тягу к класс-ориентированному дизайну программного обеспечения, мы посвятим оставшуюся часть этой главы расмотрению того, чего стоит поддержание иллюзии классов с использованием тех механизмов, что предоставляет JS, и тех проблем, которые мы при этом испытываем.

Механика Классов

Во многих классовых языках "стандартная библиотека" предоставляет "стековую" структуру данных (push, pop и др.) как класс Stack. Этот класс имеет внутренний набор переменных, которые хранят данные, и набор публичных методов, которые дают вашему коду возможность взаимодействовать со (скрытыми) данными (добавление и удаление данных и т. д.).

Но в подобных языках вы на самом деле не работаете непосредственно со Stack (если только речь не идет о Static члене класса, но это выходит за рамки нашего обсуждения). Класс Stack - это просто абстрактное описание того, что должен делать любой "стек", но это не сам "стек". Вы должны создать экземпляр класса Stack, прежде чем у вас будет конкретная структура данных нечто для работы с ней.

Строительство

Традиционная метафора понимания концепции «класс» и «экземпляр» основана на конструировании здания.

Архитектор планирует все характеристики здания: какова его ширина, высота, сколько окон и в каких местах, даже какой материал использовать для стен и крыши. На данном этапе архитектору не важно где будет построено здание, равно как и сколько копий этого здания будет построено.

Архитектор также не очень заботится о внутренностях здания - мебели, обоях, потолочных вентиляторах и т.д. - только о том, какой тип конструкции будет иметь здание.

Архитектурные проекты являются всего лишь планами здания. Они на самом деле не представляют собой здание, в которое мы можем войти и сесть. Нам нужен строитель для этой задачи. Строитель возьмет эти планы и будет следовать им по мере того, как он строит здание. Он «копирует» намеченные характеристики из планов в физическое здание.

После завершения строительства это будет физическое воплощение планов, которые, как мы надеемся, по сути, будут идеальной копией. И тогда застройщик может перейти к открытому участку по соседству и сделать это снова, создав еще одну копию.

Отношения между строительством и планом косвенные. Вы можете изучить чертеж, чтобы понять, как строилось здание, для любых частей, где прямой осмотр самого здания был недостаточен. Но если вы хотите открыть дверь, вы должны пойти в само здание - на чертеже просто нарисованы линии, которые представляют, где должна быть дверь.

Класс - это план. Чтобы на самом деле получить объект, с которым мы можем взаимодействовать, мы должны построить (иначе, "создать экземпляр") что-то из класса. Конечным результатом такой «конструкции» является объект, обычно называемый «экземпляром», с помощью которого мы можем напрямую вызывать методы и обращаться к любым общедоступным свойствам данных при необходимости.

Этот объект является копией всех характеристик, описанных классом.

Вы, вероятно, не ожидаете, что войдете в здание и найдете висящую в рамке на стене копию чертежей, используемых для планирования здания, хотя чертежи, вероятно, хранятся в государственном архиве. Точно так же вы обычно не используете экземпляр объекта для прямого доступа к его классу и манипулирования им, но, обычно, возможно по крайней мере определить, из какого класса исходит экземпляр объекта.

Более полезно рассмотреть прямую связь класса с экземпляром, а не любые косвенные отношения между экземпляром и классом, из которого он получен. Класс создается в форме объекта с помощью операции копирования.

Как видно, стрелки перемещаются слева направо и сверху вниз, что указывает на операции копирования, которые происходят как концептуально, так и физически.

Конструктор

Экземпляры классов создаются специальным методом класса, обычно с тем же именем, что и класс, называемым конструктор. Задача этого метода - инициализировать любую информацию (состояние), которая понадобится экземпляру.

Например, рассмотрим этот свободный псевдокод (изобретенный синтаксис) для классов:

class CoolGuy {
	specialTrick = nothing

	CoolGuy( trick ) {
		specialTrick = trick
	}

	showOff() {
		output( "Зацените мой трюк: ", specialTrick )
	}
}

Чтобы сделать экземпляр CoolGuy, мы бы вызвали конструктор класса:

Joe = new CoolGuy( "Прыжок через скакалку" )

Joe.showOff() // Зацените мой трюк: прыжок через скакалку

Обратите внимание, что класс CoolGuy имеет конструктор CoolGuy(), который фактически является тем, что мы вызываем, когда мы говорим new CoolGuy (..). Обратно из конструктора мы получаем объект (экземпляр нашего класса), и мы можем вызвать метод showOff(), который выводит особый трюк CoolGuy.

Очевидно, прыжки со скакалкой делают Джо довольно крутым парнем.

Конструктор класса принадлежит классу, почти всегда с тем же именем, что и класс. Кроме того, конструкторы всегда нужно вызывать с помощью new, чтобы языковой движок знал, что вы хотите создать новый экземпляр класса.

Наследование классов

В класс-ориентированных языках вы можете не только определить класс, для которого можно создать сам экземпляр, но вы можете определить другой класс, который наследуется от первого класса.

Второй класс часто называют «дочерним классом», тогда как первый - «родительским классом». Эти термины, очевидно, происходят из метафоры "родители и дети", хотя метафоры здесь немного растянуты, как вы скоро увидите.

Когда у родителя есть биологический ребенок, генетические характеристики родителя копируются в ребенка. Очевидно, что в большинстве систем биологического размножения есть два родителя, которые в равной степени передают гены ребёнку. Но в нашей метафоре мы будем предполагать только одного родителя.

Когда ребенок существует, он или она отделяется от родителя. Изначально ребенок находится под сильным влиянием наследования от своего родителя, но со временем начинает отличаться от него всё больше и больше. Если у ребенка появляются рыжие волосы, это не значит, что волосы родителя были или автоматически становятся рыжими.

Аналогичным образом, после определения дочернего класса он становится отдельным и отличным от родительского класса. Дочерний класс содержит начальную копию поведения от родителя, но затем может переопределить любое унаследованное поведение и даже определить новое поведение.

Важно помнить, что мы говорим о родительских и дочерних классах, которые не являются физическими. Вот где метафора «родитель-ребенок» становится немного запутанной, потому что на самом деле мы должны сказать, что родительский класс похож на ДНК родителя, а дочерний класс похож на ДНК ребенка. Мы должны сделать (или «создать экземпляр») человека из каждого набора ДНК, чтобы на самом деле иметь живого собеседника.

Давайте оставим в стороне биологических родителей и детей и рассмотрим наследование через несколько иную линзу: разные типы транспортных средств. Это одна из самых канонических (и часто достойных восклицания) метафор для понимания наследования.

Давайте вернемся к обсуждению Транспортное средство и Автомобиль, описанному ранее в этой главе. Рассмотрим этот свободный псевдокод (изобретенный синтаксис) для унаследованных классов:

class Vehicle {
	engines = 1

	ignition() {
		output( "Завожу двигатель." )
	}

	drive() {
		ignition()
		output( "Двигаюсь вперёд!" )
	}
}

class Car inherits Vehicle {
	wheels = 4

	drive() {
		inherited:drive()
		output( "Еду на всех ", wheels, " колёсах!" )
	}
}

class SpeedBoat inherits Vehicle {
	engines = 2

	ignition() {
		output( "Завожу мои ", engines, " двигатели." )
	}

	pilot() {
		inherited:drive()
		output( "Скольжу по воде с ветерком!" )
	}
}

Примечание: Для ясности и краткости конструкторы для этих классов были опущены.

Мы определяем класс «Транспортное средство» - Vehicle, который предполагает двигатель, способ включения зажигания и способ передвижения. Но вы бы никогда не произвели просто универсальное «транспортное средство», так что на самом деле это просто абстрактная концепция.

Итак, мы определяем два конкретных типа транспортных средств: «Автомобиль» - Car и «Скоростной катер» - SpeedBoat. Каждый из них наследует общие характеристики «Транспортного средства» - Vehicle, но затем они специализируют характеристики соответствующие для каждого вида. Автомобилю нужно 4 колеса, а скоростному катеру - 2 двигателя, а это значит, что для включения зажигания обоих двигателей требуется дополнительное внимание.

Полиморфизм

Car определяет свой собственный метод drive(), который переопределяет метод с тем же именем, унаследованный от Vehicle. Но затем метод drive() из Сar вызывает inherited:drive(), что указывает на то, что Car может ссылаться на исходный предварительно переопределенный метод drive(), который он унаследовал. Метод pilot() из SpeedBoat также ссылается на его унаследованную копию drive(). Эта техника называется «полиморфизм» или «виртуальный полиморфизм». Приближённо к нашей теме, мы будем называть это «относительным полиморфизмом».

Полиморфизм - гораздо более широкая тема, чем мы здесь рассмотрели, но наша текущая «относительная» семантика относится к одному конкретному аспекту: идея о том, что любой метод может ссылаться на другой метод (с тем же или другим именем) на более высоком уровне иерархии наследования. Мы говорим «относительный», потому что мы не определяем абсолютно к какому уровню наследования (классу) мы хотим получить доступ, а скорее ссылаемся на него, по сути, «на один уровень вверх».

Во многих языках используется ключевое слово «super» вместо «inherited:», это основывается на идее, что «super class» является родителем/предком текущего класса.

Другой аспект полиморфизма заключается в том, что имя метода может иметь несколько определений на разных уровнях цепочки наследования, и эти определения автоматически выбираются соответствующим образом при определении того, какие методы вызываются.

Мы видим два случая такого поведения в нашем примере выше: drive() определен как в Vehicle, так и в Car, а ignition() определен как в Vehicle, так и в SpeedBoat.

Примечание: Еще одна вещь, которую дают вам традиционные класс-ориентированные языки через super, - это возможность прямо ссылаться на конструктор родительского класса из конструктора дочернего класса. Это в значительной степени верно, потому что с реальными классами конструктор принадлежит классу. Однако в JS все наоборот - на самом деле более уместно думать о «классе», принадлежащем конструктору (ссылки на типы Foo.prototype ...). Поскольку в JS отношения между дочерними и родительскими объектами существуют только между двумя объектами .prototype соответствующих конструкторов, сами конструкторы не связаны напрямую, и, следовательно, не существует простого способа ссылаться из одного конструктора в другого (см. Приложение A для ES6 class, который "решает" это с помощью super).

Интересное следствие полиморфизма можно увидеть именно с помощью функции ignition(). Внутри pilot() делается относительная-полиморфная ссылка на (унаследованную) версию Vehicle drive(). Но этот drive() ссылается на метод ignition() только по имени (без относительной ссылки).

Какую версию ignition() будет использовать языковой движок: Vehicle или SpeedBoat? Он использует версию SpeedBoat ignition(). Если бы вы должны были создать экземпляр самого класса Vehicle, а затем вызвать его метод drive(), языковой движок использовал бы метод ignition() от Vehicle.

Другими словами, определение для метода ignition() - полиморфно (изменяется) в зависимости от того, на какой класс (уровень наследования) вы ссылаетесь для экземпляра.

Это может показаться слишком глубокими академическими подробностями. Но понимание этих деталей необходимо для правильного сопоставления аналогичного (но отличного) поведения в механизме JavaScript [[Prototype]].

Когда классы наследуются, существует способ для самих классов (а не для созданных из них экземпляров!) относительно ссылаться на класс-источник наследования, и эта относительная ссылка обычно называется super.

Ранее мы рассматривали схему:

Обратите внимание, как для экземпляров (a1, a2, b1 и b2) и наследования (Bar) стрелки указывают операцию копирования.

Концептуально может показаться, что дочерний класс Bar может обращаться к методам своего родительского Foo, используя относительную полиморфную ссылку (известную как super). Однако в действительности дочернему классу просто дается копия унаследованного поведения от его родительского класса. Если дочерний элемент «переопределяет» метод, который он наследует, то исходная и переопределенная версии метода фактически поддерживаются, так что обе версии метода доступны.

Не позволяйте полиморфизму сбить вас с толку, думая, что дочерний класс связан с его родительским классом. Дочерний класс вместо этого получает копию того, что ему нужно, от родительского класса. Дочерний класс подразумевает копии.

Множественное наследование

Помните наше предыдущее обсуждение родителей и детей, а также ДНК? Мы сказали, что метафора была немного странной, потому что биологически большинство потомков происходит от двух родителей. Если бы класс мог наследовать от двух других классов, он более точно соответствовал бы метафоре родитель / потомок.

Некоторые ориентированные на классы языки позволяют указывать более одного «родительского» класса для «наследования». Множественное наследование означает, что каждое определение родительского класса копируется в дочерний класс.

На первый взгляд, это кажется мощным дополнением к возможностям классов, что позволяет нам объединить больше функциональных возможностей. Однако, безусловно, возникают некоторые сложные вопросы. Если оба родительских класса предоставляют метод с именем drive(), на какую версию ссылается ссылка drive() в дочернем элементе? Всегда ли вам нужно вручную указывать, какой родительский тип drive() вы имели в виду, теряя при этом некоторую грациозность полиморфного наследования?

Есть еще один вариант, так называемая «проблема алмазов», которая относится к сценарию, где дочерний класс «D» наследует от двух родительских классов («B» и «C»), а каждый из них, в свою очередь, наследуется от общего класса «A». «A» предоставляет метод drive(), и оба класса "B" и "C" переопределяют (полиморфируют) этот метод. Когда D ссылается на drive(), какую версию следует использовать (B:drive() или C:drive())?

Но на деле всё ещё сложнее, чем кажется на первый взгляд. Мы рассматриваем это только для того, чтобы мы могли противопоставить работу механизмов JavaScript.

JavaScript проще: он не предоставляет нативный механизм «множественного наследования». Многие считают, что это хорошо, потому что экономия на сложности более чем компенсирует «урезанную» функциональность языка. Но это не мешает разработчикам пытаться подделать его(механизм) различными способами, как мы увидим дальше.

Mixins - Примеси

Механизм объектов JavaScript не автоматически выполняет поведение копирования, когда вы «наследуете» или «создаете экземпляр». Проще говоря, в JavaScript нет «классов» для создания экземпляров, только объекты. И объекты не копируются в другие объекты, они связываются вместе (подробнее об этом в главе 5).

Поскольку наблюдаемое поведение классов в других языках подразумевает наличие копий, давайте рассмотрим, как разработчики JS подделывают поведение копирования отсутствующего в JavaScript классах с помощью примесей(Mixins). Мы рассмотрим два типа «примесей»: явные и неявные.

Явные примеси

Давайте снова вернемся к нашему примеру «Транспортное средство» - Vehicle и «Автомобиль» - Car. Поскольку JavaScript не будет автоматически копировать поведение из Vehicle в Car, мы можем вместо этого создать утилиту, которая будет это делать. Такая утилита часто называется extend(..) многими библиотеками / фреймворками, но мы будем называть ее mixin(..) здесь для наглядности.

// значительно упрощенный пример `mixin(..)`:
function mixin( sourceObj, targetObj ) {
	for (var key in sourceObj) {
		// копируем только если его еще нет
		if (!(key in targetObj)) {
			targetObj[key] = sourceObj[key];
		}
	}

	return targetObj;
}

var Vehicle = {
	engines: 1,

	ignition: function() {
		console.log( "Завожу двигатель." );
	},

	drive: function() {
		this.ignition();
		console.log( "Двигаюсь вперёд!" );
	}
};

var Car = mixin( Vehicle, {
	wheels: 4,

	drive: function() {
		Vehicle.drive.call( this );
		console.log( "Еду на всех моих " + this.wheels + " колёсах!" );
	}
} );

Примечание: Важная деталь - мы больше не имеем дело с классами, потому что в JavaScript нет классов. «Транспортное средство» - Vehicle и «Автомобиль» - Car — это просто объекты, с которых мы делаем копии, соответственно.

Car теперь имеет копию свойств и функций из Vehicle. Технически, функции на самом деле не дублируются, а копируются ссылки на функции. Таким образом, Car теперь имеет свойство под названием ignition, которое является скопированной ссылкой на функцию ignition(), а также свойство, называемое engines, со скопированным значением 1 из Vehicle.

Car уже имеет свойство (функцию) drive, поэтому ссылка на свойство не была переопределена (см. Инструкцию if в mixin(..) выше).

"Полиморфизм" вновь

Давайте рассмотрим это утверждение: Vehicle.drive.call(this). Это то, что я называю «явным псевдополиморфизмом». Напомню, что в псевдокоде, который мы рассматривали выше, эта строка была inherited:drive(), которую мы назвали «относительным полиморфизмом».

JavaScript не имеет (до ES6; см. Приложение A) средства для относительного полиморфизма. Итак, потому что Car и Vehicle имеют функцию с одинаковым именем: drive(), чтобы различать вызов той или иной функции, мы должны сделать абсолютную (не относительную) ссылку. Мы явно указываем объект Vehicle по имени и вызываем функцию drive() для него.

Но если бы мы сказали Vehicle.drive(), привязка this для этого вызова функции была бы объектом Vehicle вместо объекта Car (см. Главу 2), а это не то, что нам нужно. Таким образом, вместо этого мы используем .call(this) (Глава 2), чтобы гарантировать, что drive() выполняется в контексте объекта Car.

Примечание: Если идентификатор имени функции для Car.drive() не перекрывался ("затенялся"; см. Главу 5) с Vehicle.drive(), мы бы не выполняли «метод полиморфизма». Таким образом, ссылка на Vehicle.drive() была бы скопирована вызовом mixin(..), и мы могли бы получить прямой доступ с помощью this.drive(). Выбранный идентификатор перекрывается затенением, поэтому мы должны использовать более сложный явный подход псевдополиморфизма.

В ориентированных на классы языках, которые имеют относительный полиморфизм, связь между Car и Vehicle устанавливается один раз, в верхней части определения класса, что дает только одно место для поддержания таких отношений.

Но из-за особенностей JavaScript явный псевдополиморфизм (из-за затенения!) Создает хрупкую ручную/явную связь в каждой отдельной функции, где вам нужна такая (псевдо-) полиморфная ссылка. Это может значительно увеличить стоимость обслуживания. Более того, хотя явный псевдополиморфизм может эмулировать поведение «множественного наследования», он только увеличивает сложность и хрупкость.

Результатом таких подходов обычно является более сложный, трудный для чтения и сложный в обслуживании код. По возможности следует избегать явного псевдополиморфизма, потому что стоимость в большинстве случаев перевешивает выгоду.

Смешивание копий

Вспомните утилиту mixin(..) сверху:

// значительно упрощенный пример `mixin(..)`:
function mixin( sourceObj, targetObj ) {
	for (var key in sourceObj) {
		// копируем только если его еще нет
		if (!(key in targetObj)) {
			targetObj[key] = sourceObj[key];
		}
	}

	return targetObj;
}

Теперь давайте посмотрим, как работает mixin(..). Он перебирает свойства sourceObj (в нашем примере - Vehicle), и если в targetObj (в нашем примере, Car) нет соответствующего свойства с этим именем, он создает копию. Поскольку мы делаем копию после того, как объект уже был проинициализирован, мы стараемся не копировать целевое свойство.

Если мы сначала сделаем копии, прежде чем указывать содержимое Car, мы могли бы опустить эту проверку для targetObj, но это немного более неуклюже и менее эффективно, поэтому обычно это менее предпочтительно:

// альтернативный миксин, менее "безопасный" для перезаписи
function mixin( sourceObj, targetObj ) {
	for (var key in sourceObj) {
		targetObj[key] = sourceObj[key];
	}

	return targetObj;
}

var Vehicle = {
	// ...
};

// сначала создаем пустой объект со
// скопированными свойствами `Vehicle`
var Car = mixin( Vehicle, { } );

// теперь копируем содержимое в Car
mixin( {
	wheels: 4,

	drive: function() {
		// ...
	}
}, Car );

При любом подходе мы явно копировали неперекрывающееся содержимое Vehicle в Car. Название mixin происходит от альтернативного способа объяснения задачи: у Car смешанное с Vehicle содержимое, как вы смешиваете шоколадные крошки в своем любимом тесте для печенья чтобы получить печенье с шоколадной крошкой.

В результате операции копирования Car будет работать несколько иначе, чем Vehicle. Если вы добавите свойство в Car, оно не повлияет на Vehicle, и наоборот.

Примечание: Здесь были рассмотрены несколько мелких деталей. Есть и некоторые другие тонкости, которые позволяют двум объектам «влиять» друг на друга даже после копирования, например, если они оба имеют общую ссылку на общий объект (такой как массив).

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

Функции JavaScript на самом деле не могут быть дублированы (стандартным, надежным способом), поэтому вместо этого мы получаем дублированную ссылку на один и тот же объект общей функции (функции являются объектами; см. Главу 3). Если вы изменили один из общих функциональных объектов (например, ignition()), добавив к нему свойства, то и Vehicle, и Car будут «затронуты» через общую ссылку.

Явные примеси - это прекрасный механизм в JavaScript. Но они кажутся более могущественными, чем они есть на самом деле. На самом деле небольшая выгода получается от копирования свойства из одного объекта в другой, в отличие от простого определения свойств дважды, один раз для каждого объекта. И это особенно верно, учитывая нюанс ссылки на функцию-объект, который мы только что упомянули.

Если вы явно смешиваете два или более объекта в целевой объект, вы можете частично эмулировать поведение «множественного наследования», но нет прямого способа обработки коллизий, если один и тот же метод или свойство копируется из более чем одного источника. Некоторые разработчики/библиотеки придумали методы «позднего связывания» и другие экзотические обходные пути, но по сути эти «уловки» обычно требуют больше усилий (и меньшей производительности!), чем дают пользы.

Позаботьтесь только о том, чтобы использовать явные примеси, когда это действительно помогает сделать код более читабельным, и избегайте шаблонов, если вы обнаружите, что он делает код сложным для отслеживания или он создает ненужные или громоздкие зависимости между объектами.

Если при использовании примесей писать и поддерживать код становится труднее, чем если бы вы их не использовали, вам, вероятно, следует прекратить использовать примеси. Фактически, если вам нужно использовать сложную библиотеку/утилиту для проработки всех этих деталей, это может быть признаком того, что вы идете по этому пути сложнее, возможно, чем следовало бы. В главе 6 мы попытаемся найти более простой способ достижения желаемых результатов без суеты.

Паразитическое наследование

Вариант этого явного паттерна подмешивания, который в некоторых отношениях явный, а в других неявный, называется «паразитическим наследованием», популяризируемый в основном Дугласом Крокфордом.

Вот как это может работать:

// "Традиционный JS класс" `Vehicle`
function Vehicle() {
	this.engines = 1;
}
Vehicle.prototype.ignition = function() {
	console.log( "Завожу двигатель." );
};
Vehicle.prototype.drive = function() {
	this.ignition();
	console.log( "Двигаюсь вперёд!" );
};

// "Паразитический класс" `Car`
function Car() {
	// во-первых, `car` это `Vehicle`
	var car = new Vehicle();

	// теперь, давайте модифицируем `car` чтобы придать ей специфичности
	car.wheels = 4;

	// сохранить привилегированную ссылку в `Vehicle::drive()`
	var vehDrive = car.drive;

	// переопределяем `Vehicle::drive()`
	car.drive = function() {
		vehDrive.call( this );
		console.log( "Еду на всех моих " + this.wheels + " колёсах!" );
	};

	return car;
}

var myCar = new Car();

myCar.drive();
// Завожу двигатель.
// Двигаюсь вперёд!
// Еду на всех моих 4 колёсах!

Как вы можете видеть, мы сначала делаем копию определения из «родительского класса» (объекта) Vehicle, затем смешиваем наше определение «дочернего класса» (объекта) (сохраняя при необходимости ссылки на привилегированный родительский класс) и передаем от этого составного объекта car в качестве нашего дочернего экземпляра.

Примечание: когда мы вызываем new Car(), создается новый объект и на него ссылается ссылка this (см. Главу 2). Но поскольку мы не используем этот объект и вместо этого возвращаем наш собственный объект car, первоначально созданный объект просто отбрасывается. Таким образом, Car() может быть вызван без ключевого слова new, а приведенные выше функциональные возможности будут идентичны, но без ненужного создания объекта/сборки мусора.

Неявные примеси

Неявные примеси тесно связаны с явным псевдополиморфизмом, как объяснено ранее. Как таковые, они приходят с одинаковыми предостережениями и предупреждениями.

Рассмотрим этот код:

var Something = {
	cool: function() {
		this.greeting = "Привет, мир";
		this.count = this.count ? this.count + 1 : 1;
	}
};

Something.cool();
Something.greeting; // "Привет, мир"
Something.count; // 1

var Another = {
	cool: function() {
		// неявное смешивание `Something` с `Another`
		Something.cool.call( this );
	}
};

Another.cool();
Another.greeting; // "Привет, мир"
Another.count; // 1 (нет общего состояния с `Something`)

С помощью Something.cool.call(this), которое может происходить либо при вызове «конструктора» (наиболее часто), либо при вызове метода (показано здесь), мы, по сути, «заимствуем» функцию Something.cool() и вызываем его в контексте Another (через привязку this; см. главу 2) вместо Something. Конечным результатом является то, что назначения, которые выполняет Something.cool(), применяются к объекту Another, а не к объекту Something.

Итак, сказано, что мы «смешали» поведение Something с (или в) Another.

Хотя этот вид техники, кажется, использует полезные преимущества функции связывания this, это хрупкий вызов Something.cool.call(this), который нельзя превратить в относительную (и, следовательно, более гибкую) ссылку, с которой вам следует соблюдать осторожность. Как правило, по возможности избегайте таких конструкций, чтобы сохранить более чистый и более понятный код.

Обзор (TL; DR)

Классы - это шаблон кодирования. Многие языки предоставляют синтаксис, который позволяет проектировать класс-ориентированное программное обеспечение. JS также имеет похожий синтаксис, но он ведет себя совсем иначе, чем вы ожидаете от классов из этих других языков.

Классы означают копии.

Когда создаются традиционные классы, происходит копирование поведения от класса к экземпляру. Когда классы наследуются, также происходит копирование поведения от родителя к потомку.

Может показаться что полиморфизм (имеющий разные функции на нескольких уровнях цепочки наследования с одним и тем же именем) подразумевает относительную ссылку от дочернего элемента к родительскому, но это все еще просто результат поведения копирования.

JavaScript автоматически не создает копии (как подразумевают классы) между объектами.

Шаблон примеси (как явный, так и неявный) часто используется для эмуляции поведения копирования классов, но это обычно приводит к уродливому и хрупкому синтаксису, например явному псевдополиморфизму (OtherObj.methodName.call(this, ...)), что часто приводит к усложнению понимания и поддержки кода.

Явные примеси также не совсем совпадают с копированием классов, поскольку объекты (и функции!) дублируются только общими ссылками, а сами объекты/функции не дублируются. Не обратив внимания на такой нюанс вы получите источник множества недочетов.

В целом, фальшивые классы в JS часто устанавливают больше мин для будущего кодирования, вместо решения реальных проблем.