Before jumping into the specifics of using JavaScript’s object-oriented features, let’s first understand why an object-oriented approach might be useful. The primary reason is that it allows you to write cleaner scripts, that is, scripts in which data and the code that operates upon it are encapsulated in one place. Consider the Document object. It encapsulates the currently displayed document and presents an interface by which you can examine and manipulate the document in part or as a whole. Can you imagine how confusing document manipulation would be if all of the document-related data and methods were just sitting in the global namespace (i.e., not accessed as document.something but just as something)? What would happen if all of JavaScript’s functionality were so exposed? Even simple programming tasks would be a nightmare of namespace collisions and endless hunting for the right function or variable. The language would be essentially unusable. This is an extreme example, but it illustrates the point. Even smaller-scale abstractions are often best implemented as objects.
But we haven’t really said why it is desirable to have any more advanced object-oriented features in JavaScript than those we’ve already seen (generic Objects with programmer-settable instance properties). The reason is that doing anything but small-scale object-oriented programming with the techniques covered so far would be incredibly laborious. For objects of the same type, you’d be forced to set the same properties and methods of each instance manually. What would be more efficient would be to have a way to specify those properties and methods common to all objects of a certain type once, and have every instance of that type “inherit” the common data and logic. This is the key motivator of JavaScript’s object-oriented features.
Java and C++ are class-based object-oriented languages. An object’s properties are defined by its class—a description of the code and data that each object of that class contains. In these languages, a class is defined at compile-time, that is, by the source code the programmer writes. You can’t add new properties and methods to a class at runtime, and a program can’t create new data types while it’s running.
Because JavaScript is interpreted (and therefore has no visible distinction between compile-time and runtime), a more dynamic approach is called for. JavaScript doesn’t have a formal notion of a class; instead, you create new types of objects on the fly, and you can modify the properties of existing objects whenever you please.
JavaScript is a prototype-based object-oriented language, meaning that every object has a prototype, an object from which it inherits properties and methods. When a property of an object is accessed or a method invoked, the interpreter first checks to see if the object has an instance property of the same name. If so, the instance property is used. If not, the interpreter checks the object’s prototype for the appropriate property. In this way the properties and methods common to all objects of that type can be encapsulated in the prototype, and each object can have instance properties representing the specific data for that object. For example, the Date prototype should contain the method that turns the object into a string, because the way it does so is the same for all Date objects. However, each individual Date should have its own data indicating the specific date and time it represents.
The only further conceptual aspect to the way objects work in JavaScript is that the prototype relationship is recursive. That is, an object’s prototype is also an object, and can therefore itself have a prototype, and so on. This means that if a property being accessed isn’t found as an instance property of an object, and isn’t found as a property of its prototype, the interpreter “follows” the prototype chain to the prototype’s prototype and searches for it there. If it still hasn’t been found, the search continues “up” the prototype chain. You might ask, “Where does it end?” The answer is easy: at the generic Object. All objects in JavaScript are ultimately “descendent” from a generic Object, so it is here that the search stops. If the property isn’t found in the Object, the value is undefined (or a runtime error is thrown in the case of method invocation).
Note |
The fact that Object is the “superclass” of all other objects explains why we said with confidence in Table 6-2 that the properties and methods listed there are present in every object: because these are exactly the properties and methods of a generic Object! |
Now that we’ve explained the theoretical basis for JavaScript’s object-oriented features, let’s see how it translates into implementation. If you’re feeling a bit lost at this point, that’s okay; we’ll reiterate the theory as we cover the concrete details.
Object instances are created with constructors, which are basically special functions that prepare new instances of an object for use. Every constructor contains an object prototype that defines the code and data that each object instance has by default.
Note |
Before delving any deeper, some commentary regarding nomenclature is appropriate. Because everything in JavaScript except primitive data and language constructs is an object, the term “object” is used quite often. It is important to differentiate between a type of object, for example, the Array or String object, and an instance of an object, for example, a particular variable containing a reference to an Array or String. A type of object is defined by a particular constructor. All instances created with that constructor are said to have the same “type” or “class” (to stretch the definition of class a bit). To keep things clear, remember that a constructor and its prototype define a type of object, and objects created with that constructor are instances of that type. |
We’ve seen numerous examples of object creation, for example,
var s = new String();
This line invokes the constructor for String objects, a function named String(). JavaScript knows that this function is a constructor because it is called in conjunction with the new operator.
We can define our own constructor by defining a function:
function Robot() { }
This function by itself does absolutely nothing. However, we can invoke it as a constructor just like we did for String():
var guard = new Robot();
We have now created an instance of the Robot object. Obviously, this object is not particularly useful. More information about object construction is necessary before we proceed.
When a constructor is invoked, the interpreter allocates space for the new object and implicitly passes the new object to the function. The constructor can access the object being created using this, a special keyword that holds a reference to the new object. The reason the interpreter makes this available is so the constructor can manipulate the object it is creating easily. For example, it could be used to set a default value, so we can redefine
our constructor to reflect this ability:
function Robot() { this.hasJetpack = true; }
This example adds an instance property hasJetpack to each new object it creates. After creating an object with this constructor, we can access the hasJetpack property as one would expect:
var guard = new Robot(); var canFly = guard.hasJetpack;
Since constructors are functions, you can pass arguments to constructors to specify initial values. We can modify our constructor again so that it takes an optional argument:
function Robot(needsToFly) { if (needsToFly == true) this.hasJetpack = true; else this.hasJetpack = false; } // create a Robot with hasJetpack == true var guard = new Robot(true); // create a Robot with hasJetpack == false var sidekick = new Robot();
Note that in this example we could have explicitly passed in a false value when creating the sidekick instance. However, by passing in nothing, we implicitly have done so, since the parameter needsToFly would be undefined. Thus, the if statement fails properly.
We can also add methods to the objects we create. One way to do so is to assign an instance variable an anonymous function inside of the constructor, just as we added an instance property. However, this is a waste of memory because each object created would have its own copy of the function. A better way to do this is to use the object’s prototype.
Every object has a prototype property that gives it its structure. The prototype is a reference to an Object describing the code and data that all objects of that type have in common. We can populate the constructor’s prototype with the code and data we want all of our Robot objects to possess. We modify our definition to the following:
Robot.prototype.hasJetpack = false; Robot.prototype.doAction = function() { alert("Intruders beware!"); }; function Robot(flying) { if (flying == true) this.hasJetpack = true; }
Several substantial changes have been made. First, we moved the hasJetpack property into the prototype and gave it the default value of false. Doing this allows us to remove the else clause from the constructor. Second, we added a function doAction() to the prototype of the constructor. Every Robot object we create now has both properties:
var guard = new Robot(true); var canFly = guard.hasJetpack; guard.doAction();
Here we begin to see the power of prototypes. We can access these two properties (hasJetpack and doAction()) through an instance of an object, even though they weren’t specifically set in the object. As we’ve stated, if a property is accessed and the object has no instance property of that name, the object’s prototype is checked, so the interpreter finds the properties even though they weren’t explicitly set. If we omit the argument to the Robot() constructor and then access the hasJetpack property of the object created, the interpreter finds the default value in the prototype. If we pass the constructor true, then the default value in the prototype is overridden by the constructor adding an instance variable called hasJetpack whose value is true.
Methods can refer to the object instance they are contained in using this. We can redefine our class once again to reflect the new capability:
Robot.prototype.hasJetpack = false; Robot.prototype.actionValue = "Intruders beware!"; Robot.prototype.doAction = function() { alert(this.actionValue); }; function Robot(flying, action) { if (flying == true) this.hasJetpack = true; if (action) this.actionValue = action; }
We have added a new property to the prototype, actionValue. This property has a default value that can be overridden by passing a second argument to the constructor. If a value for action is passed to the constructor, invoking doAction() will show its value rather than the default ("Intruders beware!"). For example,
var guard = new Robot(true, "ZAP!"); guard.doAction();
results in “ZAP!” being alerted rather than “Intruders beware.”
A very important aspect of the prototype is that it is shared. That is, there is only one copy of the prototype that all objects created with the same constructor use. An implication of this is that a change in the prototype will be visible to all objects that share it! This is why default values in the prototype are overridden by instance variables, and not changed directly. Changing them in the prototype would change the value for all objects sharing that prototype.
Modifying the prototypes of built-in objects can be very useful. Suppose you need to repeatedly extract the third character of strings. You can modify the prototype of the String object so that all strings have a method of your definition:
String.prototype.getThirdChar = function() { return this.charAt(2); }
You can invoke this method as you would any other built-in String method:
var c = "Example".getThirdChar(); // c set to 'a'
In addition to instance properties and properties of prototypes, JavaScript allows you to define class properties (also known as static properties), properties of the type rather than of a particular object instance. An example of a class property is Number.MAX_VALUE. This property is a type-wide constant, and therefore is more logically located in the class (constructor) rather than individual Number objects. But how are class properties implemented?
Because constructors are functions and functions are objects, you can add properties to constructors. Class properties are added this way. Though technically doing so adds an instance property to a type’s constructor, we’ll still call it a class variable. Continuing our example,
Robot.isMetallic = true;
defines a class property of the Robot object by adding an instance variable to the constructor. It is important to remember that static properties exist in only one place, as members of constructors. They are therefore accessed through the constructor rather than an instance of the object.
As previously explained, static properties typically hold data or code that does not depend on the contents of any particular instance. The toLowerCase() method of the String object could not be a static method because the string it returns depends on the object on which it was invoked. On the other hand, the PI property of the Math object (Math.PI) and the parse() method of the String object (String.parse()) are perfect candidates, because they do not depend on the value of any particular instance. You can see from the way they are accessed that they are, in fact, static properties. The isMetallic property we just defined is accessed similarly, as Robot.isMetallic.
Inheritance in JavaScript is achieved through prototypes. It is clear that instances of a particular object “inherit” the code and data present in the constructor’s prototype. But what we haven’t really seen so far is that it is also possible to derive a new object type from a type that already exists. Instances of the new type inherit all the properties of their own type in addition to any properties embodied in their parent.
As an example, we can define a new object type that inherits all the capabilities of our Robot object by “chaining” prototypes:
function UltraRobot(extraFeature) { if (extraFeature) this.feature = extraFeature; } UltraRobot.prototype = new Robot(); UltraRobot.prototype.feature = "Radar";
The only new concept in this example is setting UltraRobot’s prototype to a new instance of a Robot object. Because of the way properties are resolved via prototypes, UltraRobot objects “contain” the properties of the UltraRobot object as well as those of Robot:
var guard = new UltraRobot("Performs Calculus"); var feature = guard.feature; var canFly = guard.hasJetpack; guard.doAction();
The way the interpreter resolves property access in this example is analogous to the resolution that was previously discussed. The object’s instance properties are first checked for a match, then, if none is found, its prototype (UltraRobot) is checked. If no match is found in the prototype, the parent prototype (Robot) is checked, and the process repeats recursively finally to Object.
It is often useful to provide specific properties for user-defined objects that override the behavior of the parent. For example, the default value of toString() for objects is "[object Object]". You might wish to override this behavior by defining a new, more appropriate toString() method for your types:
Robot.prototype.toString = function() { return "[object Robot]"; };
Those classes inheriting from Robot might wish to also override the method, for example:
UltraRobot.prototype.toString = function() { return "[object UltraRobot]"; };
This is not only good programming practice, it is useful in case of debugging as well since “object Object” really doesn’t tell you what you are looking at.