3 Classes are Values
As indicated in the preceding section,
does not
define a class in the sense of C++, Java or C#; it creates a class
value. Technically,
class is keyword that marks an expression just
like
begin,
let, or
send are keywords that mark
other Racket expressions. When Racket encounters an expression, it
determines its value, and that value flows into the context. Since the
preceding sections use
class in conjunction with
define,
it is easy to imagine that class definitions in Racket have a cumbersome
syntax but are otherwise like those in conventional class-based object-oriented
languages.
Introducing classes in this way helps carry over intuition from traditional
languages to Racket. A novice can initially ignore the long-winded
define & class syntax and move on. In order to appreciate the
full power of Racket’s class system, however, a programmer must eventually
understand classes as values and the operations that manipulate
classes. The first subsection extends ordinary operations on classes to
run-time class values, the second one sketches a simple example of
exploiting their power via so-called mixins, and the last one introduces
one example of a reflective operation.
3.1 Operations on Classes
Phrased in terms of operations on values, every programmer thinks of class
instantiation and class extension, two widely used operations on classes.
From a purely syntactic perspective, the key difference between Racket and
conventional languages is that neither of these operations expects a class
name but a class value to instantiate classes or create new
ones.
Given this explanation, it is easy to see how
works. The first position in a
(new ...) expression must specify a
class but not necessarily by name. A
class expression is one
acceptable substitute. Evaluate the above expression at the read-eval-print loop
and watch it produce an object:
Indeed, any expression that evaluates to a class value works in this position:
Here
new instantiates one of two different
class values,
depending on what day of the week it is. Since both classes specify the same
public interface—
a
hello method—
the rest of the program cannot
fail with a “message not understood” error. An instantiation such as the
above can also deal with values for
init-fields and immediate
message
sends:
In a similar vein, the first position in a
class expression does
not expect the name of a class but a class value:
This definition associates c% with a class that extends an
in-lined class. Here is a step-by-step explanation:
First, (class object% (super-new) (define/public (hello) "world"))
is a class derived from the built-in object% root class. It comes
with one public method, hello.
Second,
extends [...], which must be a class value. From the rest of the
expression, it is also clear that [...] must be a class that defines a
public hello method. The result is a class that has the same
interface as [...] with extended functionality for the hello
method.
Finally, placing the first expression into the [...] position of
the second means that c% is a class with a hello method that
prints ‘world hello’ on a single line.
An experiment in Racket’s read-eval-print loop confirms this explanation:
Naturally a programmer can also use a conditional to specify a superclass:
Here c% inherits from either a% or b%, depending
on the current value of x. Try to infer the value of x
from the following interaction:
> a% |
#<class:a%> |
> b% |
#<class:b%> |
> c% |
#<class:c%> |
> (send (new c%) hello) |
a smiley hello |
|
Since the word "smiley" shows up in the final output, the super
call to hello in c% must have reached b%, which
means that x must have been #f when Racket determined
the value of the superclass expression.
In short, our experiments confirm that Racket supports class extension as a
run-time operation on class values. The Racket code base exploits this
power in many places and in many different ways. On one hand, this
perspective enables programmers to separate class hierarchies into small,
easy-to-explain pieces that are later composed into the desired whole. On
the other hand, with dynamic class composition programs can splice
functionality into a class hierarchy while staying entirely modular. That
is, the building blocks are in separate classes, not in a super-duper
superclass that unites unrelated parts of the class hierarchy.
3.2 Mixins, a First Taste
| |
> (send artist draw) | "drawing" |
| > (send cowboy draw) | "gun" |
|
Figure 8: Artists and cowboys
Figure 8 introduces the basis of a toy-size example to
illustrate how programmers create programs that create a part of the class
hierarchy at run-time. Take a look at the two, obviously unrelated class
definitions in the figure. One introduces the artist% class, the
other one a cowboy% class. Both classes define a draw
method, but when these methods are run, they behave in different ways and
return different results.
A Java programmer who wanted to add the same functionality to both is faced
with the choice of duplicating code in subclasses or creating a common
superclass. While the “duplicate code” alternative is universally
considered as unethical, the “common superclass” alternative comes with
its own problems. For one, a programmer may not have the rights to modify
the two class definitions, in which case the “common superclass”
alternative is infeasible. Even if the programmer can modify the two
classes, creating a common superclass for artists and cowboys unifies two
unrelated classes in a way that most software designers consider
objectionable.
The introduction of first-class classes solves this conundrum in an elegant
way. A programmer defines a function that consumes classes
and derives subclasses with the appropriate behavioral extension or
modification. Here is such a function for this toy example:
The function’s purpose is to map a class to a subclass. More precisely,
ready-mixin consumes a class and returns a subclass, and this subclass
overrides the draw method of the given class.
Functions such as ready-mixin are dubbed mixins. A program can
invoke ready-mixin on a class, and the result is a subclass with a
modified draw method:
(define artist-ready% | (ready-mixin artist%)) | | (define artist-ready | (new artist-ready% | [canvas "pad"])) | | (send artist-ready draw) |
| (define cowboy-ready% | (ready-mixin cowboy%)) | | (define cowboy-ready | (new cowboy-ready% | [holster "pistol"])) | | (send cowboy-ready draw) |
|
> (send artist-ready draw) | "ready! drawing" |
| > (send cowboy-ready draw) | "ready! pistol" |
|
Both artist-ready and cowboy-ready’s draw method
add the word "ready!" to the result of their respective parent’s
draw method, which remain distinct.
Another way of looking at this idea is that mixins provide a
substitute for multiple inheritance.
Now mixing up artists and cowboys is silly. In the context of our running
example, however, using mixins looks solves a serious problem
elegantly. The next section explains how to use mixins in that world.
3.3 Reflective Operations
Beyond the usual operations on classes, Racket also provides operations for
inspecting a class at run-time in a reflective manner. Suppose you wish to
write a program that inject a method m into two different parts
of the class hierarchy, regardless of whether the two branches come with
such a method already or not. According to our explanation of
define/override above, solving this problem appears impossible at
first glance.
Let’s start with a concrete example of a two-pronged hierarchy:
As the names say, the with-m% class comes with a method
m while without-m% does not. One way to state our
problem is to say that we wish to define the add-m-mixin function
and that this function maps one class to another with a specific
m method.
In principle, this add-m-mixin must have roughly the following
shape:
Furthermore, the missing piece, indicated with [...], must check
whether super% has an m method or not. We can formulate
this check with a pair of reflective operations:
Let’s illustrate this kind of reflection with a sequence of interactions:
Based on these examples, it is clear that add-m-mixin needs the
following expression
in place of [...].
Once the mixin function is complete, we can easily confirm that it adds the
desired m method regardless of its superclass:
> (= (send (new (add-m-mixin with-m%)) m) | (send (new (add-m-mixin without-m%)) m)) |
|
#t |