Using tuples as varargs, from a usage perspective, has a couple of minor downsides.
One is verbosity, and the second is that empty tuples are a bit too close to {}.
This leads to code that can seem confusing, like:
std.debug.warn("Hello World!", .{});
try set.add("key", {}); // assuming set is a StringHashMap(void)
What would be the tradeoff behind allowing some sugaring where the last argument of a function, if marked appropriately, can capture all remaining arguments from an invocation?
If this were implemented we would return to a situation where vararg functions are available again, in terms of syntax, but with a different implementation behind them.
Example:
fn normal(arg1: []const u8, args: var) void {}
fn sugared(arg1: []const u8, [args]: var) void {}
// normal can only be invoked with explicit tuple syntax
normal("hello", .{"world"});
// sugared allows implicit tuple-wrapping
sugared("hello", "world", "!");
// without any args
sugared("hello");
The syntax I used above to define sugared is most probably problematic, but there might be better ways.
Proposal to amend the proposal: instead of using new syntax, we should use a new _type_. With anytuple implemented, we could use this:
fn normal(arg1: []const u8, args: anytuple) void {}
My proposal is to let the compiler auto-bind varargs when they match an explicitly specified tuple type (which can optionally include var, though I think it should for consistency).
The principle advantage here is that the language's grammar is unaffected, as this builds over existing facilities instead of crafting new ones.
anytuple seems like it could be ambiguous sometimes:
fn varargs(a: u8, b: anytuple) {}
varargs(25, .{"a"});
varargs(25, .{"a"}, .{"b"});
varargs(25, "a");
Is the first one a tuple or a tuple of tuples?
How about just stating the type as what it basically is?
fn normal(arg1: []const u8, args: var) void {}
fn sugared(arg1: []const u8, args: []var) void {}
Where []var is just a tuple behind the scenes.
I agree with the confusion, disagree with the solution.
std.debug.warn("Hello World!", .{});
try set.add("key", {});
This tells me that:
{} is a bad way of setting void. It looks much closer to an empty list than "nothing at all" (ignoring the academic definitions)std.debug.warn("Hello World!", .{}) this syntax exists because we wrap the stderr with a separate warn function. If we exposed the underlying stream, this would be stderr.writeAll("Hello World!") and we can continue requiring a tuple for format()@fengb We already _can_ do that. The stream _is_ exposed, it's just not convenient to use.
More importantly, that still doesn't help; the idea is to have a _consistent_ API to use, whether or not we're using arguments (which is of debatable merit, but I think is useful).
I think a lighter solution might be useful: no option given to the last arg, when the last arg is of type var, should result in a void value. Then, std.debug.warn should be updated to handle single-arg non-tuples (by translating into tuples) and void types (by tail calling stderr.write).
I agree with @fengb here:
using {} for void isn't really optimal, having a dedicated nothing or empty value would be better here. Don't know a good name, though…
I have a hard time thinking of a better symbol for {}. Rust has () iirc, but that would be something new that would need to be added to the language, as with any other dedicated symbol other than {}. undefined works too, but it's normally used to express a different intent.
On the front of vararg-style syntax, I believe it would be worth to think of it in it's own merit, rather than referring to the previous situation. By that I mean: previously vararg functions were a special case in multiple points of the compiler, which had various downsides. Removing support for them simplified some things and removed one syntax feature from the language, which was replaced by tuples.
Now the situation in the codebase is different and, unless somebody that works on the implementation can provide more information, the question is purely about the syntax.
Yes, removing features from the language is good, but it's also true that vararg syntax is a feature broadly available, and moderately useful in practice, and definitely more useful than a dedicated name for the void value.
My point is that I don't think we should consider the removal of the old implementation a final verdict on the whole idea as the main motivation, as I understand it, was based on the plumbing, rather than the porcelain.
That said, I'm the first one to acknowledge that the feature would not be of fundamental importance, but varargs are basically supported by all general purpose languages so it's some extra ease that doesn't put almost any burden on the user.
On the other hand, if this were to impact compilation speed, I would be the first to recommend to drop it :)
Here's another example that works today but is unclear what should happen with the new proposal:
const args = .{1, 2, 3};
std.debug.warn("{} {} {}\n", args);
Today it is clear what this means because there is always a one-to-one mapping from call arguments to function parameters.
That being said, the rest of this post is discussion on how to handle this case with the proposed feature. It's an attempt to mitigate the complexity that this feature would add to the language as a whole given the corner cases.
With the syntax sugar, it's unclear whether args should be passed as 1 value or expanded into 3 values. Solving this ambiguity while still supporting both cases requires tuple expansion semantics, which can be implicit or explicit.
The D language chose to support these 2 cases by always expanding tuples 1 level deep when passing them as arguments. So in our example, args would be expanded to 3 values. To pass it as 1 value, you would wrap it in another tuple:
const args = .{1, 2, 3};
std.debug.warn("{} {} {}\n", args); // args gets expanded into 3 values
std.debug.warn("{} {} {}\n", .{args}); // .{args} gets expanded into just args
One issue with this is that it can add a burden on generic code to have special case handling for tuples/non-tuples because they are handled differently when passing them to functions. The subtle difference in semantics often results in code that is incorrect or overly complex to handle the corner cases. It also means that any code using tuples that are not passed to varags will likely break because their tuples will now be expanded automatically.
This also doesn't jive much with zig zen because it now requires knowledge of argument types to know how arguments map to function parameters:
foo(a, b, c);
In this example, with implicit tuple expansion, the number of arguments we pass to foo and the position of each argument is dependent on the tuple length of the arguments a, b and c. You can no longer determine that b and c are the 2nd and 3rd arguments without looking at the definition of a and b.
The alternative to implicit tuple expansion is explicit tuple expansion. This results in simpler semantics because tuple and non-tuple arguments are treated the same when passing them to functions. We always wrap the remaining args for a vararg function in a tuple regardless of whether those arguments are tuples themselves. This means we would treat args as 1 value in the above example. However, now there's no way to support passing tuples directly to functions like we can do today, because everything gets wrapped into a tuple, even tuples themselves. So tuple expansion would require some explicit syntax, something like this:
const args = .{1, 2, 3};
std.debug.warn("{} {} {}\n", args); // args treated as 1 value
std.debug.warn("{} {} {}\n", expand args); // args expanded into 3 numbers
I think this variation would jive with zig zen more because "weirdness" of tuple expansion is only triggered when you see the explicit syntax for it. Take this example:
foo(a, b, c);
With explicit tuple expansion, we still know that a, b and c will be passed as the 1st, 2nd and 3rd arguments respectively regardless of their types. It's only when we see the expansion syntax that we know this assumption is broken.
I also think tuple expansion will be the corner case, so I think it makes more sense to require special explicit syntax to enable it instead of applying these semantics by default to all code and requiring explicit syntax to disable it.
I don't have a strong opinion as to whether or not this feature is justified. However, if it is accepted, I think it would justify the need for an explicit tuple expansion syntax and I think we should avoid implicit tuple expansion.
Hi @marler8997, correct me if I'm wrong, but I think you're missing the part about tuple expansion being "requested" by the function being called. This means that you would be able to know what's up by inspecting the function signature.
As an example, an std.debug.warn that works with sugaring would look like that (consider this syntax a placeholder):
fn warn(fmt: []const u8, [args]: var) !void {}
By seeing how args is declared, you know the function will implicitly bundle up all arguments after the first in a tuple, and that it would do so reliably.
@kristoff-it I think we have a misunderstanding. Let me try to clear it up with an example.
fn foo([args]: var) void { }
fn bar([args]: var) void { foo(args); }
bar(1, 2, 3);
In this example, in the bar function, args will be a tuple of 3 values:
.{1, 2, 3}
However, in the foo function, args will be a tuple of a tuple of 3 values:
.{.{1, 2, 3}}
So the question is, with your proposal, how do you change the implementation of bar such that foo also gets a tuple of 3 values .{1, 2, 3} instead of a tuple of a tuple of 3 values .{.{1, 2, 3}}?
Also note that with today's semantics, this is a non-issue because functions do not implicitly wrap arguments in tuples, so you have no need to disable this automatic wrapping of arguments.
Ok, now I'm following, so the question is what to do when you have a tuple on hand and you want to splat it. Thanks for going over it again for me.
With what we already have, @call would be able to effectively achieve splatting:
fn bar([args]: var) void { @call(.{}, foo, args); }
So extra syntax is not strictly necessary.
@kristoff-it ok yes that indeed solves the issue, assuming that @call does not use the proposed vararg feature.
@marler8997 call would work fine if it supported varargs
fn normal(arg1: []const u8, args: anytype) void {}
fn sugared(arg1: []const u8, ...args: anytype) void {}
normal("{} {}", .{12, 24});
sugared("{} {}", 12, 24); // equivalent to normal("{} {}", .{12, 24});
sugared("{} {}", .{12, 24}); // ERROR! equivalent to normal("{} {}", .{.{12, 24}});
@call(.{}, sugared, .{"{} {}", 12, 24}); // equivalent to normal("{} {}", .{12, 24});
@call(.{}, sugared, .{"{} {}", .{12, 24}}); // ERROR! equivalent to normal("{} {}", .{.{12, 24}});
@call(.{}, sugared, .{"{} {}"} ++ .{12, 24}); // equivalent to normal("{} {}", .{12, 24});
@pfgithub When I said "assuming that @call does not use the proposed vararg feature", I was referring to @call itself using varargs rather than supporting varags within the tuple passed to @call, i.e.
@call(.{}, normal, "{} {}", .{12 24});
@call(.{}, sugared, "{} {}", 12, 24);
I find that the current with explicit .{} is much better in conveying intent at call sites and in the type signature than having varargs even with the unfortunate clash with {} along with easier to pick up on which functions use it and which don't. Having varargs of any kind has always been a "now how does this work" process where just looking at the call site isn't enough to see how it should be called and what the parameters could be.
Having anytuple could be nice if only to make it more explicit than anytype with a compile error rather than expanding to varargs since it's used so much.