Introducing for-each loops for Hare April 2, 2024 by Lorenz (xha)

Today, for-each loops were merged into Hare’s development branch! This includes patches for the compiler, standard library, specification and tutorial. I want to give some background on how we designed for-each loops and what challenges are associated with implementing them.

What kind of for-each loops do we want?

Before for-each loops, you could iterate over an array/slice like so:

let array = [1, 2, 3];
for (let i = 0z; i < len(array); i += 1) {
	fmt::printfln("{}", array[i])!;
};

Having an index can be very powerful if you require it. However, in most cases, you will not, and I’ve found that using indices makes code less readable. This is how a for-each loop over an array/slice could look:

foreach (x in [1, 2, 3]) {
	fmt::printfln("{}", x)!;
};

In addition to for-each loops over arrays/slices, you might remember the concept of iterators from languages such as Rust. The idea is very simple: you have a function, typically called something like next(), that you call every loop iteration. This is useful when, for example, iterating over every line of a file — you don’t have to load the whole file in one go, but only ever read it line by line. This concept is also used extensively in the standard library, but so far, it has been implemented using for (true) and match:

for (true) match (bufio::read_line(file)!) {
case let line: []u8 =>
	fmt::printfln("{}", strings::fromutf8(line)!)!;
case io::EOF =>
	break;
};

While this works, it takes up a lot of lines and is not very readable. We can do better. Here is what that could look like:

foreach (line = bufio::read_line(file)!) {
	fmt::printfln("{}", strings::fromutf8(line)!)!;
};

With these changes in mind, I wrote the first version of the RFC to discuss these changes to the language.

How should these loops really look?

The purpose of the Hare RFC process is to discuss and reach consensus on bigger changes to Hare. This is especially important for new language features such as for-each. In the three revisions to the RFC, we found solutions for a number of challenges.

First of all, there was the syntax question. Do we want foreach? Or should we integrate it into for? We quickly reached the conclusion that it is best to introduce a new syntax to for instead of foreach, because it should remain the only statement that can loop, since there is no while statement in Hare.

// for-each value
for (let x .. [1, 2, 3]) { // The type of x is int here
	fmt::printfln("{}", x)!;
};

// for-each reference
for (let x &.. [1, 2, 3]) { // The type of x is *int here
	fmt::printfln("{}", *x)!;
};

As you can see, we’ve also introduced another variant: for-each reference loops. Instead of assigning the value itself, we assign a pointer to the value. This is useful when you want to manipulate values in the slice/array itself or your values are too big for copying and it’s unlikely that an optimizer would catch this.

Specifying the iterator loops was especially challenging. How should these kind of loops end, by the iterator returning a special type, or void? Do we use a special method for every type or will it just be a function call? What kind of operator do we want to use? After lots of discussion, this is the syntax we came up with:

for (let line => bufio::read_line(file)!) {
	fmt::printfln("{}", strings::fromutf8(line)!)!;
};

This “for-each iterator” loop executes its binding initializer, bufio::read_line(file)!, at every start of the loop. This initializer returns a tagged union with a done type. In this case, the initializer returns ([]u8 | io::EOF). This is a tagged union, which is a type that can contain one of []u8 or io::EOF. Unlike a union in C, it also indicates what type it currently holds, using a tag.

io::EOF is defined as a done type. This means that the for-each loop checks if bufio::read_line(file)! returns done and terminates the loop in this case. Otherwise, it will assign the value to the line variable and run the body of the loop. The type of this variable is []u8, because that is the type that is left in the tagged union when excluding the done type.

Closing thoughts

There is still a lot of Hare code that needs to be updated to use the new for-each loops. In particular, the extended libraries haven’t received updates yet. We plan to go over this code during the regular course of our work.

The implementation of for-each proves that the Hare RFC process is working really well. We were able to collectively come up with a for-each design that fits Hare really well and that everyone is fine with. I am, personally, very happy with the end result.