Skip to content
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/discussion: Decide on a preference for generic functions #522

Closed
snocl opened this issue Oct 3, 2017 · 15 comments
Closed

Proposal/discussion: Decide on a preference for generic functions #522

snocl opened this issue Oct 3, 2017 · 15 comments
Milestone

Comments

@snocl
Copy link
Contributor

snocl commented Oct 3, 2017

Functions taking generic arguments can be implicitly or duckly typed, taking var and maybe making use of @typeOf:

math.max(a: var, b: var) -> @typeOf(a + b)
mem.writeInt(buf: []u8, value: var, big_endian: bool)

Or they can be explicitly typed, taking the type as an argument:

mem.max(comptime T: type, slice: []const T) -> T
endian.swap(comptime T: type, x: T) -> T

That there are these two different ways to do this seems to go against the Zen of Zig, in particular “Only one obvious way to do things”.

Proposal 1: Ban/disfavor var

For the callee, a var is essentially just a type and a value bundled together, so changing a function to take each separately is usually trivial (there might be an issue with passing integer literals, but I don't believe it would be a big problem to reify them to a concrete integer type instead).

For the caller, things are different: we introduce an entire new argument! This is a loss of ergonomics, but maybe a win in clarity?

Some pain points would be functions generic over multiple types that usually, but not always, are the same, e.g.:

math.max(comptime T: type, a: T, comptime U: type, b: U) -> ???

I’m not sure this is sufficiently better than using

math.max(comptime T: type, a: T, b: T) -> T

and having the arguments be cast to a parent/wrapper type, though.

Functions taking ... arguments would also need to pass the type on to its called functions, but I don't think this would be much of a problem in practice.

Proposal 2: Embrace var

Use var everywhere. This would mean rewriting the above functions to:

mem.max(slice: var) -> @typeOf(slice).Child
endian.swap(x: var) -> @typeOf(x)

This will certainly make the functions more ergonomic to call, but there are two major problems:

  • Using var essentially bypasses the explicit type system. In particular, it's now impossible to declare mem.max's slice to be a slice; you'll have to rely on duckly typing or casts/assertions in the function body itself.

  • The error messages will generally be less helpful, since it's impossible to type-check arguments up front.

As a very minor point, this is equally unergonomic as Proposal 1 when passing integer literals to functions:

endian.swap(i32, 1)
endian.swap(i32(1))

In addition, some functions will still require an explicit type to be passed:

mem.readInt(bytes: []const u8, comptime T: type, big_endian: bool) -> T
ArrayList(comptime T: type) -> type

Proposal 3: Compromise, somehow

Embrace that some kinds of functions should take explicit types, while others should take implicit types, essentially separating function-like functions from macro-like functions. I can't think of a good metric for what functions would fit in which categories.


I’m partial towards Proposal 1, but am aware it might be the most extreme of the three.

I think it’s important to decide on a policy; not necessarily one of these three. If I’m misunderstanding something, or I seem confusing/confused, please let me know!

I’m very curious to see how the language will evolve. :)

@thejoshwolfe
Copy link
Contributor

I like proposal 1, although it's a tough sell.

One big issue here is ... functions, which I think were the main motivation for var parameters. I'd kinda like to get rid of ... functions. They are the source of a lot of special magic in the type system that I don't like. I don't think we can get rid of them though.

Just to emphasize that there's two ways to do it, here's an excerpt from the math module:

https://github.com/zig-lang/zig/blob/f86684f4100c1dcc886357b79108df1e8f9e3f2e/std/math/index.zig#L211-L219

I agree that this is a violation of the zen of zig.

The issue where the type system doesn't tell you that a var parameter is supposed to be a slice or slice-const is a real issue. I discuss this a bit in #470. In general, there's value in restricting the "shape" of the type in the parameter declaration so that implicit type coercion can happen at the call site.

@Ilariel
Copy link

Ilariel commented Oct 3, 2017

What about being explicit and using @typeof as an aid?

