Продолжая исследование объектов, начатое в предыдущей главе, вполне естественно, что теперь мы обратим внимание на «объектно-ориентированное (OO) программирование», с «классами». Мы рассмотрим «класс-оринтированность» в качестве шаблона проектирования, прежде чем изучать механику «классов»: «создание экземпляров», «наследование» и «(относительный) полиморфизм».
Мы увидим, что эти понятия на самом деле не очень хорошо соотносятся с механизмами работы с объектами в JS (mixins и т. д.), и многие разработчики JavaScript идут на преодоление подобных вызовов.
** Примечание: ** В этой главе уделяется довольно много внимания (первая половина!) тяжеловесной теории «объективно-ориентированного программирования». В конце концов мы свяжем эти идеи с реальным JavaScript кодом во второй половине, где мы поговорим о «миксинах (mixins)». Но будет рассмотрено много концепций и псевдокода, чтобы продвинуться вперед, поэтому не теряйтесь - просто потерпите!
"Классовое наследование" описывает определенный подход к архитектуре и организации кода - способ моделирования реальных проблемных областей в нашем программном обеспечении.
ОО или класс-ориентированное программирование делает акцент на том, что данные в действительности имеют схожее поведение (конечно, разное в зависимости от типа и природы данных!), влияющее на них, поэтому соответствующий дизайн должен упаковать (ака, инкапсулировать) данные и их поведение вместе. В формальной информатике это иногда называют "структурами данных".
Например, некая последовательность символов, представляющая сообой слово или фразу, обычно называется "строка". Данными здесь являются символы. Но вам почти никогда не интересны данные, обычно Вы хотите с этими данными что-то делать, поэтому все операции, которые могут применяться к этим данным (вычисление длины, добавление данных, поиск и т. д.) разработаны как методы класса 'String'.
Любая данная строка просто является экземпляром этого класса, что означает, что это аккуратно собранная упаковка как символьных данных, так и функциональности, которую мы можем к ним применить.
Классы также несут в себе способ классификации определенной структуры данных. То, как мы это делаем, - это воспринимать любую заданную структуру как о конкретную вариации более общего базового определения.
Давайте рассмотрим этот процесс "классификации" на часто используемом примере: автомобиль можно описать как некую частную реализацию более общего "класса" предметов, называемого транспортные средства.
В программном обеспечении мы моделируем данную связь с помощью классов, определяя класс транспортные средства
и класс автомобиль
.
Определение транспортные средства
может включать в себя такие понятия, как силовая установка (ДВС и т. д.), способность перевозить людей, и так далее, все это будет неким поведением класса. Все, что мы определяем в "транспортном средстве", - это принципы, являющиеся общими для всех (или большинства) возможных типов транспортных средств ("самолеты, поезда и автомобили").
Возможно, нет смысла снова и снова переопределять в нашей программе базовую сущность "способности перевозить людей" для каждого типа транспортного средства. Вместо этого мы определим данную возможность один раз в "транспортном средстве", а далее, описывая "автомобиль", мы просто укажем, что он "наследует" (или "расширяет") базовое определение от "транспортного средства". Определение "автомобиля", как говорят, уточняет, общее определение "транспортного средства".
В то время как транспортное средство
и автомобиль
определяют поведение посредством методов, данными экземпляра будут такие вещи, как уникальный VIN конкретного автомобиля и т. д.
Таким образом возникают классы, наследование и создание экземпляров.
Другим ключевым понятием касательно классов является "полиморфизм", который описывает идею о том, что общее поведение, описанное в родительском классе может быть переопределено в дочернем классе, чтобы придать ему больше конкретики. Фактически, относительный полиморфизм позволяет нам ссылаться на базовое поведение из переопределенного.
Теория классов предполагает, что родительский и дочерний классы разделяют название методов для описания определенного поведения, так что потомок переопределеят родительскую реализацию. Как мы увидим позже, подобные вещи в вашем JavaScript коде могут привести к разочарованию и хрупкости кода.
Возможно, вы никогда не смотрели на классы как на "шаблон проектирования", так как чаще всего обсуждаются популярные "OO шаблоны проектирования", такие как "итератор", "наблюдатель", "фабрика", "синглтон" и т.д. При этом сразу предполагается, что OO классы являются механизмами более низкого уровня, с помощью которых мы реализуем все шаблоны проектирования (более высокого уровня), как будто OO является единственно-верной основой для всего (правильного) кода.
В зависимости от вашего уровня формального образования в области программирования, вы, возможно, слышали о "процедурном программировании" как способе описания кода, который состоит только из процедур (ака, функций), вызывающих другие функции, без каких-либо более высокоуровневых абстракций. Возможно, вас учили, что классы были правильным способом преобразования процедурного "спагетти-кода" в хорошо оформленный организованный код.
Конечно, если у вас есть опыт в "функциональном программировании" (монады и др.), вы хорошо знаете, что классы являются лишь одним из нескольких распространенных шаблонов проектирования. Но другие, возможно, впервые спросят себя, а действительно ли классы являются фундаментальной основой для написания кода, или они являются необязательной абстракцией.
Некоторые языки (например, Java) не оставляют вам выбора, поэтому это восе не опционально - все это класс. Другие языки, такие как C/C++ или PHP, предоставляют вам как процедурные, так и классовые синтаксисы, и больше зависит от выбора разработчика, какой стиль или смесь стилей ему подойдет.
Где JavaScript начинает иметь к этому всему отношение? JS имеет некоторые синтаксические элементы, подобные классу (например, new
и instanceof
) довольно давно, а в последнее время в ES6 появились некоторые дополнения, такие как ключевое слово class
(см. приложение A).
Но значит ли это, что в JavaScript действительно есть классы? Строго и однозначно: Нет.
Поскольку классы являются шаблоном проектирования, вы можете, приложив немало усилий (как мы увидим далее в этой главе), реализовать некоторое приближение для большей части классической функциональности класса. JS пытается удовлетворить чрезвычайно распространенное желание проектировать с классами, предоставляя, казалось бы, похожий на классы синтаксис.
Хотя у нас и может быть синтаксис, похожий на классы, это больше похоже на то, что механика JavaScript борется против того, чтобы вы использовали шаблон проектирования class. Так как под капотом механизмы, которые вы строите, работают совсем по-другому. Синтаксический сахар и (очень широко используемые) JS библиотеки для работы с "классами" проходят долгий путь скрывая эту реальности от вас, но рано или поздно вы столкнетесь с тем, что классы которые у вас есть в других языках не похожи на фейковые "классы", которые мы создаем себе в JS.
Все это сводится к тому, что классы не являются обязательным шаблоном при разработке программного обеспечения, и у вас есть выбор, использовать их в JavaScript или нет. Поскольку многие разработчики имеют сильную тягу к класс-ориентированному дизайну программного обеспечения, мы посвятим оставшуюся часть этой главы расмотрению того, чего стоит поддержание иллюзии классов с использованием тех механизмов, что предоставляет JS, и тех проблем, которые мы при этом испытываем.
Во многих классовых языках "стандартная библиотека" предоставляет "стековую" структуру данных (push, pop и др.) как класс Stack
. Этот класс имеет внутренний набор переменных, которые хранят данные, и набор публичных методов, которые дают вашему коду возможность взаимодействовать со (скрытыми) данными (добавление и удаление данных и т. д.).
Но в подобных языках вы на самом деле не работаете непосредственно со Stack
(если только речь не идет о Static члене класса, но это выходит за рамки нашего обсуждения). Класс Stack
- это просто абстрактное описание того, что должен делать любой "стек", но это не сам "стек". Вы должны создать экземпляр класса Stack
, прежде чем у вас будет конкретная структура данных нечто для работы с ней.
The traditional metaphor for "class" and "instance" based thinking comes from a building construction.
An architect plans out all the characteristics of a building: how wide, how tall, how many windows and in what locations, even what type of material to use for the walls and roof. She doesn't necessarily care, at this point, where the building will be built, nor does she care how many copies of that building will be built.
She also doesn't care very much about the contents of the building -- the furniture, wall paper, ceiling fans, etc. -- only what type of structure they will be contained by.
The architectural blue-prints she produces are only plans for a building. They don't actually constitute a building we can walk into and sit down. We need a builder for that task. A builder will take those plans and follow them, exactly, as he builds the building. In a very real sense, he is copying the intended characteristics from the plans to the physical building.
Once complete, the building is a physical instantiation of the blue-print plans, hopefully an essentially perfect copy. And then the builder can move to the open lot next door and do it all over again, creating yet another copy.
The relationship between building and blue-print is indirect. You can examine a blue-print to understand how the building was structured, for any parts where direct inspection of the building itself was insufficient. But if you want to open a door, you have to go to the building itself -- the blue-print merely has lines drawn on a page that represent where the door should be.
A class is a blue-print. To actually get an object we can interact with, we must build (aka, "instantiate") something from the class. The end result of such "construction" is an object, typically called an "instance", which we can directly call methods on and access any public data properties from, as necessary.
This object is a copy of all the characteristics described by the class.
You likely wouldn't expect to walk into a building and find, framed and hanging on the wall, a copy of the blue-prints used to plan the building, though the blue-prints are probably on file with a public records office. Similarly, you don't generally use an object instance to directly access and manipulate its class, but it is usually possible to at least determine which class an object instance comes from.
It's more useful to consider the direct relationship of a class to an object instance, rather than any indirect relationship between an object instance and the class it came from. A class is instantiated into object form by a copy operation.
As you can see, the arrows move from left to right, and from top to bottom, which indicates the copy operations that occur, both conceptually and physically.
Instances of classes are constructed by a special method of the class, usually of the same name as the class, called a constructor. This method's explicit job is to initialize any information (state) the instance will need.
For example, consider this loose pseudo-code (invented syntax) for classes:
class CoolGuy {
specialTrick = nothing
CoolGuy( trick ) {
specialTrick = trick
}
showOff() {
output( "Here's my trick: ", specialTrick )
}
}
To make a CoolGuy
instance, we would call the class constructor:
Joe = new CoolGuy( "jumping rope" )
Joe.showOff() // Here's my trick: jumping rope
Notice that the CoolGuy
class has a constructor CoolGuy()
, which is actually what we call when we say new CoolGuy(..)
. We get an object back (an instance of our class) from the constructor, and we can call the method showOff()
, which prints out that particular CoolGuy
s special trick.
Obviously, jumping rope makes Joe a pretty cool guy.
The constructor of a class belongs to the class, almost universally with the same name as the class. Also, constructors pretty much always need to be called with new
to let the language engine know you want to construct a new class instance.
In class-oriented languages, not only can you define a class which can be instantiated itself, but you can define another class that inherits from the first class.
The second class is often said to be a "child class" whereas the first is the "parent class". These terms obviously come from the metaphor of parents and children, though the metaphors here are a bit stretched, as you'll see shortly.
When a parent has a biological child, the genetic characteristics of the parent are copied into the child. Obviously, in most biological reproduction systems, there are two parents who co-equally contribute genes to the mix. But for the purposes of the metaphor, we'll assume just one parent.
Once the child exists, he or she is separate from the parent. The child was heavily influenced by the inheritance from his or her parent, but is unique and distinct. If a child ends up with red hair, that doesn't mean the parent's hair was or automatically becomes red.
In a similar way, once a child class is defined, it's separate and distinct from the parent class. The child class contains an initial copy of the behavior from the parent, but can then override any inherited behavior and even define new behavior.
It's important to remember that we're talking about parent and child classes, which aren't physical things. This is where the metaphor of parent and child gets a little confusing, because we actually should say that a parent class is like a parent's DNA and a child class is like a child's DNA. We have to make (aka "instantiate") a person out of each set of DNA to actually have a physical person to have a conversation with.
Let's set aside biological parents and children, and look at inheritance through a slightly different lens: different types of vehicles. That's one of the most canonical (and often groan-worthy) metaphors to understand inheritance.
Let's revisit the Vehicle
and Car
discussion from earlier in this chapter. Consider this loose pseudo-code (invented syntax) for inherited classes:
class Vehicle {
engines = 1
ignition() {
output( "Turning on my engine." )
}
drive() {
ignition()
output( "Steering and moving forward!" )
}
}
class Car inherits Vehicle {
wheels = 4
drive() {
inherited:drive()
output( "Rolling on all ", wheels, " wheels!" )
}
}
class SpeedBoat inherits Vehicle {
engines = 2
ignition() {
output( "Turning on my ", engines, " engines." )
}
pilot() {
inherited:drive()
output( "Speeding through the water with ease!" )
}
}
Note: For clarity and brevity, constructors for these classes have been omitted.
We define the Vehicle
class to assume an engine, a way to turn on the ignition, and a way to drive around. But you wouldn't ever manufacture just a generic "vehicle", so it's really just an abstract concept at this point.
So then we define two specific kinds of vehicle: Car
and SpeedBoat
. They each inherit the general characteristics of Vehicle
, but then they specialize the characteristics appropriately for each kind. A car needs 4 wheels, and a speed boat needs 2 engines, which means it needs extra attention to turn on the ignition of both engines.
Car
defines its own drive()
method, which overrides the method of the same name it inherited from Vehicle
. But then, Car
s drive()
method calls inherited:drive()
, which indicates that Car
can reference the original pre-overridden drive()
it inherited. SpeedBoat
s pilot()
method also makes a reference to its inherited copy of drive()
.
This technique is called "polymorphism", or "virtual polymorphism". More specifically to our current point, we'll call it "relative polymorphism".
Polymorphism is a much broader topic than we will exhaust here, but our current "relative" semantics refers to one particular aspect: the idea that any method can reference another method (of the same or different name) at a higher level of the inheritance hierarchy. We say "relative" because we don't absolutely define which inheritance level (aka, class) we want to access, but rather relatively reference it by essentially saying "look one level up".
In many languages, the keyword super
is used, in place of this example's inherited:
, which leans on the idea that a "super class" is the parent/ancestor of the current class.
Another aspect of polymorphism is that a method name can have multiple definitions at different levels of the inheritance chain, and these definitions are automatically selected as appropriate when resolving which methods are being called.
We see two occurrences of that behavior in our example above: drive()
is defined in both Vehicle
and Car
, and ignition()
is defined in both Vehicle
and SpeedBoat
.
Note: Another thing that traditional class-oriented languages give you via super
is a direct way for the constructor of a child class to reference the constructor of its parent class. This is largely true because with real classes, the constructor belongs to the class. However, in JS, it's the reverse -- it's actually more appropriate to think of the "class" belonging to the constructor (the Foo.prototype...
type references). Since in JS the relationship between child and parent exists only between the two .prototype
objects of the respective constructors, the constructors themselves are not directly related, and thus there's no simple way to relatively reference one from the other (see Appendix A for ES6 class
which "solves" this with super
).
An interesting implication of polymorphism can be seen specifically with ignition()
. Inside pilot()
, a relative-polymorphic reference is made to (the inherited) Vehicle
s version of drive()
. But that drive()
references an ignition()
method just by name (no relative reference).
Which version of ignition()
will the language engine use, the one from Vehicle
or the one from SpeedBoat
? It uses the SpeedBoat
version of ignition()
. If you were to instantiate Vehicle
class itself, and then call its drive()
, the language engine would instead just use Vehicle
s ignition()
method definition.
Put another way, the definition for the method ignition()
polymorphs (changes) depending on which class (level of inheritance) you are referencing an instance of.
This may seem like overly deep academic detail. But understanding these details is necessary to properly contrast similar (but distinct) behaviors in JavaScript's [[Prototype]]
mechanism.
When classes are inherited, there is a way for the classes themselves (not the object instances created from them!) to relatively reference the class inherited from, and this relative reference is usually called super
.
Remember this figure from earlier:
Notice how for both instantiation (a1
, a2
, b1
, and b2
) and inheritance (Bar
), the arrows indicate a copy operation.
Conceptually, it would seem a child class Bar
can access behavior in its parent class Foo
using a relative polymorphic reference (aka, super
). However, in reality, the child class is merely given a copy of the inherited behavior from its parent class. If the child "overrides" a method it inherits, both the original and overridden versions of the method are actually maintained, so that they are both accessible.
Don't let polymorphism confuse you into thinking a child class is linked to its parent class. A child class instead gets a copy of what it needs from the parent class. Class inheritance implies copies.
Recall our earlier discussion of parent(s) and children and DNA? We said that the metaphor was a bit weird because biologically most offspring come from two parents. If a class could inherit from two other classes, it would more closely fit the parent/child metaphor.
Some class-oriented languages allow you to specify more than one "parent" class to "inherit" from. Multiple-inheritance means that each parent class definition is copied into the child class.
On the surface, this seems like a powerful addition to class-orientation, giving us the ability to compose more functionality together. However, there are certainly some complicating questions that arise. If both parent classes provide a method called drive()
, which version would a drive()
reference in the child resolve to? Would you always have to manually specify which parent's drive()
you meant, thus losing some of the gracefulness of polymorphic inheritance?
There's another variation, the so called "Diamond Problem", which refers to the scenario where a child class "D" inherits from two parent classes ("B" and "C"), and each of those in turn inherits from a common "A" parent. If "A" provides a method drive()
, and both "B" and "C" override (polymorph) that method, when D
references drive()
, which version should it use (B:drive()
or C:drive()
)?
These complications go even much deeper than this quick glance. We address them here only so we can contrast to how JavaScript's mechanisms work.
JavaScript is simpler: it does not provide a native mechanism for "multiple inheritance". Many see this is a good thing, because the complexity savings more than make up for the "reduced" functionality. But this doesn't stop developers from trying to fake it in various ways, as we'll see next.
JavaScript's object mechanism does not automatically perform copy behavior when you "inherit" or "instantiate". Plainly, there are no "classes" in JavaScript to instantiate, only objects. And objects don't get copied to other objects, they get linked together (more on that in Chapter 5).
Since observed class behaviors in other languages imply copies, let's examine how JS developers fake the missing copy behavior of classes in JavaScript: mixins. We'll look at two types of "mixin": explicit and implicit.
Let's again revisit our Vehicle
and Car
example from before. Since JavaScript will not automatically copy behavior from Vehicle
to Car
, we can instead create a utility that manually copies. Such a utility is often called extend(..)
by many libraries/frameworks, but we will call it mixin(..)
here for illustrative purposes.
// vastly simplified `mixin(..)` example:
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) {
// only copy if not already present
if (!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
return targetObj;
}
var Vehicle = {
engines: 1,
ignition: function() {
console.log( "Turning on my engine." );
},
drive: function() {
this.ignition();
console.log( "Steering and moving forward!" );
}
};
var Car = mixin( Vehicle, {
wheels: 4,
drive: function() {
Vehicle.drive.call( this );
console.log( "Rolling on all " + this.wheels + " wheels!" );
}
} );
Note: Subtly but importantly, we're not dealing with classes anymore, because there are no classes in JavaScript. Vehicle
and Car
are just objects that we make copies from and to, respectively.
Car
now has a copy of the properties and functions from Vehicle
. Technically, functions are not actually duplicated, but rather references to the functions are copied. So, Car
now has a property called ignition
, which is a copied reference to the ignition()
function, as well as a property called engines
with the copied value of 1
from Vehicle
.
Car
already had a drive
property (function), so that property reference was not overridden (see the if
statement in mixin(..)
above).
Let's examine this statement: Vehicle.drive.call( this )
. This is what I call "explicit pseudo-polymorphism". Recall in our previous pseudo-code this line was inherited:drive()
, which we called "relative polymorphism".
JavaScript does not have (prior to ES6; see Appendix A) a facility for relative polymorphism. So, because both Car
and Vehicle
had a function of the same name: drive()
, to distinguish a call to one or the other, we must make an absolute (not relative) reference. We explicitly specify the Vehicle
object by name, and call the drive()
function on it.
But if we said Vehicle.drive()
, the this
binding for that function call would be the Vehicle
object instead of the Car
object (see Chapter 2), which is not what we want. So, instead we use .call( this )
(Chapter 2) to ensure that drive()
is executed in the context of the Car
object.
Note: If the function name identifier for Car.drive()
hadn't overlapped with (aka, "shadowed"; see Chapter 5) Vehicle.drive()
, we wouldn't have been exercising "method polymorphism". So, a reference to Vehicle.drive()
would have been copied over by the mixin(..)
call, and we could have accessed directly with this.drive()
. The chosen identifier overlap shadowing is why we have to use the more complex explicit pseudo-polymorphism approach.
In class-oriented languages, which have relative polymorphism, the linkage between Car
and Vehicle
is established once, at the top of the class definition, which makes for only one place to maintain such relationships.
But because of JavaScript's peculiarities, explicit pseudo-polymorphism (because of shadowing!) creates brittle manual/explicit linkage in every single function where you need such a (pseudo-)polymorphic reference. This can significantly increase the maintenance cost. Moreover, while explicit pseudo-polymorphism can emulate the behavior of "multiple inheritance", it only increases the complexity and brittleness.
The result of such approaches is usually more complex, harder-to-read, and harder-to-maintain code. Explicit pseudo-polymorphism should be avoided wherever possible, because the cost outweighs the benefit in most respects.
Recall the mixin(..)
utility from above:
// vastly simplified `mixin()` example:
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) {
// only copy if not already present
if (!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
return targetObj;
}
Now, let's examine how mixin(..)
works. It iterates over the properties of sourceObj
(Vehicle
in our example) and if there's no matching property of that name in targetObj
(Car
in our example), it makes a copy. Since we're making the copy after the initial object exists, we are careful to not copy over a target property.
If we made the copies first, before specifying the Car
specific contents, we could omit this check against targetObj
, but that's a little more clunky and less efficient, so it's generally less preferred:
// alternate mixin, less "safe" to overwrites
function mixin( sourceObj, targetObj ) {
for (var key in sourceObj) {
targetObj[key] = sourceObj[key];
}
return targetObj;
}
var Vehicle = {
// ...
};
// first, create an empty object with
// Vehicle's stuff copied in
var Car = mixin( Vehicle, { } );
// now copy the intended contents into Car
mixin( {
wheels: 4,
drive: function() {
// ...
}
}, Car );
Either approach, we have explicitly copied the non-overlapping contents of Vehicle
into Car
. The name "mixin" comes from an alternate way of explaining the task: Car
has Vehicle
s contents mixed-in, just like you mix in chocolate chips into your favorite cookie dough.
As a result of the copy operation, Car
will operate somewhat separately from Vehicle
. If you add a property onto Car
, it will not affect Vehicle
, and vice versa.
Note: A few minor details have been skimmed over here. There are still some subtle ways the two objects can "affect" each other even after copying, such as if they both share a reference to a common object (such as an array).
Since the two objects also share references to their common functions, that means that even manual copying of functions (aka, mixins) from one object to another doesn't actually emulate the real duplication from class to instance that occurs in class-oriented languages.
JavaScript functions can't really be duplicated (in a standard, reliable way), so what you end up with instead is a duplicated reference to the same shared function object (functions are objects; see Chapter 3). If you modified one of the shared function objects (like ignition()
) by adding properties on top of it, for instance, both Vehicle
and Car
would be "affected" via the shared reference.
Explicit mixins are a fine mechanism in JavaScript. But they appear more powerful than they really are. Not much benefit is actually derived from copying a property from one object to another, as opposed to just defining the properties twice, once on each object. And that's especially true given the function-object reference nuance we just mentioned.
If you explicitly mix-in two or more objects into your target object, you can partially emulate the behavior of "multiple inheritance", but there's no direct way to handle collisions if the same method or property is being copied from more than one source. Some developers/libraries have come up with "late binding" techniques and other exotic work-arounds, but fundamentally these "tricks" are usually more effort (and lesser performance!) than the pay-off.
Take care only to use explicit mixins where it actually helps make more readable code, and avoid the pattern if you find it making code that's harder to trace, or if you find it creates unnecessary or unwieldy dependencies between objects.
If it starts to get harder to properly use mixins than before you used them, you should probably stop using mixins. In fact, if you have to use a complex library/utility to work out all these details, it might be a sign that you're going about it the harder way, perhaps unnecessarily. In Chapter 6, we'll try to distill a simpler way that accomplishes the desired outcomes without all the fuss.
A variation on this explicit mixin pattern, which is both in some ways explicit and in other ways implicit, is called "parasitic inheritance", popularized mainly by Douglas Crockford.
Here's how it can work:
// "Traditional JS Class" `Vehicle`
function Vehicle() {
this.engines = 1;
}
Vehicle.prototype.ignition = function() {
console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
this.ignition();
console.log( "Steering and moving forward!" );
};
// "Parasitic Class" `Car`
function Car() {
// first, `car` is a `Vehicle`
var car = new Vehicle();
// now, let's modify our `car` to specialize it
car.wheels = 4;
// save a privileged reference to `Vehicle::drive()`
var vehDrive = car.drive;
// override `Vehicle::drive()`
car.drive = function() {
vehDrive.call( this );
console.log( "Rolling on all " + this.wheels + " wheels!" );
};
return car;
}
var myCar = new Car();
myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!
As you can see, we initially make a copy of the definition from the Vehicle
"parent class" (object), then mixin our "child class" (object) definition (preserving privileged parent-class references as needed), and pass off this composed object car
as our child instance.
Note: when we call new Car()
, a new object is created and referenced by Car
s this
reference (see Chapter 2). But since we don't use that object, and instead return our own car
object, the initially created object is just discarded. So, Car()
could be called without the new
keyword, and the functionality above would be identical, but without the wasted object creation/garbage-collection.
Implicit mixins are closely related to explicit pseudo-polymorphism as explained previously. As such, they come with the same caveats and warnings.
Consider this code:
var Something = {
cool: function() {
this.greeting = "Hello World";
this.count = this.count ? this.count + 1 : 1;
}
};
Something.cool();
Something.greeting; // "Hello World"
Something.count; // 1
var Another = {
cool: function() {
// implicit mixin of `Something` to `Another`
Something.cool.call( this );
}
};
Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1 (not shared state with `Something`)
With Something.cool.call( this )
, which can happen either in a "constructor" call (most common) or in a method call (shown here), we essentially "borrow" the function Something.cool()
and call it in the context of Another
(via its this
binding; see Chapter 2) instead of Something
. The end result is that the assignments that Something.cool()
makes are applied against the Another
object rather than the Something
object.
So, it is said that we "mixed in" Something
s behavior with (or into) Another
.
While this sort of technique seems to take useful advantage of this
rebinding functionality, it is the brittle Something.cool.call( this )
call, which cannot be made into a relative (and thus more flexible) reference, that you should heed with caution. Generally, avoid such constructs where possible to keep cleaner and more maintainable code.
Classes are a design pattern. Many languages provide syntax which enables natural class-oriented software design. JS also has a similar syntax, but it behaves very differently from what you're used to with classes in those other languages.
Classes mean copies.
When traditional classes are instantiated, a copy of behavior from class to instance occurs. When classes are inherited, a copy of behavior from parent to child also occurs.
Polymorphism (having different functions at multiple levels of an inheritance chain with the same name) may seem like it implies a referential relative link from child back to parent, but it's still just a result of copy behavior.
JavaScript does not automatically create copies (as classes imply) between objects.
The mixin pattern (both explicit and implicit) is often used to sort of emulate class copy behavior, but this usually leads to ugly and brittle syntax like explicit pseudo-polymorphism (OtherObj.methodName.call(this, ...)
), which often results in harder to understand and maintain code.
Explicit mixins are also not exactly the same as class copy, since objects (and functions!) only have shared references duplicated, not the objects/functions duplicated themselves. Not paying attention to such nuance is the source of a variety of gotchas.
In general, faking classes in JS often sets more landmines for future coding than solving present real problems.