How to refer to the result location

There’s an open issue on GitHub that I don’t see anywhere on Codeberg, which (correct me if I’m wrong) means it’s still open and simply hasn’t been migrated yet:

If this is resolved or otherwise closed please tell me how I could have known :slight_smile: .

Anyway: since I can’t post there, here goes:

What if we introduce a little extra optional syntax to function declarations that allow them to name the result variable:

fn foo() res: Point {
    res = bar();
    return res;
}

Alternatives in the same vein:

  • require the variable to be var declared in the signature:
fn foo() var res: Point { …
  • ban return statement from such functions and always return the res
2 Likes

Interesting, this is similar to Go’s named return values:

func foo() (res int) {
    res = 123
    return
}    

AFAIK it’s just a convenience feature there though, and they require a bare return no matter what which implicitly returns the named value.

I think a good approach for Zig would be to also force a bare return as if the function would return void and then just make it an AstGen error if res is never used as an lvalue in the function body (similar to local variables):

fn foo() res: i32 {
    return;
}
error: unused named return value

The policy for old github issues is that anyone can reopen them on codeberg (with a link to the github issue) if they have something new to add to the discussion:

EDIT: reading through the existing issue, something very similar seems to already have been suggested here

Without having strong opinion on either side (for/against the proposal), I think a relevant use case that I often come across is when fields depend on each other, eg. when I want to alloc+init a struct while creating its own arena allocator.

const Foo {
    arena: std.mem.Allocator,
    things: []Thing,

    const Thing = struct {foo: u8, bar: u32}; // or whatever..

    // The example is not great, since having init with
    // a zeroed "things" like this is questionable.
    fn init(allocator: std.mem.Allocator, n: usize) !Foo {
        var self: Foo = .{
            arena: std.heap.ArenaAllocator = .init(allocator),
            things: undefined,
        };
        try self.things = self.arena.allocator().alloc(Thing, n);
        @memset(self.things, .{foo = 0, .bar = 0});
        return self;
    }
}

Thing is, I always feel dirty leaving undefineds anywhere, and although I’ve seen this pattern even in std, it does tend to raise my “stack pointer” anxiety a bit.

That said, I find that most of the times I can avoid such inter-dependent fields and end up with better API and more readable code anyway, so yeah, it’s a case in point, but as is, not a good argument for the proposal (weak one at best).

2 Likes

Given the known stance on discouraging managed containers, a motivating example for the idea without allocators may be more compelling.

2 Likes

Why don’t we just make it legal to return pointers from inline function? It already works like this currently:

const std = @import("std");

const S = struct {
    num1: i32 = 0,
    num2: i32 = 0,
    num3: i32 = 0,
    self: *@This(),

    pub inline fn init() *@This() {
        var self: @This() = undefined;
        self = .{ .self = &self };
        return &self;
    }
};

pub fn main() !void {
    const s = S.init();
    std.debug.print("     s: @{x}\n", .{@intFromPtr(s)});
    std.debug.print("s.self: @{x}\n", .{@intFromPtr(s.self)});
}
     s: @7ffccf9e7e00
s.self: @7ffccf9e7e00

This is basically a generalized version of what’s being purposed. Instead of getting the address of one local variable in the caller’s scope, we’re letting the callee allocate as many as it wants.

To avoid copying, the best solution currently is this:

fn foo(res: *Point) void {
    res.* = bar();
    return;
}

I completely understand the reasons for not liking this solution: you have to declare the return symbol as var instead of const. I am also very much looking forward to this proposal being eventually resolved. In fact, I not only hope it can be resolved within functions, but also within block expressions.

1 Like

I believe having access to the Result Type of the function call site could allow to write function that have different behaviours depending on the type. A bit like what
@intCast() does: We then would be able to remove some of the types argument in std.meta helpers.

1 Like

I think with my understanding of how semantic inlining works at the moment, this could very easily lead to an explosion in code size, if it became a common pattern.

If you call an inline fn init with some arguments and those arguments are comptime-known, an inline function is generic over those arguments. A normal function is not.

If every init function that wants to return a pointer is suddenly generic-by-default because we want to use the ability to place stack pointers in the enclosing scope, that could be very bloat-prone. A bit like the current logging/formatting functions in the standard library, you’d need to make users aware of that.

1 Like

That can be fixed by “outlining” the function body. Just redefine what “inline function” means such that its use does not guarantee actual inlining of code. That’s an anachronism anyway. Given the complexity of modern CPUs, determining whether forcing the inlining of one particular function would result in better performance is basically impossible. There’s no good reason to preserve the semantic from C (and if we want to, we can make it a callconv option instead).

Redefining inline functions to mean that their local variables are in the stack space of the caller creates many opportunities for optimization and simplification. Unlike C, Zig has comptime arguments. It does happen frequently where a function returning variable-length result would know the maximum extent. Take this humble example:

    var buffer: [32]u8 = undefined;
    const name = try std.fmt.bufPrint(&buffer, "item #{d}", .{index});

Currently, the caller has to provide the buffer, even though it’s not in a good position to know how large it needs to be in order to avoid an error. bufPrint() does know based on the format string and the arg-tuple type. So we could something like this instead:

    const name = std.fmt.inlinePrint("item #{d}", .{index});

Such situations are commonplace, where the length of the result is known at comptime to the callee but not the caller. In my own, the build invokes a user-supplied function that’s supposed to look like this:

pub fn getImports(b: *std.Build, args: anytype) []const std.Build.Module.Import {
    const sqlite = b.dependency("sqlite", .{
        .target = args.target,
        .optimize = args.optimize,
    }).module("sqlite");
    return &.{
        .{ .name = "sqlite", .module = sqlite },
    };
}

The code works (for now) because I call the function with @call(.always_inline), but it’s relying on unsanctioned behavior. Why not make it official? Is it even logically possibly to implement function inlining such a way that the inlined function would see a different stack pointer?

2 Likes

I see what you’re saying —

fn f() res: anytype

might become viable.

To call such a function, it must be possible to know the result location’s type without analyzing f, which is not required of current functions. Do we think this constraint should apply to all return-location-declared functions?