Can zig do builder pattern?

I’m writing some bindings for a rust library that uses the builder pattern a lot, can this be replicated in zig?

For example, here is some rust code:

publisher
  .put(buf)
  .encoding(Encoding::TEXT_PLAIN)
  .attachment(attachment.clone())
  .await
  .unwrap();

yes, you just return self param for the builder type from each function, it can be through a pointer or copied, the former requires a variable.

2 Likes

I generally prefer returning copies, because zig treats temporary values as const, but if you are ok with the ‘var’ declaration before, you can use pointers.

var builder = Builder{}; // this is necessary to get around the const temporary.
const obj = builder.methods().stuff().unwrap(); // allowed to work with the non-const pointer now.
1 Like

I have implemented something like this for my Matrix struct in Zignal

The main challenge was that some methods allocate abs might error out. So I just track the errors internally and only return them when we unwrap the computation. I’m open to better alternatives.

1 Like

that’s roughly the same approach I used, internally the builder is a union(enum) {valid: internalBuilder, invalid: anyerror}, and then unwrap returns the error.

I haven’t found a model I like better yet.

Instead of returning an error union, you might need to keep the errors monadically.

For example:

const std = @import("std");

const Entry = struct {
    x: usize,
};

const BuildError = union(enum) {
    invalid: []const u8,
};

const BuildResult = union(enum) {
    right: std.array_list.Aligned(Entry, null),
    left: std.array_list.Aligned(BuildError, null),
    alloc_error,
};

const Builder = struct {
    allocator: std.mem.Allocator,
    entries: std.array_list.Aligned(Entry, null),
    errors: std.array_list.Aligned(BuildError, null),
    has_alloc_error: bool,

    pub fn init(allocator: std.mem.Allocator) Builder {
        return Builder{ 
            .allocator = allocator, 
            .entries = .empty, 
            .errors = .empty,
            .has_alloc_error = false,
        };
    }

    pub fn addEntry(self: *Builder, entry: Entry) *Builder {
        if (entry.x > 100) { return self.addAsInvalid(entry); }

        self.entries.append(self.allocator, entry) catch |err|  switch (err) {
            error.OutOfMemory => { self.has_alloc_error = true; },
        };
        return self;
    }

    fn addAsInvalid(self: *Builder, entry: Entry) *Builder {
        const mgs = std.fmt.allocPrint(self.allocator, "invalid entry: {}", .{ entry })
        catch |err| switch (err) {
            error.OutOfMemory => { 
                self.has_alloc_error = true;
                return self;
            },
        };

        self.errors.append(self.allocator, BuildError{ .invalid = mgs })
        catch |err| switch (err) {
            error.OutOfMemory => { self.has_alloc_error = true; } 
        };
        return self;
    }

    pub fn build(self: Builder) BuildResult {
        if (self.has_alloc_error) {
            return .alloc_error;
        }
        if (self.errors.items.len > 0) {
            return BuildResult{ .left = self.errors };
        }

        return BuildResult{ .right = self.entries };
    }
};

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    var b = Builder.init(allocator);

    const r1 = b.addEntry(Entry{ .x = 42 }).build();
    const r2 = b.addEntry(Entry{ .x = 108 }).build();
    
    std.debug.print("r1: {}\n", .{ r1 });
    std.debug.print("r2: {}\n", .{ r2 });
}

I think this falls under style and use-case. In my case, I wanted to keep the calling site error handling to a very zig idiomatic:

const value = try Builder.init(allocator).add(thing).finish();

But I eventually settled for two allocators because I had some use-cases where the building was happening in an arena, but the result needed to last longer than the arena, and I didn’t want to copy after the finish.

const value = try Builder.init(arena.allocator()) // use arena for intermediates.
    .add(thing_one)
    .add(thing_two)
    .finish(long_lived_allocator); // use the parent allocator for the final (owned) result.
errdefer value.deinit(long_lived_allocator);

I really wanted the try so that the rest of the defer statements above would just “magically” happen and the builder was easy to use. Keeping track of the adds that succeeded when the already known failure was going to erase them anyway didn’t seem like it added much value.

I did think that “buffering” the errors so that all errors could be reported at once would be valuable, but I couldn’t find a way to do it with the above call site / usage restrictions. []anyerror doesn’t work with try/catch/if the way I wanted. I’m not aware of a way to attach all the buffered errors as extra data onto a single error. As a result, I decided the first error was good enough for 80% of the real use cases and optimized from there.

I think the best builder patter for zig is:

var builder = Builder{};
builder.a();
try builder.b(32);

const final = try builder.build();

Removes all complicated error tracking you might need, the only thing left would be a flag for a fatal error, if that exists.

Function chaining is not what defines a builder, that’s just a convenience, that in the case of zig, makes error handling more complicated.

I’m getting PTSD from the number of times I had to explain that to a rust dev, lol

6 Likes

This is the way. And many times you wont even need a builder, you can instead use a options struct for init.

They are effectively the same thing.

1 Like

Some complex building patterns can be confusing to express with options struct. But for simple stuff yeah, they are essentially same.

I agree that option structs are the better approach if your use-case supports them. Why write code when a good struct with some sane defaults (and fields lacking defaults to force assignment) gets you most of the way.

As far as I know, that is called monads. Here is article about them.

I think the builder pattern and monads are two different things.
A builder is just a way to store some information about how you want to initialize some object before actually initializing it, right? So you have a builder object that you mutate till you have all the data to finely build the actual object you want.
A monad has to have a function (often called bind) with the signature fn (Monad(A), fn (A) Monat(B)) Monad(B) (Where Monad(A) is a type constructor so something of the form fn Monad(comptime t : type) type) that is a pure function with no side-effects, builders do not have this function and are not necessarily type constructors.
But I think you can implement builders with the state monad.