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: function parameters with default values #484

Closed
marler8997 opened this issue Sep 15, 2017 · 27 comments
Closed

proposal: function parameters with default values #484

marler8997 opened this issue Sep 15, 2017 · 27 comments
Labels
breaking Implementing this issue could cause existing code to no longer compile or have different behavior. enhancement Solving this issue will likely involve adding new logic or components to the codebase. proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@marler8997
Copy link
Contributor

No description provided.

@andrewrk andrewrk changed the title Discuss Pros/Cons of function parameters with default values proposal: function parameters with default values Sep 15, 2017
@andrewrk andrewrk added breaking Implementing this issue could cause existing code to no longer compile or have different behavior. enhancement Solving this issue will likely involve adding new logic or components to the codebase. labels Sep 15, 2017
@andrewrk andrewrk added this to the 0.2.0 milestone Sep 15, 2017
@PavelVozenilek
Copy link

Combination of default parameters with function pointers can get confusing. Can function pointer have default parameter value? Can it be assigned with a function of different signature?

@marler8997
Copy link
Contributor Author

I would say yes, function pointers can have default values. And you can create a function pointer from a function that has default values, and you can override the default values as well. I don't know how to do function pointers in zig, but here's some code that shows how I imagine it would work:

fn foo_no_default(a: i32, b: i32) { ... }
fn foo_with_one_default(a : i32, b : i32 = 0) { ... }
fn foo_with_two_defaults(a : i32 = 0, b : i32 = 0) { ... }
  • type of &foo_no_default is &fn(a:i32, b:i32)
  • type of &foo_with_one_default is &fn(a:i32, b:i32=0)
  • type of &foo_with_two_deafults is &fn(a:i32=0, b:i32=0)

Note that default values do not change the ABI of the function, so all of these function are ABI compatible meaning that pointers to any them are "type compatible".

Actually with this feature, you could implement a wrapper for a function by simply declaring a function pointer with default values(s)

// a funciton you want to wrap with default value(s)
fn foo(a : i32);
// the wrapper
&fn(a:i32=0) foo_with_default = &foo;

foo(0); // OK
foo(); // ERROR
foo_with_default(0); // OK
foo_with_default(); // ALSO OK

@raulgrell
Copy link
Contributor

raulgrell commented Sep 15, 2017

Default parameters sounds a bit too implicit, but your wrapper idea sounds like creating a partial function which can be made explicit enough. I was playing around with this syntax, but none of it seems particularly clear:

fn add(a: u8, b: u8) -> u8 { a + b }

const add0ne = fn(n: u8) -> add | x, y | { .x = n, .y = 1 } ;
// or as if initializing a function object
const add0ne = | n | add | x, y | { .x = n, .y = 1 };
// or kinda like an anonymous function
const addOne = fn | n | add(n, 1);

Would the compiler generate a new IR block/function? Or simply "hardcode" the default parameters at the call site?

@andrewrk
Copy link
Member

I think not having optional parameters is pretty reasonable. It makes all these considerations go away, keeps the language smaller, and it's pretty easy to do something like:

fn foo_no_default(a: i32, b: i32) { ... }
fn foo_with_one_default(a : i32) { return foo_no_defaults(a, 0); }
fn foo_with_two_defaults() { return foo_no_default(0, 0); }

I do this in C/C++ code and I don't miss optional parameters at all. I find this much easier to read and reason about.

@raulgrell
Copy link
Contributor

raulgrell commented Sep 15, 2017

Seems reasonable. The only advantage to something like the proposed above is being able to define the partial inside another function, but then we're getting to the realm of #229.

Updated above with one more syntax idea:

// kinda like an anonymous function
const addOne = fn | n | add(n, 1);

Another potential advantage of this approach is clean parameter type inferrence.

@marler8997
Copy link
Contributor Author

marler8997 commented Sep 15, 2017

@raulgrell I'm not familair with the syntax you are using in your example, I would write your example like this:

fn add(a: u8, b: u8) -> u8 { a + b }
const addOne : &fn(a: u8, b: u8 = 1) = &add;

