I Thought I Loved Functional Programming, but It Was Zig All Along
I have a bit of a history with functional programming. In fact, I wrote a functional programming library in C++. Twice. The primary reason being that I was simultaneously fascinated with the intersection of functional programming which treats data as abstract values and the hard reality of how that code compiles into assembly and machine code. There are numerous reasons the concepts of functional programming are important to me.
Functional code tends to be more terse, but the structure often does a better job of actually describing what the program does. Consider a program to print the numbers 0 through 10 inclusive with a newline between them in haskell.
main = mapM_ (\x -> putStr (show x ++ "\n")) [0..10]
In fairness to C++, I didn’t use print which automatically adds newline characters. Compare this to how you’d write this in C++, before the ranges library. For fairness, I’ll skip any boilerplate.
for (size_t i = 0; i <= 10; ++i) {
Std::cout << i << std::endl;
}
As we’re trying to understand what’s going on, we have to parse the syntax in order to answer some important questions. What is the range of the loop? I’ll mark the text that explains this in green.
main = mapM_ (\x -> putStr (show x ++ "\n")) [0..10]
for (size_t i = 0; i <= 10; ++i) {
std::cout << i << std::endl;
}
The Haskell version is more explicit in answering this question succinctly, “0..10”, while C++ offers a puzzle. “I = 0; i <= 10; ++i” is something very ordinary and you probably don’t have to think too hard about it, but imagine for a moment that you’d never seen a loop before and are coming at it from first principles. “i” starts with a value of zero. It’s checked against 10. Zero is less than ten so it’s incremented. 1 is less than 10. It’ll keep doing this until i is 11 because that’s the first number where i <= 10 could be false. These are the thoughts we skip over when we read the green text because we’ve replaced reason with institutional knowledge. The puzzle is solved and now whenever we see a for loop we jump straight to the solution.
Back to semantics, what’s being done with this value? I’ll mark that in yellow.
main = mapM_ (\x -> putStr (show x ++ "\n")) [0..10]
for (size_t i = 0; i <= 10; ++i) std::cout << i << std::endl;
Everything that’s left is the machinery of the loop, which I’ll mark in red.
main = mapM_ (\x -> putStr (show x ++ "\n")) [0..10]
for (size_t i = 0; i <= 10; ++i) std::cout << i << std::endl;
The amount of red here is the syntactic cost of looping.
The advantage of functional programming isn’t necessarily that it is more concise–these two code snippets are about the same size–but that the semantics are more clear. The range isn’t specified by a logic puzzle, but a clear concept of a “range”. The machinery “mapM_” is minimized.
C++ with its std::ranges and std::views has somewhat attempted to address the desire for functional style in C++ code.
for (size_t i : iota(0, 11)) std::cout << i << std::endl;
for (size_t i : iota(0, 11)) std::cout << i << std::endl; // colored
While I would call this better, this is after an omitted “using std::ranges::iota” – I mean std::views::iota, and if we look at more complex examples using std::ranges, it gets a bit tricky.
using namespace std::views;
return iota(0, p.segmentNum()) |
adjacent_transform<2>([&g, &p](size_t i, size_t j) {
return std::pair(g.id(p[i].toNode()),
g.id(p[j].toNode()));
}) |
std::ranges::to<std::set>();
The code becomes more abstract. The machinery becomes interspersed with the actual logic and more verbose. The functional paradigms become more institutional knowledge, hiding the obfuscation that would be apparent if this knowledge wasn’t internalized.
Here’s zig
for (0..11) |i| std.debug.print(“{d}”, .{i});
for (0..11) |i| std.debug.print(“{d}\n”, .{i}); // colored
There are numerous things at play here. Firstly, the value from the “0..11” range is placed into “i”, and then I is packed into a tuple (“.{i}”) in order for the string format to be validated at compile time. Combining the brevity and clarity of a functional programming language with the type-safety of C++ and the simplicity of C.
But what I love about this is its simple and clear presentation. No iota, no mapM_ and needing to know the difference between that and map, and it reads well as “for zero to eleven, std debug print i”. The only unfortunate part is the IO story, in this example, with the best options being std.debug.print() and std.fs.File.stdout(). Read any tutorial from last year and that’s actually spelled std.io.getStdOut(). But today it’s not std.io, it’s std.Io, so old tutorials look like typos.
Neither clean APIs nor stability are features of this upcoming language only in version 0.16, but what it’s got going for it makes me feel, at least to some degree, functional programming was never necessary.
Let’s look at another classic example, finding the index of an item in a list.
hs:
findIndex val xs = impl 0 xs
where
impl _ [] = Nothing
impl n (y:ys)
| val == y = Just n
| otherwise = go (n + 1) ys
C++
std::optional<size_t> findIndex(int value, const std::vector<int>& v) {
for (auto [index, v] : std::views::enumerate(v)) {
if (v == value) return index;
}
return std::nullopt;
}
Zig:
fn findIndex(value: i32, array: []i32) ?usize {
return for (array, 0..) |x, i| {
if (x == value) break i;
} else null;
}
It’s a bit ironic that despite Haskell being famously terse, I find it the hardest to follow and most verbose of the three. It requires a full "impl" function to actually work and has to break it into three distinct cases. C++ is better for “auto [index, v] : std::views::enumerate(v)” describing very succinctly what is being looped over, and “return index”/“return std::nullopt” is pretty clear. But zig still kinda takes the cake for me here. Iterating over the array while maintaining the count isn’t a library function, it’s a core language feature, and being able to “break” with a value and the “else” being able to attach to the “for” allows the whole function to be one expression while clarifies the purpose of the loop.
But unlike how typically with functional programming, the moment you need to do something else, like return an error, this has to be wrapped up in the value type of the expression, zig just lets you escape out. Here’s a snippet from my current project.
pub fn lookupNode(self: *const State, id: Id) ?*Node {
const idx = std.sort.binarySearch(Node, self.nodes.items, id, order) orelse return null;
return &self.nodes.items[idx];
}
The “orelse return null” there lets us escape out of the function entirely with a return right in the middle of an expression. The functional community tried to answer the complexity of optional values, errors, and control flow by incorporating it all in the type system. C++ and other languages tried to adopt the useful parts of FP by building abstractions on top of preexisting syntax. Zig tears down abstractions and achieves a simplicity that just doesn’t need them.
Okay, so compared to Haskell, C++, or most other languages, I think zig code just looks nicer and reads cleaner. Let’s talk about zig’s special features on their own terms. Generics are easy to implement because types are just values.
fn buffer(T: anytype) type {
return struct { buff: [255]T, written: usize = 0 };
}
Here’s a code snipped showing how labels work on a switch statement, turning a switch into a looping construct, taken from the Zig Language Reference:
sw: switch (@as(i32, 5)) { // label sw established here
5 => continue :sw 4,
// `continue` can occur multiple times within a single switch prong.
2...4 => |v| {
if (v > 3) {
continue :sw 2; // control flow back to the switch statement
} else if (v == 3) {
// `break` can target labeled loops.
break :sw;
}
continue :sw 1;
},
1 => return,
else => unreachable,
}
https://ziglang.org/documentation/master/#Labeled-switch
Result Location Semantics make it so that types can be omitted, leaving just semantics-rich values in the code.
var arr: [2]u32 = .{ 1, 2 };
arr = .{ arr[1], arr[0] }; // value of “.{ }” is based on arr ([2]u32)
Zig uses iterators to iterate over complex data structures, but this does something semantically interesting. This block iterates over command line arguments.
var args = try init.minimal.args.iterateAllocator(init.gpa); _ = args.skip(); // skip process name while (args.next()) |a| { if (std.mem.eql(u8, a, "--step")) { const float = args.next() orelse return error.FlagRequiresArgument; step = try std.fmt.parseFloat(f32, float); } else if (std.mem.eql(u8, a, "--mode")) { const mode = args.next() orelse return error.FlagRequiresArgument; if (std.mem.eql(u8, mode, "play")) { play_state = .play; } else if (std.mem.eql(u8, mode, "pause")) { play_state = .pause; } else return error.UnrecognizedPlayMode; } else return error.UnrecognizedCliArgument; }
And this is all without discussing defer and errdefer, the type system, comptime, etc. There is just too much to love about this language to talk about it all at once.
Despite my thesis being that I don’t need functional programming anymore, that’s not entirely true. Zig doesn’t have a great solution for lambdas, forcing its users to instead create structs. The example previously using a binary search function was truncated.
const idx = std.sort.binarySearch(Node, items, id, struct {
fn order(context: Id, item: Node) std.math.Order {
return std.math.order(context, item.id);
}
}.order) orelse return null;
That’s the full code. The fact that a struct can be defined inline is incredible, although the necessity of doing so sucks. There’s no function composition or chaining and even just passing functions to higher order functions can be painful. There isn’t even variable capture.
But in exchange for not being the best at certain styles, it carves its own style.
EXTRA
I wanted to compare zig to rust, but it didn’t really fit into my essay properly and I’m not the biggest expert in rust. Specifically, I want to talk about object models.
Rust’s big thing is how it manages variable lifetimes and like C++ (and unlike most other modern languages) it uses RAII. I love it for this. As much as referencing a null pointer is a footgun in C++, opening a file without closing it properly is a footgun in the supposedly-easier python, C#, and other object-oriented languages that don’t use RAII. However, the downside is that now data ownership needs to be conceptualized by the type system.
It was actually kind of striking, the moment when I realized that in zig it was fine to just copy a type that needs to be deallocated, but not have to pass ownership, nor maintain a reference to the owner. You can just copy, then decide not to call free(). That’s really liberating because now you don’t need to negotiate ownership contracts between functions quite as much, you can pass values around freely.
Suddenly, RAII isn’t so attractive, at least not in the same way. I’d love the convenience of not having to follow every “acquire()” call with a “defer delete()”, but maybe I don’t want the type system touching that.
Comments
Post a Comment