Minimalism in Programming Language Design
Four years ago, I wrote a blog post titled Minimalism in Programming, in which I tried to formulate an argument as to why it’s usually a good idea to try to minimize complexity in your programming projects. Today, I want to write about something I’ve been thinking about for a long time, which is the idea that we also ought to take a more intentionally minimalistic philosophy when designing programming languages.
Designing a programming language to be intentionally minimalistic is an idea that’s highly underrated in my opinion. Most modern programming languages adopt much more of a maximalist design approach. Rapidly adding new features is seen as a competitive edge over other programming languages. The general thinking seems to be that if your language doesn’t have feature X, then people will choose to use another language, or that adding more features is an easy way to show progress. This line of thinking is simplistic, and disregards many other key aspects that are necessary for a programming language to succeed and thrive, such as learnability, stability, tool support and performance.
Change and Churn
I’d like to make the argument that intentionally designing a programming languages to have fewer features, and to change less rapidly over time, is in itself a powerful feature. When a programming language changes often, it necessarily causes breakage and churn. Tools become out of date, codebases need to be updated, libraries become broken, but it causes churn on the human side too.
I first started programming in C++ around 1998. I haven’t really touched the language in a few years, and I have to say, I feel kind of lost. So many new features have been added that it’s a different language now. Last year, I wanted to use C++20 modules in a new project, only to find that support in G++ and Clang was so incomplete that modules were just not a viable feature. My general impression at the time was that there aren’t enough people working on C++ compilers to keep said compilers up to date. The language has become so complex, and so many new features have been added, that compiler developers are kind of burned out. It seems to me that slowly but surely, C++ is crumbling under its own weight.
Something that many people forget, is that for a language to succeed, there has to be good tool support. If the language and its feature set keeps changing, then tools need to be updated constantly. One of the many problems with C++ is that its grammar is very hard to parse. That was already the case back in 1998. If you add on top of that the problem that the grammar changes to become even more complex every year or two, what do you think the impact of that will be? The people maintaining C++ tools are going to want to go do something else with their lives, and so will the users of those tools.
Learnability and the Human Element
More recently, colleagues and I have decided to port a C codebase to Rust. I’m generally pleased with the core feature set of Rust and I feel that in many ways it’s a great improvement over C and C++. However, one of the main weaknesses of Rust, in my opinion, is its high complexity. Both at the syntactic and semantic level, Rust is a very complex language. The syntax can get very verbose, and there’s a lot to know, a lot of rules and unintuitive subtleties about what you can and can’t do where. The learning curve is steep and the cognitive load is high.
Last week, I was pair programming with a colleague when he said “I feel like the Rust compiler is always telling me that I’m too stupid”. That remark surprised me, because I’d had the same thought. Somehow Rust feels unergonomic, and the high complexity of the language surely contributes to that feeling that the language is a bit user-hostile. It breaks your intuition, and it constantly feels like the compiler is telling you that you’re writing code wrong. Two days after my colleague made that remark, I saw a post appear on Hacker News titled Rust: A Critical Retrospective which echoed similar feelings about Rust’s complexity.
In a lot of ways, I feel like designing a language to be minimalistic, to have fewer concepts, and to choose primitives that combine well together, is a good way to make the language easier to learn. If the programming language has fewer concepts, there’s less to learn, and your level of proficiency will increase faster. Code written in a more minimalistic language may also be easier to read. If we think about C++ code, we have a situation where the language has so many redundant features that a typical workplace will mandate that code be written in a subset of C++, with some language features being explicitly banned. That can mean that people writing C++ code at different workplaces will have a hard time reading each other’s code because foreign C++ code will be written in a different dialect.
In some ways, I feel like intentionally minimizing complexity and keeping the feature set small is a way of better respecting programmers. It means we respect that programmers are people with potentially busy lives and many things to do, and that they probably don’t have time to read hundreds of pages of documentation to learn our language. Programming languages are user interfaces, and as such, they should obey the principle of least surprise. Minimizing complexity is also a way to reduce cognitive load and respect human limitations. Human beings are amazingly capable creatures, but we’re also basically just clever monkeys that can talk. We can only keep a few items in our working memory, we can only account for so many design constraints, and we can only focus for so long. A well-designed programming language ought to help us succeed despite our human limitations.
At the end of the day, I think that a language’s complexity and how intuitive it feels is going to affect its ability to attract and retain new users. In my opinion, the focus on reducing friction contributed greatly to Python’s initial success and rapid increase in popularity. I think it’s also fair to say that many people were frustrated when the complexity of the Python ecosystem increased, for example, during the switch from Python 2 to 3, or when the redundant walrus operator was introduced.
Minimalism
So far, I’ve made multiple references to minimalism and I’ve also briefly mentioned the principle of least surprise. I’ve hinted that minimalism also means having a smaller feature set and less concepts to learn. Minimalism doesn’t just mean a smaller feature set though. It also means carefully choosing features that combine together seamlessly. If we design a language with a large feature set, there’s a combinatorial explosion in how these different features could interact, which means we’re more likely to end up with situations where some language features interact together poorly.
Imperative programming languages typically make a grammatical distinction between statements and expression. Functional languages instead tend to be structured in a way that everything inside a function body is an expression. The latter is more minimalistic, and also imposes less constraints on the programmer. Some languages impose a distinction between code that can be run at compile time vs code that can be run at program execution time. This distinction often increases the complexity of the language as there tends to be a duplication of language features and fairly arbitrary restrictions as to what code the compiler is able to run at compilation time.
In terms of minimizing surprise, we want to avoid introducing strange corner cases that only show up in some circumstances. Another important pitfall to avoid is introducing hidden behaviors that the programmer may not expect. An example of this would be the equality (==) operator in JavaScript, which actually includes an implicit conversion to the string type, meaning that 1 == “1” evaluates to true. Because of this undesirable hidden behavior, JS actually has a separate strict equality operator (===) which doesn’t perform the hidden string conversion. This suggests to me that JS should only ever have had a strict equality operator, and that if you want to convert the values you’re comparing to strings before performing the equality comparison, you should just have to explicitly spell that out.
Implementation Complexity
Language design is hard because the space of possible programming languages is infinite, and so compromises have to be made. It’s hard to provide hard numbers to quantify what makes one design better than another. Some of the things that can be quantified to some degree are the complexity of the implementation of a language and also the way that a particular language implementation performs.
My PhD thesis involved the implementation of a JIT compiler for JavaScript ES5. As such, I got to become intimately familiar with the semantics of the language and everything that has to go on behind the scenes to make JavaScript code run fast. At times, that was a frustrating experience. I’ve become convinced that a lot of the complexity and the hidden behaviors in JS and in many other languages are essentially bad for everyone.
Unnecessary complexity in a language is bad for those learning the language, because it makes the language less intuitive and harder to learn. It’s bad for the programmers working with the language everyday, because it increases their cognitive load and makes it harder to communicate about code. It’s bad for language implementers and tool maintainers, because it makes their job harder, but at the end of the day, it’s also bad for end users, because it leads to software with more bugs and poorer performance.
To give you an example of unnecessary implementation complexity, many object-oriented languages have this idea, borrowed from Smalltalk, that everything should be an object, including booleans and integer values. At the same time, languages implementation for these languages have to do a lot of work behind the scenes to try and represent integers efficiently (as machine integers) while presenting an interface to the user that resembles that of an object. However, the abstraction presented to the user for an integer object is typically not really the same as that of a normal OOP object, it’s a leaky abstraction, because being able to redefine integer values makes no sense, because integer values have to be singletons, and because being able to store properties/attributes on integers is both dumb and terrible for performance and so typically isn’t allowed.
Ultimately, integers are not objects in the object oriented sense. They’re a distinct type of atomic value with a special meaning, and that’s okay. The mistaken idea that “everything should be an object” doesn’t actually simplify anything in practice. We’re lying to ourselves, and in doing so, we actually makes the life of both language implementers and programmers more complicated.
Actionable Advice
This blog post has turned into more of a rant than I expected it to be. It’s easy to critique the status quo, but I’ll also try to conclude with some actionable advice. My first piece of advice for aspiring language designers is that you should start small. Your language is a user interface, and an API which people use to interface with machines. The smaller the API surface, the less you risk introducing accidental complexity and subtle design mistakes.
My second piece of advice is that if you can, you should try to keep your language small. Limiting yourself to a smaller feature set likely means you will want to choose features that don’t overlap and that provide the most expressiveness, the most value to programmers. If you do want to grow your language, do it slowly. Take some time to write code in your language and work through the potential implications of the design changes that you are making.
It’s easy to add new features later on, but if you add new features and people begin using them, it’s going to be hard or even impossible to take these features back, so choose wisely. Remember that you don’t have to please everyone and say yes to every feature request. No language or tool can possibly satisfy every use case, and in my opinion, trying to do so is a mistake.
Lastly, remember that language design is an art. It’s a delicate balance of many different constraints, just like user interface design. Brainfuck is a language that is very small and has very few concepts, but nobody would call it expressive or elegant. Lisp is regarded by many as one of the most beautiful and elegant languages in existence, but my PhD advisor, a Scheme fanatic, had the habit of writing code with single-letter variable names and very few comments. An elegant language doesn’t automatically make for elegant code, but you can encourage good coding practices if you lead by example.
I agree with what you wrote. Based on the ideas you expressed, I immediately thought of Golang, Clojure, and Zig.
Though another programming language feature I’ve come to see as incredibly desirable is making it easy or even default to have variables be immutable after initialization. Clojure and Zig have that, Golang doesn’t.
And while Clojure itself is a simple language, for better or for worse you can reach into the entire Java library ecosystem and use anything you like.
I fully agree with your perception and rant. Most programming languages are designed from a technical point of view, not a use-case or a business perspective.
There is one language, I think, that comes close to solving the problems you mention: Go
It’s the first language and ecosystem where you can feel and see that the business perspective of a programming language has a strong influence. Go is made to getting big projects done with ever-changing and mediocre teams while keeping the code base maintainable over a very long time.
The language is boring, evolves super slow, requires explicitness in all places, and has excellent tooling and only a few surprises. There is mainly one way how to do things and not zillions.
If you didn’t give it a try yet, do it. It’s a great experience. It frees your mind to concentrate on the problem and not the technology of your tools.
There is one little problem with simple languages: the simpler the language, the harder it is to write complex programs in it. Perhaps non-intuitively, complexity in a language often, though not always, is there to manage the complexity of programs written in it.
Take Rust, for example. It can be hard, especially for a newbie, to wrestle with rustc and its error messages about incorrect lifetime usage. But ultimately, lifetimes are there to not let pointers go wild, not to make your brain needlessly explode on a daily basis, because devs are evil or whatever. Yeah, I had a rough time too a few years ago when Rust appeared on my radar, but now I rarely even think about lifetimes, it has become a second nature to manage them properly and there is no need to worry about memory corruption anymore. There are some rough edges here and there, but I still think it’s much better to spend time on smoothing those manually, than to spend time later debugging the mess you wrote and apologising to users for a yet another buffer overflow.
The most popular other way, as you know well, is introducing a GC, which is a whole another can of worms, one of which is inevitably somewhat reduced performance, but more importantly, reduced predictability of performance due to GC pauses. Compromises have to be made, always, if your languages is supposed to be any good for big and/or complex projects. And, as you probably know well too from experience, not everything can be made simple and “UNIX-way”. It would be nice for everything to be simple, but sadly, it is not very realistic.
To conclude my argument, I would like to point out two very famous, but not very popular languages, that are the pinnacles of simplicity – Forth and Lisp. Have you tried reading code written in them? Don’t know about you, but when I do it, I feel like multiplying 40-digit integers in my head, as if people that designed these languages deliberately decided to sacrifice complexity of implementation at user’s expense. Sure, theoretically you can do anything Forth or Lisp, if you’re persistent enough, but the end result would most likely be a complex graph of hacks, rather than a readable and elegant program designed to be read by humans. I am talking not just about noisy syntax, but ad-hoc data structures made of lists or whatever (a Russian phrase “from shit and sticks” comes to mind) and weird algorigthmic tricks, that may look elegant from a mathematics perspective, but are quite ugly in practice.
But of course, I am not saying that C++ kind complexity is inevitable, far from it (I also despise C++). Not even arguing with you, to be honest. It’s just every time I try to use a simple language, I eventually end up dropping it because there are always some obstacles that I just cannot overcome. Am I reaching the limits of my brain or limits of applicability of a language? Hard to say definitively, but I lean towards the latter, judging from the projects I have actually completed without going insane.
I don’t think that lifetimes are the biggest problem with Rust. It seems like the borrow checker is a good idea for a systems programming language. It’s more things like, Rust has 6 different string types, and arrays have methods like .into_iter(), .iter(), and .iter_mut() which are all subtly different… And this kind of verbosity and redundancy is found everywhere in the language. I just want to be able to call .map() and .filter() on an array directly, without having to worry about the type of iterator. It ought to be possible to do that while still having a borrow checker.
I think there’s good ideas in Rust. Maybe Rust is just the first mainstream language to have a borrow checker, but the concept and the syntax around this concept still needs to be refined and iterated on in other languages.
To be fair, Rust only has string slices (&str) and string “vectors” (String). Other things you might be thinking of are orthogonal features, e.g. iterators, traits and “smart pointer” types like Cow etc. We seem to disagree on how good is that for the usability of the language.
If one would ever like to not call malloc all the time in the steady state of one’s program, then one needs to grow at least the additional string type backed by alloc::vec::Vec.
When dealing with C code we also have to deal with CString, CStr, and raw c_char pointers. We also have to be able to convert between all of these (can you do it without looking up documentation?).
I don’t know much about Lisp, but FORTH is basically a language which is written entirely in “point-free” or “tacit” style. It’s not that the language is any harder to read than anything else, it’s just a different way of thinking and organizing programs.
FORTH (and other “line-noise” languages like APL) just as easy for beginners to pick up as any other language, but harder for the “initiated”, because they expect certain patterns which aren’t there, and don’t recognize the patterns that are there. I certainly feel like a total idiot any time I try to write C-style languages and end up spending hours wrestling with the compiler.
I very much agree with everything in your article except for this one point: “The mistaken idea that “everything should be an object” doesn’t actually simplify anything in practice.“
“They’re a distinct type of atomic value with a special meaning….” That sounds like an object to me.
It’s been 30 years since I’ve had to deal with Java (version 1.0.2 IIRC), but it was nightmarish to constantly convert back and forth between the int primitive type and the Integer class. I know C# fixed that mistake and I suspect Java has as well.
It’s true that it’s not particularly useful for individual integers to be objects in terms of adding properties to them, but adding properties to individual instances isn’t something I do in OO programming. ? It IS frequently useful to declare a variable to be of type “object”, or a method to dispatch on “object”, and having that include, well, all objects, including integers.
In Dylan (as in Common Lisp) you write generic functions that dispatch on the types of all required arguments. A typical example: https://play.opendylan.org/shared/8354b5b94dbcda98
If integers weren’t objects I would have to resort to defining the generic function on a type-union. Something like this:
define constant = type-union(int, string, float, …, );
define generic print-it (thing :: , stream :: ) => ();
Would this cripple the language? Maybe not, but it would have far-reaching effects that would make the language significantly less clean, not to mention drawing into question what is the point of having the class at all.
Maybe there’s a better example of unnecessary implementation complexity?
Why does Java need an Integer class in the first place?
Because sometimes an object is needed. For example, you can have a `List`, but not a `List`. Also, only `Integer` is compatible with `Object` (the most general type).
Work is underway to unify `int` and `Integer`, but it’s a huge effort that has been many years in the making.
That’s one of the big inefficiencies of Java: there aren’t “real” generic classes. The language has had auto-boxing/unboxing for some time, but if anything that obscures the performance impact of not being able to instantiate algorithms generically, something C++ and C# / .NET can do: https://stackoverflow.com/questions/116988/do-c-sharp-generics-have-a-performance-benefit
[WordPress seems to have eaten parts of my post that were inside angle brackets. Not sure if anyone can follow it.]
I can’t really speak for Java, but my guess is that since all non-built-in code belongs to classes in Java that was the only way to provide a range of static methods that would grow over time without adding more and more built-ins. And boy did it grow… https://docs.oracle.com/javase/8/docs/api/java/lang/Integer.html
In this case what feels unnatural IMO is the Java insistence that every function has to be a method, and everything has to be part of a class.
I can’t agree with this: “the simpler the language, the harder it is to write complex programs in it.”
If I were to write the most complex language possible I think we can agree it would not be easier to write programs in it. There has to be a middle ground. Moderation in all things etc etc.
The middle ground could be different for everyone.
What an acceptable language is depends rather heavily on what the goal is. If I’m writing a core algorithm that will run on 1M machines and will be used by services written in all languages, I may want Rust or C++. If I’m writing a simple service that will handle 100 queries/second Java or Common Lisp may be just fine. There is a TON of Java and Go code at Google, for example, and no one is clamoring to rewrite it in something faster.
I quite agree. In software development, complexity is the primary enemy. Sometimes with complex problems, complexity is essential, but when it is not, you waste all kinds of time dealing with it, and you wind up not able to be sure what a piece of code is going to do. Pounding nails with a sledgehammer, as they say. A lot of “technical debt” comes from past attempts to solve everything with one program. I have worked with a variety of fancy languages, and sometimes they can be handy, but most of the time I work in C.
Thank you! I’ve worked extensively in C and OCAML. I was shocked at Rust, which I expected to be Cish with an OCAMLish type system. The worst explanations to give for putting something in a language are ‘well the designers like it’ and ‘we thought it was a good idea’. In my opinion, any new language that deserves attention should have a formal semantics. If the compiler is the only formalization, things are going to suck. Ok. End of rant. Thanks again for the article.
nice domain name
Thank you :)
I wrote a very simple language with ideas from ColorForth called r3 , If you think about simplicity you can’t ignore FORTH. Simplicity in language always produces simpler systems, and it is false that it is not possible to make large systems
Niklaus Wirth’s Oberon07: https://people.inf.ethz.ch/wirth/Oberon/Oberon07.Report.pdf.
mood: “Make it as simple as possible, but not simpler. (A. Einstein)”