Functions with error union types under the hood

Today in a seminar about Zig there was a question: why doesn’t a function that uses + operator, which can overflow and lead to panic, include an error union type in its signature? The question wasn’t addressed to me but the question (god…) did actually make sense!

After the seminar, I tried to understand how plus actually looks like “under the hood” using https://godbolt.org. Not so much I was able to see tbh. In one case, the plus was encoded into a “load effective address” instruction (as part of + that calculates the address, which again leaves me with a mystery of what + is). In the other, plus becomes a normal add instruction. In that case, I suppose, if add overflows, it interrupts, where zig already shove some predefined table of handlers to deal with this particular interrupt code.

Anyway, after reading here and there, I was lost in abyss of details and decided to move on to error handling. I wanted to understand what exactly an error is on the assembly level, even though I barely can understand it (just in case). I know that everyone says this is just a “number” or an “enum” in the type system terms. Ok, that makes sense but when I decided to see how it translates to asm using, say:

export fn square(num: i32) !i32 {
    return if (num > 1) num + num else error.Overflow;
}

I got:

<source>:3:29: error: return type '@typeInfo(@typeInfo(@TypeOf(example.square)).Fn.return_type.?).ErrorUnion.error_set!i32' not allowed in function with calling convention 'C'
export fn square(num: i32) !i32 {
                            ^~~
Compiler returned: 1

I’ve heard that if you want to pass your function to C (the part I’m quite far away from now), you have to get rid of error handling and go “raw types”. Well, maybe but if I don’t go to C world, then how errors will be represented?

export function need to be compatible with C in case you’d want to call zig code from another language.

To avoid this in godbolt you can just make a separarate function and call that. And you need also ensure that the result isn’t optimized away:

const std = @import("std");
fn square2(num: i32) !i32 {
    return if (num > 1) num + num else error.Overflow;
}
export fn square(num: i32) void {
    std.mem.doNotOptimizeAway(square2(num));
}
3 Likes

Regarding the question about overflow and errors, I think that Zig gives you the choice of what behavior you want, eithr a panic or an error. If overflow is intolerable you may benefit from the panic alerting you of this situation and halting execution immediately. If on the other hand you wish to handle the overflow like an error condition, check out these std.math functions that return errors. There are also these builtins that return a tuple with the result and an overflow bit flag.

4 Likes

Could you please give a little overview on what is happening here? It seems at the end of the day a function, in case of an error, jumps to a label (LBB0_2) where it returns a number (specifically prepared for a caller site). It seems a caller site is the one who is supposed to figure out what this number means and where exactly it is on the stack. Besides that, it’s a complete mystery to me :smiling_face_with_tear:.

Yes, I saw those functions but the misunderstanding didn’t go away. I think the issue is that I’m not sure how panics work (in general). Like who creates them, how they triggers, etc.

Hmm, that doesn’t look right. To me it looks like in case of an error it’s just skipping the addition???
I guess the compiler somehow still optimized the error away. Not sure how that could be avoided here.

.LBB0_2 is the error.
It places .L__unnamed_l into xmm0, and then places that onto the stack. That would be equivalent to writing into the stack:

00 00 00 00 00 00 00 00
01 00 00 00 00 00 00 00

The 01 is probably where the compiler stored the tag of the tagged union, and 1 is the error tag. The first row of 0s would be the i64. The compiler would need 2 bytes for the error, since they are implemented as u16 behind the scenes, but they are overlapped with the i64. Errors are numbered from 0 upwards. Since this is the only error in this code, it was assigned number 0 (which overlap with the low-order 2 bytes of the i64). Due to alignment, everythin after the 01 is just padding.

It should be noted that the compiler didn’t need to zero out the i64, since the contents of the payload are undefined in case of an error. When I put this code into godbolt, I get different assembly than what was shown here, both in 0.10 and trunk. The code I get doesn’t zero out the i64. Because of that, it wastes less space with the .L__unnamed_1 label and it’s more efficient, as it uses a normal scalar register rather than SIMD, which has lower latency.

Error numbers are global, so both caller and calleé have that information available. Indeed, the number was stored somewhere in the binary specifically so this function can return it.
Where it is placed on the stack is determined by ABI, so also available to both.