Result Location Semantics and error handling

What would be the intended semantics of a function, which uses RLS, but also can fail in the middle? Ie, something like this:

fn f() !S {
    return .{
        .x = try g(),
        .y = try h(),
    };
}

Would x and y of the result be updated in-place (risking partially initialized state, if h, but not g, returns an error), or would this force copies? Right now, there are copies

const std = @import("std");

const S = struct {
    x: i32,
    y: i32,
};

fn f() !S {
    return .{
        .x = try g(),
        .y = try h(),
    };
}

fn g() !i32 {
    return 92;
}

fn h() !i32 {
    return error.oups;
}

pub fn main() void {
    var s: S = .{ .x = 0, .y = 0 };
    s = f() catch {
        std.debug.print("{}\n", .{s}); // Prints 0, 0 in Debug and ReleaseFast
        return;
    };
}

but I am wondering how intentional is that.

Context: at TigerBeetle, we have a central struct, Replica, which consists of various directly embedded substructs, and which has humongous size-of. Each of subparts (and subparts of subparts) has an init function, and many inits can fail.

The semantics we want here is to everything to be constructed in-place, and for de-inits of already initialized parts to run if case of an error in the middle.

It looks like the best way to implement this is probably just passing an out parameter, doing an in-place construction manually, but I am wondering if we could actually use RLS here?

9 Likes

I think this shouldn’t be a problem. If f returns an error, the state of the S field of the error union is undefined, so the partially initialized state shouldn’t matter. I think what you’re seeing is just a limitation of the compiler.
From my experience with C++, copy elision can be thrown off if the type you’re returning is not exactly what you declared in the return type, even when it would be trivial for the compiler to elide the copy. In this particular case, you’re returning an S, which is not exactly the same as a !S. Perhaps you could try this:

fn f() Error!S {
    const result: Error!S = S{
        .x = try g(),
        .y = try h(),
    };
    return result;
}

This makes the relationship between the result and the return type more explicit, maybe this could help the compiler.

Your best way seems correct to me. I would suggest that the ‘correct’ RLS for error unions, is that the value which is actually returned makes the other value invalid, so if the calling code assigns to a variable, has a catch clause, and the catch is activated, it should be safety-checked illegal behavior to even look at the variable after that point.

That seems like the only recipe for sanity. The fields might be filled in, but relying on that would be a fantastic recipe for buggy programs, and it would be nuts to make that a defined semantics of RLS.

As you point out, when that behavior is desirable, you can pass in a pointer to an uninitialized struct, and assign the fields once per statement, making the happy path return value void. Given that each possible error is unique, that means you can determine which fields made it on to the struct, and proceed on that basis.

But you can surely see that this a risky thing to do, so setting it up explicitly is wiser than any approach which lets a program access the left-hand value of an expression which heads to the right with a catch. It really must be either-or.

1 Like

In other words, I think your example should panic: instead of the assignment just failing, it should also poison the variable.

2 Likes

I don’t know the compiler internals, but it seems to me that the least surprising design for how the compiler should work your problem is the following, and it’s all working the way it should right now.

Your fn f() should be semantically similar to:

fn f2() !S {
    const tmp1 = try g();
    const tmp2 = try h();
    return .{
        .x = tmp1,
        .y = tmp2,
    };
}

I don’t see why there should ever be a risk of a partially-initialised result event with Result Location Semantics. In other words, RLS only comes into force once all fields have been successfully initialised, because prior to that point in the control flow, there is no result.

If you really wanted a partially-initialised result, then you could write into a pointer to your Replica, but I think if you have complex initialisation, it would be quite hard to reason about all the potential states Replica could be in. If you need to capture some information about exactly what failed during initialisation, I would do that with a more complex return type rather than returning !S.

A worked example of the simple case is here: Compiler Explorer (godbolt.org)

As a bonus question, what about initialisation order? This example also works according to the principle of least surprise (at least for me):

fn f3() !S {
    return .{
        // note initialisation order
        .y = try j(),
        .x = try j(),
    };
}

fn j() !i32 {
    const static = struct {
        var count: i32 = 0;
    };

    static.count += 1;
    return static.count;
}