Static Local Variables surprised me, what am I missing to make them intuitive?

Yes, but here we have called foo() with different arguments, so it should not be deduplicated IMHO. What I expected to have was one version of S for each distinct pair of integers I passed, not one version for each sum of integers.

I shouldn’t have to deduce in my head that 1 + 2 leads to the same default values than 2 +1 which means the same version of S is used in both versions of foo().

One does not need to look at the source code of std.ArrayList to know each function call will yield the same std.ArrayList(u8), and that std.ArrayList(u16) will be different.

I think it boils down to the fact that it is not really the function instantiating the variants of the struct type from its parameters a and b, rather, it’s the struct type instantiating itself from its inputs, in this case the value c.

Can you think of a case where this would be a footgun? The only consequence I personally see is that it might make it possible to assign types that you would not initially expect to be compatible.

1 Like

I don’t think it allows to do what you describe. To be assignable the structs would need to have the same layout, which is only possible here because a and b affect the type S in a ‘symetrical’ way. But as soon as you introduce some fields, this effect disappears, or the definition of the fields should also be symetrical with respects to a and b.

I agree that it is rare in practice.

1 Like

It is, rather, a static variable, but of local scope. A static local variable.

The accessibility and where it’s defined both accord to lexical scope, local and global being matters of scope.

Zig doesn’t have ‘global’ variables the way C does, fortunately: every identifier is scoped to its container, there’s no possibility of spooky leakage. “Global” is easier to say than “container-local static”, so no surprise, we’re entitled to our own definitions of things, but it’s an important point: Zig globals behave like C static in the top-level scope, not C extern (the default if no linkage modifier is used).

I’m ignoring the @extern builtin on purpose, it’s one of those things which if you need it, you’ll know.

It’s also a good habit to use threadlocal for ‘global’ vars: there aren’t many exceptions and you’ll know them when you see them. The bugs this can prevent are a real pain to track down, and are often written years after the fact: insofar as a program is single-threaded, there’s no disadvantage to using threadlocal anyway.

TL;DR: global and local are scope words, static is a duration word. Any data which is static exists for the entire program, in a single location (or one per thread, for threadlocals), how it is scoped does not affect that quality.

@vulpesx I know we’re both trying to convey the same knowledge here. It seemed to me that grounding it in the C terminology where the concepts originate is a useful part of doing that, is all.

3 Likes

Answer to my riddle: It has to do with type the equivalency rules introduced in 0.12. First, there’s two broad classes: types with namespaces and those without. Those without are easy: The same definition is the same type. Eg, one u8 is another u8.

const assert = @import("std").debug.assert;

pub fn main() void {
    {
        const A = u8;
        const B = u8;
        assert(A == B);
    }
    { // Tuples, despite using the struct keyword, are not namespace types.
        const A = struct { u8 };
        const B = struct { u8 };
        assert(A == B);
    }
}

Types with namespace are considered the same type under two conditions:

  1. They have the same source location:
const assert = @import("std").debug.assert;

pub fn main() void {

      const A = struct { foo: u8 };
      const B = struct { foo: u8 };
      assert(A != B);
      assert(A == A);
      assert(B == B);
}
  1. The type captures the same values. So in
const assert = @import("std").debug.assert;

pub fn main() void {
    {
        const A = Foo(1, 2);
        const B = Foo(2, 1);
        assert(A == B);
    }
    {
        const A = Foo(0, 1);
        const B = Foo(0, 2);
        assert(A != B);
    }
}

fn Foo(a: comptime_int, b: comptime_int) type {
    const c = a + b;
    return struct {
        comptime {
            _ = c;
        }
    };
}

Despite Foo(1,2) and Foo(2,1) having different parameters, they both add to 3, so the value captured by the struct declaration is the same, so they’re the same type.

See 0.12.0 Release Notes ⚡ The Zig Programming Language and Proposal: namespace type equivalence based on AST node + captures · Issue #18816 · ziglang/zig · GitHub

Aside: The reason you’re seeing three instances of foo in the original puzzle has to do with memoization. That is, when functions are called with the same comptime parameters (of which comptime_int is implicitly), the compiler only runs it once during semantic analysis. Since there’s three different variations of comptime parameters, they’re three runs. Since two of those runs result in the same captured value, there’s only 2 different types declared.

The container variable declaration in my original puzzle is a simple offshoot of the type equivalency rules. Same type, same variables. Different type, different variables.

You can remove the memoization by using inline fn in the above example (not that you should, but you can), and the types are still the same.

inline fn Foo(a: comptime_int, b: comptime_int) type {
    const c = a + b;
    return struct {
        comptime {
            _ = c;
        }
    };
}
5 Likes

And if you really wanted to, you could compute c within the struct, that would make the struct depend on a and b instead of only the c result. If you do that you end up with 3 different types.

I think that it is good that you can express both.

5 Likes

indeed, capturing the captures’ captures, which is one way of describing what we seem to think we might want, is quickly seen to be untenable if you think about capturing vs memoizing or deduplicating more complicated comptime expressions…

like imagine if every function with an unrelated comptime parameter also had var x: std.ArrayList(u8) = .empty in there! should those ArrayList calls be deduplicated because their parameters are the same? or should they not be because different (but uncaptured) comptime values are in scope at the time?

There is the secret third option of being able to choose on a case by case basis!! ofc it has the same “is that a good idea” as all other options.

1 Like

How did you check this? A newbie question…

Place the original code in a file named, foo.zig. On Linux the following works.

$ zig build-exe foo.zig
$ ./foo
3
4
5
1
$ nm foo | grep foo
00000000011a8070 t foo.foo__anon_30321
0000000001221560 d foo.foo__anon_30321.S.d
00000000011a8100 t foo.foo__anon_30322
00000000011a8190 t foo.foo__anon_30323
0000000001221570 d foo.foo__anon_30323.S.d
00000000011a76a0 t foo.main 

1 Like