JavaScript – Classes in ECMAScript 5

ECMAScript 5 adds methods for specifying property attributes
(getters, setters, enumerability, writability, and configurability)
and for restricting the extensibility of objects. These methods were
described in Property Getters and Setters, Property Attributes, and The extensible Attribute, but turn out to be quite useful when
defining classes. The subsections that follow demonstrate how to use
these ECMAScript 5 capabilities to make your
classes more robust.

Making Properties Nonenumerable

The Set class of Example 9-6 used a trick to
store objects as set members: it defined an “object id” property on
any object added to the set. Later, if other code uses that object
in a for/in loop, this added
property will be returned. ECMAScript 5 allows us to avoid this by
making properties nonenumerable. Example 9-17
demonstrates how to do this with Object.defineProperty() and also shows how
to define a getter function and how to test whether an object is
extensible.

Example 9-17. Defining nonenumerable properties

// Wrap our code in a function so we can define variables in the function scope
(function() { 
     // Define objectId as a nonenumerable property inherited by all objects.
     // When this property is read, the getter function is invoked.
     // It has no setter, so it is read-only.
     // It is nonconfigurable, so it can't be deleted.
     Object.defineProperty(Object.prototype, "objectId", {
                               get: idGetter,       // Method to get value
                               enumerable: false,   // Nonenumerable
                               configurable: false  // Can't delete it
                           });

     // This is the getter function called when objectId is read
     function idGetter() {             // A getter function to return the id
         if (!(idprop in this)) {      // If object doesn't already have an id
             if (!Object.isExtensible(this)) // And if we can add a property
                 throw Error("Can't define id for nonextensible objects");
             Object.defineProperty(this, idprop, {         // Give it one now.
                                       value: nextid++,    // This is the value
                                       writable: false,    // Read-only
                                       enumerable: false,  // Nonenumerable
                                       configurable: false // Nondeletable
                                   });
         }
         return this[idprop];          // Now return the existing or new value
     };

     // These variables are used by idGetter() and are private to this function
     var idprop = "|**objectId**|";    // Assume this property isn't in use
     var nextid = 1;                   // Start assigning ids at this #

}()); // Invoke the wrapper function to run the code right away

Defining Immutable Classes

In addition to making properties nonenumerable, ECMAScript 5
allows us to make properties read-only, which is handy if we want to
define classes whose instances are immutable. Example 9-18 is an immutable version of our Range class
that does this using Object.defineProperties() and with
Object.create(). It also uses
Object . define Properties() to set up the prototype
object for the class, making the instance methods nonenumerable,
like the methods of built-in classes. In fact, it goes further than
this and makes those instance methods read-only and nondeletable,
which prevents any dynamic alterations (“monkey-patching”) to the
class. Finally, as an interesting trick, Example 9-18 has a constructor function that works as a
factory function when invoked without the new keyword.

Example 9-18. An immutable class with read-only properties and
methods

// This function works with or without 'new': a constructor and factory function
function Range(from,to) {
    // These are descriptors for the read-only from and to properties.
    var props = {
        from: {value:from, enumerable:true, writable:false, configurable:false},
        to: {value:to, enumerable:true, writable:false, configurable:false}
    };
    
    if (this instanceof Range)                // If invoked as a constructor
        Object.defineProperties(this, props); // Define the properties
    else                                      // Otherwise, as a factory 
        return Object.create(Range.prototype, // Create and return a new
                             props);          // Range object with props
}

// If we add properties to the Range.prototype object in the same way,
// then we can set attributes on those properties.  Since we don't specify
// enumerable, writable, or configurable, they all default to false.
Object.defineProperties(Range.prototype, {
    includes: {
        value: function(x) { return this.from <= x && x <= this.to; }
    },
    foreach: {
        value: function(f) {
            for(var x = Math.ceil(this.from); x <= this.to; x++) f(x);
        }
    },
    toString: {
        value: function() { return "(" + this.from + "..." + this.to + ")"; }
    }
});

Example 9-18 uses Object.defineProperties() and Object.create() to define immutable and
nonenumerable properties. These are powerful methods, but the
property descriptor objects they require can make the code difficult
to read. An alternative is to define utility functions for modifying
the attributes of properties that have already been defined. Example 9-19 shows two such utility
functions.

