100 Languages Speedrun: Episode 67: Io

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 method fizzbuzz to prototype of Number
  • Prototype of Number is 0, obviously. If you do Number + 69 you get 69. There are no classes in Io.
  • if(condition, thenBranch, elseBranch) does not evaluate its thenBranch and ifBranch before it can figure out the condition - everything in Io has this evaluation model.
  • ifTrue (code) and ifFalse (code) are more Smalltalk-style methods. They'll run the code or not depending on which object you send it to - true or false
  • 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, namely x and y with default values, + operator, and asString method
  • to create a new Point we do Point clone, then update any slots we want to change - there's no real difference between overriding instance variables and methods, we can override a asString to say "Nice Point" as easily as overriding a 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's instance_eval, code will be executed in context of whichever object we called it on
  • ::= is a shorthand for :=, but it also defines setters (setX and setY)
  • 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 set contents, 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 with doInContext - because caller is its prototype we get full access to caller's context as well!
  • Matrix saveAs and Matrix 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.

Code for the Io episode is available here.