I love simple programming languages, like Gleam, Go, and C. I know I'm not alone; there's something wonderful about using a simple language: reading it, using it in a team, coming back to it after a long time, the list goes on.
In this post I want to make this kind of simplicity more precise and talk about some reasons it's important. I propose five key ideas for simple programming languages: ready-at-hand features, fast iteration cycles, a single way of doing things, first-order reasoning principles, and simple static type systems. I discuss each of these at length below.
In the philosophy of technology there's a very useful concept: "presence-at-hand" vs. "readiness-at-hand." When something is present-at-hand, it's currently taking up our thoughts and in our immediate consciousness. If it's instead ready-at-hand, it's like we can't even tell that it's there until we want to use it. For example, when we walk into our kitchen for the millionth time, we're not really aware of all the cupboards, food, appliances, table, chairs, decorations, or whatever else you have in your kitchen. At least for me, it's almost like I only see the fridge if I'm coming for a quick bite. As I sit down, I become aware of the table and chairs, which were ready-at-hand and now become present-at-hand, and I don't think a single thought about the fridge, which becomes ready-at-hand.
Just because you're using something doesn't make it become present-at-hand. Glasses, for example, remain ready-at-hand even as you see through them, because your mind acts as if they aren't even there. However, when you use ready-at-hand things and they disfunction then they become present-at-hand: dirt on your glasses makes the presence of the glasses suddenly very loud to your mind.
This is like the idea of a small working memory that limits the number of concepts you can juggle in your mind at once, but generalized to include how we filter out noise from all the sense data we're constantly bombarded with.
I bring up this idea of readiness-at-hand because simple programming languages often do have many features, but they're designed to stay invisible to our mind whenever we're not using them. For example, Gleam, Go, and C are all very cross-platform, and making them support more platforms is a big chunk of the work that goes into them. Being able to run your code in a browser, or on a Raspberry Pi, or on a phone, or on a server, are real features that are added to the language, but they don't hurt its simplicity at all. Another example is LSP support, which is a major focus for the developers of Gleam and Go, and for C is doing pretty well now in spite of its age.
I won't say too much more about this because hopefully you'll be able to see it as a little bit of a theme in the following sections. The paper I'd recommend for finding more about these ideas in the philosophy of technology is here.
A very fast iteration cycle (meaning compile times, mostly) is a very nice feature that simple languages aim for. Prototyping and experimentation is very cheap and the developer can stay in a flow-state that a 2+ second compile time would make impossible.
Obviously, C needs a little bit of slack here, because it's designed as a single-pass compiler originally, but its design is quite good considering that constraint. Say what you will about header files being annoying, I think they're quite ergonomic under those circumstances. They offer an out-of-orderness that we take for granted now but that definitely counts as a ready-at-hand feature.
But for Gleam and Go, compiler performance is some of the best in class. Go is famous for this, so I won't say too much about it. The Gleam compiler is written in Rust and the designers have been very explicit that it will never be self-hosted, as that would hurt compiler performance and make distribution harder. Files are parsed and processed in parallel where possible, and I've personally found my Gleam projects to compile instantly.
It's also worth mentioning gleam's dependency system is extremely nice. It works with the Hex package manager of Erlang and Elixir, and therefore generates beautiful HexDocs documentation pages for you to make libraries easy to find and make good documentation the norm. To see how convenient gleam makes everything, see the options that come up when I type gleam
into my terminal and hit enter:
$ gleam
\
gleam 1.0.0
\
USAGE:
gleam <SUBCOMMAND>
\
OPTIONS:
-h, --help Print help information
-V, --version Print version information
\
SUBCOMMANDS:
add Add new project dependencies
build Build the project
check Type check the project
clean Clean build artifacts
deps Work with dependency packages
docs Render HTML documentation
export Export something useful from the Gleam project
fix Rewrite deprecated Gleam code
format Format source code
help Print this message or the help of the given subcommand(s)
hex Work with the Hex package manager
lsp Run the language server, to be used by editors
new Create a new project
publish Publish the project to the Hex package manager
remove Remove project dependencies
run Run the project
shell Start an Erlang shell
test Run the project tests
update Update dependency packages to their latest versions
That's a lot of very straightforward and convenient subcommands! I've been using Gleam for a few months now and published a couple packages and added many to my projects and I'm very happy with this process.
Designing a language for fast compile times often means a lot of fancy features aren't possible. For example, Go isn't planning on adding metaprogramming, and for a long time wasn't even planning on adding generics.
But in many cases these languages argue that the sacrifices made for performance are actually better language design choices anyway. Go wants all of its looping code to be with a for loop, all of its "this-or-that" code to be with if statements, and all of its "choose-one-of-these" code to be with switch statements. To that end, for loops and switch statements are a little unusual in Go, and there's no while loop. Go's concurrency story is very committed to one approach, unlike Rust which is the opposite. Functional code is possible to some degree but Go's lambdas are a pain in the ass to write. Go's type system solves every type challenge with interfaces.
Gleam takes this idea even further. Following its functional lineage, there are no loop constructs, just recursion and things like map
and fold
. Tail call optimization is used so these compile a lot like how a while loop would. Furthermore, Gleam doesn't even have if
! Instead, there is only (powerful) pattern matching with (powerful) guards. Fibonacci might be written like so:
pub fn fib(n: Int) -> Int {
case n < 2 {
True -> n
False -> fib(n - 1) + fib(n - 2)
}
}
Pattern matching on True
and False
is just like an if
statement, so this "limitation" is never that annoying in practice.
Gleam also enforces snake_case for variable and function names, and PascalCase for type names. Gleam also has a great opinionated code formatter (just like Go) and starting a gleam project includes, by default, a github action for checking your formatting. Really! All the restrictions quickly corral you into a specific style that everyone else is also using.
Gleam explicitly makes a small, synergystic feature set the goal, optimizing for fast learning times and ease of reading code. A tagline of the language is that you can learn it in an afternoon. This focus is a big deal, and definitely resonates with what I like about Go as well. You won't understand how useful this is until you experience it for a while yourself.
As AI code completion becomes more popular, this one-way approach becomes even more valuable. I see generative AI as aesthetics-engines, in a philosophical sense, because of their word-at-a-time nature (instead of trains of thought) and their basis in statistics. That means simple languages like C, Go, and Gleam, whose programs are always written in the same way, will be producing more accurate code suggestions. They have a very consistent aesthetic for humans and computers to understand. My fibonacci function above was almost completely generated by Claude, with no edits in post, just by writing it in the codebase for this blog (a small- to mid-size Gleam application). I'm quite sure that Claude has zero or almost zero experience with Gleam code in its training set, and there's a danger of confusing it with Rust because of the (intentional) syntax similarities, but still it did very well.
In academia there's a language called OBJ) designed to be like a functional language with no lambdas (technically it's a "term rewriting" language). The scholars behind it argue that higher-order functions are difficult for humans to reason about, and they offer interesting ways to recover much of the expressivity of closures in other (vaguely object-oriented) ways.
C and Go are fairly first-order. They both support higher order functions (though C closures are very do-it-all-yourself of course), but that style of code is not idiomatic at all. Like I said before, loops should be with the provided loop constructs and dynamic behavior should generally be achieved in other ways. This feels almost explicit as you write Go and C code, and Go is clearly doing this more out of ideology than technical issues. Python lambdas are like this too, to a lesser extent.
You'd think Gleam would be hurting in this category, as a functional language, but it actually has design choices for this too. Local variable bindings in Gleam aren't recursive, explicitly to encourage functions to be lifted to the top-level. Gleam uses the |>
operator to make higher-order code much easier to read and think about. Gleam's (awesome!) use
syntax subsumes most uses of lambdas in functions, in a way that feels a lot like writing comfy, simple imperative code. For example, you can get something like for-loops:
import gleam/int
import gleam/list
import gleam/io
\
/// for each i in a list, print i+1
pub fn print_all_plus_one(l: List(Int)) {
// this is contrived; normally you'd use only one loop
// a loop:
let res = {
use i <- list.map(l)
int.to_string(i + 1)
}
// another loop:
use s <- list.each(res)
io.print(s)
}
Note that this style of code is a little gross and you generally wouldn't use use
here, you'd just call list.each
as normal. I just want to show how use
turns higher-order code into something that feels imperative, and indeed I have seen code like this is gleam codebases occasionally.
If this is an area of programming language design that's interesting for you, I'll also link this cool blog post.
One might wonder why Python doesn't make my list. The reason is that python code feels very different to write, because of refactoring. The type systems of Gleam, Go, and C are very helpful when I make big changes to my code, allowing me to not keep everything in my head at once. Python makes me feel lost and alone, feeling around in the dark, wondering what runtime type error I'll discover next. Python makes next to no effort to manage the complexity of the project for me, which makes the project terrifying to touch in any significant way. Optimizing for readability generally goes alongside optimizing for refactoring.
On the other side of the spectrum, one might wonder why Haskell doesn't make my list. After all, it's like, nothing but lambdas, right? Oh so simple. Of course, I don't really believe anyone thinks haskell would make this list. Because of type system features (and an unfortunate culture of "pick random strings of symbols to express complex ideas, just to make absolutely everything a one-liner") haskell is not simple, there are a million ways to write it, and it's incredibly hard to read (though pretty fun to write once you get it).
Simple languages straddle an interesting balance between expressiveness and restrictiveness. C, Go, and Gleam all offer some form of dynamic typing that's explicitly for a small number of usecases. Then, they all offer a little bit of fanciness to express what you want without dynamic typing: Go interfaces, Gleam's powerful polymorphism, and C preprocessor macros and casts. After that, the type systems are very bare-bones and restrictive. This balance struck by simple programming languages feels very nice to work in. It's like how perfect parents find a balance between their child's freedom and their own control, keeping their child both safe and happy.
Finally, I definitely have to link these famous gleam blog posts about doing wonderful things simply with Gleam's type system: All you need is data and functions and Phantom Types in Gleam.
Hopefully this article was interesting. I know many people like simple languages for their simplicity, but I haven't seen many attempts to systematize it as I do here.
I'm quite burnt out from working so long on SaberVM, with so many tiny bugfixes, so my mind has been wandering to programming language ideas and philosophy.
I'm quite interested in simple programming languages, obviously, and I plan to write a little language that pushes these ideas even further than Gleam does, with no lambdas like OBJ. I also have some ideas for doing this with no garbage collector, borrowing ideas from Mojo's Python-friendly borrow checker.
To summarize, the five key ideas are: readiness-at-hand, iteration speed, one-way-of-doing-things, first-order reasoning, and simple static type systems. I truly think this is the right direction for language design to go in the long run, not complex languages like Haskell and Rust.
© 2024 Ryan Brewer.