We should always pass some types as parameters if we use generics, but we should be able to compute types by using comptime functions returning types. I think this should be allowed if it already isn't possible.

pub fn MaxIntegerType(comptime T:type, comptime U:type) -> type {
//check if T and U are integers, otherwise compile error. Do we have a comptime assert?
//return whichever is bigger
}
pub fn MaxFloatType(comptime T:type, comptime U:type) -> type 

math.max(comptime T: type, a: T, comptime U: type, b: U) -> MaxIntegerType(T,U)
math.maxf(comptime T: type, a: T, comptime U: type, b: U) -> MaxFloatType(T,U)

Now we would be able to always be explicit in some way. The use of @typeof() could then be mostly limited to querying types which we can't name or would be tedious to name.

pub fn SomeType(comptime T: type, comptime U:type) -> type {
    struct {
        a: T
        b: U
    }
}

pub fn useSomeType(comptime T: type, comptime U:type, thing : SomeType(T,U)) -> @typeof(SomeType(T,U).a) {
    return thing.a; 
}

The example might be a bit bad given we can easily determinate that a is T, but there are situations where you would just want to get the type of a field, but it might be dependant on many comptime type arguments which also might be computed.

@andrewrk andrewrk added this to the 0.2.0 milestone Oct 3, 2017
@andrewrk
Copy link
Member

andrewrk commented Oct 3, 2017

I agree math.max is better as fn max(comptime T: type, a: T, b: T) -> T and we can change it.

I think status quo is (3) compromise. 2 examples where var seems right:

  • math functions like sin, cos, floor, @mod, @rem, @divTrunc, @divFloor, @divExact.
  • std.fmt.format:
/// Renders fmt string with args, calling output with slices of bytes.
/// Return false from output function and output will not be called again.
/// Returns false if output ever returned false, true otherwise.
pub fn format(context: var, output: fn(@typeOf(context), []const u8)->bool,
    comptime fmt: []const u8, args: ...) -> bool

Here, var actually achieves something objectively valuable: instead of a void * context like you would do in C, this is a type safe any-type-you-want context that passes the same thing back in the callback function.

Edit: I realized you can still accomplish the same thing by taking the type explicitly as the first argument. My mistake.

@Ilariel
Copy link

Ilariel commented Oct 3, 2017

So basically one could say that var has the type of "any" and generic parameters have an explicit type which has to be specified. Therefore they can be used to achieve similar thing that is semantically different.

So basically we should just make it clear how they are different and always use comptime T: type where the semantics of ´var´ aren't required.

Perhaps it shouldn't be just var but instead comptime T: value or something?

@snocl
Copy link
Contributor Author

snocl commented Oct 3, 2017

AFAICT, (x: var) is always semantically equivalent to (comptime T: type, x: T), meaning the only reason to favor var in a function declaration is that it lets the caller pass one fewer argument.

I think the varargs syntax could work like it does today. That is, with args: ..., you’d have args.len: usize and args[i]: Ti, and would access the type with @typeOf. (Alternatively, args[i].T and args[i].value could be used.)

I agree with @thejoshwolfe that there’s a lot of unfortunate magic attached to ... function, though. :) The only way I can think of to try and tame this magic would be to introduce some sort of “heterogenous slice” type, somewhat analogous to va_list, but with compile time type checking.

@andrewrk
Copy link
Member

andrewrk commented Oct 3, 2017

It seems to me that if you're going to pass comptime T: type, x: T and that is the only place T is used, unless there's a reason to be extra explicit, we already pass T in the form of @typeOf(x). Values have types and the type of a value is the canonical place for the type of a value.

I think I can come up with a flow chart of when to use var or not, which, if it is a reasonably clear flow chart, solves the one-obvious-way issue:

  • Use comptime T: type, unless...
    • If your comptime T: type parameter is only used to capture @typeOf() another argument, use var instead, unless...
      • If the return type is computed, use comptime T: type for the types that the return type depends on. (This is why max should have the type explicitly passed in)
      • If the type carries meaning beyond just the type of a parameter; for example in parseInt(comptime T: type, buf: []const u8, radix: u8) -> %T, the type T is actually specifying the upper bound of the integer and decides what input gets considered error.Overflow or not. Then use the explicit form.

