How does Payload Capturing work?

Hey everyone, I’m a relatively new user of Zig with a background in Rust. Currently I’m learning the language step-by-step and it’s quite a smooth ride, however Payload Captures seem like a road-block for now as I can’t figure out what is happening, and unfortunately I can’t find any resources on the internet breaking it down, only examples as to how it’s used.

For now I understand you can do the following:

    // Iterating over an array
    const msg = "hello";

    for (msg) |ltr| {                                                                                                                                                     
        try expect(ltr > 'a' and ltr < 'z');                                                                                                                              
    }

    // Destructuring optionals
    var maybe_num: ?usize = 10;    
    if (maybe_num) |n| {
        try expect(n == 10);            
    }

    // With tagged unions (code copied from https://ziglearn.org/chapter-1/#unions)
    const Tag = enum { a, b, c };
    const Tagged = union(Tag) { a: u8, b: f32, c: bool };
    
    var value = Tagged{ .b = 1.5 };    
    switch (value) {
        .a => |*byte| byte.* += 1,
        .b => |*float| float.* *= 2,
        .c => |*b| b.* = !b.*,
    }
    try expect(value.b == 3);

Assuming I’ve understood correctly, these are three examples of payload capturing, however each example is quite different, in that the “meaning” of (variable) |extract| changes depending on whether it is preceded by switch, for or if. I think what would help a lot is a breakdown of the syntax, something like (if | for | switch) (IDENTIFIER) |IDENTIFIER| { } that details what’s going on, if something like that makes sense?

From Rust I’m used to pattern destructuring, where any structure can be destructured (arrays, structs, tagged unions) in a consistent way independent of the structure. I guess Payload Capturing does not function analogously? Any information on this topic would help me out I think, the problem is that I can’t find any beginner-friendly information on this topic elsewhere (besides examples). Thanks a lot in advance!

Edit: Just to illustrate my point, if I were to do

    if (msg) |ltr| {
        try expect(ltr > 'a' and ltr < 'z');
    }

Instead in the code above, that would now give me a compile-error saying that msg must be an optional type, not a u8 array.
As a result I’m left with quite a lot of questions; is if (variable) |extract| only usable with optionals? Similarly is for (variable) |extract| only usable with arrays? Are there any other terms (other than if, for and switch) that can precede a (variable) |extract|? Is all of this syntactic sugar for something else?

1 Like

Hey @Brysen, thanks for joining the forum.

Here’s another example that is under-represented in the literature:

pub fn main() !void {

    // optional integer
    var x: ?i32 = 42;

    // capture the value
    while (x) |i| { 
        std.debug.print("\nIn the loop: {}\n", .{i});
        // breaks the while loop
        x = null;
    }
}

In this case, we can actually understand what’s happening here with a little more of an involved example.

const CountUntilNull = struct {
    // belongs to struct declaration
    const array: [5]i32 = .{ 1, 2, 3, 4, 5 };

    // belongs to struct instances
    index: usize = 0,

    // catch a pointer to self for index mutation
    pub fn next(self: *@This()) ?i32 {
        if (self.index < array.len) {
            const tmp = array[self.index];
            self.index += 1;
            return tmp;
        }
        return null;
    }
};

pub fn main() !void {

    var counter = CountUntilNull{ };

    while (counter.next()) |i| {
        std.debug.print("\nCount value: {}\n", .{i});
    }
}

In the second example, it appears like we’re iterating over a range. What’s really going on is the while loop is getting a series of optionals to unpack. We know this because the case of a single optional-int works as well and there’s nothing to iterate over.

Switch statements are fairly similar to how they are in C or C++ and will not allow you to unpack optionals. Think about it this way - if I have an optional int, it could be null or have a value… so my switch statement could try to enumerate those values. Here’s the problem… if it can be compared against a literal like 42, then we must have already unpacked the optional. If we can compare it against null, then we must have not unpacked the optional - see the problem here?

You’ll get a handy compiler error in this case.

error: switch on type '?i32'

You sound like you’re quite familiar with iterators (always good to see Rust people on the forum), so I’m sure you get the idea - different process and is ultimately syntactic sugar for what could be written as an index or pointer based loop.

For a little more evidence, here’s what happens if you try that last example with a for statement:

main.zig:116:22: error: type '?i32' is not indexable and not a range
    for (counter.next()) |i| {

So yes, for loops are indeed for ranges.

4 Likes

Very interesting, thanks for the additional examples. So I guess a while (variable) |extract| is similar to if (variable) |extract| only that the former keeps looping until a null is returned, that makes sense. Would you say the examples we have so far are exhaustive? In that:

for (range) |element| {} Is used to iterate over any range

if (optional) |some| {} Is used to “extract” a value from an optional if its non-null

while (optional) |some| {} Similar to the above, but loops until optional evaluates to null

// Here we first switch over the tagged union variants, 
// then we "extract" the value in that field if a variant matches
switch (optional_enum) {
    .variant1 => |some|  { // do stuff with value in variant1 }
    .variant2 => |some|  { // do stuff with value in variant2 }
    //...
    .variantn => |some|  { // do stuff with value in variantn }
}

Did we forget anything?

Edit: I now realise a big part of my confusion is that I expected the (variable) |extract| thingy to have a meaning of its own, but it doesn’t. That’s to say
for (variable) |extract| is completely diffrent from if (variable) |extract|.

I think it helps to summarize the behavior all in one place.

if

With booleans (no captures here):

if (true) return x;
if (a > b) x = a else x = b;

With optionals (capture payload if not null):

if (maybe_byte) |byte| x = byte else x = 0;
if (maybe_byte == null) return;

With error unions (capture payload on success, or the error value on error):

if (canFail()) |result| {
    x = result;
} els |err| {
   print("error: {}\n", .{err});
    return err;
}

while

With booleans (no capture):

while (i < n) : (i += 1) { ... }

With optionals (capture the payload if not null; break loop if null):

while (maybe_byte) |byte| { ... }

With error unions (capture payload on success, else clause is mandatory here):

while (canFail()) |result| { ... } else |err| { ... }

for

for loops only work on arrays, slices, and ranges. The payload is always the current iteration item. You can iterate over multiple objects simultaneously.

for (list) |item| { ... }
for (list_a, 0..) |item, i| { ... }
for (list_a, list_b, list_c) |a, b, c| { ... }

switch

When switching over a tagged union, the prongs can capture the payload if any. Also when a prong expression is a range or a list of expressions, you can capture the actual matching value.

switch (my_tagged_union) {
    .a => |a| ...,
    .b => ...,
    .c => |c| ...,
}

switch (x) {
    0...9 => |n| ...,
    13, 19, 23 => |n| ...,
    else => ...,
}

So basically the combination of type of control flow expression (if, for, while, switch) and the type of expression being worked on (optional, error union, boolean, other) is what determines the allowed syntax, the payload capture type, if there’s an error capture, etc. And this is pretty much the type of rules that just have to be memorized given the variety of these combinations.

9 Likes

This was exactly what I was looking for, thanks! An exhaustive list of all the ways to do Paylod Capturing, along with all the “types” that can be captured/operated on. I feel like I grasp this topic now thanks to your and @AndrewCodeDev answers. As I said earlier, I think my confusion was due to the (variable) |extract| being used in all four cases, despite the if case having nothing to do with the for case, but now that’s cleared up.

3 Likes

Just for completeness, there’s also someErrableFn() catch |err| { ... } which is useful for capturing the exact error returned by a function. A small example from the Zig docs can be seen here. The resulting error payload can then be used with a switch statement to handle different errors.

4 Likes