I have not yet seen var’s and const’s in struct’s, so could somebody please confirm if my understanding of them, based on intuition:
structs work as namespaces, and var’s and const’s declared in them ‘belong’ to the namespace, rather than a certain instance of the struct.
This is similar to static fields in struct’s or classes in other languages, e.g. C++.
I have a feeling this is correct, but would like to know for sure.
Thank you.
consts at a struct level must be compile time known, so constants.
vars at a struct level do work like global variables, but in this circumstance behave more like C’s static function variables.
I used this feature when developing a plugin for the Falco CNCF project.
Beware of race conditions during initialization. If your static data needs allocations, you might want to use the c_allocator or else try to find a way to give an allocator at initialization time. For caching purposes, ArenaAllocator is your friend here.
In addition to that, in Zig, every file is implicitly a struct, and the resulting struct type is what you get when you @import the file. This means that everything you know about using const and var at the top level of a file also applies to using them within a struct by definition (such as them not being bound to a particular instance).
Sometimes an example helps. Here’s a handy function that returns a unique id for every type using static variables - I think SpexGuy originally proposed this…
fn typeID(comptime T: type) usize {
// function that returns unique integer for each type
_ = T;
const ID = struct {
var byte: u8 = 0;
};
return @intFromPtr(&ID.byte);
}
Isn’t it almost exactly the same as fn foo() i32 from the documentation I’ve linked in my question above?
Only fn typeID takes a T parameter, which it then promptly discards, and then returns the pointer to var byte as usize for whatever reason. I don’t understand what’s the point of both of these things, TBH.
And it doesn’t increment byte. I think the += highlights the fact that the value of a static variable is kept between the calls to the function, and the caller is getting new result on every call.
So here’s what happens (and I think this will clarify some things).
if I call typeID on two different types, it will create two different functions because it deduces T differently for both cases.
So…
typeID(i32) != typeID(i64)
Each of these function calls will then have a unique ID struct in them and those will have a unique byte. That byte (to be unique) needs to have it’s own memory address assigned to it.
I think the misunderstanding here is that it will be the same struct for each function call even if the types are different. It won’t be - a new struct with a new ID will be generated for each function call because the function itself gets deduced differently.
In the end, for every unique type I call typeID with, I will get a new function created by the compiler. Therefore, it will also generate a unique ID struct, thus generating a unique byte with a unique memory address.
typeID(f32) // creates function for f32's
typeID(i32) // creates function for i32's
typeID(u64) // creates function for u64's
This could be used to identify types by using an identifier value. For instance…
const Erased = struct {
ptr: *anyopaque,
id: usize,
};
// .... later ....
var v: i32 = 42;
var e = Erased {
.ptr = &v,
.id = typeID(@TypeOf(v)),
};
We can now capture a generic reference and still have an ID that tells us what type the pointer is pointing to.
Naturally. This is how templates / generics work (in Zig anyway).
Of course. The number of functions generated by the compiler from fn typeID template will be equal to the number of distinct parameter types in calls to typeID.
However, these functions will always return 0, as byte is not modified at all and does not depend on anything, e.g. T.
fn typeID(comptime T: type) usize {
// function that returns unique integer for each type
_ = T;
const ID = struct {
var byte: u8 = 0;
};
return @intFromPtr(&ID.byte);
}
pub fn main() !void {
// the inferred case:
std.debug.print("\nType ID: {}\n", .{ typeID(i32) });
std.debug.print("\nType ID: {}\n", .{ typeID(u32) });
std.debug.print("\nType ID: {}\n", .{ typeID(f32) });
std.debug.assert(typeID(i32) == typeID(i32));
}
We are not returning the byte value. We are returning the address of the byte.
Since each struct has a unique byte, and each function has a unique struct, it always returns a unique value per type. Thus, the address of the byte is dependent on T.
Oh. Of course. This is what I’ve been missing.
But the approach of using a variable address as an identifier is … quirky, for the lack of better word. Also we’re wasting one byte of RAM per type.
I’d say waste is relative to use - if you’re using it, I wouldn’t say it’s a waste. This is a cheap way to generate RTTI - it depends on if you think that’s helpful.
Your initial post was about const and vars for structs. This example is really meant to show that they act similar to variables and are unique per type generated. By in large, they operate like normal data that are accessible via a qualifier.
In particular here, const and var are different. Var has a reliable runtime memory address - const in the case of comptime does not. Hence why @intFromPtr is a runtime function and the variable in that struct is a var.
That’s interesting. The reason I chose the other way is the title of the topic is “Variables and constants in structs” so my example was meant to demonstrate something about structs in particular.
According to @dude_the_builder’s post, that may work because of comptime memoization. I don’t know what the guarantees are though; for this to work in general, I believe stack variables at comptime need guaranteed unique addresses - I’d have to really test this to be sure.
I was just curious whether that one byte can be eliminated. My version only works in debug, so it’s a non-solution. Plus the byte is still there, winding up in the executable’s data segment. A byte of disk space would be wasted too, ha ha.