However I don't think this is a good use case, because you could call addOne(x, 10) and that would just add 10, which definitely seems wrong.

Personal Anecdote

Before I started using D years ago I did alot of C#. In C# the common pattern to emulate default values was to create wrapper functions that forward the call to the real function like in andrew's example. I was never happy with this because it took alot of typing and made development a pain. Whenever I added/removed parameters to the real function, changed parameter names, or changed types, I had to modify all the wrapper functions as well. Couple this with having to document each function and you end up in this situation where you have to repeat yourself every time you want a different default value.

I remember when I started using default parameter values in D all these problems magically disappeared. I didn't need to create wrapper functions, didn't need to keep track of whether they were in sync, had less documentation to maintain and all the relevant information for the function was in one place instead of spread throughout the code. It was very clear that default parameter values were superior to wrapper functions. And guess what they added to C# in version 4...default values for function parameters.

This is just my personal experience. I think default arguments make programming a little less tedious which allows you to spend more time on the fun parts. I also appreciate that zig is a limited-scope langauge. I think it should stay that way so even though I think default arguments are a clear win, they may not be right for zig. Since zig doesn't support overloading it's usage would be different than with a language like D so I can't say for sure what it would feel like with/without them.

@raulgrell
Copy link
Contributor

raulgrell commented Sep 15, 2017

@marler8997: My first option was awful. Looking at your adjustment, perhaps a better way to write what I proposed would be:

fn add(a: u8, b: u8) -> u8 { a + b }

// Instead of
const addOne = fn(n: u8) -> add | x, y | { .x = n, .y = 1 } ;
// do this
const addOne:  fn(n: u8) -> u8 = add | x, y | {.x = n, .y = 1};
// or this
const addOne:  fn(n: u8) -> u8 = add(n, 1);

// Which corresponds more with my last suggestion:
const addOne = fn | n | add(n, 1);

The reason I propose using the fn | n | syntax is that it doesn't look so much like an actual function call and assignment. The | name | syntax appears when the compiler is declaring identifiers for you, like in for loops or error/null unwrapping in ifs.

I too am not a fan of so much function forwarding, but at least the semantics are plenty clear, and optimizations might be more straightforward since we're using more primitive constructs.

@tiehuis tiehuis added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Sep 15, 2017
@kyle-github
Copy link

Couldn't you use comptime to do this as in @raulgrell's last example:

const addOne : fn(n: u8) -> u8 = comptime add(n, 1);

?? This allows for a sort of poor-man's currying. Since it would all be at compile time, it should not add overhead.

@andrewrk
Copy link
Member

comptime evaluates the expression following the keyword at compile time. At global scope, everything is implicitly comptime. So your example is equivalent to:

fn add(a: u8, b: u8) -> u8 { a + b }
const addOne : fn(n: u8) -> u8 = add(n, 1);

This doesn't make sense semantically:

/home/andy/dev/zig/build/test.zig:2:38: error: use of undeclared identifier 'n'
const addOne : fn(n: u8) -> u8 = add(n, 1);
                                     ^

@kyle-github
Copy link

D'oh, copied the earlier example and did not edit it :-(

Here is what I meant (not sure if this compiles):

fn add(a: u8, b: u8) -> u8 { a + b } const addOne : fn(n: u8) -> u8 = comptime fn(n: u8)-> u8 { add(n, 1); }

Now I do not think this will compile because n is not known at compile time. Sigh, it sounded good in my head...

@raulgrell
Copy link
Contributor

raulgrell commented Sep 17, 2017

At this point we're essentially just doing fn addOne(n: u8) -> u8 { return add(n, 1); }, which is exactly what @andrewrk suggested clear and easy to reason about.

I expect that if you do fn addOne(comptime n: u8) -> u8 { add(n, 1) } you increase the chances of some nice optimizations happening, like some compile time evaluation if add is inlined. You could also enforce the inlining by using @inlineCall and maybe get some good results.

