A while back, I started tinkering with a new flavor of Lisp (for me):
Scheme,
using the
ChezScheme
dialect.
A Scheme-compliant Lisp is one which (among some other bits) implements a faily
minimal set of functions/macros; simple string manipulations, data
structures and compound data types, basic math and I/O,
and a fairly sophisticated function definition, error handling, and hygienic macro facility
(a full summary of the language spec can be found here).
I chose Chez Scheme because of its fascinating heritage as an industrial-strengh
production-ready Scheme dialect, and because it seemed more traditional (being fully self-hosted);
but that’s a conversation for another post.
As a minimal Lisp, Scheme has only a primitive “object” system
(if you can even call it that): records,
which are roughly analogous to struct
s in C.
For example, a tree-node structure from the worked examples in
The Scheme Programming Language Version 4,
produces a structure not unlike one which would be used in Java.
(define-record-type tnode
(fields (immutable word)
(mutable left)
(mutable right)
(mutable count))
(protocol
(lambda (new)
(lambda (word)
(new word '() '() 1)))))
The define-record-type
macro generates (in this case) a custom constructor of a single parameter,
a predicate for testing whether objects are instances of tnode
,
and both getters/setters
(the setters by default are the getters with the suffix -set!
):
make-tnode
tnode?
tnode-word
tnode-left
tnode-left-set!
tnode-right
tnode-right-set!
tnode-count
tnode-count-set!
Scheme intentionally does not include a particular implementation of a
object-oriented programming paradigm
(classes),
preferring to let programmers roll their own.
Now, I don’t think I can invent a novel or particularly interesting
object-oriented programming system; but I do find it fascinating to study
how others have done it.
Typically, objects are at least somewhat self-aware
(ie it is possible to ask an object for its type, and usually also to
ask an object whether it conforms to some type).
Prototypical inheritance
affords one of the simpler ways to devise
an object system, especially for dynamically typed programming
languages, for a few reasons:
- Objects can be built using only one data structure: the all-powerful dictionary.
- Instances of objects, and object prototypes, do not need to be differentiated.
Prototypes are typically objects with associated code but no data; instances
are typically objects with associated data but no code; but there is no firm
requirement to make this distinction.
- Combining objects is as simple as including them in the delegation chain
of another object.
One such neat system I’ve recently stumbled upon is
“TinyTalk”,
a minimal Smalltalk-like prototype-based object system,
developed by Ken Dickey as a spiritual successor
to his Yet-Another-Simple-Object-System (YASOS)
system.
(For those of you unfamiliar with Smalltalk, think JavaScript, but more consistent.)
It defines a full object-oriented system in under 400 lines-of-code!
In JavaScript, one might define a “Point” class as:
var Point = (function() {
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.distanceBetween = function(other) {
if (Point !== other.constructor) {
throw new Error("Need two points!");
}
var dx = this.x - other.x;
var dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
Point.prototype.isEqualTo = function(other) {
if (Point !== other.constructor) {
throw new Error("Need two points!");
}
return this.x === other.x && this.y === other.y;
}
return Point;
}());
In this case, Point
is both the name of the type, as well as the factory-function
with which to produce instances. However, there isn’t really a Point
class, so
much as an anonymous Point
object which contains both the constructor and function
definitions. Per the convention described above, data are stored on the object itself
(this.x = x
), but the code is stored on the object’s prototype
(Point.prototype.distanceBetween = ...
).
And, due to historical baggage, JavaScript class factories generally share a name
with their types, and are invoked by calling the new
operator on a particular
prototype.
In TinyTalk, something equivalent looks like:
(define proto-point
(object () ;; Methods only
[(point? self) #t]
[(distance-between self other)
(unless (point? other)
(error "Needs two points!"))
(let ((dx (- [$ x self] [$ x other]))
(dy (- [$ y self] [$ y other])))
(sqrt (+ (* dx dx) (* dy dy))))]
[(=? self other)
(unless (point? other)
(error "Needs two points!"))
(and (= [$ x self] [$ x other]) (= [$ y self] [$ y other]))]))
(define (new-point x y)
(object
([x x] [y y]) ;; Each instance has its own data...
[(delegate self) proto-point])) ;; but shares code...
Here, new-point
is the factory-function for creating instances.
TinyTalk names its prototype lookup delegate
instead of prototype
,
although it is functionally identical.
Of course, prototypes have other fun capabilities, such as dynamic
extension. Because objects are backed by the dictionary data structure,
adding new functionality to an existing prototype or object is as simple
as adding a new entry into the appropriate dictionary.
($ add-method! proto-point '->string
(lambda (self)
(string-append
"(point "
(->string [$ x self])
" "
(->string [$ y self])
")")))
Objects all the way… up?
Now, in a typical programming environment which supports classes, all
objects should strive to conform to the class system. How does TinyTalk
manage to do this for e.g. ChezScheme, whose built-in data structures like
lists and vectors exist outside the object system?
The answer is both clever and either satisfying or ugly
(depending on your taste). TinyTalk includes a dynamic “escape hatch” for its
type system, to allow retrofitting the rest of the Scheme types with the
object system. Unlike typical TinyTalk objects, whose code lives exclusively
inside their self-contained prototype chain, the built-in types can be given
“global” prototypes.
TinyTalk contains a global getter/setter called custom-method-finder
, which
is included in the delegation chain for all TinyTalk objects. By default
it is always disabled, running and built-in types do not have prototypes.
However, TinyTalk also ships with a module named tiny-talk-plus.sls
, which
activates the custom-method-finder
and binds it to a global dictionary.
This global dictionary leverages the Scheme
built-in type-types (e.g. (list? (list))
returns true).
Virtual prototypes are created for the built in types, and
added to the global dictionary. Whenever an unknown object is invoked,
not only is that object’s own prototype consulted (when it exists), but the
dictionary is, too.
When would I use this?
That’s a good question! Most of the time, when I do personal programming,
I’m using a language like Python/Clojure, which already has a robust
object system built in.
(Python’s object system is essentially prototype-based, whereas Clojure’s
has prototype-like flexibility but isn’t actually prototype-based.)
I suppose that if I were writing a video game from scratch,
and I was also writing the engine from scratch,
and I was going to embed an engine (that wasn’t Lua),
I could use ChezScheme with TinyTalk to do so.
But even if I don’t do that, it’s nice to know that it’s there.