How to use generic error types?

I’m writing a function that takes a callback (TransformFunction), this callback has to return TrackData but could also throw whatever error since the function is written by the user.

I was thinking something like this would work, but it doesn’t seem to(and anyerror works but is recommended against in the docs)

pub const TransformFunction = fn (
    comptime errors: type,
    data: []f32,
    sample_rate: usize,
    track_data_allocator: TrackDataAllocator,
) errors!TrackData;

Also unrelated, but do anonymous functions still effectively have to be wrapped in structs?

Thanks!

The reason anyerror is not recommended is that it is difficult to correctly handle errors higher up in the call chain, because you have no idea where the error came from.
I’d recommend to handle the errors locally and only return OutOfMemory errors, if any, in your callback.

Also unrelated, but do anonymous functions still effectively have to be wrapped in structs?

Yes, and the proposal to change that has been rejected: RFC: Make function definitions expressions · Issue #1717 · ziglang/zig · GitHub

2 Likes

Could you invert the relationship instead? (I can’t answer it for you, but something to think about) (or rather un-invert the inversion?)

Instead of creating your code with a hole that gets plugged by the user and becomes highly generic and abstract.

Could you instead let the user just reach into your data structure and access data directly?

So instead of your code driving the control flow and calling back to the user through provided callbacks, let the user write the control flow and then use documentation to tell the user: call this, before that.

I don’t know if my point comes across very well, I find it a bit difficult to describe in abstract terms.

Basically what I am thinking of is to emulate something like the raylib api, where you call raylib functions in specific ways like this:

pub fn main() !void
{
    ray.InitWindow(800, 450, "raylib [core] example - basic window");

    while (!ray.WindowShouldClose())
    {
        // Update
        //----------------------------------------------------------------------------------
        // TODO: Update your variables here
        //----------------------------------------------------------------------------------

        // Draw
        //----------------------------------------------------------------------------------
        ray.BeginDrawing();

            ray.ClearBackground(ray.WHITE);

            ray.DrawText("Congrats! You created your first window!", 190, 200, 20, ray.LIGHTGRAY);

        ray.EndDrawing();
        //----------------------------------------------------------------------------------
    }

    ray.CloseWindow();
}

Instead of doing something like this:

pub fn main() !void {
    var window = Window(struct {
         pub const width = 800;
         pub const height = 450;
         pub const title = "raylib [core] example - basic window";

         pub fn update() !void {
         }
         pub fn draw() !void {
             ray.ClearBackground(ray.WHITE);
             ray.DrawText("Congrats! You created your first window!", 190, 200, 20, ray.LIGHTGRAY);
         }
     });
     try window.run();
}

While the latter code may look prettier (at first glance) it hides internal plumbing and is annoying, because it forces you to put your code into a bunch of predefined callback slots and also forces you into creating context for communicating between these different slots, it forces an arbitrary structure on the user.

I appreciate the former because it doesn’t force specific structure on the user and just lets the user build their program how they want to, allowing the user to use the stack more naturally, instead of having to put things into context objects (as a poor replacement for the stack).

So my question is:
Could you get rid of the callback and instead design your library in such a way that it doesn’t take away the control from the user, but instead is being controlled by the user of the library.

I also think that doing that may lead to a design, where the user can directly deal with errors, right where they happen, instead of those errors being obscured by inversion of control and the more abstract code it forces.

The user can still choose to create abstract interfaces on top of it, but they are no longer forced into it.

5 Likes

I agree generally that library users shouldn’t be forced into a specific style- but in this case its a little different in that writing similar code without this style would be just as easy and invalidate the library as its not really meant to add functionality as much as enable this style. It’s also definitely not Zig-“ish” but I’m working on this for fun and learning Zig for fun so wanted to do both. That said, I’m also not necessarily set in this design, so I’d be happy if you had any ideas for it. Basically though, its for sound synthesis to be layered as a series of transformations/filters so for example:


