loading...

JavaScript – Subclasses

In object-oriented programming, a class B can
extend or subclass another
class A. We say that A is the superclass and B is
the subclass. Instances of B inherit all the
instance methods of A. The class B can define its own instance
methods, some of which may override methods of
the same name defined by class A. If a method of B overrides a method
of A, the overriding method in B may sometimes want to invoke the
overridden method in A: this is called method
chaining
. Similarly, the subclass constructor B() may sometimes need to invoke the
superclass constructor A(). This is
called constructor chaining. Subclasses can
themselves have subclasses, and when working with hierarchies of
classes, it can sometimes be useful to define abstract
classes
. An abstract class is one that defines one or more
methods without an implementation. The implementation of these
abstract methods is left to the
concrete subclasses of the abstract
class.

The key to creating subclasses in JavaScript is proper
initialization of the prototype object. If class B extends A, then
B.prototype must be an heir of
A.prototype. Then instances of B
will inherit from B.prototype which
in turn inherits from A.prototype.
This section demonstrates each of the subclass-related terms defined
above, and also covers an alternative to subclassing known as
composition.

Using the Set class of Example 9-6 as a starting
point, this section will demonstrate how to define subclasses, how to
chain to constructors and overridden methods, how to use composition
instead of inheritance, and finally, how to separate interface from
implementation with abstract classes. The section ends with an
extended example that defines a hierarchy of Set classes. Note that
the early examples in this section are intended to demonstrate basic
subclassing techniques. Some of these examples have important flaws
that will be addressed later in the section.

Defining a Subclass

JavaScript objects inherit properties (usually methods) from
the prototype object of their class. If an object O is an instance
of a class B and B is a subclass of A, then O must also inherit
properties from A. We arrange this by ensuring that the prototype
object of B inherits from the prototype object of A. Using our
inherit() function (Example 6-1), we write:

B.prototype = inherit(A.prototype); // Subclass inherits from superclass
B.prototype.constructor = B;        // Override the inherited constructor prop.

These two lines of code are the key to creating subclasses in
JavaScript. Without them, the prototype object will be an ordinary
object—an object that inherits from Object . prototype—and this means that your
class will be a subclass of Object like all classes are. If we add
these two lines to the defineClass() function (from Java-Style Classes in JavaScript), we can transform it into the
defineSubclass() function and the
Function.prototype.extend()
method shown in Example 9-11.

Example 9-11. Subclass definition utilities

// A simple function for creating simple subclasses
function defineSubclass(superclass,  // Constructor of the superclass
                        constructor, // The constructor for the new subclass
                        methods,     // Instance methods: copied to prototype
                        statics)     // Class properties: copied to constructor
{
    // Set up the prototype object of the subclass
    constructor.prototype = inherit(superclass.prototype);
    constructor.prototype.constructor = constructor;
    // Copy the methods and statics as we would for a regular class
    if (methods) extend(constructor.prototype, methods);
    if (statics) extend(constructor, statics);
    // Return the class
    return constructor;
}

// We can also do this as a method of the superclass constructor
Function.prototype.extend = function(constructor, methods, statics) {
    return defineSubclass(this, constructor, methods, statics);
};

Example 9-12 demonstrates how to write a
subclass “manually” without using the defineSubclass() function. It defines a
SingletonSet subclass of Set. A SingletonSet is a specialized set
that is read-only and has a single constant member.

Example 9-12. SingletonSet: a simple set subclass

// The constructor function 
function SingletonSet(member) {
    this.member = member;   // Remember the single member of the set
}

// Create a prototype object that inherits from the prototype of Set.
SingletonSet.prototype = inherit(Set.prototype);

// Now add properties to the prototype.
// These properties override the properties of the same name from Set.prototype.
extend(SingletonSet.prototype, {
           // Set the constructor property appropriately
           constructor: SingletonSet,
           // This set is read-only: add() and remove() throw errors
           add: function() { throw "read-only set"; },    
           remove: function() { throw "read-only set"; }, 
           // A SingletonSet always has size 1
           size: function() { return 1; },                
           // Just invoke the function once, passing the single member.
           foreach: function(f, context) { f.call(context, this.member); },
           // The contains() method is simple: true only for one value
           contains: function(x) { return x === this.member; }
       });

Our SingletonSet class has a very simple implementation that
consists of five simple method definitions. It implements these five
core Set methods, but inherits methods such as toString(), toArray() and equals() from its superclass. This
inheritance of methods is the reason for defining subclasses. The
equals() method of the Set class
(defined in Comparison Methods), for example,
works to compare any Set instance that has working size() and foreach() methods with any Set that has
working size() and contains() methods. Because SingletonSet
is a subclass of Set, it inherits this equals() implementation automatically and
doesn’t have to write its own. Of course, given the radically simple
nature of singleton sets, it might be more efficient for
SingletonSet to define its own version of equals():

SingletonSet.prototype.equals = function(that) {
    return that instanceof Set && that.size()==1 && that.contains(this.member);
};

Note that SingletonSet does not statically borrow a list of
methods from Set: it dynamically inherits the methods of the Set
class. If we add a new method to Set.prototype, it immediately becomes
available to all instances of Set and of SingletonSet (assuming
SingletonSet does not already define a method by the same
name).

Constructor and Method Chaining

The SingletonSet class in the last section defined a
completely new set implementation, and completely replaced the core
methods it inherited from its superclass. Often, however, when we
define a subclass, we only want to augment or modify the behavior of
our superclass methods, not replace them completely. To do this, the
constructor and methods of the subclass call or chain
to
the superclass constructor and the superclass
methods.

Example 9-13 demonstrates this. It defines
a subclass of Set named NonNullSet: a set that does not allow
null and undefined as members. In order to restrict
the membership in this way, NonNullSet needs to test for null and
undefined values in its add()
method. But it doesn’t want to reimplement the add() method completely, so it chains to
the superclass version of the method. Notice also that the NonNullSet() constructor doesn’t take any
action of its own: it simply passes its arguments to the superclass
constructor (invoking it as a function, not as a constructor) so
that the superclass constructor can initialize the newly created
object.

Example 9-13. Constructor and method chaining from subclass to
superclass

/*
 * NonNullSet is a subclass of Set that does not allow null and undefined
 * as members of the set.
 */
function NonNullSet() {
    // Just chain to our superclass.
    // Invoke the superclass constructor as an ordinary function to initialize
    // the object that has been created by this constructor invocation.
    Set.apply(this, arguments);
}

// Make NonNullSet a subclass of Set:
NonNullSet.prototype = inherit(Set.prototype);
NonNullSet.prototype.constructor = NonNullSet;

// To exclude null and undefined, we only have to override the add() method
NonNullSet.prototype.add = function() {
    // Check for null or undefined arguments
    for(var i = 0; i < arguments.length; i++)
        if (arguments[i] == null)
            throw new Error("Can't add null or undefined to a NonNullSet");

    // Chain to the superclass to perform the actual insertion
    return Set.prototype.add.apply(this, arguments);
};

Let’s generalize this notion of a non-null set to a “filtered
set”: a set whose members must pass through a filter function before
being added. We’ll define a class factory function (like the
enumeration() function from Example 9-7) that is passed a filter function and
returns a new Set subclass. In fact, we can generalize even further
and define our class factory to take two arguments: the class to
subclass and the filter to apply to its add() method. We’ll call this factory
method filteredSetSubclass(), and
we might use it like this:

// Define a set class that holds strings only
var StringSet = filteredSetSubclass(Set,
                                    function(x) {return typeof x==="string";});

// Define a set class that does not allow null, undefined or functions
var MySet = filteredSetSubclass(NonNullSet,
                                function(x) {return typeof x !== "function";});

The code for this class factory function is in Example 9-14. Notice how this function
performs the same method and constructor chaining as NonNullSet
did.

Example 9-14. A class factory and method chaining

/*
 * This function returns a subclass of specified Set class and overrides 
 * the add() method of that class to apply the specified filter.
 */
function filteredSetSubclass(superclass, filter) {
    var constructor = function() {          // The subclass constructor
        superclass.apply(this, arguments);  // Chains to the superclass
    };
    var proto = constructor.prototype = inherit(superclass.prototype);
    proto.constructor = constructor;
    proto.add = function() {
        // Apply the filter to all arguments before adding any
        for(var i = 0; i < arguments.length; i++) {
            var v = arguments[i];
            if (!filter(v)) throw("value " + v + " rejected by filter");
        }
        // Chain to our superclass add implementation
        superclass.prototype.add.apply(this, arguments);
    };
    return constructor;
}

One interesting point to note about Example 9-14 is that by wrapping a function
around our subclass creation code, we are able to use the superclass argument in our constructor and
method chaining code rather than hard-coding the name of the actual
superclass. This means that if we wanted to change the superclass,
we would only have to change it in one spot, rather than searching
our code for every mention of it. This is arguably a technique that
is worth using, even if we’re not defining a class factory. For
example, we could rewrite our NonNullSet using a wrapper function and
the Function .prototype.extend() method (of Example 9-11) like this:

var NonNullSet = (function() {  // Define and invoke function
    var superclass = Set;       // Only specify the superclass once.
    return superclass.extend(
        function() { superclass.apply(this, arguments); },  // the constructor
        {                                                   // the methods
            add: function() {
                // Check for null or undefined arguments
                for(var i = 0; i < arguments.length; i++)
                    if (arguments[i] == null)
                        throw new Error("Can't add null or undefined");

                // Chain to the superclass to perform the actual insertion
                return superclass.prototype.add.apply(this, arguments);

            }
        });
}());

Finally, it is worth emphasizing that the ability to create
class factories like this one arises from the dynamic nature of
JavaScript. Class factories are a powerful and useful feature that
has no analog in languages like Java and C++.

Composition Versus Subclassing

In the previous section, we wanted to define sets that
restricted their members according to certain criteria, and we used
subclassing to accomplish this, creating a custom subclass of a
specified set implementation that used a specified filter function
to restrict membership in the set. Each combination of superclass
and filter function required the creation of a new class.

There is a better way to accomplish this, however. A
well-known principle in object-oriented design is “favor composition
over inheritance.”[16] In this case we can use composition by defining a new
set implementation that “wraps” another set object and forwards
requests to it, after filtering out prohibited members. Example 9-15 shows how it is done.

Example 9-15. Composing sets instead of subclassing them

/*
 * A FilteredSet wraps a specified set object and applies a specified filter
 * to values passed to its add() method.  All of the other core set methods 
 * simply forward to the wrapped set instance.
 */
var FilteredSet = Set.extend(
    function FilteredSet(set, filter) {  // The constructor
        this.set = set;
        this.filter = filter;
    }, 
    {  // The instance methods
        add: function() {
            // If we have a filter, apply it
            if (this.filter) {
                for(var i = 0; i < arguments.length; i++) {
                    var v = arguments[i];
                    if (!this.filter(v))
                        throw new Error("FilteredSet: value " + v +
                                        " rejected by filter");
                }
            }

            // Now forward the add() method to this.set.add()
            this.set.add.apply(this.set, arguments);
            return this;
        },
        // The rest of the methods just forward to this.set and do nothing else.
        remove: function() {
            this.set.remove.apply(this.set, arguments);
            return this;
        },
        contains: function(v) { return this.set.contains(v); },
        size: function() { return this.set.size(); },
        foreach: function(f,c) { this.set.foreach(f,c); }
    });

One of the benefits of using composition in this case is that
only a single FilteredSet subclass is required. Instances of this
class can be created to restrict the membership of any other set
instance. Instead of using the NonNullSet class defined earlier, for
example, we can do this:

var s = new FilteredSet(new Set(), function(x) { return x !== null; });

We can even filter a filtered set:

