The Io language is a small, very cool object-oriented language.
There's a broad consensus among the programming-language
aficionados I talk to: object-oriented programming is not nearly
as useful as it was once considered to be.
It's easy to come to that conclusion
after seeing the OOP-mania of the 90's give way to bloated,
complicated codebases, the infamous piles of
AbstractStrategyFactory
subclasses and dependency injection
frameworks needed to build flexible Java software,
the signed-in-triplicate verbosity of Objective-C code, or
the impossible-to-optimize amounts of indirection in Ruby or
Python.Certainly, some problem domains map well to
object-oriented programming: the traditional example is GUI
programming, in which the little heterogeneous chunks of
state that are objects map quite well to the widgets
in a traditional WIMP interface. But in other areas, the
object-oriented approach falls flat: high-performance video
games, for example, have realized that traditional
object-oriented modeling techniques result in abysmal
cache performance, and end up using object-oriented languages
to produce
very much non-object-oriented designs.
It's common to see fans of object-orientation object, "Ah, well,
that's because C++ isn't really what we mean," which does
sound a little bit weaselly,
No true object puts sugar on its porridge!
but even Alan Kay, originator of
the phrase "object-oriented", once said
In particular, in his awesome talk at OOPSLA in 1997,
which you should certainly watch at some point.
I made up the term 'object-oriented', and I can tell you I didn't have C++ in mind!
Well, what did Kay have in mind? From
a classic response by Kay himself:
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP. There are possibly other systems in which this is possible, but I'm not aware of them.
Everything feature Kay describes is something that forces the
programmer to write abstract programs: hiding of local state
means that features must rely on external interfaces;
messaging means that interfaces must be abstract and
generally specified; late-binding means the programmer must write code
that works with alternate indistinguishable implementations,
rather than specifying a concrete implementation up-front. Clearly,
C++ does not fit the bill: it lacks messaging as a language feature,
has only optional protection of state-process, and heavily discourages
late-binding when it allows it at all! So: what does a Kay-style
object-oriented language look like?
Kay wrote the above passage in 2003, and since then, at least a few other languages
have come around that are in the original spirit of Kay's vision.
One of them is
Steve Dekorte's Io language,
which I'd like to provide a whirlwind tour of here.
The wonderful thing about Io is how small it is: it chooses
a few simple pieces, adds some simple sugar, and builds everything
else out of those pieces. It's very much feels like the
distilled essence of a language paradigm.
"Hello, world!" println
Syntactically, it's very sparse—the above snippet, which contains
just two tokens, invokes a method called
println
on a string
literal.
More properly, we're sending a message. The difference seems pedantic
at first, but Io—in contrast to Java or C++—lets us consider the message
as a thing, examining it as a value or sending it elsewhere by delegating
or duplicating it.
We could include the trailing parentheses—as
the println
method here takes an empty argument list—but Io allows us
to omit them."Hello, world!" println()
Unlike most members of the SmallTalk family, Io doesn't have
unusual split-apart method names.
In SmallTalk, method names have "holes" for arguments, indicated
by a colon. A method name will look like
withFoo:andBar:
and invoking it will look like
myObj withFoo: y andBar: z
. This is a compelling design
choice in that it often forces a programmer to document an interface,
because a method will look like
rectWithWidth: x andHeight: y
,
giving you omnipresent documentation about the order of arguments.
It is, however, an unusual design choice.
A method name is a single identifier, and it takes trailing
arguments in parens, unless there are no arguments.
There's some special syntax for operators, to make
precedence work right, but operators are themselves sugar for calling
methods named things like +
or *
:# we can write this
2 + 3 * 4 println
# or, equivalently, this
2 +(3 *(4)) println()
It's an imperative language, so we can create and assign variables:
Io> x := 2
Io> x println
2
Io> x = x + 1
Io> x println
3
But assignment is also syntactic sugar—underneath, it's still method calls!
The code above can be desugared to explicit calls to
assignment-related methods:
Io> setSlot("x", 2)
Io> getSlot("x") println()
2
Io> updateSlot("x", x +(1))
Io> getSlot("x") println()
3
If you look closely you'll notice that those methods aren't being
called on any object in particular: when we don't supply an explicit
object to call a method on, it will get called on some ambient
self
object,
sort of like this
in other OO languages. When we're sitting at
the interactive language prompt, that ambient object is called
the Lobby
. The above code is therefore also equivalent
to
Although notice that Lobby
is itself a variable, so it
could itself be taken as sugar for getSlot("Lobby")
called on
the Lobby
object. This starts to hint at the stack of turtles
underneath the Io language: it really is objects all the way
down, in several ways.
Io> Lobby setSlot("x", 2)
Io> Lobby getSlot("x") println()
2
Io> Lobby updateSlot("x", x +(1))
Io> Lobby getSlot("x") println()
3
So in Io, all actions are method calls, even "primitive" actions like
assignment or variable access.
Unlike most common object-oriented languages today, Io does not use
class declarations: it's a prototype-based language. The other
well-known language that uses prototypal OO is JavaScript, and
my opinion is that it does so very badly: consequently, many people
have strongly negative ideas about prototype OO. Io's model is
not quite so hairy or complicated. In Io, to create a new object,
we clone an old one. We do so with the
clone
method, which
we can use on the generic built-in Object
.Io> myPoint := Object clone
Once we have a new object, we can use
:=
to change the values
of its slots, or local values.Io> myPoint x := 2
Io> myPoint y := 8
Io> (myPoint x + myPoint y) println
10
If we clone an object, the new object remembers which object it was
cloned from—its prototype—and every time we look up a slot in
that object, it will first check whether it has that slot;
otherwise, it'll check to see if its prototype has the slot, and so
on back up the chain. That means we can clone
myPoint
into a
new object, but the new object will still have access to everything
we defined on myPoint
:Io> newPoint := myPoint clone
Io> newPoint x println
2
We can override values on
newPoint
without changing them on
its parent object:Io> newPoint x := 7
Io> newPoint x println # The child has the new value
7
Io> myPoint x println # The parent still has the old one
2
On the other hand, changes to the parent object will be reflected
in non-overridden values on the child object:
Io> myPoint y = 3 # we can change the parent value
Io> newPoint y println # and the child can see it
3
We can create methods on objects as well, using slot assignment
and the
method
constructor. Within the code of a method
,
the ambient object points to the object in which the method is held,
so all variables accesses will be looked up inside the object that
holds the method:Io> myPoint isOrigin := method(x == 0 and y == 0)
Io> myPoint isOrigin println
false
This means that if we copy that method to another object, the
x
and
y
variables referenced in the method will now refer to that new object.
Determining what self
means for a method like this
is simple: it refers to the object through which we invoke the method.Io> otherPoint := Object clone
Io> otherPoint isOrigin := myPoint getSlot("isOrigin")
Io> otherPoint x := 0
Io> otherPoint y := 0
Io> otherPoint isOrigin println
true
Methods can of course take arguments:
Io> myPoint eq := method(other,
x == other x and y == other y)
Io> myPoint eq(myPoint) println
true
Io> myPoint eq(otherPoint) println
false
Because we can clone any object, any object can serve as prototype for
another object. I probably would, in practice, build up a proper Point
abstraction a little bit differently:
Point := Object clone
Point new := method(nx, ny,
p := Point clone;
p x := nx;
p y := ny;
p)
Point isOrigin := method(x == 0 and y == 0)
Point add := method(other,
new (x + other x, y + other y))
Point sub := method(other,
new (x - other x, y - other y))
Here I fill in all the relevant methods on a
Point
, and when I want to
create an "instance", I clone the object and fill in the x
and y
values.
Cloning doesn't just serve the same purpose as instatiation in other OO languages,
though; it's also how we'd implement subclassing. To create a new "subclass"
of Point
, I clone the Point
object and start filling in new methods
instead of instantiating variables:MutablePoint := Point clone
MutablePoint setX := method(nx, x = nx; self)
MutablePoint setY := method(ny, y = ny; self)
For that matter, there's no reason we have to distinguish between
instantiating and subclassing: that's just me explaining things
in the traditional terms of class-based OO languages.
We could simultaneously create a new "instance" and add extra methods,
which corresponds neither strictly to subclassing nor
to instantiation. If that object turns out to be useful, we can create
new copies by cloning and modifying those as needed, allowing that
"instance" to form a new "class". It's really quite flexible, and
extensive
resources have been written about how to use prototype-based modelling effectively.
So we know that
method
s look up their locals in the object where
they're stored. But consider the classic Scheme counter, a function
which returns one number higher every time you call it:(define (mk-counter)
(let ((n 0))
(lambda ()
(set! n (+ n 1))
n)))
If we try to translate this to Io using a
method
, we'll run into a
problem:mkCounter := method(
n := 0
method( n = n + 1 )
)
When we run this, we get an error:
Io> c := mkCounter
Io> c
Exception: Object does not respond to 'n'
I said before: a
method
looks up any variables mentioned inside the
context where it's stored. The inner method
we create and return is
stored in the Lobby
, because we're calling this at the prompt. Therefore,
it is looking for n
in the Lobby
, and not in the enclosing
lexical scope! If we add an n
to the Lobby
, then our code will start
working:Io> n := 0
Io> c println
1
Io> c println
2
But that's not what we wanted! We want the variable to be hidden inside a
closure, so we have private, exclusive access to it. So in this case, instead
of using a
method
, we can use a block
, which is like a method
except that variables are looked up in the enclosing lexical scope
intead.mkCounter := method(
n := 0
block( n = n + 1 )
)
Unlike
method
s, block
s have to be invoked with a call
method:Io> c := mkCounter
Io> c call
1
Io> c call
2
For both
method
s and block
s, the place where local variables are
stored is just an object: they have a new fresh object to store new local variables,
but the prototype of that object that corresponds to the
location of the method
or the enclosing static scope around the block
.
Looking up variables in those scopes uses the same getSlot(...)
operation
to look up the prototype chain, and assigning to a slot uses the same
setSlot(...)
or updateSlot(...)
operations.
It really is objects (and messages) all the way down.At this point, I've explained almost all the core
features of the Io language. There are some more dynamic features:
an object can, for example, resend or forward messages to other
objects, and Io has frankly staggering amounts of introspection.
Methods and blocks (which are, themselves, objects) even have their own
code
method, which gives us the source code of the method in a
manipulable form at runtime, so we can
introspect on (and modify) the AST itself.
Additionally, Io has a well-designed standard library, a nice
concurrency model (both actors and futures implemented via
coroutines) and a clean, well-defined C interface, making
it incredibly easy to embed into a larger project as a scripting language.But a major reason I like Io is that it builds so much of itself
on top of so few, straightforward features: a
barebones grammar,
combined with cloning objects and dispatching messages gives us a
lot of expressive power. This is much closer, language-wise, to what
Kay had in mind: late-bound, message-passing-based interfaces that
hide internal state behind public APIs, and very little else.