remove `@Type` from the language, replacing it with individual type-creating builtins
Motivation
As noted in https://github.com/ziglang/zig/issues/10705#issuecomment-1023860174, here are some reasons that @Type is janky:
@Type(.Opaque)should be avoided in favor ofopaque {}.@Type(.Void)should be avoided in favor ofvoid@Type(.Type)should be avoided in favor oftype@Type(.Bool)should be avoided in favor ofbool@Type(.NoReturn)should be avoided in favor ofnoreturn.@Type(.ComptimeFloat)should be avoided in favor ofcomptime_float@Type(.ComptimeInt)should be avoided in favor ofcomptime_int@Type(.Undefined)is equivalent to@TypeOf(undefined)and it is unclear which is preferred, and that is a problem.@Type(.Null)is equivalent to@TypeOf(null)and it is unclear which is preferred, and that is a problem.@Type(.EnumLiteral)is equivalent to@TypeOf(.foo)and I guess the@Typeone is slightly better.@Type( .{ .Optional = .{ .child = T } })should be avoided in favor of?T.@Type( .{ .ErrorUnion = .{ .error_set = E, .payload = T } })should be avoided in favor ofE!T.@Type(.BoundFn)should be avoided@Type(.{ .Vector = .{ .len = len, .child = T }})sucks,@Vector(len, child)is way better. and please don't suggest to reach intostd.metato use basic primitive types of the language.
Also, after living with @Type for a couple years now, I just have a feeling about it and I feel that I don't like it. I liked the old way better.
Proposed Changes
This is a reversal of #2907. This proposal is to do the following things:
- Remove
@Type - Add
@Int - Add
@Float - Add
@Pointer - Add
@Array - Add
@Struct - Add
@Enum - Add
@Union
Related Issues
- #10706
- #9484
- #8643
Also, after living with
@Typefor a couple years now, I just have a feeling about it and I feel that I don't like it. I liked the old way better.
I've really enjoyed the change: the symmetry with @typeInfo is really nice and easy to keep a mental model of.
EDIT 2024: My original comment here was based on not seeing any benefit to this change. I now see a benefit, namely, splitting up the API allows Zig to more accurately represent what can be "reified". With this I'm in favor of the change. Below is my original comment.
This change doesn't really seem like much of a win or loss to me since both mechanisms can be implemented in terms of the other. We could either keep @Type and implement these simpler variations in std.meta, or we could split up @Type into many builtins and implement the current @Type in std.meta if desired. Since I don't see much benefit in either case, I would tend to stick with the status quo. This being said, you're comparing how it feels to use @Type directly rather than a theoretical set of helper function in std.meta, do you think the "feel" would change if such helper functions existed?
P.S. If there really is benefit to splitting up @Type, is there also benefit to splitting up @typeInfo?
I really like this change, as it removes both unnecessary features from @Type, but also removes some boilerplate.
If we are going this way, then there could be a case for not having @Float, @Array as these types can be constructed using comptime logic alone:
/// @Float
switch (bits) {
16 => f16,
32 => f32,
64 => f64,
80 => f80,
128 => f128,
}
/// @Array
if (m_sentinel) |sen| [size:sen]T else [size]T
@Pointer is also possible, but requires a lot of code, which could make creating pointer types at comptime slow
If we are going this way, then there could be a case for not having
@Float,@Arrayas these types can be constructed using comptime logic alone:/// @Float switch (bits) { 16 => f16, 32 => f32, 64 => f64, 80 => f80, 128 => f128, } /// @Array if (m_sentinel) |sen| [size:sen]T else [size]T
@Pointeris also possible, but requires a lot of code, which could make creating pointer types at comptime slow
If we did do this, we could very well make helper functions in std.meta, in the case of pointers, floats and arrays.
I personally really like the completeness of the current interface. The same can be provided by userland code, however that needs to be kept up-to-date with the compiler.
Putting it in std.meta means it should always be in sync, however that puts extra pressure on that interface/design over other userland code.
I don't think granting std "authority" in this way, just because it is likely to be compiler-provided, is desirable (where not strictly necessary).
With the current interface, the simplest @typeInfo -> @Type cycle implementation is always correct (and works to the extent the language doesn't impose limitations).
I think in the new design an unrelated change (f.e. introducing a new type, like promoting c_char to a distinct type) is more likely to lead to a feature gap in previously-correct code.
(EDIT: finished edits)
@rohlem
I personally really like the completeness of the current interface.
I don't think completeness for the sake of completeness is really that great; currently, as outlined in the proposal, all this completeness does is create two ways to declare a lot of types, in ways that add no utility to comptime code.
The same can be provided by userland code, however that needs to be kept up-to-date with the compiler.
That is already the case with status quo; in what way would this differ, except in reducing redundancy?
Putting it in std.meta means it should always be in sync, however that puts extra pressure on that interface/design over other userland code. I don't think granting std "authority" in this way, just because it is likely to be compiler-provided, is desirable (where not strictly necessary).
What do you mean exactly? The capability to do these things isn't being made exclusive to std, just utility functions that would ease particular use-cases, in the same way that we have std.meta.fields; you can still do @typeInfo(T).Struct.fields, but the former is more generic, and looks nicer. Also, the proposal itself hasn't actually suggested adding any of these utility functions, only some responses have.
I think in the new design an unrelated change (f.e. introducing a new type, like promoting c_char to a distinct type) is more likely to lead to a feature gap in previously-correct code.
I'd like to ask, why would it be more likely to lead to a feature gap in previously-correct code, in such a scenario? And in what way would the example of c_char be representative of this?
I like most of this change's effects but I think it's a pretty significant downside that you can no longer do @Type(@typeInfo(T)) anymore. Obviously no one would do this in real code (unless you want to duplicate a type ig?), but I've written code before that calls @typeInfo(T), modifies a few attributes, and then calls @Type on the modified data, and it's nice that the code is agnostic to if T is a struct, enum, or anything else. This could probably be implemented in userspace, but imho it's unnecessary logic to have compared to just keeping @Type, esp. since if we don't have @Type what is the purpose of the union that @typeInfo returns? Again, the union would still be used in userspace, but it just seems natural to me to have a builtin to consume the interface that another builtin produces.
@Type(.Null)is equivalent to@TypeOf(null)and it is unclear which is preferred, and that is a problem.
both should be avoided in favor of proper optionals. @Type(.Null) should be zero sized if not already.
If we did do this, we could very well make helper functions in
std.meta, in the case of pointers, floats and arrays.
given how simple their construction, i think even having std.meta functions is superfluous
@Apppppppple @Type could easily be reimplemented in std.meta after this change
@InKryption (long text with individual replies)
I don't think completeness for the sake of completeness is really that great
Well, okay, that sounds like a difference in preference/perspective. An operation defined as complete covers its entire domain. An incomplete operation has exceptions to what it covers, which need to be considered (-> known/documented). In that sense it's more complex, and less self-explanatory. Of course, if those cases are inapplicable or heterogenous in your use case, an incomplete operation may be what you want sometimes. Generally, with Zig's "comptime is written like runtime" and "types are values" decisions/aesthetics, I'm more inclined to appreciate completeness/uniformity. (Though that property may not be objectively comparable without the context of someone's mental model.)
[...] add[s] no utility to
comptimecode.
The particular use case I was thinking of is similar to what @Apppppppple wrote:
- query
const t = @typeInfo(T); - inspect the value, maybe modify / compose a new value
t2 - create a new type from that type info, currently via
const new_T = @Type(t2);
It's valid to say "no code does this in practice", or bring arguments of why code shouldn't do this in practice, but having to switch on t2 and call different builtins for different kinds of types complicates this usage scenario.
The same can be provided by userland code, however that needs to be kept up-to-date with the compiler.
That is already the case with status quo
The construct @Type(@typeInfo(T)) works in status quo, and this proposal is to remove the outer function.
While I probably won't write this exact construction, I might write a more elaborate version. Any complication to @Type similarly applies to the longer form.
The capability to do these things isn't being made exclusive to std, [...]
My point was that if I need a utility function instead of @Type, that function needs to be updated.
- If I write and use my own package, I need to update it.
- If I use
std, chances are someone else updates it.
Keeping @Type as a compiler built-in gives me the most confidence it won't go out of sync with std.builtin.TypeInfo.
Though of course, this only solves incompatible changes within the implementation of @Type, and not in any other code.
I'd like to ask, why would it be more likely to lead to a feature gap in previously-correct code, in such a scenario? And in what way would the example of c_char be representative of this?
I was originally thinking of the scenario of adding a new kind of type (not that we need one). @Type(@typeInfo(T)) continues working, while the proposed form adds a new builtin that needs to be added to the switch statement.
The previous builtins, like @IntType, separated the struct fields into arguments, so adding or removing a field would change their signature and break old code.
If the proposed builtins instead still take a single struct as an argument, then reification code can still be agnostic to these structural changes. Example:
switch(@typeInfo(T)){
else => unreachable,
.Int => |int_info| {
comptime var new_info = int_info;
new_info.bits *= 2; // agnostic to all other fields,
return @Int(new_info);
}
}
I might have been too pessimistic when referencing c_char: To represent it, we need to modify std.builtin.TypeInfo.Int.
Originally I thought about adding another field, though now it seems obvious to me that extending the Signedness enum with a value neither or none would be much more intuitive, so it wouldn't apply to that case.
EDIT: Now that I've written it all out, the compatibility concerns are in respect to language changes. If the language is by definition designed to never be changed, then these criticisms can be ignored.
In that case my point amounts to not seeing the benefit of removing the builtin over implementing it in userland code.
And I agree with @Apppppppple that having @Type consume what @typeInfo produces seems the most self-explanatory and obvious choice.
But if reducing the total code of builtin function implementations itself is a worthwhile goal, then that sufficiently justifies accepting the proposal.
I think a better solution would be to keep the status quo and just add convenient wrappers for @Type in std.meta. Instead of @Pointer you'd use meta.Pointer, meta.Enum instead of @Enum and so on.
This issue can be solved by a better API which in this case could be provided in the "userspace" of the language (through code accessible to the end user). I don't see why we should complicate the language with more builtins. It doesn't seem like the simplest solution in this case.
@apppppppple
@Typecould easily be reimplemented instd.metaafter this change
There are a few things that can't be reimplemented in the language itself at present: https://gist.github.com/tauoverpi/e8fc90d44659d86a0ac25dc7fb1081bc
But it isn't much.
@tauverpi I think the idea was to use the newly proposed @Int, @Float, @Pointer, etc. builtins to impelement Type in userspace.
P.S. your Pointer implementation is impressive nonetheless :)
@marler8997 Yes, those which use @Type in the implementation would @Int, @Float, and so on where the above is what works now to reimplement @Type in stage2 (as far as I've tested).
However .ErrorSet and .Opaque (as far as I understand them) won't work with the proposal.
@Type(.Fn) wasn't mentioned yet. If you're doing this, could you also consider adding a @Fn builtin?
For simple cases I can imagine a workaround of @TypeOf(struct { fn f() i32 { unreachable; } }.f), but if you tried to recreate all of @Type(.Fn) in userland, the combinatorial explosion of arity, calling convention, noalias, etc would quickly get out of hand.
What about the result of @typeInfo ?
The reciprocity it have with @Type is one of the testimonial of zig's type reflection. Removing one makes the other less "real".
I want to suggest that the intention of this change could be accomplished by limiting the allowable set of Type union fields which can be created with @Type to the selected set. The others could provide useful compile errors explaining how to go about getting the type in question.
That keeps the @typeInfo/@Type duality, which I think is rather nice, it limits profusion of builtins, while also preventing the list of duplicated ways to get a type which motivated this issue.
I'd even offer that it's less conceptual burden for the learner: @typeInfo returns a Type, @Type takes a Type and returns a type. But you're blocked from using it pointlessly. It occurs to me that with this change, it would be possible to build a Type from parts, but then there's nothing which can be done with it. Obviously the right thing there is just to construct a Struct, say, but it feels incomplete that way.
I don't have strong feelings on the subject, it just struck me as the simplest thing which would achieve the stated objective, and I hadn't seen it discussed. It does appear to solve every bullet point in the motivation section, although not the hunch, which I can't speak to one way or the other.
I dont know if my feedback really changes anything, but this would be very good for consistancy!
Regarding enum literals, IMO it would make a lot of sense if enum_literal became a keyword, kinda like comptime_int and comptime_float. I've been using enum literals more frequently in my code, and I think this type deserves a better name.
Now that this has landed and splits would-be fields into separate arguments is there a plan for the same to be applied to say the Type namespace where instead of StructField the returned Struct provides name, type, and attrubutes as separate fields? That would avoid having to repack StructField each time one just wants the names alone and such (be it struct_info.field_names or struct_info.fields.names, or whatever).