Tomasz Wegrzanowski
Posted on January 26, 2022
Smalltalk was the original minimalist object-oriented language. As everything about Smalltalk was so modifiable, it didn't remain a single language, instead spawning huge number of incompatible descendants, each trying their different ideas. Some kept the Smalltalk names, others did not.
Io is one of such descendants. The main difference are prototype-based rather than class-based object-oriented system, and even more minimal grammar.
Hello, World!
Io can be ran from Unix scripts, and that's what I'll be doing. Here's the Hello, World:
#!/usr/bin/env io
"Hello, World!\n" print
We construct a "Hello, World!\n"
string object, and send it a message to print
itself, which it does.
$ ./hello.io
Hello, World!
Smalltalk terminated the statements with .
which wasn't exactly an improvement over ;
. Io figured out newlines are perfectly fine statement terminators, so it's much clearer.
Io also has println
message for printing something with a newline.
We can also run io
from command line to get REPL, but it doesn't have proper line editing, so I recommend running it with rlwrap io
.
Math
Io has normal operator precedence, so none of the Smalltalk's silliness where it would just go left to right ignoring established mathematical convention in name of "simplicity". Most Smalltalk descendants figured out that was one of Smalltalk's dumber ideas.
#!/usr/bin/env io
a := 2
b := 3
c := 4
(a + b * c) println
As expected it prints correct 14 not naively-left-to-right 20:
$ ./math.io
14
You can also define your own new operators and assign them precedence levels, but it won't apply to the current file (files are parsed before being executed).
FizzBuzz
Smalltalk does control structures with blocks. Io can do that, but there are also other ways.
Unlike pretty much every other language, arguments in Io are not evaluated automatically, and the called function needs to decide if it wants to evaluate them or not.
Let's try to write a simple FizzBuzz program:
#!/usr/bin/env io
# FizzBuzz in Io
Number fizzbuzz := method(
if(self % 15 == 0, "FizzBuzz",
if(self % 5 == 0, "Buzz",
if(self % 3 == 0, "Fizz",
self))))
for(i, 1, 100,
i fizzbuzz println)
Here's a completely different one:
#!/usr/bin/env io
Number fizzbuzz := method(
(self % 15 == 0) ifTrue (return "FizzBuzz")
(self % 5 == 0) ifTrue (return "Buzz")
(self % 3 == 0) ifTrue (return "Fizz")
self
)
100 repeat(i, (i+1) fizzbuzz println)
Let's go through what's going on step by step:
-
Number fizzbuzz := method(...)
adds a methodfizzbuzz
to prototype ofNumber
- Prototype of
Number
is0
, obviously. If you doNumber + 69
you get69
. There are no classes in Io. -
if(condition, thenBranch, elseBranch)
does not evaluate itsthenBranch
andifBranch
before it can figure out thecondition
- everything in Io has this evaluation model. -
ifTrue (code)
andifFalse (code)
are more Smalltalk-style methods. They'll run the code or not depending on which object you send it to -true
orfalse
-
Number repeat()
is one way of looping - but it starts from 0 so we need to add 1 to the counter. -
for(i, 1, 100, ...)
is another way of looping. - methods have more traditional names and arguments, Smalltalk convention of having names be lists of their keyword argument (like
ifTrue:ifFalse:
) is gone
Fibonacci
This code is a treat:
#!/usr/bin/env io
Number fib := method((self-2) fib + (self-1) fib)
1 fib := method(1)
2 fib := method(1)
for(i, 1, 30, "fib(#{i}) = #{i fib}" interpolate println)
We define fib
on Number
prototype to be (self-2) fib + (self-1) fib
. Then since Io doesn't have classes, we casually redefine it on objects 1
and 2
to be our base case.
The we loop. Io doesn't support string interpolation, but due to its lazy evaluation, String interpolate
method can do all the interpolating for us!
$ ./fib.io
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5
fib(6) = 8
fib(7) = 13
fib(8) = 21
fib(9) = 34
fib(10) = 55
fib(11) = 89
fib(12) = 144
fib(13) = 233
fib(14) = 377
fib(15) = 610
fib(16) = 987
fib(17) = 1597
fib(18) = 2584
fib(19) = 4181
fib(20) = 6765
fib(21) = 10946
fib(22) = 17711
fib(23) = 28657
fib(24) = 46368
fib(25) = 75025
fib(26) = 121393
fib(27) = 196418
fib(28) = 317811
fib(29) = 514229
fib(30) = 832040
Unicode
Io can correctly see lengths of Unicode strings, but somehow cannot convert them to upper or lower case.
#!/usr/bin/env io
"Hello" size println
"Żółw" size println
"🍰" size println
"Żółw" asUppercase println
"Żółw" asLowercase println
$ ./unicode.io
5
4
1
ŻółW
Żółw
This is not something Smalltalk had any idea about, as it predates Unicode by decades, but Io should know better, and it failed here.
Lists
Io doesn't have any special syntax for lists, but list(...)
method works, and it comes with the usual methods, with more modern Ruby-style naming (map
and reduce
; not collect
and inject
):
#!/usr/bin/env io
a := list(1, 2, 3, 4, 5)
a map(x, x * 2) println
a select(x, x % 2 == 0) println
a reduce(x, y, x + y) println
a at(0) println
a at(-1) println
$ ./lists.io
list(2, 4, 6, 8, 10)
list(2, 4)
15
1
5
Maps
Io of course has maps (also known as hashes, or dictionaries, or objects etc. - why can't all languages just agree on a single name), but these are quite awkward:
#!/usr/bin/env io
a := Map clone
a atPut("name", "Alice")
a atPut("last_name", "Smith")
a atPut("age", 25)
"""#{a at("name")} #{a at("last_name")} is #{a at("age")} years old""" interpolate println
a println
a asJson println
$ ./maps.io
Alice Smith is 25 years old
Map_0x7f9c840998b0:
{"last_name":"Smith","age":25,"name":"Alice"}
A few things to note here:
- because string interpolation is not part of Io syntax, we cannot just use
"
inside#{}
blocks like we could in Ruby - the workaround is triple-quoting the outside string in such cases, and once-quoting the inner strings. Io doesn't support single quotes for strings either, so that wouldn't work. - default
Map print
is useless - most Io objects come with usable
asJson
- but somehow there's no way to parse JSON included in Io! That's really weird.
Point prototype
There are no classes in Io - instead we just have prototypes we can clone.
#!/usr/bin/env io
Point := Object clone
Point x := 0
Point y := 0
Point + := method(other,
result := self clone
result x := self x + other x
result y := self y + other y
return result
)
Point asString := method(return "Point(#{self x}, #{self y})" interpolate)
a := Point clone
a x := 60
a y := 400
b := Point clone do(
x := 9
y := 20
)
a println
b println
(a + b) println
"Slots of Object prototype: #{Object slotNames}" interpolate println
"Slots of Point prototype: #{Point slotNames}" interpolate println
"Slots of individual point: #{a slotNames}" interpolate println
$ ./point.io
Point(60, 400)
Point(9, 20)
Point(69, 420)
Slots of Object prototype: list(pause, hasSlot, coroFor, serializedSlotsWithNames, not, continue, markClean, removeSlot, >=, appendProto, in, memorySize, actorProcessQueue, setIsActivatable, isIdenticalTo, hasProto, newSlot, justSerialized, thisLocalContext, , slotDescriptionMap, addTrait, print, argIsCall, while, ifNilEval, argIsActivationRecord, evalArg, prependProto, message, write, asSimpleString, <=, setSlot, inlineMethod, lazySlot, ancestors, thisMessage, init, ifNil, futureSend, if, doRelativeFile, serialized, become, isTrue, getSlot, foreachSlot, perform, returnIfNonNil, type, ifNonNil, ancestorWithSlot, for, isKindOf, slotValues, evalArgAndReturnNil, asBoolean, raiseIfError, shallowCopy, method, .., ==, deprecatedWarning, ifNonNilEval, returnIfError, <, doFile, asyncSend, clone, list, ifError, removeAllProtos, stopStatus, uniqueId, doString, apropos, super, block, isNil, evalArgAndReturnSelf, coroDoLater, isActivatable, launchFile, >, slotNames, isLaunchScript, setSlotWithType, and, break, @, try, performWithArgList, loop, -, setProto, switch, asString, uniqueHexId, actorRun, !=, proto, getLocalSlot, lexicalDo, removeAllSlots, coroDo, slotSummary, removeProto, compare, wait, do, coroWith, ?, cloneWithoutInit, relativeDoFile, contextWithSlot, currentCoro, protos, isError, @@, resend, serializedSlots, return, hasDirtySlot, thisContext, handleActorException, or, yield, updateSlot, writeln, hasLocalSlot, println, ownsSlots, doMessage, setProtos)
Slots of Point prototype: list(x, type, y, asString, +)
Slots of individual point: list(x, y)
- we start by
Object clone
to get a new object with all the usual stuff defined on it - then we addd some slots to the
Point
prototype, namelyx
andy
with default values,+
operator, andasString
method - to create a new
Point
we doPoint clone
, then update any slots we want to change - there's no real difference between overriding instance variables and methods, we can overridea asString
to say"Nice Point"
as easily as overridinga x
to move it somewhere else - Io doesn't really have keywords and such, all the basic functionality is implemented as methods on
Object
prototype - as you can see the list is very long - any method not defined by the object will be called on its prototype
More OO
Io OOP does very little for us. clone
calls init
, so if we need to do some object initialization we can do it there, but it's not meant as a constructor, it's mainly so clone
can also clone
any instance variables that need it.
The closest to a "constructor" Io has is a convention of with(arguments)
method closing the receiver and calling various setters on arguments
.
Here's another, and much more concise, implementation of Point
:
#!/usr/bin/env io
Point := Object clone do(
x ::= 0
y ::= 0
with := method(xval,yval,self clone setX(xval) setY(yval))
asString := method(return "Point(#{self x}, #{self y})" interpolate)
+ := method(other, return self with(x + other x, y + other y))
)
a := Point with(60, 400)
b := Point with(9, 20)
(a+b) println
$ ./point2.io
Point(69, 420)
-
do(...)
is sort of like Ruby'sinstance_eval
, code will be executed in context of whichever object we called it on -
::=
is a shorthand for:=
, but it also defines setters (setX
andsetY
) - setters return the original object, so they can be chained like
aPoint setX(x) setY(y)
-
with
is just a convention, but very useful one, everything is so concise now
Autoloading
Another nice thing Io does is it doesn't pollute your programs with import
statements.
Let's say we have this lorem.io
:
Lorem := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum."
And then we run this print_lorem.io
:
#!/usr/bin/env io
Lorem println
If we run it:
$ ./print_lorem.io
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
Any unknown constant gets autoloaded, Ruby on Rails style. You can configure the paths etc.
This cuts on stupid boilerplate so much, I have no idea why this Ruby on Rails innovation didn't spread everywhere yet. Even most new languages start every file with pile of stupid import boilerplate code.
Io evaluation model
Let's get to the most interesting thing about Io - its evaluation model. Unlike Smalltalk, Io doesn't have any block in the syntax. That's because everything is a block.
If you do a atPut("name", "Alice")
, you're not actually sending a message with atPut
with two values "name"
, and "Alice"
as arguments, like you would in Ruby.
What you're actually sending is a message atPut
with two code blocks! It's up to you to send them back to the sending context with "please evaluate them for me".
Now as this is such a common 99% case, Io will do this part for you if you define any named arguments to the method. So if you define method(a, b, c, code)
, the first three arguments will be evaluated and assigned to a
, b
, and c
. Any arguments you did not define won't be evaluated.
Here's some fun code:
#!/usr/bin/env io
Date dayOfWeek := method(self asString("%A") asLowercase)
onDay := method(
arg0 := call message argAt(0) asString
arg1 := call message argAt(1)
day := Date now dayOfWeek
(day == arg0) ifTrue(call sender doMessage(arg1))
)
onDay(monday, "I love Mondays!" println)
onDay(tuesday, "Tuesdays are allright too I guess..." println)
First, we need to define Date dayOfWeek
to return string like "monday"
, "tuesday"
, etc. Io standard library is quick lacking overall.
Then we add new operator to the language, onDay(day, code)
. It will run code
on specific day of the week. As we didn't specify any arguments to method(argument0, argument1, code)
, it will not evaluate them.
We can extract these arguments with call message argAt(0)
etc., as code blocks. Then we can convert them to strings with asString
. Notice we didn't need to do any monday
- it's not a real method, it's just a syntax we created for onDay
.
We can also tell the caller to evaluate them with call sender doMessage(arg1)
.
The whole call sender doMessage(call message argAt(1))
can also be much more concisely expressed as call evalArgAt(1)
.
$ ./week.io
Tuesdays are allright too I guess...
Testing Library
Let's do something more useful and build a testing library! This is one thing where even Ruby struggles a bit, as RSpec needs to be a bit awkward expect(something).to eq(expected)
.
Io has so much syntax flexibility, we should have no problem with this.
#!/usr/bin/env io
assertEqual := method(left, right,
(left == right) ifFalse(
leftExpr := call message argAt(0) asString
rightExpr := call message argAt(1) asString
"assertion failed:\n #{leftExpr} # => #{left}\ndoes not equal\n #{rightExpr} # => #{right}" interpolate println
)
)
# Io uses normal math
assertEqual(2 + 2 * 2, 6)
# Io does not use Smalltalk math
assertEqual(2 + 2 * 2, 8)
This is surprisingly nice, except spacing of the blocks wasn't preserved:
$ ./assert_equal.io
assertion failed:
2 +(2 *(2)) # => 6
does not equal
8 # => 8
Unfortunately when I tried to use this on strings, the whole thing fell apart:
#!/usr/bin/env io
assertEqual := method(left, right,
(left == right) ifFalse(
leftExpr := call message argAt(0) asString
rightExpr := call message argAt(1) asString
"assertion failed:\n #{leftExpr} # => #{left}\ndoes not equal\n #{rightExpr} # => #{right}" interpolate println
)
)
# Ascii works
assertEqual("hello" asUppercase, "HELLO")
# Sadly no Unicode
assertEqual("żółw" asUppercase, "ŻÓŁW")
$ ./assert_equal2.io
assertion failed:
" asUppercase # => |�BW
does not equal
" # => {�AW
We already know Io doesn't fully support Unicode, but I thought it would at least be able to print Unicode strings.
Better testing library
I also tried to do something more:
#!/usr/bin/env io
assert := method(comparison,
(comparison) ifFalse(
code := call message argAt(0)
"This code failed: #{code}" interpolate println
)
)
# Io uses normal math
assert(2 + 2 * 2 == 6)
assert(2 + 2 * 2 > 5)
assert(6 == 2 + 2 * 2)
# Io does not use Smalltalk math
assert(2 + 2 * 2 == 8)
assert(2 + 2 * 2 > 7)
assert(8 == 2 + 2 * 2)
If Io was like Lisp or Ruby, we'd be able to get the block and see the top level operator, and its arguments, that is splitting 2 + 2 * 2 == 8
into 2 + 2 * 2
, ==
, and 8
- this would enable us to have some really nice testing library with great messages.
Unfortunately there doesn't seem to be any way to dig into syntax tree in Io. I can access raw text of the block, and I can run the block, and it looks like I can get it token by token, but no parse tree. That doesn't make Io bad, it's just a "we were so close to greatness" moment.
Square Brackets
Interestingly Io defines overloadable operators even for characters it doesn't actually use. For example if you use []
in your Io code, you get an error that squareBrackets
is not recognized. So let's define it!
#!/usr/bin/env io
squareBrackets := method(
result := list()
call message arguments foreach(item, result append(doMessage(item)))
return result
)
array := [1, 2, 3+4, ["foo", "bar"]]
array asJson println
In this case we use call message arguments
not because we do any crazy metaprogramming, but just to support variable number of arguments.
This is something more languages should consider doing. For example Ruby could support def <<<
etc. for those objects which just really need a few extra symbols. Then again, it might want to keep its future syntax options open, so I understand why it's not doing it.
Matrix
And after all the toy examples, something more substantial, a small Matrix
class, for NxM matrices.
#!/usr/bin/env io
Matrix := Object clone
Matrix init := method(
self contents := list()
self xsize := 0
self ysize := 0)
Matrix dim := method(x,y,
contents = list()
xsize = x
ysize = y
for(i,1,x,
row := list()
for(j,1,y, row append(0))
contents append(row))
self)
Matrix rangeCheck := method(x,y,
if(x<1 or y<1 or x>xsize or y>ysize,
Exception raise("[#{x},#{y}] out of bonds of matrix" interpolate)))
Matrix get := method(x,y,
rangeCheck(x,y)
contents at(x-1) at(y-1))
Matrix set := method(x,y,v,
rangeCheck(x,y)
contents at(x-1) atPut(y-1,v))
Matrix asString := method( contents map(row,
"[" .. (row join(" ")) .. "]") join("\n"))
Matrix foreach := method(
# like method(xi,yi,vi,blk,...)
# except we do not want to evaluate it
args := call message arguments
xi := args at(0) name
yi := args at(1) name
vi := args at(2) name
msg := args at(3)
ctx := Object clone
ctx setProto(call sender)
for(i,1,xsize,
for(j,1,ysize,
ctx setSlot(xi, i)
ctx setSlot(yi, j)
ctx setSlot(vi, get(i,j))
msg doInContext(ctx))))
Matrix transpose := method(
result := Matrix clone dim(ysize, xsize)
foreach(x,y,v,result set(y,x,v))
result)
Matrix saveAs := method(path,
file := File open(path)
file write(asString, "\n")
file close)
Matrix loadFrom := method(path,
file := File open(path)
lines := file readLines map(line,
line removeSuffix("\n") removeSuffix("]") removePrefix("[") split(" ") map(x, x asNumber))
ysize := lines size
xsize := lines at(0) size
result := Matrix clone dim(xsize, ysize)
for(i,1,xsize,
for(j,1,ysize,
result set(i,j,lines at(j-1) at(i-1))))
result)
newMatrix := Matrix clone dim(2,3)
newMatrix println
newMatrix contents println
newMatrix set(1, 1, 10)
newMatrix set(1, 2, 20)
newMatrix set(1, 3, -30)
newMatrix set(2, 1, 15)
# (2,2) defaults to 0
newMatrix set(2, 3, 5)
"Matrix looks like this:" println
newMatrix println
"\nPrinted cell by cell:" println
newMatrix foreach(a,b,c,
("Matrix[" .. a .. "," .. b .. "]=" .. c) println
)
"\nTransposed:" println
newMatrix transpose println
newMatrix saveAs("matrix.txt")
matrix2 := Matrix loadFrom("matrix.txt")
"\nLoaded:" println
matrix2 println
matrix2 get(69,420) println
./matrix.io
[0 0 0]
[0 0 0]
list(list(0, 0, 0), list(0, 0, 0))
Matrix looks like this:
[10 20 -30]
[15 0 5]
Printed cell by cell:
Matrix[1,1]=10
Matrix[1,2]=20
Matrix[1,3]=-30
Matrix[2,1]=15
Matrix[2,2]=0
Matrix[2,3]=5
Transposed:
[10 15]
[20 0]
[-30 5]
Loaded:
[10 15]
[20 0]
[-30 5]
Exception: [69,420] out of bonds of matrix
---------
Exception raise matrix.io 20
Matrix rangeCheck matrix.io 22
Matrix get matrix.io 96
CLI doFile Z_CLI.io 140
CLI run IoState_runCLI() 1
I won't get too in-depth, but here's some highlights:
- we need
Matrix init
to setcontents
, otherwise we'd share storage with parent matrix -
Matrix rangeCheck
shows how exceptions work in Io - we raise one by invalid operation on the final line -
Matrix foreach
shows how we can do complex block programming without any special support by the language - we create new context object, set slots there, and evaluate it withdoInContext
- because caller is its prototype we get full access to caller's context as well! -
Matrix saveAs
andMatrix loadFrom
show file I/O
Forwarding
Like in any real OOP language, we can do simple proxy:
#!/usr/bin/env io
Cat := Object clone
Cat meow := method("Meow!" println)
Cat asString := "I'm a Kitty!"
Spy := Object clone
Spy object := Cat
Spy forward := method(
m := call message name
args := call message arguments
"Someone's trying to ask #{object} to #{m} with #{args}" interpolate println
object doMessage(call message))
Cat meow
Spy meow
$ ./forward.io
Meow!
Someone's trying to ask I'm a Kitty! to meow with list()
Meow!
Class-based OOP vs Prototype-based OOP
For a while there were two kinds of genuine OOP - class-based and prototype-based - as well far more popular as half-assed OOP of Java variety, which I won't mention here.
Descendants of Smalltalk are also split between class-based (anything that kept the name "Smalltalk") and prototype-based (Io, Self).
It was hard to tell which one was better, until a large scale "natural experiment" happened, and millions of programmers were forced to experience prototype-based OOP in JavaScript whether they liked it or not. And I have trouble recalling any concept in programming that was more soundly and universally rejected. Every JavaScript framework pre-ES6 had its own half-assed class-based OOP system, as literally anything was better than using prototype-based OOP. CoffeeScript's main selling point were the classes, and it was on track to replace JavaScript for a while, before it copied that too. And since ES6, everyone switched to classes (or to pseudo-functional programming like React Hooks) with nobody looking back to the prototypes, still technically being in the language. The question is absolutely solved - class-based OOP is absolutely superior to prototype-based OOP. It's an empirically established fact by now.
Prototype-based OOP is still interesting esoteric way to program, it's just important to acknowledge lessons learned.
Should you use Io?
There's a reason why this is one of the longest episodes yet, as Io is really fascinating.
But I wouldn't recommend it for anything serious, Io is seriously lacking a lot of practical features all across the board. The language is quite elegant, but the standard library is extremely lacking, and very inconsistently designed. It also looks like it's not really actively developed anymore.
But at least it's trying to do a modern take on Smalltalk. All others I tried, starting with GNU Smalltalk, were hopelessly stuck in the past. Many other Smalltalks and descendants I tried wouldn't even install or run on modern systems, so just working with brew install io
, and dropping most of the silly historical baggage makes Io likely the best Smalltalk-like language of today (not counting more distant descendants like Ruby, JavaScript etc.).
I think Smalltalk-land is in much worse shape than Lisp-land where Racket and Clojure are reasonable languages to use. I'd only recommend Io as a language to play with, but that's still more than any other Smalltalk-like.
Io could be turned into an interesting small language if someone spent some time making its standard library not awful, added package manager, and so on, but that's very speculative.
Code
All code examples for the series will be in this repository.
Posted on January 26, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.