Semantically, what we are doing is saying - Give me the function that remains once you've provided some parameters to another function. In this case, addOne is a pointer to a function that takes a u8 parameter and returns a u8. It is assigned to the pointer of a function which takes that parameter and is equivalent to calling the add function with the second parameter equal to 1.

This kinda tricks you into thinking it's assigning to the value resulting from that operation and is therefore pretty unsuitable:

const addOne:  fn(n: u8) -> u8 = add(n, 1);

This does somewhat suggest you're taking the add function and doing something to it. There are also no parens in the right side, so there is no actual suggestion of calling it right there, which is nice. What I don't like is the declaration of the n identifier, which is inconsistent with the rest of the language:

const addOne:  fn(n: u8) -> u8 = add | x, y | {.x = n, .y = 1};

This one makes it clear that you're returning a function, and that it's related to add. It might be too implicit though, especially regarding the type of n. Unfamiliar syntax, but it's how I imagine anonymous functions/closures - we'd keep these consistent.

const addOne = fn | n | add(n, 1);

// Which would probably something like what kyle said
const addOne : fn(a: u8) -> u8 = fn(n: u8) { return add(n, 1); };

At this point, the only disadvantage with the wrapper functions is namespace pollution, but there is talk of allowing scoped functions which would help. I guess I'm just saying anonymous functions are a cool complement to function wrappers.

@kyle-github
Copy link

kyle-github commented Sep 17, 2017

Really what we are aiming for is currying... That is why I was trying to figure out how some interaction with comptime might work. I admit, I kind of like the syntax:

const addOne = fn | n | add(n, 1);

that @raulgrell has above, but that might be my Smalltalk showing :-)

It is possible that the environment capture requirement is going to make this too hard for now.

@marler8997
Copy link
Contributor Author

I feel this topic has gone a bit off the rails. It seems most of the discussion is about edge cases that fail to demonstrate the common reasons why default function arguments make life much easier. Consider the following:

Using Function Forwarding:

fn foo(a: i32, b : i32 = 0, c : []u8, d : i32) -> i32
{
   ...
}
fn foo_with_default1(a: i32, b: i32, c: []u8) -> i32 { return foo(a, b, c, 10); }
fn foo_with_default2(a: i32, b: i32) -> i32 { return foo_with_default1(a, b, "hello"); }
fn foo_with_default3(a: i32) -> i32 { return foo_with_default2(a, 0); }

Using Default Arguments:

fn foo(a : i32, b : i32 = 0, c : []u8 = "hello", d : i32 = 10) -> i32
{
    ...
}

The first thing I'd point out is that the default argument version is quicker to decipher. It's more "information dense" because it's only provides a subset of what function forwarding does. With default arguments you don't have to worry about what the author was trying to do, you know immediately that they're just trying to provide default values for some of the arguments.

Now take a look at that the "function forwarding" example again but take away the function bodies.

fn foo(a: i32, b : i32 = 0, c : []u8, d : i32) -> i32;
fn foo_with_default1(a: i32, b: i32, c: []u8) -> i32;
fn foo_with_default2(a: i32, b: i32) -> i32;
fn foo_with_default3(a: i32) -> i32;

The first thing you should notice is that you can no longer see the default values! Now if you want your documentation to indicate what the default value is you either have to put it in your comments (and make sure it stays in sync with the code) or you have implement a special case in your doc generator to handle forward functions. Also since you can't see the function bodies, you have no idea everything but foo is just a forward function. You'll have to rely on documentation/conventions and trust that the author uses them correctly to discover this.

Furthermore, another issue comes up because zig does not support overloading, namely, you have to come up with a name for the forwarding function that doesn't give you any useful information. Instead of one function named foo, you have to come up with something like foo_without_string or foo_with_one_default or foo_1arg... Maybe a convention could be established that establishes consistency but now we're just solving problems that would have already been solved using default arguments.

The next frustrating thing about function forwarding is "refactorability". Take a look at the example again now with added documentation.

Using Default Arguments:

// description: this function does something really cool, it takes
//                    a bunch of values and does stuff with them
// parameter a: the first integer
// parameter b: the second integer
// parameter c: a string
// parameter d: another integer
// return: a really cool integer
fn foo(a : i32, b : i32 = 0, c : []u8 = "hello", d : i32 = 10)
{
    ...
}