As for magic, let's list the magic attached to ...:

  • Types not allowed to be passed by value are cast to &const T. (Also true for var arguments)

That's all I can think of. Anything else?

@snocl
Copy link
Contributor Author

snocl commented Oct 3, 2017

@andrewrk does something trivial like f(slice: var) -> @typeOf(slice).Child count as a “computed” return type?

I don’t know anything about the internal magic of ..., just the magic present at the use site (it isn’t usable in variables, it must be the last argument, each element has a different type).

@andrewrk
Copy link
Member

andrewrk commented Oct 4, 2017

does something trivial like f(slice: var) -> @typeOf(slice).Child count as a “computed” return type?

I'd say yes.

@kyle-github
Copy link

Backing up a step here.

Where is "..." used? Just printf-like things? Are there other more "Zig-like" ways to do the same thing keeping "be explicit" in mind?

It seems like "..." is itself a bit of departure from one of the key Zig points: be explicit. Taking the case of the format function, what if I pass a mutex as one of the arguments? Shouldn't that be a compile time error? You should not be able to copy a mutex. A mutex reference, sure.

@kyle-github
Copy link

I strongly prefer to have the type explicit. While this makes for a bit of boilerplate, it serves a dual purpose: 1) it tells the compiler exact what is required and so it can check the inputs, 2) it forces the programmer to think about it and make sure that it makes sense. No hidden magic.

If the whole purpose of var is to support the things that @andrewrk mentioned, but ... in particular, are there better ways of doing those things?

@thejoshwolfe
Copy link
Contributor

thejoshwolfe commented Oct 13, 2017

As for magic, let's list the magic attached to ...:

  • ... takes the place of the "type" of a parameter, but it's not allowed anywhere else where you would expect a type (as far as i know).
  • what is @typeOf(args) where args is a ... parameter?
  • when calling a function that has ..., passing in parameters of type ... results in expanding/forwarding/concatenating the args, not creating a single arg as the syntax of the call would suggest. https://github.com/zig-lang/zig/blob/7f9dc4ebc10eb13e73466224f28ba62747693df9/std/fmt/index.zig#L432-L436
  • this expansion/forwarding/concatenation has a bunch of special rules, like you can only do it from ... to ..., you can prepend extra args before it (or can you? i don't know.), you cannot append extra args after it (even though you could, because it's all comptime anyway.) (or can you? i don't know.), and you cannot expand the args multiple times in the same function call (or can you? i don't know.).
  • at comptime, can i build a local array of type ... for passing into a ... function? maybe i want to map/filter/sort the ... args i'm given, and then pass them along after modification. is this possible?

@kyle-github
Copy link

kyle-github commented Oct 13, 2017

@thejoshwolfe, your filter/sort/map example is interesting. I had not thought of that. That is something that is covered in the tuple idea (or []var slice option of it) in #533.

Can you assign to a ... value or part of it? i.e.

args[4] = 42;

If so, how is type checking done?

@kyle-github
Copy link

This discussion seems to be more about ... than it is about generic functions. Should this be split into a separate issue?

@andrewrk andrewrk modified the milestones: 0.2.0, 0.3.0 Oct 19, 2017
@andrewrk andrewrk modified the milestones: 0.3.0, 0.4.0 Feb 28, 2018
@ghost
Copy link

ghost commented Jul 10, 2018

just ran into the same issue #1205 (comment)

not sure what is better though, I think the vom-time thing is a bit too verbose but the var thing (without interfaces hehe) feels a bit generic as well.

@ghost
Copy link

ghost commented Jul 21, 2018

Another idiosyncrasy with var - you can't replicate the special situation where a function takes *const T but the caller syntax can use a value without &. If the caller omits the &, it will be passed by value.

@andrewrk andrewrk modified the milestones: 0.4.0, 0.5.0 Feb 7, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants