Table of Contents
In this summary we take all important points of the classical JavaScript summary by Douglas Crockford into consideration.
JavaScript has three primitive datatypes: string
, number
and
boolean
, and we can test if a variable v
holds a
value of such a type with the help of the JS operator typeof
as, for instance, in typeof v === "number"
.
There are five reference types: Object
,
Array
, Function
, Date
and
RegExp
. Arrays, functions, dates and regular expressions are
special types of objects, but, conceptually, dates and regular expressions
are primitive data values, and happen to be implemented in the form of
wrapper objects.
The types of variables, array elements, function parameters and return values are not declared and are normally not checked by JavaScript engines. Type conversion (casting) is performed automatically.
The value of a variable may be
a data value: either a string, a number, or a boolean;
an object reference: either referencing an ordinary object, or an array, function, date, or regular expression;
the special data value null
, which is typically
used as a default value for initializing an object variable;
the special data value undefined
, which is the
implicit initial value of all variables that have been declared, but
not initialized.
A string is a sequence of Unicode
characters. String literals, like "Hello world!", 'A3F0', or the empty
string "", are enclosed in single or double quotes. Two string expressions
can be concatenated with the +
operator, and checked for
equality with the triple equality operator:
if (firstName + lastName === "JamesBond") ...
The number of characters of a string can be obtained by applying the
length
attribute to a string:
console.log("Hello world!".length); // 12
All numeric data values are represented in
64-bit floating point format with an optional exponent (like in the
numeric data literal 3.1e10
). There is no explicit type
distinction between integers and floating point numbers. If a numeric
expression cannot be evaluated to a number, its value is set to
NaN
("not a number"), which can be tested with the built-in
predicate
isNaN(
expr)
.
The built-in function, Number.isInteger
allows testing
if a number is an integer. For making sure that a
numeric value is an integer, or that a string representing a number is
converted to an integer, one has to apply the built-in function
parseInt
. Similarly, a string representing a decimal number
can be converted to this number with parseFloat
. For
converting a number n
to a string, the best method is using
String(n)
.
There are two predefined Boolean data literals,
true
and false
, and the Boolean operator symbols
are the exclamation mark !
for NOT, the double ampersand
&&
for AND, and the double bar ||
for
OR. When a non-Boolean value is used in a condition, or as an operand of a
Boolean expression, it is implicitly converted to a Boolean value
according to the following rules. The empty string, the (numerical) data
literal 0, as well as undefined
and null
, are
mapped to false
, and all other values are mapped to
true
. This conversion can be performed explicitly with the
help of the double negation operation, like in the equality test
!!undefined === false
, which evaluates to
true
.
In addition to strings, numbers and Boolean values, also calendar dates
and times are important types of primitive data values, although they are
not implemented as primitive values, but in the form of wrapper objects
instantiating Date
. Notice that Date
objects do,
in fact, not really represent dates, but rather date-time instants
represented internally as the number of milliseconds since 1 January, 1970
UTC. For converting the internal value of a Date
object to a
human-readable string, we have several options. The two most important
options are using either the standard format of ISO date/time strings of
the form "2015-01-27", or localized formats of date/time strings like
"27.1.2015" (for simplicity, we have omitted the time part of the
date/time strings in these examples). When x instanceof Date
,
then x.toISOString()
provides the ISO date/time string, and
x.toLocaleDateString()
provides the localized date/time
string. Given any date string ds
, ISO or localized, new
Date(ds)
creates a corresponding date object.
For testing the equality (or inequality) of two
primitive data vales, always use the triple equality symbol
===
(and !==
) instead of the double equality
symbol ==
(and !=
). Otherwise, for instance, the
number 2 would be the same as the string "2", since the condition (2
== "2")
evaluates to true in
JavaScript.
Assigning an empty
array literal, as in var a = []
is the
same as, but more concise than and therefore preferred to, invoking the
Array()
constructor without arguments, as in var a =
new Array()
.
Assigning an empty
object literal, as in var o = {}
is the
same as, but more concise than and therefore preferred to, invoking the
Object()
constructor without arguments, as in var o =
new Object()
. Notice, however, that an empty object literal
{}
is not really an empty object, as it contains property
slots and method slots inherited from Object.prototype
.
So, a truly empty object (without any slots) has to be created with
null
as prototype, like in var emptyObject =
Object.create(null)
.
A summary of type testing is provided in the following table:
Type | Example values | Test if x of type |
---|---|---|
string | "Hello world!", 'A3F0' | typeof x === "string" |
boolean | true, false | typeof x === "boolean" |
(floating point) number | -2.75, 0, 1, 1.0, 3.1e10 | typeof x === "number" |
integer | -2, 0, 1, 250 | Number.isInteger(x) |
Object | {}, {num:3, denom:4}, {isbn:"006251587X," title:"Weaving the Web"}, {"one":1, "two":2, "three":3} | excluding including |
Array | [], ["one"], [1,2,3], [1,"one", {}] | Array.isArray(x) |
Function | function () { return "one"+1;} | typeof x === "function" |
Date | new Date("2015-01-27") | x instanceof Date |
RegExp | /(\w+)\s(\w+)/ | x instanceof RegExp |
A summary of type conversions is provided in the following table:
Type | Convert to string | Convert string to type |
---|---|---|
boolean | String(x) | Boolean(y) |
(floating point) number | String(x) | parseFloat(y) |
integer | String(x) | parseInt(y) |
Object | x.toString() or
JSON.stringify(x) | JSON.parse(y) |
Array | x.toString() or
JSON.stringify(x) | y.split() or
JSON.parse(y) |
Function | x.toString() | new Function(y) |
Date | x.toISOString() | new Date(y) |
RegExp | x.toString() | new RegExp(y) |
In ES5, there have only been two kinds of scope for
variables declared with var
: the global scope (with
window
as the context object) and function scope, but
no block
scope. Consequently, declaring a variable with
var
within a code block is confusing and should be avoided.
For instance, although this is a frequently used pattern, even by
experienced JavaScript programmers, it is a pitfall to declare the counter
variable of a for
loop in the loop, as in
function foo() {
for (var i=0; i < 10; i++) {
... // do something with i
}
}
Instead of obtaining a variable that is scoped to the block defined
by the for
loop, JavaScript is interpreting this code (by
means of "hoisting" variable declarations) as:
function foo() {
var i=0;
for (i=0; i < 10; i++) {
... // do something with i
}
}
Therefore all function-scoped variable declarations (with
var
) should be placed at the beginning of a function. When a
variable is to be scoped to a code block, such as to a for
loop, it has to be declared with the keyword let
, as in the
following example:
function foo() {
for (let i=0; i < 10; i++) {
... // do something with i
}
}
Whenever a variable is supposed to be immutable (having a frozen
value), it should be declared with the keyword
const
:
const pi = 3.14159;
It is generally recommended that variables be declared with
const
whenever it is clear that their values will never be
changed. This helps catching errors and it allows the JS engine to
optimize code execution.
Starting from ES5, we can use strict mode for getting more runtime error checking. For instance, in strict mode, all variables must be declared. An assignment to an undeclared variable throws an exception.
We can turn strict mode on by typing the following statement as the
first line in a JavaScript file or inside a <script>
element:
'use strict';
It is generally recommended to use strict mode, except when code depends on libraries that are incompatible with strict mode.
JS objects are different from classical OO/UML objects. In particular, they need not instantiate a class. And they can have their own (instance-level) methods in the form of method slots, so they do not only have (ordinary) property slots, but also method slots. In addition they may also have key-value slots. So, they may have three different kinds of slots, while classical objects (called "instance specifications" in UML) only have property slots.
A JS object is essentially a set of name-value-pairs, also called slots, where names can be property names, function names or keys of a map. Objects can be created in an ad-hoc manner, using JavaScript's object literal notation (JSON), without instantiating a class:
var person1 = { lastName:"Smith", firstName:"Tom"};
An empty object with no slots is created in the following way:
var o1 = Object.create( null);
Whenever the name in a slot is an admissible JavaScript identifier, the slot may be either a property slot, a method slot or a key-value slot. Otherwise, if the name is some other type of string (in particular when it contains any blank space), then the slot represents a key-value slot, which is a map element, as explained below.
The name in a property slot may denote either
a data-valued property, in which case the value is a data value or, more generally, a data-valued expression;
or
an object-valued property, in which case the value is an object reference or, more generally, an object expression.
The name in a method slot denotes a JS function (better called method), and its value is a JS function definition expression.
Object properties can be accessed in two ways:
Using the dot notation (like in C++/Java):
person1.lastName = "Smith"
Using a map notation:
person1["lastName"] = "Smith"
JS objects can be used in many different ways for different purposes. Here are five different use cases for, or possible meanings of, JS objects:
A record is a set of property slots like, for instance,
var myRecord = {firstName:"Tom", lastName:"Smith", age:26}
A map (also called 'associative array', 'dictionary', 'hash map' or 'hash table' in other languages) supports look-ups of values based on keys like, for instance,
var numeral2number = {"one":"1", "two":"2", "three":"3"}
which associates the value "1" with the key "one", "2" with "two", etc. A key need not be a valid JavaScript identifier, but can be any kind of string (e.g. it may contain blank spaces).
An untyped object does not instantiate a class. It may have property slots and method slots like, for instance,
var person1 = { lastName: "Smith", firstName: "Tom", getFullName: function () { return this.firstName +" "+ this.lastName; } };
Within the body of a method slot of an object, the special
variable this
refers to the object.
A namespace may be defined in the form of an untyped object referenced by a global object variable, the name of which represents a namespace prefix. For instance, the following object variable provides the main namespace of an application based on the Model-View-Controller (MVC) architecture paradigm where we have three subnamespaces corresponding to the three parts of an MVC application:
var myApp = { model:{}, view:{}, ctrl:{} };
A more advanced namespace mechanism is provided by ES6 modules, as explained in Chapter 5, .
A typed object instantiates a class that is defined either by a JavaScript constructor function or by a factory object. See Section 1.10, “Defining and using classes”
A JS array represents, in fact, the logical data structure of an array list, which is a list where each list item can be accessed via an index number (like the elements of an array). Using the term 'array' without saying 'JS array' creates a terminological ambiguity. But for simplicity, we will sometimes just say 'array' instead of 'JS array'.
A variable may be initialized with a JS array literal:
var a = [1,2,3];
Because they are array lists, JS arrays can grow dynamically: it is
possible to use indexes that are greater than the length of the array. For
instance, after the array variable initialization above, the array held by
the variable a
has the length 3, but still we can assign
further array elements, and may even create gaps, like in
a[3] = 4; a[5] = 5;
The contents of an array a
are processed with the help
of a standard for loop with a counter
variable counting from the first array index 0 to the last array index,
which is a.length-1
:
for (let i=0; i < a.length; i++) { ...}
Since arrays are special types of objects, we sometimes need a
method for finding out if a variable represents an array. We can test, if
a variable a
represents an array by applying the predefined
datatype predicate isArray
as in Array.isArray(
a)
.
For adding a new element to an array, we
append it to the array using the push
operation as
in:
a.push( newElement);
For appending (all elements of) another
array b
to an array a
, we push
all
the elements of b
to a
with the help of the ES6
spread operator ...
, like
so:
a.push( ...b);
For deleting an element at position
i
from an array a
, we use the predefined array
method splice
as in:
a.splice( i, 1);
For searching a value v
in an
array a
, we can use the predefined array method
indexOf
, which returns the position, if found, or -1,
otherwise, as in:
if (a.indexOf(v) > -1) ...
For looping over an array a
,
there are two good options: either use a classical for
(counter variable) loop or a more concise for-of
loop. The
best performance is achieved with a classical for
loop:
for (let i=0; i < a.length; i++) { console.log( a[i]); }
If performance doesn't matter and no counter variable is needed,
however, the best option is using a for
-of
loop
(introduced in ES6):
for (const elem of a) { console.log( elem); }
Notice that in a for-of loop, the looping variable (here:
elem
) can be declared as a frozen local variable with const
whenever it is not re-assigned in the loop body.
For cloning an array a
, we
can use the array function slice
in the following way:
var clone = a.slice(0);
Alternatively, we can use a new technique based on the ES6 spread operator:
var clone = [ ...a ];
A map (also called 'hash map', 'associative array' or
'dictionary') provides a mapping from keys to their associated values.
Traditionally, before the built-in Map
object has been added to JS (in ES6), maps have been implemented in the
form of plain JS objects where the keys are string literals that may
include blank spaces like in:
var myTranslation = { "my house": "mein Haus", "my boat": "mein Boot", "my horse": "mein Pferd" }
Alternatively, a proper map can be constructed with the help of the
Map
constructor:
var myTranslation = new Map([
["my house", "mein Haus"],
["my boat", "mein Boot"],
["my horse", "mein Pferd"]
])
A traditional map (as a plain JS object) is processed with the help
of a loop where we loop over all keys using the predefined function
Object.keys(m)
, which returns an array of all keys of a map
m
. For instance,
for (const key of Object.keys( myTranslation)) {
console.log(`The translation of ${key} is ${myTranslation[key]}`);
}
A proper map (i.e. a Map
object) can be processed with
the help of a for-of
loop in one of the following
ways:
// processing both keys and values for (const [key, value] of myTranslation) { console.log(`The translation of ${key} is ${value}`); } // processing only keys for (const key of myTranslation.keys()) { console.log(`The translation of ${key} is ${myTranslation.get( key)}`); } // processing only values for (const value of myTranslation.values()) { console.log( value) }
For adding a new entry to a traditional map, we simply associate the new value with its key as in:
myTranslation["my car"] = "mein Auto";
For adding a new entry to a proper map, we use the set
operation:
myTranslation.set("my car", "mein Auto");
For deleting an entry from a traditional
map, we can use the predefined delete
operator as
in:
delete myTranslation["my boat"];
For deleting an entry from a proper map, we can use the
Map::delete
method as in:
myTranslation.delete("my boat");
For testing if a traditional map contains an entry for a certain key value, such as for testing if the translation map contains an entry for "my bike" we can check the following:
if ("my bike" in myTranslation) ...
For testing if a proper map contains an entry for a certain key
value, we can use the Boolean-valued has
method:
if (myTranslation.has("my bike")) ...
For cloning a traditional map
m
, we can use the composition of JSON.stringify and
JSON.parse. We first serialize m
to a string representation
with JSON.stringify, and then de-serialize the string representation to a
map object with JSON.parse:
var clone = JSON.parse( JSON.stringify( m));
Notice that this method works well if the map contains only simple
data values or (possibly nested) arrays/maps containing simple data
values. In other cases, e.g. if the map contains Date
objects, we have to write our own clone method. Alternatively, we could
use a new technique based on the ES6 spread operator:
var clone = { ...m };
For cloning a proper map m
, we can use the
Map
constructor in the following way:
var clone = new Map(m);
Since proper maps (defined as instances of Map
) do not
have the overhead of properties inherited from
Object.prototype
and operations on them, such as adding and
deleting entries, are faster, they are preferable to using ordinary
objects as maps. Only in cases where it is important to be compatible with
older browsers that do not support Map
, it is justified to
use ordinary objects for implementing maps.
In summary, there are four types of important basic data structures:
array
lists, such as
["one","two","three"]
, which are special JS objects
called 'arrays', but since they are dynamic, they are rather array lists as defined in the Java programming language.
records, which are special JS
objects, such as {firstName:"Tom", lastName:"Smith"}
, as
discussed above,
maps, which can be realized as
ordinary JS objects having only key-value slots, such as
{"one":1, "two":2, "three":3}
, or as Map
objects, as discussed above,
entity tables, like for instance the table shown below, which are special maps where the values are entity records with a standard ID (or primary key) slot, such that the keys of the map are the standard IDs of these entity records.
Table 2.1. An example of an entity table representing a collection of books
Key | Value |
---|---|
006251587X | { isbn:"006251587X," title:"Weaving the Web", year:2000 } |
0465026567 | { isbn:"0465026567," title:"Gödel, Escher, Bach", year:1999 } |
0465030793 | { isbn:"0465030793," title:"I Am A Strange Loop", year:2008 } |
Notice that our distinction between records, (traditional) maps and
entity tables is a purely conceptual distinction, and not a syntactical
one. For a JavaScript engine, both {firstName:"Tom",
lastName:"Smith"}
and {"one":1,"two":2,"three":3}
are
just objects. But conceptually, {firstName:"Tom",
lastName:"Smith"}
is a record because firstName
and
lastName
are intended to denote properties (or fields), while
{"one":1,"two":2,"three":3}
is a map because
"one"
and "two"
are not intended to denote
properties/fields, but are just arbitrary string values used as keys for a
map.
Making such conceptual distinctions helps in the logical design of a program, and mapping them to syntactic distinctions, even if they are not interpreted differently, helps to better understand the intended computational meaning of the code and therefore improves its readability.
Generally, a (parametrized) procedure is like a sub-program that can be called (with a certain number of arguments) any number of times from within a program. Whenever a procedure returns a value, it is called a function. In OOP, procedures are called methods, if they are defined in the context of a class or of an object.
In JavaScript, procedures are called "functions", no matter if they return a value or
not. As shown below in Figure 2.1, JS functions are special JS objects, having an optional
name
property and a length
property providing
their number of parameters. If a variable v
references a
function can be tested with
if (typeof v === "function") {...}
Being JS objects implies that JS functions can be stored in variables, passed as arguments to functions, returned by functions, have properties and can be changed dynamically. Therefore, JS functions are first-class citizens, and JavaScript can be viewed as a functional programming language.
The general form of a JS function definition is an assignment of a JS function expression to a variable:
var myMethod = function theNameOfMyMethod( params) { ... }
where params
is a comma-separated list of
parameters (or a parameter record), and theNameOfMyMethod
is
optional. When it is omitted, the method/function is anonymous. In any case, JS functions are
normally invoked via a variable that references the function. In the above
case, this means that the JS function is invoked with
myMethod()
, and not with theNameOfMyMethod()
.
However, a named JS function can be invoked by name from within the
function (when the function is recursive). Consequently, a recursive JS
function must be named.
Anonymous function expressions are called lambda expressions (or shorter lambdas) in other programming languages.
As an example of an anonymous function expression being passed as an
argument in the invocation of another (higher-order) function, we can take
a comparison function being passed to the predefined function
sort
for sorting the elements of an array list. Such a
comparison function must return a negative number if its first argument is
smaller than its second argument, it must return 0 if both arguments are
of the same rank, and it must return a positive number if the second
argument is smaller than the first one. In the following example, we sort
a list of number pairs in lexicographic order:
var list = [[1,2],[2,1],[1,3],[1,1]]; list.sort( function (x,y) { return x[0] === y[0] ? x[1]-y[1] : x[0]-y[0]); }); // results in [[1,1],[1,2],[1,3],[2,1]]
Alternatively, we can express the anonymous comparison function in the form of an arrow function expression:
list.sort( (x,y) => x[0] === y[0] ? x[1]-y[1] : x[0]-y[0]);
A JS function declaration has the following form:
function theNameOfMyFunction( params) {...}
It is equivalent to the following named function definition:
var theNameOfMyFunction = function theNameOfMyFunction( params) {...}
that
is, it creates both a function with name theNameOfMyFunction
and a variable theNameOfMyFunction
referencing this
function.
JS functions can have inner functions. The closure mechanism allows a JS function using
variables (except this
) from its outer scope, and a function
created in a closure remembers the environment in which it was created. In
the following example, there is no need to pass the outer scope variable
result
to the inner function via a parameter, as it is
readily available:
var sum = function (numbers) {
var result = 0;
for (const n of numbers) {
result = result + n;
}
return result;
};
console.log( sum([1,2,3,4])); // 10
When a method/function is executed, we can access its arguments
within its body by using the built-in arguments
object, which
is "array-like" in the sense that it has indexed elements and a
length
property, and we can iterate over it with a normal
for
loop, but since it's not an instance of
Array
, the JS array methods (such as the forEach
looping method) cannot be applied to it. The arguments
object
contains an element for each argument passed to the method. This allows
defining a method without parameters and invoking it with any number of
arguments, like so:
var sum = function () {
var result = 0;
for (let i=0; i < arguments.length; i++) {
result = result + arguments[i];
}
return result;
};
console.log( sum(0,1,1,2,3,5,8)); // 20
A method defined on the prototype of a constructor function, which
can be invoked on all objects created with that constructor, such as
Array.prototype.forEach
, where Array
represents
the constructor, has to be invoked with an instance of the class as
context
object referenced by the this
variable
(see also the next section on classes). In the following example, the
array numbers
is the context object in the invocation of
forEach
:
var numbers = [1,2,3]; // create an instance of Array
numbers.forEach( function (n) {
console.log( n);
});
Whenever such a prototype method is to be invoked not with a context
object, but with an object as an ordinary argument, we can do this with
the help of the JS function
method call
that takes an object, on
which the method is invoked, as its first parameter, followed by the
parameters of the method to be invoked. For instance, we can apply the
forEach
looping method to the array-like object
arguments
in the following way:
var sum = function () { var result = 0; Array.prototype.forEach.call( arguments, function (n) { result = result + n; }); return result; };
A two-argument variant of the Function.prototype.call
method, collecting all arguments of the method to be invoked in an
array-like object, is Function.prototype.apply
. The first
argument to both call
and apply
becomes
this
inside the function, and the rest are passed through.
So, f.call( x, y, z)
is the same as f.apply( x, [y,
z])
.
Whenever a method defined for a prototype is to be invoked without a
context object, or when a method defined in a method slot (in the context)
of an object is to be invoked without its context object, we can bind its
this
variable to a given object with the help of the JS function bind
method (Function.prototype.bind
). This
allows creating a shortcut for invoking a method, as in var querySel
= document.querySelector.bind( document)
, which allows to use
querySel
instead of
document.querySelector
.
The option of immediately invoked JS function expressions can be used for obtaining a namespace mechanism that is superior to using a plain namespace object, since it can be controlled which variables and methods are globally exposed and which are not. This mechanism is also the basis for JS module concepts. In the following example, we define a namespace for the model code part of an app, which exposes some variables and the model classes in the form of constructor functions:
myApp.model = function () { var appName = "My app's name"; var someNonExposedVariable = ...; function ModelClass1() {...} function ModelClass2() {...} function someNonExposedMethod(...) {...} return { appName: appName, ModelClass1: ModelClass1, ModelClass2: ModelClass2 } }(); // immediately invoked
The concept of a class is fundamental in object-oriented programming. Objects instantiate (or are classified by) a class. A class defines the properties and methods (as a blueprint) for the objects created with it.
Having a class concept is essential for being able to implement a data model in the form of model classes in a Model-View-Controller (MVC) architecture. However, classes and their inheritance/extension mechanism are over-used in classical OO languages, such as in Java, where all variables and procedures have to be defined in the context of a class and, consequently, classes are not only used for implementing object types (or model classes), but also as containers for many other purposes in these languages. This is not the case in JavaScript where we have the freedom to use classes for implementing object types only, while keeping method libraries in namespace objects.
Any code pattern for defining classes in JavaScript should satisfy five requirements. First of all, (1) it should allow to define a class name, a set of (instance-level) properties, preferably with the option to keep them 'private', a set of (instance-level) methods, and a set of class-level properties and methods. It's desirable that properties can be defined with a range/type, and with other meta-data, such as constraints. There should also be two introspection features: (2) an is-instance-of predicate that can be used for checking if an object is a direct or indirect instance of a class, and (3) an instance-level property for retrieving the direct type of an object. In addition, it is desirable to have a third introspection feature for retrieving the direct supertype of a class. And finally, there should be two inheritance mechanisms: (4) property inheritance and (5) method inheritance. In addition, it is desirable to have support for multiple inheritance and multiple classifications, for allowing objects to play several roles at the same time by instantiating several role classes.
There was no explicit class definition syntax in JavaScript before ES6 (or ES2015). Different code patterns for defining classes in JavaScript have been proposed and are being used in different frameworks. But they do often not satisfy the five requirements listed above. The two most important approaches for defining classes are:
In the form of a constructor function that
achieves method inheritance via the prototype chain and allows to
create new instances of a class with the help of the
new
operator. This is the classical approach
recommended by Mozilla in their JavaScript
Guide. This is also the approach implemented in ES6 with the
new class
definition syntax.
In the form of a factory object that uses the
predefined Object.create
method for creating new
instances of a class. In this approach, the prototype chain method
inheritance mechanism is replaced by a "copy & append"
mechanism. Eric
Elliott has argued that factory-based classes are a viable
alternative to constructor-based classes in JavaScript (in fact, he
even condemns the use of classical inheritance with
constructor-based classes, throwing out the baby with the bath
water).
When building an app, we can use both kinds of classes, depending on the requirements of the app. Since we often need to define class hierarchies, and not just single classes, we have to make sure, however, that we don't mix these two alternative approaches within the same class hierarchy. While the factory-based approach, as exemplified by mODELcLASSjs, has many advantages, which are summarized in Table 2.2, the constructor-based approach enjoys the advantage of higher performance object creation.
Table 2.2. Required and desirable features of JS code patterns for classes
Class feature | Constructor-based approach | Factory-based approach | mODELcLASSjs |
---|---|---|---|
Define properties and methods | yes | yes | yes |
is-instance-of predicate | yes | yes | yes |
direct type property | yes | yes | yes |
direct supertype property of classes | no | possibly | yes |
Property inheritance | yes | yes | yes |
Method inheritance | yes | yes | yes |
Multiple inheritance | no | possibly | yes |
Multiple classifications | no | possibly | yes |
Allow object pools | no | yes | yes |
In ES5, we can define a base class with a subclass in the form of constructor functions, following a code pattern recommended by Mozilla in their JavaScript Guide, as shown in the following steps.
Step 1.a) First define the constructor function that implicitly defines the properties of the class by assigning them the values of the constructor parameters when a new object is created:
function Person( first, last) { this.firstName = first; this.lastName = last; }
Notice that within a constructor, the special variable
this
refers to the new object that is created when the
constructor is invoked.
Step 1.b) Next, define the
instance-level methods of the class
as method slots of the object referenced by the constructor's
prototype
property:
Person.prototype.toString = function () { return this.firstName + " " + this.lastName; }
Step 1.c) Class-level ("static") methods can be defined as method slots of the constructor function itself (recall that, since JS functions are objects, they can have slots), as in
Person.checkLastName = function (ln) { if (typeof ln !== "string" || ln.trim()==="") { console.log("Error: invalid last name!"); } }
Step 1.d) Finally, define class-level ("static") properties as property slots of the constructor function:
Person.instances = {};
Step 2.a) Define a subclass with additional properties:
function Student( first, last, studNo) { // invoke superclass constructor Person.call( this, first, last); // define and assign additional properties this.studNo = studNo; }
By invoking the supertype constructor with Person.call(
this, ...)
for any new object created as an instance of the
subtype Student
, and referenced by this
, we
achieve that the property slots created in the supertype constructor
(firstName
and lastName
) are also created for
the subtype instance, along the entire chain of supertypes within a
given class hierarchy. In this way we set up a property inheritance mechanism that makes sure
that the own properties defined for an object on creation include the
own properties defined by the supertype constructors.
In Step 2b), we set up a
mechanism for method inheritance via
the constructor's prototype
property. We assign a new
object created from the supertype's prototype
object to the
prototype
property of the subtype constructor and adjust
the prototype's constructor property:
// Student inherits from Person Student.prototype = Object.create( Person.prototype); // adjust the subtype's constructor property Student.prototype.constructor = Student;
With Object.create( Person.prototype)
we create a new
object with Person.prototype
as its prototype and without
any own property slots. By assigning this object to the
prototype
property of the subclass constructor, we achieve
that the methods defined in, and inherited from, the superclass are also
available for objects instantiating the subclass. This mechanism of
chaining the prototypes takes care of method inheritance. Notice that
setting Student.prototype
to Object.create(
Person.prototype)
is preferable over setting it to new
Person()
, which was the way to achieve the same in the time
before ES5.
Step 2c) Define a subclass method that overrides a superclass method:
Student.prototype.toString = function () {
return Person.prototype.toString.call( this) +
"(" + this.studNo + ")";
};
An instance of a constructor-based class is created by applying
the new
operator to the constructor and providing suitable
arguments for the constructor parameters:
var pers1 = new Person("Tom","Smith");
The method toString
is invoked on the object
pers1
by using the 'dot notation':
alert("The full name of the person is: " + pers1.toString());
When an object o
is created with o = new
C(
...)
, where C
references a named
function with name "C", the type (or class) name of o
can
be retrieved with the introspective expression
o.constructor.name
, which returns "C". The
Function::name
property used in this expression is
supported by all browsers, except Internet Explorer versions before
version 11.
In JavaScript, a prototype object is an object with
method slots (and sometimes also property slots) that can be inherited
by other objects via JavaScript's method/property slot look-up
mechanism. This mechanism follows the prototype chain defined by the
built-in reference property __proto__
(with a double
underscore prefix and suffix) for finding methods or properties. As
shown below in Figure 2.1, every constructor function has a reference
to a prototype object as the value of its reference property
prototype
. When a new object is created with the help of
new
, its __proto__
property is set to the
constructor's prototype
property.
For instance, after creating a new object with f = new
Foo()
, it holds that Object.getPrototypeOf(f)
, which
is the same as f.__proto__
, is equal to
Foo.prototype
. Consequently, changes to the slots of
Foo.prototype
affect all objects that were created with
new Foo()
. While every object has a __proto__
property slot (except Object
), only objects constructed
with new
have a constructor
property
slot.
Notice that we can retrieve the prototype of an object with
Object.getPrototypeOf(o)
.
In this approach we define a JS object Person
(actually representing a class) with a special create
method that invokes the predefined Object.create
method for
creating objects of type Person
:
var Person = { typeName: "Person", properties: { firstName: {range:"NonEmptyString", label:"First name", writable: true, enumerable: true}, lastName: {range:"NonEmptyString", label:"Last name", writable: true, enumerable: true} }, methods: { getFullName: function () { return this.firstName +" "+ this.lastName; } }, create: function (slots) { // create object var obj = Object.create( this.methods, this.properties); // add special property for *direct type* of object Object.defineProperty( obj, "type", {value: this, writable: false, enumerable: true}); // initialize object for (prop of Object.keys( slots)) { if (prop in this.properties) obj[prop] = slots[prop]; } return obj; } };
Notice that the JS object Person
actually represents
a factory-based class. An instance of such a factory-based class is
created by invoking its create
method:
var pers1 = Person.create( {firstName:"Tom", lastName:"Smith"});
The method getFullName
is invoked on the object
pers1
of type Person
by using the 'dot
notation', like in the constructor-based approach:
alert("The full name of the person is: " + pers1.getFullName());
Notice that each property declaration for an object created with
Object.create
has to include the 'descriptors'
writable: true
and enumerable: true
, as in
lines 5 and 7 of the Person
object definition above.
In a general approach, like in the mODELcLASSjs
library for model-based development, we would not repeatedly define the
create
method in each class definition, but rather have a
generic constructor function for defining factory-based classes. Such a
factory-based class constructor, like mODELcLASS, would also provide an
inheritance mechanism by merging the
own properties and methods with the properties and methods of the
superclass. This mechanism is also called Inheritance
by Concatenation.