JavaScript 2.0
Rationale
Execution Model
|
Tuesday, September 10, 2002
When does a declaration (of a value, function, type, class, method, pragma, etc.) take effect? When are expressions evaluated? The answers to these questions distinguish among major kinds of programming languages. Let’s consider the following function definition in a language with C++ or Java-like syntax:
gadget f(widget x) { if ((gizmo)(x) != null) return (gizmo)(x); return x.owner; }
In a static language such as Java or C++, all type expressions are evaluated at compile time. Thus, in this example widget
and gadget
would be evaluated at compile time. If gizmo
were a type, then it too would be evaluated
at compile time ((gizmo)(x)
would become a type cast). Note that we must be able to statically distinguish identifiers
used for variables from identifiers used for types so we can decide whether (gizmo)(x)
is a one-argument function
call (in which case gizmo
would be evaluated at run time) or a type cast (in which case gizmo
would
be evaluated at compile time). In most cases, in a static language a declaration is visible throughout its enclosing scope,
although there are exceptions that have been deemed too complicated for a compiler to handle such as the following C++:
typedef int *x; class foo { typedef x *y; typedef char *x; }
Many dynamic languages can construct, evaluate, and manipulate type expressions at run time. Some dynamic languages (such
as Common Lisp) distinguish between compile time and run time and provide constructs (eval-when
) to evaluate
expressions early. The simplest dynamic languages (such as Scheme) process input in a single pass and do not distinguish between
compile time and run time. If we evaluated the above function in such a simple language, widget
and gadget
would be evaluated at the time the function is called.
JavaScript is a scripting language. Many programmers wish to write JavaScript scripts embedded in web pages that work in a variety of environments. Some of these environments may provide libraries that a script would like to use, while on other environments the script may have to emulate those libraries. Let’s take a look at an example of something one would expect to be able to easily do in a scripting language:
Bob is writing a script for a web page that wants to take advantage of an optional package MacPack
that is
present on some environments (Macintoshes) but not on others. MacPack
provides a class HyperWindoid
from which Bob wants to subclass his own class BobWindoid
. On other platforms Bob has to define an emulation
class BobWindoid
' that is implemented differently from BobWindoid
— it has a different set of private
methods and fields. There also is a class WindoidGuide
in Bob’s package; the code and method signatures of classes
BobWindoid
and BobWindoid
' refer to objects of type WindoidGuide
, and class WindoidGuide
’s
code refers to objects of type BobWindoid
(or BobWindoid
' as appropriate).
Were JavaScript to use a dynamic execution model (described below), declarations take effect only when executed, and Bob can implement his package as shown below.
class WindoidGuide; // forward declaration if (onMac()) { import "MacPack"; global class BobWindoid extends HyperWindoid { private var x; var g:WindoidGuide; private function speck() {...}; public function zoom(a:WindoidGuide, uncle:HyperWindoid = null):WindoidGuide {...}; } } else { // emulation class BobWindoid' global class BobWindoid { private var i:Integer, j:Integer; var g:WindoidGuide; private function advertise(h:WindoidGuide):WindoidGuide {...}; private function subscribe(h:WindoidGuide):WindoidGuide {...}; public function zoom(a:WindoidGuide):WindoidGuide {...}; } } class WindoidGuide { var currentWindoid:BobWindoid; function introduce(arg:BobWindoid):BobWindoid {...}; }
On the other hand, if the language were static (meaning that types are compile-time expressions), Bob would run into problems.
How could he declare the two alternatives for the class BobWindoid
?
Bob’s first thought was to split his package into three HTML SCRIPT tags (containing BobWindoid
,
BobWindoid
', and WindoidGuide
) and turn one of the first two off depending on the platform. Unfortunately
this doesn’t work because he gets type errors if he separates the definition of class BobWindoid
(or BobWindoid
')
from the definition of WindoidGuide
because these classes mutually refer to each other. Furthermore, Bob would
like to share the script among many pages, so he’d like to have the entire script in a single BobUtilities.js file.
Note that this problem would be newly introduced by JavaScript 2.0 if it were to evaluate type expressions at compile time. JavaScript 1.5 does not suffer from this problem because it does not have a concept of evaluating an expression at compile time, and it is relatively easy to conditionally define a class (which is merely a function) by declaring a single global variable g and conditionally assigning either one or another anonymous function to it.
There exist other alternatives in between the dynamic execution model and the static model that also solve Bob’s problem. One of them is described at the end of this chapter.
In a pure dynamic execution model the entire program is processed in one pass. Declarations take effect only when they are executed. A declaration that is never executed is ignored. Scheme follows this model, as did early versions of Visual Basic.
The dynamic execution model considerably simplifies the language and allows an interpreter to treat programs read from a file identically to programs typed in via an interactive console. Also, a dynamic execution model interpreter or just-in-time compiler may start to execute a script even before it has finished downloading all of it.
One of the most significant advantages of the dynamic execution model is that it allows JavaScript 2.0 scripts to turn parts of themselves on and off based on dynamically obtained information. For example, a script or library could define additional functions and classes if it runs on an environment that provides a CSS unit arithmetic library while still working on environments that do not.
The dynamic execution model requires identifiers naming functions and variables to be defined before they are used. A
use occurs when an identifier is read, written, or called, at which point that identifier is resolved to a variable or a function
according to the scoping rules. A reference from within a control statement such as if
and while
located outside a function is resolved only when execution reaches the reference. References from within the body of a function
are resolved only after the function is called; for efficiency, an implementation is allowed to resolve all references within
a function or method that does not contain eval
at the first time the function is called.
According to these rules, the following program is correct and would print 7
:
function f(a:Integer):Integer { return a+b; } var b:Integer = 4; print(f(3));
Assuming that variable b
is predefined by the host if featurePresent
is true, this program would
also work:
function f(a:Integer):Integer { return a+b; } if (!featurePresent) { var b:Integer = 4; } print(f(3));
On the other hand, the following program would produce an error because f
is referenced before it is defined:
print(f(3)); function f(a:Integer):Integer { return a*2; }
Defining mutually recursive functions is not a problem as long as one defines all of them before calling them.
JavaScript 1.5 does not follow the pure dynamic execution model, and, for reasons of compatibility, JavaScript 2.0 strays from that model as well, adopting a hybrid execution model instead. Specifically, JavaScript 2.0 inherits the following static execution model aspects from JavaScript 1.5:
In addition to the above, the evaluation of class declarations has special provisions for delayed evaluation to allow mutually-referencing classes.
The second condition above allows the following program to work in JavaScript 2.0:
const b:String = "Bee"; function square(a:Integer):Integer { b = a; // Refers to local b defined below, not global b return b*a; var b:Integer; }
While allowed, using variables ahead of declaring them, such as in the above example, is considered bad style and may generate a warning.
The third condition above makes the last example from the pure execution model section work:
print(f(3)); function f(a:Integer):Integer { return a*2; }
Again, actually calling a function at the top level before declaring it is considered bad style and may generate a warning. It also will not work with classes.
Perhaps the easiest way to compile a script under the dynamic execution model is to accumulate function definitions unprocessed and compile them only when they are first called. Many JITs do this anyway because this lets them avoid the overhead of compiling functions that are never called. This process does not impose any more of an overhead than the static model would because under the static model the compiler would need to either scan the source code twice or save all of it unprocessed during the first pass for processing in the second pass.
Compiling a dynamic execution model script off-line also does not present special difficulties as long as eval
is
restricted to not introduce additional declarations that shadow existing ones (if eval
is allowed to do this,
it would present problems for any execution model, including the static one). Under the dynamic execution model, once
the compiler has reached the end of a scope it can assume that that scope is complete; at that point all identifiers inside
that scope can be resolved to the same extent that they would be in the static model.
Bob’s problem could also be solved by using conditional compilation similar in spirit to C’s preprocessor. If we do this, we have to ask about how expressive the conditional compilation meta-language should be. C’s preprocessor is too weak. In JavaScript applications we’d often find that we need the full power of JavaScript so that we can inspect the DOM, the environment, etc. when deciding how to control compilation. Besides, using JavaScript as the meta-language would reduce the number of languages that a programmer would have to learn.
Here’s one sketch of how this could be done:
(x)(y)
is a function call of function x
or a cast of y
to type
x
.#
symbol. For example, #{var x:int = 3}
defines a compile-time constant x and initializes
it to 3. One can also lift a var
, const
, or function
declaration directly by
preceding it with a #
symbol, so #var x:int = 3;
would accomplish the same
thing.int
in the preceding example is such a TypeExpression.#{#var x:int = 3}
) is evaluated at
compile compile time, and so forth.#
if
(
Expression )
Statements [#
else
if
(
Expression )
Statements] ... [#
else
Statements] #
end
if
#
’s can appear anywhere on a line.#if
to conditionally exclude compile time code, etc.Note that because variable initializers are not evaluated at compile time, one has to use #var a = int
rather
than var a = int
to define an alias a
for a type name int
.
This sketch does not address many issues that would have to be resolved, such as how typed variables are handled after they are declared but before they are initialized (this problem doesn’t arise in the dynamic execution model), how the lexical scopes of the run time pass would interact with scoping of the compile time pass, etc.
Both approaches solve Bob’s problem, but they differ in other areas. In the sequel "conditional compilation" refers to the conditional compilation alternative described above.
Another alternative execution model briefly considered but rejected is the idea of allowing compiler blocks. A compiler block has the syntax:
compile
{
Statement ... Statement }
The compile attribute is a hint that the block may be (but does not have to be) evaluated early. The statements inside this block should depend only on each other, on the results of earlier compiler blocks, and on properties of the environment that are designated as being available early. Other than perhaps being evaluated early, compiler blocks respect all of the scope rules and semantics of the enclosing program. Any definitions introduced by a compiler block are saved and reintroduced at normal evaluation time. On the other hand, side effects may or may not be reintroduced at normal evaluation time, so compiler blocks should not rely on side effects.
compile
is an attribute, so it may also be applied to individual definitions without
enclosing them in a block.
As an example, after defining
compile var x = 2; function f1() { compile { var y = 5; var x = 1; while (y) x *= y--; } return ++x; } function f2() { compile { var y = x; } return x+y; }
the value of global x
will still be 2
, calling f1()
will always return 121
,
and calling f2()
will return 4
. If the statement x=5
is then evaluated at the global
level, f1()
will still return 121
because it uses its own local x
. On the other hand,
calling f2()
may return either 7
or 10
at the implementation’s discretion — 7
if the implementation evaluated the compile
block early and saved the value of y
or 10
if it didn’t. As this example illustrates, it is poor technique to define variables inside compiler blocks;
constants are usually better.
A fully dynamic implementation of JavaScript 2.0 may choose to ignore the compile
attribute
and evaluate all compiler blocks at normal evaluation time. A fully static implementation may require that all user-defined
types and attributes be defined inside compiler blocks.
Should const
definitions with simple constant expressions such as const four = 2+2
be treated as though they were implicitly compiler definitions (compile const four = 2+2
)?
Waldemar Horwat Last modified Tuesday, September 10, 2002 |