Using Function Forwarding:

// description: this function does something really cool, it takes
//                    a bunch of values and does stuff with them
// parameter a: the first integer
// parameter b: the second integer
// parameter c: a string
// parameter d: another integer
// return: a really cool integer
fn foo(a: i32, b : i32 = 0, c : []u8, d : i32)
{
   ...
}
// description: this function does something really cool, it takes
//                    a bunch of values and does stuff with them
// parameter a: the first integer
// parameter b: the second integer
// parameter c: a string
// return: a really cool integer
fn foo_with_default1(a: i32, b: i32, c: []u8) { return foo(a, b, c, 10); }

// description: this function does something really cool, it takes
//                    a bunch of values and does stuff with them
// parameter a: the first integer
// parameter b: the second integer
// return: a really cool integer
fn foo_with_default2(a: i32, b: i32) { return foo_with_default1(a, b, "hello"); }

// description: this function does something really cool, it takes
//                    a bunch of values and does stuff with them
// parameter a: the first integer
// return: a really cool integer
fn foo_with_default3(a: i32) { return foo_with_default2(a, 0); }

Now think about how difficult it's going to be when you want to change something about the original foo function. Anything you change is going to create N times as much work where N is the number of default arguments you want to support for a function. Every time your add/remove a parameter you're gonna curse the day you learned about function forwarding :)

@hasenj
Copy link

hasenj commented Sep 19, 2017

If I may interject ..

It seems most of the discussion is about edge cases that fail to demonstrate the common reasons why default function arguments make life much easier.

I think the edge cases matter more than the common use case. If a feature creates too many question marks for edge cases it probably means the cost is higher than the benefits.

I think a lot of languages end up bloated precisely because they try to make certain parts of coding easier, and in the process creating complications else where.

Anything you change is going to create N times as much work where N is the number of default arguments you want to support for a function.

If your N is large maybe it makes more sense to pack these arguments into a struct and have the struct itself have default arguments (which AFAIK was accepted), or if the language does not support default struct params you can just create a function to initialize the struct with default params. So instead of passing separate N arguments, you just initialize a struct and pass it.

@andrewrk
Copy link
Member

(which AFAIK was accepted)

that proposal is still pending, but you can get something very close, pretty easily by using struct initialization syntax, because if you omit a field you get a compile error. so it gives you a canonical place to put default values.

@kyle-github
Copy link

I started another issue, #491, for currying. It may not be in Zig's future path, but @marler8997 is right that this was far afield from the original proposal.

@thejoshwolfe
Copy link
Contributor

I don't see any real actual examples here, so let me attempt to provide one:

error NotFound;
fn findString(text: []const u8, target: []const u8, startOffset: usize = 0) -> %usize {
    if (text.len < target.len) return error.NotFound;
    {var i: usize = startOffset; while (i < text.len - target.len) : (i += 1) {
        {var j: usize = 0; while (j < target.len) : (j += 1) {
            if (text[i + j] != target[j]) goto next_i;
        }}
        return i;
        next_i:
    }}
    return error.NotFound;
}

@thejoshwolfe
Copy link
Contributor

thejoshwolfe commented Sep 19, 2017

And then following up my own example, here's a workaround for not having optional parameters:

error NotFound;
fn findStringFromOffset(text: []const u8, target: []const u8, startOffset: usize) -> %usize {
    if (text.len < startOffset) return error.NotFound;
    return startOffset + %return findString(text[startOffset..], target);
}
fn findString(text: []const u8, target: []const u8) -> %usize {
    if (text.len < target.len) return error.NotFound;
    {var i: usize = 0; while (i < text.len - target.len) : (i += 1) {
        {var j: usize = 0; while (j < target.len) : (j += 1) {
            if (text[i + j] != target[j]) goto next_i;
        }}
        return i;
        next_i:
    }}
    return error.NotFound;
}

