-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: User definable type constraints on polymorphic parameters #1669
Comments
I'm a much bigger fan of this form of constraint checking compared to any other option I've seen (e.g. an interface definition). It is a much more general solution that avoids locally maximizing. |
We can take this to the extreme if we want. We really don't need With this, we can define fn isAny(comptime T: type) bool {
return true;
}
fn generic(any: isAny) @typeOf(any) {
return any;
} I don't think we lose much readability. It's always good to remove features when possible, and with a system like this, |
And another thing that would be nice, is if functions without a body was allowed to be declared. This would allow us to have an abstraction like this: const isSomething = interface(struct {
x: u8,
y: u8,
pub fn add(@This(), @This()) @This();
}); Currently, this is not possible: pub fn a() void; // test.zig:1:5: error: non-extern function has no body
test "" {
@compileLog(@typeOf(a)); // test.zig:4:25: error: use of undeclared identifier 'a'
} I really think the error |
@Hejsil i do like explicitness of ..and for example if i would like to use var just like now then i would probably need to declare something like this |
Just want to share my idea of what zig interfaces could look like. Note the angle brackets to distinguish interface implementations from normal types:
Another example here: https://gist.github.com/user00e00/85f106624557b718673d51a8458dbab8 |
With @Hejsil's proposal above, how would we tell if the thing should be called or used as a type? |
@daurnimator It is ambiguous, yep. It would break in this case: fn f(a: fn(comptime T: type) bool) void {
// The compiler converted `a` to `type` because it thought it was a type constraint
_ = a(usize); // error: expected function, found 'type'
} So I think a syntactic change per the original proposal would be necessary. Note that since pub fn write(w: var(isWriter)) !usize {
// ...
} |
I must say I really like this. Earlier, I was thinking about writing a proposal for allowing the aliasing of fn isActionListener(b : type) bool { ...}
/// Single point of reference for documentation
const ActionListener = @VarAlias(isActionListener);
fn doActionIfCondition(al : ActionListener, cond: bool) void { ...}
fn doActionIfOk(al : ActionListener, state: State) void { ...} With your idea, the above becomes /// Single point of reference for documentation
fn isActionListener(b : type) bool { ...}
fn doActionIfCondition(al : var(isActionListener), cond: bool) void { ...}
fn doActionIfOk(al : var(isActionListener), state: State) void { ...} I prefer the latter. It is simpler, and makes it easier to see the difference between a type constraint and a normal type. |
@hryx Can you explain the ambiguity? The type of the expression |
Why not fn ActionListener(b : type) type { ...}
fn doActionIfCondition(al : ActionListener(var), cond: bool) void { ...}
fn doActionIfOk(al : ActionListener(var), state: State) void { ...} This doesn't introduce any new mental overhead of having a special syntax for type constraints, since it's just a generic generalization of the already existing type syntax. I also think this would be better than user defined constraints since with those you would have to write a This is also more versatile than fn Array(len: usize, T : type) type { ...}
fn doSmtnWithArrayOfInt(arr : Array(var, i32)) void { ...}
fn doSmtnWithArrayOfLen5(arr : Array(5, var)) void { ...} Essentially pattern matching, and since Zig doesn't have function overloading we automatically avoid the problem of ambiguity that usually comes with it. I don't think this would be a complicated implementation either. An expression On the parsing side you wouldn't be able to distinguish between a type constraint and a regular function call until after the parsing stage, I don't think. But this doesn't really matter since Zig already doesn't distinguish between types and values and could easily enforce a type constraint to only be used where a type is expected at the semantic analysis stage, just like with types. A downside of taking this approach is that you can't express more complicated constraints than "this is this kind of generic type" in the function declaration, whereas user defined constraints could express anything that can be expressed in comptime code, for example, "this is any struct which has a field 'count' with type usize". But I think the user defined approach is a non-solution to the problem it's trying to solve for these reasons:
I think pattern-matching-like type constraints are better suited for language support because:
|
At first reading I had some criticisms of the above proposal, however while typing them up I realized they weren't really problems at all. To me, this is a sign that the idea is a good one. The more I think about it, the more I think @eriksik2's proposal is better than my original idea. |
I feel like I'm missing something, but is this not just slightly more sugary syntax for the following? fn ActionListener(b : type) type { ...}
fn doActionIfCondition(T: type, al : ActionListener(T), cond: bool) void { ...}
fn doActionIfOk(T: type, al : ActionListener(T), state: State) void { ...} |
@ifreund That requires you know the type of al, which isn't always possible. doActionIfCondition(? what is T ?, .{"1", "2", "3"}, true); This is slightly more sugary (and more readable) syntax for: fn doActionIfCondition(al: var, cond: bool) void {
checkActionListener(@TypeOf(al));
} |
I don't think this is an issue
If If Also, even if it was a type constraint, fn f(a: typeConstraintFn) void {
_ = a(usize); // error: expected function, found 'comptime_int'
}
f(25); // note: called from here |
After reading @eriksik2's comment i think that we have three district usecases for The first is status quo fn eatsEverything(x: anytype) void {...}
// in terms of existing semantics that would be expressed as
fn eatsEverything2(x: @TypeOf(x)) void {...}
// status quo: use of undeclared Second is proposed type constraints (a more generalised version of the first usecase): fn typeConstraint(x: var/anytype(isSomething)) void {...}
// compile error when type constraint is not satisfied
fn isSomething(comptime T: type) bool {...} And the third being pattern matching (an unrelated use case): fn List(comptime len: usize, comptime T: type) type {...}
fn listLenAny_Typei32(x: List(var/anytype, i32)) void {...}
fn listLen5_TypeAny(x: List(5, var/anytype)) void {...} fn eatsEverything(x: @TypeOf(x)) void {...}
fn typeConstraint(x: IsSomething(@TypeOf(x))) void {...}
fn isSomething(comptime T: type) type {
if (std.meta.trait.hasFn("alloc")(T)) {
return T;
} else {
@compileError("type does not have function 'alloc'");
}
}
// another possibility is to define a new builtin instead of generalising @TypeOf()
fn eatsEverything2(x: @Infer()) void {...}
fn typeConstraint2(x: IsSomething(@Infer())) void {...}
// @Infer() works in a scope of type declaration of a parameter of the function
// something like @This() fn List(comptime len: usize, comptime T: type) type {...}
fn listLenAny_Typei32(x: List(@Anything(), i32)) void {...}
fn listLen5_TypeAny(x: List(5, @Anything())) void {...}
// @Anything() is not a real type, but it makes compiler to create a type pattern
// if the type of value matches this pattern then it is accepted otherwise its a compile error
const list7i32 = List(7, i32).init(.{-1, 1000, 8, 2, -345, 4, 5});
const list5u8 = List(5, u8).init(.{0x1, 0x2, 0x3, 0x4, 0x5});
listLenAny_Typei32(list7i32); // ok
listLen5_TypeAny(list5u8); // ok
listLenAny_Typei32(list5u8); // error: expected type List(_, i32) found List(5, u8)
listLen5_TypeAny(list7i32)l // error: expected type List(5, _) found List(7, i32) |
I support the All of this can (and should, IMO) be implemented in userland. I'm doing something similar in my Redis client: Having dedicated functions in, say,
If you look at this composition then you can also see why it might make sense to have type-checking functions to have a void return value, instead of bool: if something is wrong, you expect the function to |
Discussed this issue with @andrewrk and @marler8997 . This proposal has clear ergonomic benefits for writing generic code. However, everything that this proposal introduces is already possible by calling the validation functions in the first few lines of the function. There are a lot of problems with generic code. Generic code is harder to read, reason about, and optimize than code using concrete types. Even if it compiles successfully for one type, you may see errors only later when a user passes a different type. Generic code with type validation code has an even worse problem - the validation code has to match with the implementation when it changes, and there’s no way to validate that. So the position of Zig is that code using concrete types should be the primary focus and use case of the language, and we shouldn’t introduce extra complexity to make generics easier unless it provides new tools to solve these problems. Since everything in this proposal is possible with the current language, we don’t think this is worth the complexity it adds. Especially since this feature is only for generic functions, which should be used sparingly. Because of that, we've decided to reject this proposal, with the aim of keeping the language simple. We know this may be somewhat unexpected, given the popularity of this issue. However, having a simple language means that there will always be places where it would improve ergonomics to have a little bit more language. In order to keep the language small, we will have to reject many proposals which introduce sugar for existing features. |
@SpexGuy I can accept this outcome for my personal use, but the following statement just doesn't match reality and makes me worry about the future of Zig:
The Zig stdlib has tons of generic code for mundane things like array lists, hash maps, equality, etc. These are not all that different than code that users will write, especially for larger projects. The only way I can understand the statement above is if you only expect Zig to be used for small projects and that very few libraries will be created. Generics are also very commonly used in all other statically typed languages I've tried. The one exception I know of, Go, is now adding generics. It is unconvincing to say that concrete types are better than generic types, when people commonly use generic types and they're used commonly in Zig's code as well. |
I'll add 2 cents along with @jumpnbrownweasel. Here are my pain points as a new Zig user, implementing a serialization library for Zig. Readable Discovery pub fn stringify(
value: anytype,
options: StringifyOptions,
out_stream: anytype,
) @TypeOf(out_stream).Error!void {
// ... ... seeming to indicate that
Both The requirements of Tooling Discovery Other languages have solved this with types/interfaces/contracts/traits - and while having to write and work around those contractual definitions does create overhead, having that information available in a centralized place at development time is really, really handy - especially when interfacing with other people's code (or my code over time). On the other hand, if there were a tool that could dynamically run the Zig compiler or otherwise do whole program comptime analysis, and surface that contractual information in a coherent way during development... that could be a whole new ballgame. |
As shown in #1268 (and to an extent #130), several users have requested some form of comptime interfaces/contracts/concepts/typeclasses/insert-terminology-here. While the current status-quo, duck typing, is sufficient to provide the desired functionality, it does not offer many clues as to what the parametric function is looking for, and errors thrown for performing an illegal operation on the passed-in type can occur in the middle of large function bodies, obscuring that source of the problem (the function was passed an inappropriate type).
I propose a change to the
var
keyword allowing it to take a parameter of typefn(type)bool
. At comptime, when a new type is passed in the parametric parameter, the compiler will evaluate the function against the type of the passed parameter and, if the result is false, throw an appropriate compile error identifying the constraint function, the passed parameter type, and the call site of the parametric procedure.std.meta.trait
, recently merged in #1662, provides several simple introspection functions that could be used in this way:But since the constraint functions are completely user-definable, they can be arbitrarily complex.
See this gist for an overly complex example.
The text was updated successfully, but these errors were encountered: