This is the third in a series of posts on the Minima language. Here’s the first,
and here’s the second.
One of the motivating factors behind Minima is to make it as small as possible,
so that when it comes time to add features to it, those changes are easy.
But Minima isn’t as small as possible. It’s quite small, but some of its
features are implementable in terms of other features.
Variables
Let’s start with variables. We need to be able to access variables, but we have
two ways of defining them: with is
declarations, and as function parameters.
We can’t really get rid of function parameters, but we can rewrite any code
which uses is
to use function parameters instead. For example:
greet is [name] => (
start is "Hello, ",
print[start:concat[name]]
)
We could eliminate the variable start
by inlining it, but for demonstrative
purposes, we want to give it a name. So instead we can do:
greet is [name] => (
([start] => print[start:concat[name]])["Hello, "]
)
Grouping
Let’s say we want to write a function which prints three things:
visit is [name] => (
print["Hello, ":concat[name]]
print[name:concat[", may I take your coat?"]
print["Goodbye, ":concat[name]]
)
We can do that without grouping, by creating a function which takes and ignores
three parameters, and immediately invoking it with our three side-effecting
expressions:
visit is [name] => (
([ignore1, ignore2, ignore3] => SUCCESS)[
print["Hello, ":concat[name]],
print[name:concat[", may I take your coat?"]],
print["Goodbye, ":concat[name]]
]
)
This achieves both purposes of grouping: allowing side effects, and creating
variables bound to locally evaluated values.
It’s worth noting that whilst this obviates the need for sequencing expressions
in a group, we still do need parentheses for precedence - otherwise, we’d be
trying to invoke SUCCESS
instead of our locally declared function.
An expression like: apply is [a, b] => a[b]
can be interpreted in two ways:
either as (apply is [a, b] => a)[b]
, or as apply is ([a, b] => a[b])
. Here,
we clearly mean the former, but that’s only obvious as a reader from context.
We can apply various precedence rules: access binds the most tightly, then
invocation, then implication and then assignment the most weakly - and that will
usually do what we want. But sometimes we want invocation to apply after
implication, and for that we need control of precedence.
This means we can’t entirely get rid of grouping, at least not with this
syntax - but we could downgrade it to only taking a single expression: ie
regular parenthesisation.
Objects
Objects are how we build larger data structures out of smaller ones. For example,
if we want to represent a 2D vector, and we had access to both a square
and
a sqrt
function:
vector is { x : 2, y : 5 }
length is [vector] => (
x is vector:x,
y is vector:y,
sqrt[square[x]:plus[square[y]]]
)
print[length[vector]]
But we can also represent that with a function:
vector is [f] => f[2, 5]
length is [vector] => (
calc is [x, y] => sqrt[square[x]:plus[square[y]]],
vector[calc]
)
print[length[vector]]
That’s just one way of interacting with a function datatype. We may
prefer to do this:
vector is [f] => f[2, 5]
getX is [x, y] => x
getY is [x, y] => y
length is [vector] => (
x is vector[getX],
y is vector[getY],
sqrt[square[x]:plus[square[y]]]
)
print[length[vector]]
This is just another way of binding the values together. It’s interesting to
note how instead of performing operations on the vector, we give
operations to the vector - which is a bit counterintuitive with how we usually
use functions, but is surprisingly analagous to the field access. Just compare
vector:x
to vector[getX]
- it’s basically the same semantics.
This approach has its problems, though.
Particularly, there’s no association of name to position - we could easily
confuse ourselves if we were to do something like this:
vector is [f] => f[2, 5]
height is vector[[h, w] => h]
Our intention was that width is the x-component and height is the y-component,
and we list those components in the order x, y. But we don’t have any way of
enforcing that, or even documenting it well.
Where an object is a mapping of name to value, here it’s just a mapping of
position to value, so contextual mistakes like this are easy to make.
Also, this is all well and good when we’re concerned about representing a concise
bundle of data like a 2D point, but it quickly becomes unwieldly when dealing
with something which has a lot of fields - such as a rich object like a number,
with many methods.
Polymorphism and abstraction
There’s a bigger problem, too: we don’t just want to represent objects as a
fixed set of fields/methods. We want to enable polymorphism - for example,
print
is a function which should be able to render anything with a show
method. Function datatypes don’t provide an obvious approach to this.
What we could do, instead, is build a datatype that is just a mapping from
name to value, build some useful functions around it, and use that instead.
Like, well, a map.
At this point I’m going to skip ahead and make two assertions: firstly, that’s
something we can do, and secondly, it ends up being a lot more verbose. The best
case scenario is something like this:
vector is mapOf["x", 2, mapOf["y", 5, empty]]
length is [vector] => (
x is get[vector, "x"],
y is get[vector, "y"],
sqrt[square[x]:plus[square[y]]]
)
Effectively, we can build an object system in our code. It doesn’t feel like
an object system, but it gives us the capabilities we need, albeit clunkily. In
fact, that’s pretty much what the Java implementation of Minima does.
Numbers
We don’t need numbers in our language either. We can build our own, using just
functions. Let’s start with the natural numbers: counting numbers, starting
with zero and going up by one.
A number can be defined as being either zero, or the successor of another
number. Let’s define a number which represents how many times an operation will
be repeated:
zero is [s, z, acc] => z[acc]
succ is [pred] => [s, z, acc] => pred[s, z, s[acc]]
three is succ[succ[succ[zero]]]
bangs is three[
[acc] => acc:concat["!"],
[acc] => acc,
""
]
print[bangs]
This prints three bangs. Let’s get more adventurous: addition!
…wait, no, let’s not, this is getting dangerously indulgent now. Suffice it
to say at this point, whilst it’s possible to represent numbers - or, for that
matter, any data type - this way, it’s not very practical. I think it’s time
to sum up a bit.
Summing up a bit
Ultimately, of Minima’s features, we can represent most of them using functions.
We can create datatypes, remove the need for traditional variables, and remove
the need to group multiple expressions into one. What we do need - the tools we
keep going back to - are those three fundamental operations:
- Define a function
- Invoke a function
- Read a variable
If we wanted a truly minimal language, that would be it. There’s a name for
that language: the lambda calculus. Anything you can express in Minima can also be
expressed in the lambda calculus: for that matter, anything you can express in
any language can also be expressed in the lambda calculus.
So why do we need any other languages? Because the lambda calculus is a pain in
the ass to work with. Every time we remove a feature from Minima, the code ended
up worse. A lot worse.
Removing the ability to bind variables or group expressions twists our code
into awkward contortions that don’t logically flow. It looked pretty bad
in small snippets, but having to introduce nested components just for access to
variables breaks down incredibly quickly as program size increases. Try writing
a program which defines four functions, each in terms of the prior, using
parameters as your only variables, and you’ll see what I mean.
We can do some interesting things using functions to represent data-types - and
probably will - but it’s not a good general-purpose approach: there are plenty
of cases where we need objects and would really prefer not to have to build
our own. As for removing numbers: well, we were never seriously considering that.
So, back to the original purpose of Minima. It’s not here to be as small as it
can possibly be - it’s here to be as small as it can get away with being, whilst
still being reasonable to actually program in, so we can use it as a meaningful
base.
So whilst it’s been interesting to see what’s technically redundant, and why,
now is the time to make peace with our feature-set and start seeing what we can
do with this language.