The 'Why?'
This article was inspired by some conversations on the Zig discord server about various ways to emulate closures. So I thought I would take some of
those ideas and expand upon them in case any other newcomers to zig have the same questions that came up. Furthermore, functional programming is a programming paradigm with a lot of interesting features.
It can help guarantee bug free code.
It has a tendency to be incredibly stable.
The 'What?'
We are going to implement the following two functional programming features in Zig 0.12 dev
- Closures
- Monads (monoids in the category of endofunctors)
The Hurdles
Zig at first does not seem like the ideal candidate for a functional programming style. There are initially many issues
that someone would need to overcome. Zig does not have language level support for closures. Zig does not allow for
currying, zig has no hidden allocations and requires all dynamic allocations to be managed manually. However, Zig does have
two things that are going to make our lives much easier, generics and function pointers.
The Implementation
Closures
In the context of programming when people say a closure they generally mean a function
that captures variable from an outside context and uses them to process other data later on.
First lets take a look at what a naive approach might be:
fn bar(x: 2) fn(i32) i32 {
return fn(y: i32) i32 {
var counter = 0;
for (x..y) |i|{
if ((i % 2) == 0 ){
counter += i * i;
}
}
};
}
Here we are trying to capture the value x and use it as the start of a range but this won't work. We cannot generate
anonymous functions in Zig. So lets change it a little. We can generate anonymous structs and then return a function from them.
fn bar(comptime x: i32) fn (i32) i32 {
return struct {
pub fn foo(y: i32) i32 {
var counter = 0;
for (x..y) |i| {
if ((i % 2) == 0) {
counter += i * i;
}
}
return counter;
}
}.foo;
}
Here we are creating a struct with a function named foo and returning a pointer to that function. You will notice that we have marked x as comptime. That is because in Zig functions must be comptime so any value they wrap
must also be comptime. This greatly limits what we are able to do because we have forcefully removed the chance
for any variable state. This means we have largely lost the purpose of a closure. So let's instead define a named struct that contains the information we need which has a member function that can operate on that information.
const Foo = struct {
x: i32,
pub fn foo(y: i32) i32 {
var counter = 0;
for (x..y) |i| {
if ((i % 2) == 0) {
counter += i * i;
}
}
return counter;
}
};
pub fn Bar(y: i32) Foo {
return Foo{ .x = y };
}
Now we are returning a struct instead of a function, The struct contains any data we need to capture which means
we do not need that information at comptime allowing us to for instance capture user input and use that to generate our data. This example is simplified to the point of being pretty useless but it can be scaled up fairly easily.
Monads
When someone uses the term monad in programming they generally mean a wrapper that allows you to safely chain operations. One good example of that is the maybe-monad. The maybe-monad allows you to apply a function to a stored value if that value is not null. If that procedure is successful we will return a new struct with the result, otherwise we will return one containing a null value. Here we define a very simplified version of the maybe-monad that allows us to apply, chain and unwrap the value with a default in case the stored value is null.
fn Maybe(comptime T: type) type {
return struct {
val: ?T,
pub fn apply(self: Maybe(T), fun: *const fn (T) ?T) Maybe(T) {
if (self.val != null) {
return Maybe(T){ .val = fun(self.val.?) };
}
return Maybe(T){ .val = null };
}
pub fn unwrapOr(self: anytype, default: T) T {
if (self.val != null) {
return self.val.?;
}
return default;
}
pub fn init(val: T) @This() {
return Maybe(T){ .val = val };
}
};
}
Looking at the type signature of Maybe (
zig fn Maybe(comptime T: type) type
) We see that we need to know at comptime what type our monad will contain. This is a limitation of Zig but also a feature since it helps guarantee type safety (among other benefits). However, we can use this same declaration for any number of types so long as we know ahead of time which types they are. Now let's take a look at the signature of the apply function
pub fn apply(self: Maybe(T), fun: *const fn (T) ?T) Maybe(T)
in both cases
Maybe(T)
is how we say we are working with the type returned when the function
is given the type
```T```
.
```fun: *const fn (T) ?T)```
is how we ask for a pointer to a function that takes an argument that is of the same type our monad is wrapping and returns a nullable variable of that same type. The benefit of this function is that we can for instance chain operations instead of writing a series of if statements manually checking if our variable contains a null. For instance:
```zig
pub fn increment(x: i32) ?i32 {
return x + 1;
}
pub fn main() void {
const a = Maybe(i32).init(0);
//this will succeed and print 2
const b = a.apply(increment).apply(increment);
std.debug.print("Successful Operation: {d}\n", .{b.val.?});
}
The problem is that if any of the functions returned null we would have a potential runtime error. So instead of accessing our value directly we can use the
function to ensure that we never try to use a null value and instead provide a default value to use instead of null.
```zig
pub fn increment(x: i32) ?i32 {
return x + 1;
}
pub fn squareOver100(x: i32) ?i32 {
if (x < 100) {
return null;
}
return x * x;
}
pub fn main() void {
//squareOver100 will fail returning a null so our default value will be printed
const a = Maybe(i32).init(0).apply(increment).apply(squareOver100);
std.debug.print("Failed Operation: {d}\n", .{b.unwrapOr(0)});
}
In the real world we could also have a function return a null if some error occurs.
Combining the Two
If we alter our maybe-monad to take an anonymous struct using the keyword 'anytype' (which must be comptime known) instead of a pointer to a function we can use our closures together with our monads.
fn Maybe(comptime T: type) type {
return struct {
val: ?T,
pub fn apply(self: Maybe(T), x: anytype) Maybe(T) {
if (self.val != null) {
return Maybe(T){ .val = x.fun(self.val.?) };
}
return Maybe(T){ .val = null };
}
pub fn unwrapOr(self: anytype, default: T) T {
if (self.val != null) {
return self.val.?;
}
return default;
}
pub fn init(val: T) @This() {
return Maybe(T){ .val = val };
}
};
}
const Foo = struct {
x: i32,
pub fn fun(self: Foo, y: i32) i32 {
return self.x + y;
}
};
pub fn Bar(y: i32) Foo {
return Foo{ .x = y };
}
pub fn main() !void {
const a = Maybe(i32).init(0);
//this will succeed and print 10
const b = a.apply(Bar(10));
std.debug.print("Successful Operation: {d}\n", .{b.val.?});
}
The Conclusion
Monads and closures are a mostly foreign concept to Zig; however, they are possible to implement with very little effort. Zig offers the flexibility to do basically whatever you want to do as long as you work within its relatively simple syntax. Perhaps in a future article we can also discuss emulating OOP features in Zig.
Oldest comments (3)
Interesting article. There are two places where the formatting went awry. Cheers!
It has been pointed out to me that a monadic apply should really be
fn(A, fn(A) Maybe(B)) Maybe(B))
and not
fn(A, fn(A) ?A) Maybe(A)
and that there are other improvements that can be made to the code. So I thought I would just note that here.
ak no like fp, i like.
Hope to see more interesting examples