Another Modern C: The Hare Programming Language
I do enjoy writing C, it is simple, straight-forward and I have a good idea of everything that is happening and where it is stored. But damn, C is so clunky. I have been looking for modern alternative, all with their strengths and weaknesses. Please note that I don’t want to shame or talk bad about other alternatives. This is a love letter to the one I chose.
At the moment of writing, I still haven’t thought much about what I’ll say nor how much I’ll write, which means it will once again be way too long. So I will start with the most important: thank you to the devs. I know what it takes to design a language and implement a compiler. I know the insane amount of work it requires to design its standard library and maintain it. So I would like to start by a big acknowledgment of this work and by showing a bit of gratitude. I am not thinking of writing my own modern C anymore, and I think it’s beautiful.
What I love in C
C is sometimes described as a textual assembly language. Obviously it isn’t the reality, but it bears some truth. Indeed, most control-flow constructs of the language can be translated into the equivalent assembly code without much work1. But most importantly I think, the data manipulation is straight-forward. Your types exist in memory in the exact way you defined them in your code (modulo alignment, padding and all this stuff), you are in total control of how objects are allocated, when they are freed etc. Even though it is primitive, the error handling in C is also entirely under your control2.
These are naturally not always a good thing, you may prefer more comfort and more safety. But sometimes, depending on what you want to develop, being in total control of everything may be what is best for you. I’ll give you an example. I spent the month of February writing a parser for my friends' notation language SHAUN. This was supposed to be a minimal, performant and memory-efficient implementation of their language in C. There is something I’m relatively happy with: the error handling.
My error type is a 64-bit integer. Actually, it is a big union of error cases
that was designed to fit into a 64-bit integer. Not only I could just decide to
use an actual typedef sn_error uint64_t to define it, but the type system of C
allowed me to transmute that error type into a sn_decoded_error back and forth
since one was a mere integer and the other the complex union. Why would I do
that? It’s simple: I have a nicely constructed error type at the price of a
integer. As long as you’re not explicitly trying to decode the error (and use
the macros to do it for you), you just deal with the integer. This allows you to
write code like this:
sn_value val;
if (sn_load_from_string(THE_STRING, &val)) {
// much error, oh no
}
Yep, the error value for “everything went right” is 0, which is extremely easy
to check.
What is exhausting with C
As per the last example, you may already have a good idea of what the code may
look like when you use this kind of error handling. It looks like Go: an endless
list of if statements to manage the errors. You can’t factorize the error
handling, since each function has to check for the error value, with another
if etc etc.
When you allocate memory, open a file, use a resource or whatever, please don’t
forget to free or close it and the end of the function, about 150 lines below. I
rarely encounter errors because I forgot to free my resources, but it still
forces me to remember to do it and I sometimes need to ask valgrind what it
thinks of my code, to be sure I didn’t forget anything.
Type definition can be tedious and verbose at times. Having to typedef
everything is not particularly a passion for me. Strings are a whole layer of
difficulty with C, since it was invented long before utf-8. When you write
bigger programs, you want to split the code between files. C allows that of
course, but you have to bookkeep a header file (meant to be concatenated
included into another source file) and the source file which defines the
variables and functions that you need. It is so painful!
Sometimes I think of what a good modern C alternative could be. Something
minimal and straight-forward, but with more modern features that would free me
from the pains of C. I have thought of designing it myself, but you may start to
know me: I can’t be concise, minimal and able to stop. All my attempts led me to
design either another Rust or another Go or even a mix of both.
That’s not a rabbit, but it’s cute anyway
I did my little research. I found good candidates. Please take the time to read
this: I know of Rust3, I know of Zig4, I know of Go etc. Those
have one thing in common that disqualify them as C replacement: my brave
Everest is absolutely unable to do
anything with them: Rust and Zig take to long to compile and I can’t even
bootstrap TinyGo for the dependencies filled my poor SD card.
Then I decided to try Hare. It is marketed as a systems programming language
designed to be simple, stable, and robust. It has a minimal runtime (which is
statically linked) and is well-suited to writing about all the things that give
me fun. Let’s go!
That gemtext parser
I started that new journey writing a simple parser for texts in gemtext format. The format is simple, line-based and the parsing of each element depends entirely on the 3 first characters of the line. What could go wrong?5
I installed hare, defined some types, wrote obvious functions and then I
proceeded to think precisely about how to do it. While my implementation is not
exactly something I would be proud of, I must admit that it was surprisedly
simple and natural to write. Hare really is a comfortable C.
First, the language uses tagged-unions profusely. This is a native feature of
the language that allows you to say “well, this type foo and be either a bar or
a baz” and call it a day. There is no need for boilerplate, englobing type or
hand-made discriminant (the tag). Hare does it for you. For example, here is
the type of the lines in my gemtext parser:
type line = (heading | link | list_item | block_quote | preformatted | str);
Each case here is a struct I had already defined6 and str is the native
type for strings. Since I will want to discriminate between the different types
of lines went using that type, Hare has a neat language construct to help us:
the match expression. In Hare, you have switch and match. Both do the
same thing but one acts on values and the other on types respectively. Hence, if
you want to use a line, you can write:
match (my_line) {
case let h: heading =>
do_stuff_with_headings(h);
case let l: link =>
do_stuff_with_links(l);
case =>
do_the_default_stuff();
};
I have to say that this is quite comfortable. I am finally able to write C the
way I have always written OCaml, Rust or other functional language code.
The language does a good use of these tagged unions. It is how errors are
handled. One can define a type as being an error type by prefixing its
definition by a ! character, e.g.:
type dividedbyzero = !f64;
fn div(num: f64, den: f64) (f64 | dividedbyzero) = {
if (den == 0) {
return; // dividedbyzero
} else {
return num / den;
};
};
Unlike Go which would require me to handle the errors at every step of my life
as an adult, Hare decided to take the Rust route: using punctuation. One can
use ? to return an error as-is (just like Rust’s equivalent) or even ! to
tell the compiler to foda-se, não ligo com caralhos desses! and just crash
here in case of error, that is particularly convenient for functions such as
alloc from which there is usually no good way to recover. This enables me to
write code the way I like it: tell me what you are supposed to do, we’ll handle
the issues later.
That small virtual machine
These days, I’m working on virtual machines, emulators etc so it felt natural to
write a small emulator for a home-made machine. The machine itself is quite
simple: 32-bit, register based (R0 set to a constant 0 as in RISC-V,
R1-R7, SP the stack pointer and PC the program counter, FLAGS for
flags), a return stack hidden from the programmer and user and its dedicated
RTSP, variable-length instructions with an 8-bit opcode. The ISA is quite
small but each opcode contains more information than just an ID:
- condition flags (to conditionally execute the instruction)
- immediate size if applicable
- actual opcode
For example, a single JMP 32-bit:0xCAFE if Z which means jump at the 32-bit
address0xCAFE if the condition flag is zero. Many instructions set the
flags (arithmetics, load and store, etc), which allows “complex” control-flow to
be expressed quite simply.
Once again, I had pleasure writing it in Hare7. The syntax of switch
statements is comfortable to write and read, which is important for someone like
me obsessively writing VMs, compilers and stuff like that. I did a good use of
tagged unions to discriminate between register and immediate operands. Also,
unlike C99 (at least), Hare has a static assert builtin which helped me test
that some types have the size I wanted them to have (typically my 8-bit
opcodes).
It is not perfect, but still
Naturally, Hare is far from perfect. I do face some problems from time to
time. More of them are essentially due to the language and its ecosystem being
young. I hope the situation will improve and I trust the developpers for that.
First comes the lack of learning resources. Granted, the language comes with a tutorial and the whole specification. This has to be acknowledged, for the textual specification serves as an available comprehensive definitive authority. There also exists a website with good examples of how to use most of the language features and how to leverage the standard library. But once you are ok with the basics8 and need guidance on more advance questions, you’re on your own.
What struck me was the sensation of being the first one to ask some questions. For example, in C, you can define bit structures such as the following:
typedef struct {
int immediate_size_flag : 2;
int conditiong_flag : 2;
int operation : 4;
} opcode_t;
This allows to encode complex information into a single integer-like object,
allowing to define values of less than a byte - even though the entire datatype
will be a multiple of 8-bit long. In Hare, it seems that you can’t do
that. What you have to do is define you type as being an alias for an integer
and add functions to manipulate the sub-byte values:
type opcode = u8;
fn immediate_size_flag(op: opcode) u8 = op >> 6;
fn condition_flag(op: opcode) u8 = (op >> 4) & 0b11;
fn immediate_size_flag(op: opcode) u8 = op & 0b1111;
It works and I think this is how I’d compile bit-structs in C (with function
inlining, etc). But it makes it quite uncomfortable to manipulate this kind of
data and I think it is sad for a C alternative. I have looked about everywhere
to see if at least people had ask about such a feature. But I found nothing.
I had also designed my emulator to make the instruction-execution callback call
the next instruction themselves instead of relying on an interpretation loop
which a switch. In C, it would have worked very well for the main compilers
support tail-call optimization. If a function ends by the call to a function,
it can be compiled into a jump instead of a call, which allows to use the same
stack frame as the calling function instead of push another frame on top of it
(i.e. you can recursively call functions indefinitely, the stack won’t
overflow). But also, it allows to return from all these tail called functions
with only one return instruction. Indeed, if you don’t call a function but
jump into it, for the CPU, it is like you are still executing the first
function, you did not push a new return address on the stack. If you return, you
return from the first function - and all its children as well. However, Hare
doesn’t seem to support such an optimization. Is it because of the compiler’s
backend (QBE)? Is it because the optimization hadn’t been implemented yet? Is it
because it is not planned to be supported at all? I don’t know. I asked on the
IRC channel, received no answer and just rewrote everything with an
interpretation loop.
I don’t blame anyone, it is probably quite early to implement this kind of
optimization. But that’s the biggest point I’d like to highlight here: Hare is
a young language, and if you want to adopt it, this is something you have to
consider.
What makes it so good then?
It is not finished. It is not brilliant. Batteries are not included. But damn,
it feels well designed. The language has clearly been designed by people who
wanted to write C in the 2020s. The syntax in consistent, the type system is
consistent, the little syntaxic sugars are welcome, the defer statement is
welcome, the tagged unions are so welcome. Its module system is
clear9.
You have the right amount of control
Hare is young, but it has so much potential. It is the kind of languages that
enables you to write extremely simple yet powerful code. Unlike Go, it will
never remove complex features from you, at the end of the day you are still the
boss, there is a way for you to implement the dumbest footgun if you really want
to. But just like Rust, you have to explicitly aim for your foot to circumvent
the most basic checks. Let me give you an example:
In a virtual machine with a fixed 65536 (i.e. 2¹⁶) bytes of RAM, you may want to represent that memory with a simple static array as follows:
use types;
def RAM: [types::U16_MAX + 1]u8 = [0u8...];
Now, the machine uses 16-bit registers and I want to load a word from memory
into a register with an instruction such as ld r1, 0xCAFE. This means that I
need to read a u16 from my array of u8. I have two solutions. The first one
leverages bitwise arithmetics:
fn read_word(address: u16) u16 = {
const low = RAM[address]: u16;
const high = (RAM[address + 1]: u16) << 8;
return low | high;
};
It is perfectly safe as long as address does not refer to the very last byte of
RAM. The second solution tells Hare to trust the programmer when they want
to access RAM to read a 16-bit word, using pointer convertion:
fn read_word(address: u16) u16 = {
const ptr: *u16 = &RAM[address]: *u16;
return *ptr;
};
This solution has the exact same problem as the first one. The error can be
handled the exact same way for both (write a guard and returning an error,
leveraging tagged unions), one looks safer but uses intermediate computation.
The other looks unsafe but if you think about it, as a human, you can tell it
works: the address parameter is a u16, its value cannot go out of the bounds
for RAM, with the exception of the last byte which we know how to manage. The
convertion is safe. Just do it, don’t ask.
The compiler is simple, fast and frugal
As Hare’s creator, Drew DeVault, said, it is
important that one programmer alone be able to understand the entire language
and its entire compiler. In the context of permacomputing, it is of utmost
importance that one could be able to maintain the compiler themself or even to
rewrite it from scratch.
The fact that the official implementation uses QBE instead of LLVM for its backend reflects that. LLVM is a behemoth while QBE is a smaller hobbyist-oriented project. It allows the compiler to keep a small footprint, to be bootstrapped on smaller computers (remember my RaspberryPI 3B+?).
- Just a little control-flow analysis, but pinky-promise the CFG construction is the hardest part.↩
- You can totally implement an exception system if you want, it’s entirely your problem.↩
- I’m actually quite good at it↩
- And its frighteningly long list of orthogonaly compatible features, I swear the specification gave me a headache.↩
- Everything lmao, my implementation is so bugged…↩
-
Most of them only contain only a
textfield but some likelinkalso hold additional information.↩ - And never finish it, I guess… I just need to implement interruptions and devices and fucking test it, come on…↩
- Which should happen quickly since the language is quite simple and really does feel like a modern C.↩
- Mostly inspired by Go, I think?↩