Named and Optional Arguments are Awesome
54 points by jado
54 points by jado
it makes parameter names part of the public API of the function,
I really like the way gleam (and swift too I believe) goes about this. The public parameter name has to be picked explicitly rather than all arguments being named automatically.
pub fn replace(
in string: String,
each value: String,
with replacement: String,
) -> String {
// do something with “string”,
// “value” and “replacement”
}
pub fn main() {
replace(“Wibble”, each: “i”, with: “o”)
}
I think the best APIs are well thought out rather than happening by accident so using named arguments should be an intentional choice
OCaml’s labeled & optional arguments using ~ + ? sigils is pretty novel in the functional space. I ended up liking it quite a lot ergonomically since a) you can easily forward along arguments, but also the labels can be alias for the scope meaning you don’t need to come up with argument names! A trivial example:
let add ~x ?(y = 0) () =
x + y
let print_sum ~x ?y () =
print_string "Sum: ";
let sum = add ~x ?y () in
print_endline (string_of_int sum)
let () =
let x = 1
and y = Some 2
in
(* using labels from scope *)
print_sum ~x ?y (); (* Sum: 3 *)
(* manual + omitted y *)
print_sum ~x: 4 () (* Sum: 4 *)
it makes parameter names part of the public API of the function,
After using Python's equivalent (keyword arguments) and Ada's equivalent (named parameter associations) quite a bit, I went from "parameter names being part of the API is bad" to "perhaps this isn't a bad idea." You're forced to think more about clear parameter names, which improves reading intent from the function signature.
It does make library API changes a bit more fragile. Changing parameter names becomes a breakage, since call with named arguments will no longer compile. However, it can detect semantic changes to arguments where the type remains the same.
Swift’s approach to this is pretty neat, it separates internal parameter names and external argument labels, so you can rename the parameter without affecting the argument, and the other way around. In keeping with objc (and Smalltalk) it also defaults to named arguments, but you can opt out (or opt in positional arguments) on a per-argument basis.
I did a bunch of Objective-C a few years back, I thought it's messaging system with thing:paramA:paramB felt very natural.
Sure but objc has a completely different logic of mixfix method names inherited from smalltalk.
It has advantages, notably “overloading” just being a bunch of actually differently named methods and segments clearly documenting parameters, but (ignoring the awkward first segment) it doesn’t deal well with positionals (e.g. block’s chains of value:) or parameter combinatorics (Boolean needing 4 methods to deal with variations of if/if not).
I'm unsure why the parameter names being part of the API is presented as a problem? It's literally the point of feature. You just need to make sure that the syntax permits the implementation to use a different name, because it's the implementation - this is something that objective-c and swift both support, which means APIs are often written as
func scroll(to location:Point) { scrolloffset.x=location.x; scrolloffset.y=location.y; ... }
func scroll(by delta: Distance) { scrolloffset.x+=delta.x; scrolloffset.y+=delta.y; ... }
// note swift does not have += ... but you can make it exist if you want :D :D :D
and the caller code looks like
scroll(to: whereIWant)
scroll(by: theDistance)
which is obviously equivalent to scroll_to(...), scroll_by(...) - but the general reason for wanting named parameters is when there is more than one, and naming the parameters lets you place the names that determine the function to be called, next to the parameter. e.g
f(x:.., l:..., y:..., doggo:...)
vs
f_x_l_y_doggo(...,...,..,..,)
as the argument list gets longer, or if there are multiple parameters with compatible types, having the parameter name next to the expression helps.
Purely syntactically having them in line mean you don't get the JS or C++ default argument problem where all the default arguments have to be at the end, and if you want to override only one of them you have to override all of them.
I'm unsure why the parameter names being part of the API is presented as a problem?
When it’s implicit / opt out / default it is a routine problem because you declare an API without knowing it.
It’s a relatively common problem in Python where the default argument mode is that they can be passed by position or keyword, and people routinely do not really consider the latter. It’s also a source of issues and confusions when dealing with interfaces / protocols, as the essay explains at length.
These are not unresolvable but they are issues to be conscious of and resolve. They’re also especially salient when you don’t distinguish between internal parameter name and external argument label.
When it’s implicit / opt out / default it is a routine problem because you declare an API without knowing it.
That's not my experience in the context of objective-c and swift: it's well understood that the parameter names are part of the function name. It may be more difficult if you start from a PoV that the parameter names are simply decorative, and I can see that as being a problem in any language where they're starting from a point where they weren't.
I'd need to read the rust proposal, but if it is literally just a syntactic convenience, e.g. given
fn foo(x: Int)
if both
foo(x:1)
foo(1)
are valid, then this concern is absolutely reasonable, as functionally the parameter names are implicitly becoming API. But the solution here is to recognize that if named parameters in calls are a thing, then parameter names are API, and so the language needs a way to distinguish functions where that is intentional.
I'm unsure why the parameter names being part of the API is presented as a problem?
I think the problem is when the label is the internal name. Then, if you don’t have a way to choose which ones are public and which private, changing a parameter name breaks the public API.
In my reply to @masklinn I realized that if the named parameters are optional - e.g the same function can be called with or without named parameters that is a giant - and real - footgun.
In swift, objc, smalltalk the named parameters are not the variable names - swift allows that as a short hand (and has syntax for no parameter name). E.g
func add(_ value: int, to base: int) { return value + base; }
add(1, to: 3)
It's also worth noting that when parameter naming is the default, the way the parameters are named in the external API changes substantially. Here are some examples of the functions on swift's Dictionary type:
mutating func remove(a: Index) -> Element
func sorted(by: (Element, Element) throws -> Bool) rethrows -> [Element]
So you'd do
someDict.remove(at: 1)
someDict.sorted(by:{$0 > $1})
rather than
someDict.remove(index: 1)
someDict.sorted(sortFunc: {$0 > $1})
Whether that is better or worse is obviously subjective, and of course the fact that swift has this short hand
func makeVector(x:Float, y:Float, z:Float)->(Float, Float, Float) { return (x, y, z) }
Is because there are many cases where the API parameter name is the obvious name for the variable in the implementation.
https://crates.io/crates/bon Is a good option for some of these use cases. But it’s not a thing I would build a public crate interface on/with.
I fully agree that named and (named) optional arguments would be awesome to have in Rust. I do not have a strong preference for what that syntax should look like. (I generally care much more about semantics than syntax.)
But that spawn example reminds me of Python, and it's not a good memory. Every time I need to spawn a process in Python I either copy what I wrote somewhere else or I spend way too much time trying to figure out from the docs which arguments even exist and what they do. In contrast, the documentation for Command in Rust is much easier to read so it's trivial to figure out what I have to do. Maybe there is a way to document functions with many named arguments that's not terrible, but I haven't seen it yet. So IMO, named arguments cannot replace all builders.
That's not an argument against named arguments, though it is a cautionary tale, and IMO it means the feature should be optimized for functions with few named arguments.
THIS. (And Swift also does named/optional parameters well, IMO.) This has been one of my pain points in moving from Swift to Rust.
I like ObjC's solution:
example(named: 1, arg: 2)
desugars to something like:
example_named_arg(1, 2)
The arg order is fixed, but Rust's syntax error can suggest autofix. Lack of support for all the possible permutations is a plus: makes it harder to go wild with overly configurable methods. It doesn't need any new object types. It doesn't have any overhead of passing unused/default fields. It could even be made compatible with the existing with_capacity_and_hasher methods.
If Rust ever gets this I'd rather it used an anonymous struct as an argument instead. I would certainly not want any functions currently written suddenly be callable with named args because then all of a sudden changing an argument's name is a breaking change for crate authors. This would have to be explicit on the part of the API designer instead.
In ObjC, the name and the label are separate, so you things like:
- (NSString *) stringByPaddingToLength:(NSUInteger) len
withString:(NSString *) pad
startingAtIndex:(NSUInteger) idx;
which you'd call something like:
[str stringByPaddingToLength: 123 withString: @" " startingAtIndex: 10]
the variables inside the function are named len, pad, and idx, and can be freely renamed without affecting the ABI.
It takes a little bit of getting used to but I cannot understate how much it improves code readability. I have never had to consult the docs when reading someone else’s Objective-C code to see what the parameters meant to a function. I can’t say that about most other languages.
I'm not sure I buy that it's so terrible to wade through n different functions with all the variations of input parameters. If, instead, it were one function with behavior dependent on something like kwargs, one still has to mentally wade through the implicit variations of the function. I'd much rather go through an explicit list produced by the library author than make an implicit list in my head based on documentation.
I would be so happy if this rfc got implemented. I regularly wish for named and optional parameters in my own code, and I get extremely tired of wading though dozens of similar-but-not-quite functions on docs.rs to find the one that I need.
On the flip side, as soon as you introduce optional parameters you open the floodgates for something like pandas, where a single function has dozens of optional parameters that completely change the behavior of the function.
fn print(text: &str, bold: bool, italics: bool, underline: bool);
This is easily addressed, use an enum.
I think comparing with dynamically-typed languages is not optimal. With dynamically-typed languages, named parameters are nearly mandatory otherwise things are too error-prone.
With statically-typed languages, I think any function which does not take arguments of types that can be confused is automatically fine. And functions with many arguments IMHO are a smell anyway.
Certainly these things can help, but I should say that even with significant Python and Java experience, I still get confused frequently with how specifics of their implementations work, so it's quite difficult in my opinion to get this right without adding extra overhead. I think I would steer people towards designing for simpler function signatures.
I don't like optional positional arguments (i.e Typescript. function foo(a: Blah, b?: Baz) {...}). When introducing additional arguments, I prefer if all call sites cause compilation errors so I can check that the introduction of the argument is correct for all cases.
I don't mind named optional arguments as much.
That C# approach is surprisingly clean! I haven't used C# in 20 years and not sure if that's a new-ish feature or if I just forgot about that feature.
Article seemed to skip Python's approach despite mentioning it as a tolerable language. I was curious the author's thoughts on Ruby's approach though might just be the author never felt like exploring that due to already knowing Python.
Parameter names being part of the API isn't so bad, but default values being part of the API can result in some unexpected behavior. For example, if you have some sort of compute(a, b = 10) function where b = 10 indicates an optional argument with the default value of 10, changing that default to e.g. 20 could produce unexpected results.
Of course one could argue that if you carefully follow semantic versioning and "just" document everything clearly, this isn't a problem. Unfortunately, doing that is quite difficult for non-trivial programs as it's just too easy to overlook.
Parameter names being part of the API isn't so bad, but default values being part of the API can result in some unexpected behavior. For example, if you have some sort of compute(a, b = 10) function where b = 10 indicates an optional argument with the default value of 10, changing that default to e.g. 20 could produce unexpected results.
I don't think it's "unexpected behavior" if you change the code to do something different and then it does something different. These two changes are functionally the same:
# Keep the default value unchanged:
def compute(a:int, b:int=None) -> int:
if b is None:
b = 10 # Change this to 20
return a + b
# Edit the default value:
def compute(a:int, b:int=10) -> int: # Change this to 20
return a + b
The only difference is that if you look at the function signature, def compute(a:int, b:int=10) -> int is more descriptive.
Sometimes not noticed, but commands at a CLI are basically exactly named/optional arguments and once a language has that with a little introspection you can get a kind of auto-FFI-binding like https://github.com/c-blake/cligen . That is Nim, but the same can be done in Python - too many to list, D, and maybe the upcoming C++26 with reflection, and probably several others. I've always thought it strange that the "primitive CLI" was often more advanced in this way than many PLangs.
Although it looks exactly like just a language shortcoming, and the typical golang excuse "it's by design", I have to admit that I think that rust is okay without named nor optional arguments.
Less god functions. It also forces you to think twice before bolting on (yet) another optional variant.