Example 9-19. Property descriptor utilities

// Make the named (or all) properties of o nonwritable and nonconfigurable.
function freezeProps(o) {
    var props = (arguments.length == 1)              // If 1 arg
        ? Object.getOwnPropertyNames(o)              //  use all props
        : Array.prototype.splice.call(arguments, 1); //  else named props
    props.forEach(function(n) { // Make each one read-only and permanent
        // Ignore nonconfigurable properties
        if (!Object.getOwnPropertyDescriptor(o,n).configurable) return;
        Object.defineProperty(o, n, { writable: false, configurable: false });
    });
    return o;  // So we can keep using it
}

// Make the named (or all) properties of o nonenumerable, if configurable.
function hideProps(o) {
    var props = (arguments.length == 1)              // If 1 arg
        ? Object.getOwnPropertyNames(o)              //  use all props
        : Array.prototype.splice.call(arguments, 1); //  else named props
    props.forEach(function(n) { // Hide each one from the for/in loop
        // Ignore nonconfigurable properties
        if (!Object.getOwnPropertyDescriptor(o,n).configurable) return;
        Object.defineProperty(o, n, { enumerable: false });
    });
    return o;
}

Object.defineProperty() and
Object.defineProperties() can be
used to create new properties and also to modify the attributes of
existing properties. When used to define new properties, any
attributes you omit default to false. When used to alter existing
properties, however, the attributes you omit are left unchanged. In
the hideProps() function above,
for example, we specify only the enumerable attribute because that is the
only one we want to modify.

With these utility functions defined, we can take advantage of
ECMAScript 5 features to write an immutable class without
dramatically altering the way we write classes. Example 9-20 shows an immutable Range class that uses our
utility functions.

Example 9-20. A simpler immutable class

function Range(from, to) {    // Constructor for an immutable Range class
    this.from = from;
    this.to = to;
    freezeProps(this);        // Make the properties immutable
}

Range.prototype = hideProps({ // Define prototype with nonenumerable properties
    constructor: Range,
    includes: function(x) { return this.from <= x && x <= this.to; },
    foreach: function(f) {for(var x=Math.ceil(this.from);x<=this.to;x++) f(x);},
    toString: function() { return "(" + this.from + "..." + this.to + ")"; }
});

Encapsulating Object State

Private State and Example 9-10 showed how you can use variables or arguments
of a constructor function as private state for the objects created
by that constructor. The shortcoming of this technique is that in
ECMAScript 3, the accessor methods that provide access to that state
can be replaced. ECMAScript 5 allows us to encapsulate our state
variables more robustly by defining property getter and setter
methods that cannot be deleted. Example 9-21
demonstrates.

Example 9-21. A Range class with strongly encapsulated endpoints

// This version of the Range class is mutable but encapsulates its endpoint
// variables to maintain the invariant that from <= to.
function Range(from, to) {
    // Verify that the invariant holds when we're created
    if (from > to) throw new Error("Range: from must be <= to");

    // Define the accessor methods that maintain the invariant
    function getFrom() {  return from; }
    function getTo() {  return to; }
    function setFrom(f) {  // Don't allow from to be set > to
        if (f <= to) from = f;
        else throw new Error("Range: from must be <= to");
    }
    function setTo(t) {    // Don't allow to to be set < from
        if (t >= from) to = t;
        else throw new Error("Range: to must be >= from");
    }

    // Create enumerable, nonconfigurable properties that use the accessors
    Object.defineProperties(this, {
        from: {get: getFrom, set: setFrom, enumerable:true, configurable:false},
        to: { get: getTo, set: setTo, enumerable:true, configurable:false }
    });
}

// The prototype object is unchanged from previous examples.
// The instance methods read from and to as if they were ordinary properties.
Range.prototype = hideProps({
    constructor: Range,
    includes: function(x) { return this.from <= x && x <= this.to; },
    foreach: function(f) {for(var x=Math.ceil(this.from);x<=this.to;x++) f(x);},
    toString: function() { return "(" + this.from + "..." + this.to + ")"; }
});

Preventing Class Extensions

It is usually considered a feature of JavaScript that classes
can be dynamically extended by adding new methods to the prototype
object. ECMAScript 5 allows you to prevent this, if you want to.
Object.preventExtensions() makes
an object nonextensible (The extensible Attribute), which
means that no new properties can be added to it. Object.seal() takes this a step further:
it prevents the addition of new properties and also makes all
current properties nonconfigurable, so that they cannot be deleted.
(A nonconfigurable property
can still be writable, however, and can still be converted into a
read-only property.) To prevent extensions to Object.prototype, you can simply
write:

Object.seal(Object.prototype);

Another dynamic feature of JavaScript is the ability to
replace (or “monkey-patch”) methods of an object:

var original_sort_method = Array.prototype.sort;
Array.prototype.sort = function() {
    var start = new Date();
    original_sort_method.apply(this, arguments);
    var end = new Date();
    console.log("Array sort took " + (end - start) + " milliseconds.");
};

You can prevent this kind of alteration by making your
instance methods read-only. The freezeProps() utility function defined
above is one way to accomplish this. Another way is with Object.freeze(), which does everything
that Object.seal() does, but also
makes all properties read-only and nonconfigurable.

There is a feature of read-only properties that is important
to understand when working
with classes. If an object o
inherits a read-only property p,
an attempt to assign to o.p will fail and will not create
a new property in o. If you want
to override an inherited
read-only property, you have to use Object.defineProperty() or Object.define Properties() or
Object.create() to create the new
property. This means that if you make the instance methods of a
class read-only, it becomes significantly more difficult for
subclasses to override those methods.

It is not usually necessary to lock down prototype objects
like this, but there are some circumstances where preventing
extensions to an object can be useful. Think back to the enumeration() class factory function of
Example 9-7. That function stored the
instances of each enumerated type in properties of the constructor
object, and also in the values
array of the constructor. These properties and array serve as the
official list of instances of the enumerated type, and it is worth
freezing them, so that new instances cannot be added and existing
instances cannot be deleted or altered. In the enumeration()
function we can simply add these lines of code:

Object.freeze(enumeration.values);
Object.freeze(enumeration);

Notice that by calling Object.freeze() on the enumerated type, we
prevent the future use of the objectId property defined in Example 9-17. A solution to this problem is to read the
objectId property (calling the
underlying accessor method and setting the internal property) of the
enumerated type once before freezing it.

Subclasses and ECMAScript 5

Example 9-22 demonstrates subclassing using
ECMAScript 5 features. It defines a StringSet class as a subclass of
the AbstractWritableSet class from Example 9-16. The
main feature of this example is the use of Object . create() to create a prototype object
that inherits from the superclass prototype and also define the
properties of the newly created object. The difficulty with this
approach, as mentioned earlier, is that it requires the use of
awkward property descriptors.

Another interesting point about this example is that it passes
null to Object . create() to create an object that
inherits nothing. This object is used to store the members of the
set, and the fact that it has no prototype allows us to use the
in operator with it instead of
the hasOwnProperty()
method.

Example 9-22. StringSet: a set subclass using ECMAScript 5

function StringSet() {
    this.set = Object.create(null);  // Create object with no proto
    this.n = 0;
    this.add.apply(this, arguments);
}

// Note that with Object.create we can inherit from the superclass prototype
// and define methods in a single call. Since we don't specify any of the
// writable, enumerable, and configurable properties, they all default to false.
// Readonly methods makes this class trickier to subclass.
StringSet.prototype = Object.create(AbstractWritableSet.prototype, {
    constructor: { value: StringSet },
    contains: { value: function(x) { return x in this.set; } },
    size: { value: function(x) { return this.n; } },
    foreach: { value: function(f,c) { Object.keys(this.set).forEach(f,c); } },
    add: {
        value: function() {
            for(var i = 0; i < arguments.length; i++) {
                if (!(arguments[i] in this.set)) {
                    this.set[arguments[i]] = true;
                    this.n++;
                }
            }
            return this;
        } 
    },
    remove: {
        value: function() {
            for(var i = 0; i < arguments.length; i++) {
                if (arguments[i] in this.set) {
                    delete this.set[arguments[i]];
                    this.n--;
                }
            }
            return this;
        } 
    }
});

Property Descriptors

Property Attributes described the property
descriptors of ECMAScript 5 but didn’t include many examples of
their use. We conclude this section on ECMAScript 5 with an extended
example that will demonstrate many operations on ECMAScript 5
properties. Example 9-23 will add a properties() method (nonenumerable, of
course) to Object . prototype. The return value of this
method is an object that represents a list of properties and defines
useful methods for displaying the properties and attributes (useful
for debugging), for obtaining property descriptors (useful when you
want to copy properties along with their attributes), and for
setting attributes on the properties (useful alternatives to the
hideProps() and freezeProps() functions defined earlier).
This one example demonstrates most of the property-related features
of ECMAScript 5, and also uses a modular coding technique that will
be discussed in the next section.

Example 9-23. ECMAScript 5 properties utilities

/*
 * Define a properties() method in Object.prototype that returns an
 * object representing the named properties of the object on which it
 * is invoked (or representing all own properties of the object, if
 * invoked with no arguments).  The returned object defines four useful 
 * methods: toString(), descriptors(), hide(), and show().
 */
(function namespace() {  // Wrap everything in a private function scope

     // This is the function that becomes a method of all object
     function properties() {
         var names;  // An array of property names
         if (arguments.length == 0)  // All own properties of this
             names = Object.getOwnPropertyNames(this);
         else if (arguments.length == 1 && Array.isArray(arguments[0]))
             names = arguments[0];   // Or an array of names
         else                        // Or the names in the argument list
             names = Array.prototype.splice.call(arguments, 0);

         // Return a new Properties object representing the named properties
         return new Properties(this, names);
     }

     // Make it a new nonenumerable property of Object.prototype.
     // This is the only value exported from this private function scope.
     Object.defineProperty(Object.prototype, "properties", {
         value: properties,  
         enumerable: false, writable: true, configurable: true
     });

     // This constructor function is invoked by the properties() function above.
     // The Properties class represents a set of properties of an object.
     function Properties(o, names) {
         this.o = o;            // The object that the properties belong to
         this.names = names;    // The names of the properties
     }
     
     // Make the properties represented by this object nonenumerable
     Properties.prototype.hide = function() {
         var o = this.o, hidden = { enumerable: false };
         this.names.forEach(function(n) {
                                if (o.hasOwnProperty(n))
                                    Object.defineProperty(o, n, hidden);
                            });
         return this;
     };

     // Make these properties read-only and nonconfigurable
     Properties.prototype.freeze = function() {
         var o = this.o, frozen = { writable: false, configurable: false };
         this.names.forEach(function(n) {
                                if (o.hasOwnProperty(n))
                                    Object.defineProperty(o, n, frozen);
                            });
         return this;
     };

     // Return an object that maps names to descriptors for these properties.
     // Use this to copy properties along with their attributes:
     //   Object.defineProperties(dest, src.properties().descriptors());
     Properties.prototype.descriptors = function() {
         var o = this.o, desc = {};
         this.names.forEach(function(n) {
                                if (!o.hasOwnProperty(n)) return;
                                desc[n] = Object.getOwnPropertyDescriptor(o,n);
                            });
         return desc;
     };

     // Return a nicely formatted list of properties, listing the 
     // name, value and attributes. Uses the term "permanent" to mean
     // nonconfigurable, "readonly" to mean nonwritable, and "hidden"
     // to mean nonenumerable. Regular enumerable, writable, configurable 
     // properties have no attributes listed.
     Properties.prototype.toString = function() {
         var o = this.o; // Used in the nested function below
         var lines = this.names.map(nameToString);
         return "{\n  " + lines.join(",\n  ") + "\n}";
         
         function nameToString(n) {
             var s = "", desc = Object.getOwnPropertyDescriptor(o, n);
             if (!desc) return "nonexistent " + n + ": undefined";
             if (!desc.configurable) s += "permanent ";
             if ((desc.get && !desc.set) || !desc.writable) s += "readonly ";
             if (!desc.enumerable) s += "hidden ";
             if (desc.get || desc.set) s += "accessor " + n
             else s += n + ": " + ((typeof desc.value==="function")?"function"
                                                                   :desc.value);
             return s;
         }
     };

     // Finally, make the instance methods of the prototype object above 
     // nonenumerable, using the methods we've defined here.
     Properties.prototype.properties().hide();
}()); // Invoke the enclosing function as soon as we're done defining it.

Comments are closed.