Defaults + varargs + keyword arguments

The Wings programming language has this feature called struct expansion where the last parameter of a function is implicitly allowed to use inline field init syntax, e.g.

foo(1, 2, .a = 3, .b = 4);
is syntax sugar for
foo(1, 2, .{ .a = 3, .b = 4 });.

This got me thinking that something like this can be used to implement default arguments, varargs, keyword arguments, and parameter splatting in one fell swoop.

It can take 3 forms:

  1. varargs
// varargs must be last parameter
fn foo(i: i32, inline args: []string) {}

foo(1, "a", "b");
foo(1, inline []string{ "a", "b" });
@call(.auto, foo, .{ 1, []string{ "a", "b" } });
  1. keyword args (with optional default values)
const Vec2 = struct {
	x: i32,
	y: i32 = 0,
};

// keywords must be either first or last parameter
fn foo(i: i32, inline pt: Vec2) {}

foo(1, .x = 2);
foo(1, inline Vec2{ .x = 2 });
@call(.auto, foo, .{ 1, Vec2{ .x = 2 } });
  1. keywords + varargs
// keywords must be first parameter
// varargs must be last parameter
fn foo(inline pt: Vec2, i: i32, inline args: []string) {}

foo(.x = 2, 1, "a", "b");
foo(inline Vec2{ .x = 2 }, 1, inline []string{ "a", "b" });
@call(.auto, foo, .{ Vec2{ .x = 2 }, 1, []string{ "a", "b" } });

To be clear, default arguments are already possible with struct type arguments (quite common for configs).

pub fn foo(config: struct {
    x: usize, // no default... needs to be specified
    y: usize = 42, // default, can be omitted
}) { 
 // implementation ...
}

// later...

foo(.{ .x = 5 }); // y will equal 42

I personally like this better than “naked default” because I know structs at call sites can be hiding more than meets the eye.

I’m personally against naked-default arguments. Usually, what ends up happening is that people need to change a function but they don’t want to change all the call sites. So, they add a default. This really gets ugly in big projects.

Most use cases for variadics are also handled by tuples as well. I’m not trying to shootdown all of your ideas here, but I like the choices that have been made so far by Zig and I don’t personally see value in these other options.

Maybe others disagree with me and with good evidence, I’ll be happy to change my opinion. @DerpMcDerp, what does this afford beyond another way of doing the same thing? I’m not sure I follow why we’d want this.

6 Likes
  1. Users can define functions that act like the @compileLog(...) and @TypeOf(...) functions.

  2. Old code can be (source-API compatible) silently upgraded to use keywords and varargs under this proposal as opposed to the workarounds under current Zig.

  3. This proposal opens up other possibilities, e.g. allowing fixed sized tuples anywhere in the function so you can (source-API compatible) silently upgrade old code from:

fn foo(a: i32, b: i32, c: i32, d: i32);

foo(1, 2, 3, 4);

to:

const Bar = struct { i32, i32 };

fn foo(a: i32, inline bar: Bar, d: i32);

foo(1, 2, 3, 4); // source API-compatible upgrade

I agree that these are outlier functions, but I’d like to see the exact opposite happen where those actually get brought back inline with the rest of the language.

I strongly oppose this idea. I’ve worked in code bases like this for the last 5 years where people have done this and it’s created an unbelievable amount of headache and has cost a small fortune in bugs.

This opens the door to ambiguous variadic packs. It’s a nasty problem in C++ and I don’t think it’s worth it. If I have foo(args_a, args_b) and I pass the following 3 arguments to it like so: foo(1,2,3), does 2 belong to args_a or args_b?

Same problem exists here: foo(args_a, x, args_b). If I pass foo(1,2,3,4), should 1 and 2 go to args_a and then 3 to x and 4 to args_b? Or should 1 go to args_a, 2 to x, and 3, 4 to args_b?

If variadics can be empty, it’s even worse. 1, 2, 3 could go to args_a, and 4 to x, leaving args_b empty (and vice versa).

Ultimately, you’ll have to create a new rule set surrounding variadic packs which adds more complexity.

At this point, I’ll let other people chime in and I’ll bow out - I simply disagree with what’s being proposed here, but I’ll let other people have their say because I’ve outlined my position.

3 Likes

Well, half of them anyway. The size of a tuple is part of its type, so using them for variadics is a comptime-only affordance, and you get a new monomorphization of the function for each call. Sometimes that’s fine.

The other half of the use cases are handled by slices. So we’re really talking about syntax sugar for things which don’t need it.

The only use case here which makes sense to me is in extern functions, to offer a C compatible way to do varargs. This isn’t a use-case I’ve encountered and I have no idea if Zig handles it, or if so, how. If it doesn’t, I can imagine wanting a way to.

But within the Zig call convention I don’t see the point.

Zig could have variable arguments if it implemented syntactic sugar for collecting arguments into tuples.


Currently std.debug.print is declared as:

pub fn print(comptime fmt: []const u8, args: anytype) void

If we mark the last parameter of the declaration for parameter collection:

pub fn print(comptime fmt: []const u8, args: ...anytype) void

we can have the following calls translated to calls with tuples:

print("Hello World\n");     =>  print("Hello World\n", .{});
print("{d} units", units);  =>  print("{d} units", .{units});
print("({d},{d})\n", x, y); =>  print("({d},{d})\n", .{x, y});

Actually we are asking the compiler to collect the parameters to a tuple.


Keywords with defaults are currently covered very well with a single option parameter.

I’ll point out a problematic case here. Say I have a tuple, we’ll call it t.

t = .{ ... };

print(..., t);

Does t get deduced as an argument in another tuple? As in, args[0] is t? Or do we unpack t so that it becomes the arguments to print? Then, how does that play with @call which takes a tuple of arguments?

It gets pretty murky here. Meanwhile, we already have a very clear syntax - it takes a tuple which gets split into the individual arguments.

1 Like

To be fair to the idea, languages with varargs usually have a syntax for unpacking tuples, so we could have both:

print("fmt...", t);
print("fmt...", t...);

Though as I said, I don’t see a need for this, or want it.

Yes, you are totally correct - more rules could be introduced. That’s exactly my point. It’s a viral change and you can always add more syntax.

(I said I’d stop adding to this, but here I am… lol)

Changes like this usually come in a very well intentioned (and sometimes very clever) package. Behind that clean presentation is often a myriad of changes that effect things quite profoundly and that’s what I’m trying to point out here.

1 Like

In the lang ref section on Sentinel Terminated Pointers, there’s this example:

const std = @import("std");

// This is also available as `std.c.printf`.
pub extern "c" fn printf(format: [*:0]const u8, ...) c_int;

pub fn main() anyerror!void {
    _ = printf("Hello, world!\n"); // OK

    const msg = "Hello, world!\n";
    const non_null_terminated_msg: [msg.len]u8 = msg.*;
    _ = printf(&non_null_terminated_msg);
}

So the ... are the syntax for that.

2 Likes