fn sin(duration: usize) TransformFunction {
    return struct {
        pub fn call(
            _: []f32,
            _: usize,
            track_data_allocator: TrackDataAllocator,
        ) anyerror!TrackData {
            var sample = try track_data_allocator.alloc(duration);

            for (0..duration) |i| {
                const out: f32 = @floatFromInt(i);
                sample[i] = std.math.sin(out * 0.1);
            }

            return sample;
        }
    }.call;
}

fn scale(multiplier: f32) TransformFunction {
    return struct {
        pub fn call(
            data: []f32,
            _: usize,
            track_data_allocator: TrackDataAllocator,
        ) anyerror!TrackData {
            var sample = try track_data_allocator.alloc(data.len);

            for (0..data.len) |i| {
                sample[i] = data[i] * multiplier;
            }

            return sample;
        }
    }.call;
}

test "play sin wave" {
    var track = try Track.init(64000, std.testing.allocator);
    try track.mutate(sin(64000));

    try track.mutate(scale(0.5));

    try track.play();
    
    var new_track = try track.project(scale(2));

    track.free();
    
    try new_track.play();
    new_track.free();
}

And to do it without the style of the library would be essentially the same, I just wrote a play function which just calls ffplay but you could also use zaudio or something.

I don’t really like this sort of thing (at least not as much as I used to, when I had an infatuation with generator style pipelines and functional transformations), I don’t think it makes a lot of sense in the context of Zig, I think there is a mismatch between this more functional style and Zigs more imperative style.

The part I don’t like about this api specifically is that you loop over the data for every operation. If I were to use a library like this, I would expect it to combine the operations and apply them as one combined function only iterating over the data once. Only iterating over it multiple times if there is a good reason for it.

I think part of why I lost interest in this approach, is because it focuses on pretty combinator things, more than what the code actually does. The interfaces and how you construct your code becomes more important than whether the resulting code is actually good and performs well.

It detracts from actually dealing with the data and transforming it in a straight forward way. Just write one for loop manually that does 5 operations, instead of inventing DSLs for writing 5 for loops that apply one operation.

So I would rather want something like this:

test "play sin wave" {
    const allocator = std.testing.allocator;

    var track = try allocator.alloc(f32, 64000);
    defer allocator.free(track);

    for(track, 0..) |*dest, i | {
        dest.* = std.math.sin(@as(f32, @floatFromInt(i)) * 0.1 * 0.5);
    }

    try player.play(track);

    // instead of this
    // var new_track = try track.project(scale(2));
    // either mutate or dupe
    // var new_track = try allocator.dupe(track);

    for(track) |*dest| {
        dest.* *= 2;
    }
    try player.play(track);
}

My 2 cents are that manual for loops are better and it is better to avoid having to deal with generic error types.

When you have a specific application you don’t need to deal with generic errors.

But once you try to make everything changeable at run time things become difficult, I think in those situations it would make sense to constrain the set of possible errors as much as possible.
Basically disallowing certain types of errors.

1 Like

I agree completely about not reinventing the language through a DSL, and also generally that this isn’t an efficient approach, its more for creative coding. Not to be used in actual applications. But to clarify, I didn’t mean that loops or any of the iteration would be done by the library. That would all be defined in the callbacks the user defines. (In the example the callbacks are sin and scale)

But I do agree with your input that this definitely isn’t Zig’s style

1 Like

Shameless plug: I have been experimenting with something similar but with a very different approach.

My goal was also to have fun and learn zig, and the approach might not be very idiomatic.

2 Likes

I think one approach that is becoming more and more relevant would be to try and use incremental compilation to just have the code recompile, without having to restart the program or making the program more changeable at run-time, by making the code less specific and constrained.

But I haven’t used it yet and I don’t know how good it works already.

1 Like

That’s really intriguing! It would be possible to do livecoding in zig, wouldn’t it ?

1 Like

This topic makes it look like it is starting to work / be useful for some usecases: