What makes "ban returning pointer to stack memory" difficult?

Is there any systems language that does not release at block scope close? C, C++, Rust, Nim and Swift all release, as far as I can tell. (I think Go does, too, but I’m not a Go user.)

1 Like

No idea. Let’s pretend they don’t exist: what should Zig do here?

My thinking is that, specifically because of named blocks with breaks, it should allow escape. That sure makes it look like the contents of a block can make it out, and I don’t see compelling reasons to forbid it.

I could be persuaded, it’s not a hill worth dying on. But I think the current behavior is preferable.

3 Likes

Here’s another worked example:

const ABC: []const u8 = slice: {
    var buf: [12]u8 = undefined;
    buf[0] = 'a';
    buf[1] = 'b';
    buf[2] = 'c';
    const const_buf: []const u8 = &.{ buf[0], buf[1], buf[2] };
    break :slice const_buf;
};

test "Comptime ABC" {
    try std.testing.expectEqualStrings("abc", ABC);
}

Which, as usual, is a silly version of something otherwise worth doing.

If memory can’t escape a block, how do we do this?

Regardless of how block scopes deal with stack memory, the compiler should be able to figure out “this stack memory isn’t used after this, so I can re-use it for something else”

1 Like

Indeed, liveness analysis is essential and basic to compiling, they’re generally fairly good at it. It’s good for more than just reusing memory, but it’s good for that as well.

The stack memory might no longer be accessible outside the scope, but that doesn’t mean that the stack memory is freed. AFAIK most (all?) languages only allocate the ‘stack frame’ once at function entry (e.g. bump the stack pointer) and free that stack frame once on exit (e.g. readjusting the stack pointer to what it was on entry). There is only one stack frame for the function, no matter how deeply nested the scope blocks in the function are.

Don’t confuse stack allocation with C++ style RAII or Zig’s defer which works at block scope level (as will the proposed C2y defer, while Go’s defer is called on function exit) - but calling a destructor or defer doesn’t ‘free’ the stack memory, that only happens at function exit.

6 Likes

You’ll need to check the output assembly. I’m pretty sure all languages you listed only change the stack pointer once on function entry and back on function exit.

This is independent from calling destructors or defer which happens on block scope (except in Go where defer is called on function exit - e.g. see this blog post about the current defer proposal for C which discusses various languages: The Defer Technical Specification: It Is Time | The Pasture)

Liveness analysis is not suficient for reusing memory. If you pass a pointer of that memory to an external function, the pointer is escaped, and the compiler must assume that variable will remain alive for as long as the language allows it. For variables inside blocks, it’s just silly to keep them alive. If you want a variable to continue alive outside a block, put it outside the block.
For automatic variables, if you can refer to them by name, they’re alive, otherwise they’re dead. This rule is simple and allow for optimal code generation. It doesn’t hurt ergonomics and, in fact, improve readability. With this rule, when considering which variables at alive at any point in the program, you can just look at whatever is in this scope or parent scopes. You never have to worry about what inside blocks.

That is true. However, all the languages alias the memory between the block scopes. So, while the stack pointer doesn’t move, the memory gets reused/recycled.

1 Like

Not sure that’s true, for instance when you check a similar example like your Zig code with two arrays in separate code blocks in C or C++ on Godbolt, the addresses are different (by the size of the first array), e.g. the behaviour is exactly the same as in Zig. (unfortunately this forum software doesn’t seem to accept Godbolt links for some reason).

PS: e.g try this in Godbolt in C or C++:

#include <stdint.h>

int main() {
    {
        uint8_t a[16] = {0};
        __builtin_printf("a: %p\n", a);
    }
    {
        uint8_t b[16] = {0};
        __builtin_printf("b: %p\n", b);
    }
    return 0;
}

This prints for instance:

a: 0x7fffc8b776e0
b: 0x7fffc8b776d0

PPS: hah interesting, with optimization enabled (-O1 and above) the memory actually is reused:

a: 0x7fff3320b4f0
b: 0x7fff3320b4f0
1 Like

2 posts were split to a new topic: Errors linking to Compiler Explorer

You can’t put a variable outside a block if that variable is only meant to exist when certain comptime conditions are met.

2 Likes

I’m trying to address this objection above. This is container-level code which happens at comptime:

How is this supposed to be done, given the stricture you would like the language to impose on block memory?

I don’t think that building up a comptime constant is silly at all, it’s one of the things which makes the language great.

3 Likes

There is a significant difference between evaluating this at compile time versus runtime.

Comptime memory is, by definition, completely recycled and has no bearing at runtime. You could even have infinite scope on comptime if you choose because it all disappears at the end of the phase. It may make your compilation either slow or gigantic, but it has no bearing after compilation.

This is very much not true at runtime. Realiasing stack memory is extremely important on small memory machines. Being unable to alias memory between if/then/else blocks would be untenable. Processors with 1K RAM are still mainstream:

1 Like

Are you suggesting that block memory should have different semantics at comptime than it does at runtime?

I think that would be surprising, in the bad way.

1 Like

It already does. You can return pointers to “stack” memory from function blocks at comptime.

1 Like

I am simply pointing out that runtime semantics for stack variables are more essential to nail down precisely than the comptime semantics.

If the scopes last a little too long at comptime, that’s simply an error of thought or efficiency that disappears once compilation is finished. If scopes last a little too long at runtime, your program won’t fit in your chip.

(Side note: I’m pretty sure that the compiler does assume infinite scope for all comptime memory allocations but I have not tested this.)

1 Like

Yes, comptime zig is a managed memory language. Things last as long as they need to.

1 Like

Consider the following:

const std = @import("std");

const Point = struct {
    x: u64,
    y: u64,
    z: u64,
};

fn foo(n: u64) void {
    const ptr1, const ptr2 = init: {
        const a: Point = .{ .x = 123, .y = 456, .z = 789 };
        const b: Point = .{ .x = 18_446_744_073_709_551_615, .y = 18_446_744_073_709_551_615, .z = 18_446_744_073_709_551_615 - 3_000_000 * n };
        break :init .{ &a, &b };
    };
    std.debug.print("a@{x}\n", .{@intFromPtr(ptr1)});
    std.debug.print("b@{x}\n", .{@intFromPtr(ptr2)});
}

pub fn main() !void {
    foo(1);
}

ptr1 points to the data section while ptr2 points to the stack, as can be discerned in the ouput:

a@10217c0
b@7ffc36134208

Right now, at least within the function the two behave the same. Do we really want to make the language more weird by making one of the pointers invalid?

4 Likes

That makes total sense.

Data section is optimal.
However b contains a runtime value so it has to be put on the stack which gives slightly worse performance and takes stack memory which is a limited resource.