var t = new FilteredSet(s, { function(x} { return !(x instanceof Set); });

Class Hierarchies and Abstract Classes

In the previous section you were urged to “favor composition
over inheritance.” But to illustrate this principle, we created a
subclass of Set. We did this so that the resulting class would be
instanceof Set, and so that it
could inherit the useful auxiliary Set methods like toString() and equals(). These are valid pragmatic
reasons, but it still would have been nice to be able to do set
composition without subclassing a concrete implementation like the
Set class. A similar point can be made about our SingletonSet class
from Example 9-12—that class subclassed Set,
so that it could inherit the auxiliary methods, but its
implementation was completely different than its superclass. SingletonSet is not a specialized
version of the Set class, but a completely different kind of Set.
SingletonSet should be a sibling of Set in the class hierarchy, not
a descendant of it.

The solution in classical OO languages and also in JavaScript
is to separate interface from implementation. Suppose we define an
AbstractSet class which implements the auxiliary methods like
toString() but does not implement
the core methods like foreach(). Then, our set
implementations, Set, SingletonSet, and FilteredSet, can all be
subclasses of AbstractSet. FilteredSet and SingletonSet no longer
subclass an unrelated implementation.

Example 9-16 takes this approach further and
defines a hierarchy of abstract set classes. AbstractSet defines
only a single abstract method, contains(). Any class that purports to be
a set must define at least this one method. Next, we subclass
AbstractSet to define AbstractEnumerableSet. That class adds
abstract size() and foreach() methods, and defines useful
concrete methods ( toString(),
toArray(), equals(), and so on) on top of them.
AbstractEnumerableSet does not define add() or remove() methods and represents read-only
sets. SingletonSet can be implemented as a concrete subclass.
Finally, we define AbstractWritableSet as a subclass of
AbstractEnumerableSet. This final abstract set defines the abstract
methods add() and remove(), and implements concrete methods
like union() and intersection() that use them. AbstractWritableSet is the appropriate
superclass for our Set and FilteredSet classes. They are omitted
from this example, however, and a new concrete implementation named
ArraySet is included instead.

Example 9-16 is a long example, but worth
reading through in its entirety. Note that it uses Function.prototype.extend() as a shortcut
for creating subclasses.

Example 9-16. A hierarchy of abstract and concrete Set classes

// A convenient function that can be used for any abstract method
function abstractmethod() { throw new Error("abstract method"); }

/*
 * The AbstractSet class defines a single abstract method, contains().
 */
function AbstractSet() { throw new Error("Can't instantiate abstract classes");}
AbstractSet.prototype.contains = abstractmethod;

/*
 * NotSet is a concrete subclass of AbstractSet.
 * The members of this set are all values that are not members of some
 * other set. Because it is defined in terms of another set it is not
 * writable, and because it has infinite members, it is not enumerable.
 * All we can do with it is test for membership.
 * Note that we're using the Function.prototype.extend() method we defined
 * earlier to define this subclass.
 */
var NotSet = AbstractSet.extend(
    function NotSet(set) { this.set = set; },
    {
        contains: function(x) { return !this.set.contains(x); },
        toString: function(x) { return "~" + this.set.toString(); },
        equals: function(that) {
            return that instanceof NotSet && this.set.equals(that.set);
        }
    }
);


/*
 * AbstractEnumerableSet is an abstract subclass of AbstractSet.
 * It defines the abstract methods size() and foreach(), and then implements
 * concrete isEmpty(), toArray(), to[Locale]String(), and equals() methods
 * on top of those. Subclasses that implement contains(), size(), and foreach() 
 * get these five concrete methods for free.
 */
var AbstractEnumerableSet = AbstractSet.extend(
    function() { throw new Error("Can't instantiate abstract classes"); }, 
    {
        size: abstractmethod,
        foreach: abstractmethod,
        isEmpty: function() { return this.size() == 0; },
        toString: function() {
            var s = "{", i = 0;
            this.foreach(function(v) {
                             if (i++ > 0) s += ", ";
                             s += v;
                         });
            return s + "}";
        },
        toLocaleString : function() {
            var s = "{", i = 0;
            this.foreach(function(v) {
                             if (i++ > 0) s += ", ";
                             if (v == null) s += v; // null & undefined
                             else s += v.toLocaleString(); // all others
                         });
            return s + "}";
        },
        toArray: function() {
            var a = [];
            this.foreach(function(v) { a.push(v); });
            return a;
        },
        equals: function(that) {
            if (!(that instanceof AbstractEnumerableSet)) return false;
            // If they don't have the same size, they're not equal
            if (this.size() != that.size()) return false;
            // Now check whether every element in this is also in that.
            try {
                this.foreach(function(v) {if (!that.contains(v)) throw false;});
                return true;  // All elements matched: sets are equal.
            } catch (x) {
                if (x === false) return false; // Sets are not equal
                throw x; // Some other exception occurred: rethrow it.
            }
        }
    });

/*
 * SingletonSet is a concrete subclass of AbstractEnumerableSet.
 * A singleton set is a read-only set with a single member.
 */
var SingletonSet = AbstractEnumerableSet.extend(
    function SingletonSet(member) { this.member = member; },
    {
        contains: function(x) {  return x === this.member; },
        size: function() { return 1; },
        foreach: function(f,ctx) { f.call(ctx, this.member); }
    }
);


/*
 * AbstractWritableSet is an abstract subclass of AbstractEnumerableSet.
 * It defines the abstract methods add() and remove(), and then implements
 * concrete union(), intersection(), and difference() methods on top of them.
 */
var AbstractWritableSet = AbstractEnumerableSet.extend(
    function() { throw new Error("Can't instantiate abstract classes"); }, 
    {
        add: abstractmethod,
        remove: abstractmethod,
        union: function(that) {
            var self = this;
            that.foreach(function(v) { self.add(v); });
            return this;
        },
        intersection: function(that) {
            var self = this;
            this.foreach(function(v) { if (!that.contains(v)) self.remove(v);});
            return this;
        },
        difference: function(that) {
            var self = this;
            that.foreach(function(v) { self.remove(v); });
            return this;
        }
    });

/*
 * An ArraySet is a concrete subclass of AbstractWritableSet.
 * It represents the set elements as an array of values, and uses a linear
 * search of the array for its contains() method. Because the contains()
 * method is O(n) rather than O(1), it should only be used for relatively
 * small sets. Note that this implementation relies on the ES5 Array methods
 * indexOf() and forEach().
 */
var ArraySet = AbstractWritableSet.extend(
    function ArraySet() {
        this.values = [];
        this.add.apply(this, arguments);
    },
    {
        contains: function(v) { return this.values.indexOf(v) != -1; },
        size: function() { return this.values.length; },
        foreach: function(f,c) { this.values.forEach(f, c); },
        add: function() { 
            for(var i = 0; i < arguments.length; i++) {
                var arg = arguments[i];
                if (!this.contains(arg)) this.values.push(arg);
            }
            return this;
        },
        remove: function() {
            for(var i = 0; i < arguments.length; i++) {
                var p = this.values.indexOf(arguments[i]);
                if (p == -1) continue;
                this.values.splice(p, 1);
            }
            return this;
        }
    }
);


[16] See Design Patterns by Erich Gamma et
al. or Effective Java by Joshua Bloch, for
example.

Comments are closed.

loading...