@andrewrk andrewrk modified the milestones: 0.2.0, 0.3.0 Oct 19, 2017
@PavelVozenilek
Copy link

No default parameters means that one will be forced to invent more and more unique names. This is, at least for me, the most annoying part of writing code, and the one where I seldom find satisfying solution. Reasonable IDE has no problem to navigate me to the proper function in project of any size, but it is of not help with naming.

To reduce this burden rules could be reconsidered to allow more characters in names, beyond the usual alphanumerics + underscore. I do not mean full Unicode (too dangerous) but characters like #, !, %, +, which have commonly understood semantic meaning.

Random example: name addAssembleAndLinkTests contains 5 words, Could be addAssemble&LinkTests, which saves one word.

@thejoshwolfe
Copy link
Contributor

thejoshwolfe commented Nov 20, 2017

I think if you really want to use arbitrary characters in symbol names, you can use the @"foo" syntax and use whatever characters you want. like fn @"add assemble & link tests"() {}. But i don't think all the extra characters like @ and " will be very appealing.

Keeping identifier grammar simple is valuable for keeping the language easy to read. If & were allowed in symbol names, then a&b would be different from a& b and a & b. It's certainly possible to come up with concise grammar rules for what all those mean, but using a simpler grammar is better for understanding the mechanics of what you're reading.

I've been very unhappy using languages where a-b is different from a - b, and i think the current identifier grammar is just right [_A-Za-z][_0-9A-Za-z]*.

@PavelVozenilek
Copy link

@thejoshwolfe: that @"foo" looks somehow usable (though I'd prefer Lisp's |foo| ). It should be documented (also what characters are allowed in).

Expressions using special characters +/- etc are not that frequent in typical code (I wouldn't be surprised if there 100x more names than math expressions), and having more readable names IMO outweighs the minor typing inconvenience with stricter expression formatting.


Back to the default values: how about making defaulted value automatically named one, and using * as the default?

fn foo(x : int 32 = 0) { ... }

foo(x = *);

This would avoid the horror of spreading magic literals/symbols everywhere and then tearing ones hair when the would-be-better-default value has to change.

@marler8997
Copy link
Contributor Author

@andrewrk I've been reading up on the various zig proposals and I noticed this one was referenced a few times. I went back to read up on this one to understand the rationale of why it was rejected but I didn't see any explanation from you. Since this is referenced in other proposals, do you mind writing up some of your thoughts and/or rationale as to why it was rejected? I think it helps the community to get on the same page if we all understand your rationale when you accept/reject proposals.

@marler8997
Copy link
Contributor Author

@MasterQ32 provided rationale for not supporting default arguments here: #3721 (comment)

What I'd like to see is a document explaining language decisions and their rationale, with pointers to discussion but also summaries on why certain features are or aren't supported. Keeping everyone on the same page means discussion can be optimized as we won't need to re-hash the same arguments. This helps everyone understand the reasons certain decisions are made which helps developers infer what features and designs may or may not make sense in Zig as those same reasons will be applied to all features and designs in the language. I think this would help organize the community as Zig grows and would help everyone focus on the right things.

@Mouvedia
Copy link

What I'd like to see is a document explaining language decisions and their rationale, with pointers to discussion but also summaries on why certain features are or aren't supported.

https://github.com/joelparkerhenderson/architecture_decision_record

@nigelatdas
Copy link

nigelatdas commented Dec 9, 2023

Is there a reason not to define your method to take a struct, and have your struct have default values,
Then you can only override the values you want to set?

like this...

fn sum(args: struct { a: i32 = 1, b: i32 }) i32 {
    return args.a + args.b;
}

test "basic add functionality" {
    try testing.expect(sum(.{ .a = 3, .b = 7 }) == 10);
    try testing.expect(sum(.{ .b = 7 }) == 8);
}

@nektro
Copy link
Contributor

nektro commented Dec 9, 2023

because taking a struct requires all "parameters" in that case to be named

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking Implementing this issue could cause existing code to no longer compile or have different behavior. enhancement Solving this issue will likely involve adding new logic or components to the codebase. proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests