Optional arguments with defaults #1122
Replies: 16 comments 61 replies
-
Would default arguments always have to be the last argument(s)? Aka would the following be acceptable:
|
Beta Was this translation helpful? Give feedback.
-
What is the expected behaviour when piping to a function where all the arguments are optional? Also, if multiple optional params have the same type does it simply follow left-to-right ordering? |
Beta Was this translation helpful? Give feedback.
-
This seems like a really cool addition, but I worry if abused that it could lead to overly complicated function signatures. From a clarity perspective, it would be much more legible to relegate this kind of thing to just rebinding with |
Beta Was this translation helpful? Give feedback.
-
Responding to the OP
Only after all required positional arguments
I can't foresee any incoherence from simply introducing this feature, but it would be great to incorporate the "unlabeled arguments are defaulting to a label named after them, it's just omitted" idea I mentioned in #1122 (reply in thread).
IMO that's unnecessary complication. Such thing can always be done inside of the function body anyways. I'd definitely go with disallowing it until someone comes up with an actual use-case, otherwise you might end up having to support usage of a poorly-designed/overlooked aspect for backwards compatibility. |
Beta Was this translation helpful? Give feedback.
-
To contribute to the completeness of the discussion, I feel like there was an argument against optional args with default values which is that it allows you to add an argument to a function that is already called in many places without having the compiler tell you to go and inspect those places and make sure that that option makes sense for each place. I guess this is the other side of the same coin that allows you to introduce the argument without breaking peoples compiles. I don't think this is a strong argument :) I guess the simple counter is that if you introduce an optional argument to a pre-existing function then the default value for the argument should persist previous behaviour. But to the degree to which you might not do that, the feeling is of using custom types without an exhaustiveness check for case statements. You introduce something new and the compiler doesn't force you to consider the consequences of your action. As I said, not the most compelling argument and probably entirely based on a poor experience in one particular job on a C++ code base but I thought I'd include it :) |
Beta Was this translation helpful? Give feedback.
-
I think its valuable when a language has reduced syntax for optional bool value arguments. Say an argument 'x' defaults to false if it is not expressly written but can be set to true by just writing x instead of needing to write x=true. EDIT: I made the argument for why I think this type of approach is worth the extra syntax in my next post. EDIT: For the brief version of this, you could skip to my final suggestion in *Example 5", at the end of this post. For example, this is how they could define a function called 'compute' in the Rebol language: "You can specify refinements to the way a function operates, simply by preceding optional operation arguments with a forward slash ("/"):
Calling the function:
Quote taken from: http://www.re-bol.com/rebol.html#section-6.7 A useful example with this syntax could be something like: Example 1.
I like #1. (above) more. Its prettier and there is a kind of sensibility to thinking about the boolean argument as a refinement to the function (name) rather than just as an argument. The type of 'line' is inferred as Bool and its value is determined by its presence in a function call: if present it's True, if absent its False. Example 2. Example 3. Making it more general...the 'refinement' could be one of multiple options rather than just binary. Something like:
Then calling the function:
Example 4. (Simplifying Example 3) There could be special syntax for a case statement that simultaneously declares the arg type and does the matching...to avoid declaring the type of the arg:
// Below, the type of 'line' is inferred as Bool and its value is determined by
Example 5. My favorite and final possibility.. (Further simplifying Example 3) In summary...here I'm sketching out an efficient syntax for optional arguments using custom types in which the values of custom types can appear after the name of the function, as in:
The values 'Line' and 'ToStdErr' are optional arguments to the function print. The rationale for them appearing before the brackets is that their purpose is selecting or refining the nature of the function and therefore they belong by the name of the function. With more traditional syntax this could be written as:
When no values of these custom types appear in the function call, they default to Nill (see case statement below). The type definition for the optional variants can be omitted because it can be inferred from the case statement where they are matched.
|
Beta Was this translation helpful? Give feedback.
-
To keep things simple, maybe an initial implementation could be limited to:
Then from there we can see if there is need for more from real world usage or if this is sufficient or if we need to break up one of these limitations if possible? |
Beta Was this translation helpful? Give feedback.
-
Added a question about degrading to anonymous functions above. |
Beta Was this translation helpful? Give feedback.
-
One potential solution to the "Degrading to anonymous functions" is to treat optional arguments as a sort of syntax sugar for arguments of type pub fn add(a: Int, b: Int = 1) -> Int {
a + b
}
pub fn main() {
let f = add // : fn(Int, Option(Int)) -> Int
f(1) // Callable without optional argument
f(1, None) // Explicit `None`, same as previous line
f(1, 2) // Callable with optional argument
f(1, Some(2)) // Explicit `Some`, same as previous line
} Default values would also essential be syntax sugar. The above pub fn add(a: Int, b: Option(Int)) -> Int {
let b = option.lazy_unwrap(b, fn() { 1 })
a + b
} One potential concern with this approach is that it creates a disconnect between the type the function says It would allow non-defaulted optional arguments though, which would be neat pub fn log_some_stuff(message: String, context: Option(a)) {
case context {
None -> todo
Some(x) -> todo
}
}
pub fn main() {
log_some_stuff("hello!")
log_some_stuff("hello!", get_a_value(x, y, z))
} This could also apply well to constructors! pub type Person {
// The `Bool` type makes more sense here, because you don't have to see the function implementation!
// Even if optional function arguments don't get defaults, I think optional constructor arguments definitely should
Person(name: String, cool: Bool = True)
}
pub fn main() {
// `Person` constructor has type `fn(String, Option(Bool)) -> Person`
let lpil = Person("Louis") // implicitly `cool: True`
let hayleigh = Person("Hayleigh", cool: True) // explicitly `cool: True`
} |
Beta Was this translation helpful? Give feedback.
-
This could potentially introduce some ambiguity in pipelines... pub fn do_stuff(a: Int, b: Int = 1) -> fn(Int) -> Int {
todo
}
pub fn main() {
3
|> do_stuff(4)
|> io.debug() // what do we get here?
// Could be:
// - `fn(Int) -> Int`
// - `Int`
// Depends on if the pipe is equivalent to...
// - `do_stuff(3, 4)`
// - `do_stuff(4)(3)`
// Maybe this is a good reason to get rid of the pipe operator magic?
// or maybe we could require optional arguments be preceded with `~` like in OCaml?
} Unfortunately we can't require labels, because not every function has named arguments. |
Beta Was this translation helpful? Give feedback.
-
What about doing the same we do with labels, at least for now: The function loses its optionals with defaults feature and the full signature is required? This would be an easy rule to understand from a programmers point of view as well and it would be consistently in line with how we handel labels. IMHO optional args with defaults are an ergonomic feature after all to reduce a lot of repeatative boiler plate. They can also be used to specify intented defaults in public interfaces, much as labels help with understanding public interfaces. I feel it is a no issue for anon functions to not support them (same as labels) fn my_top_level_fun_with_label(my_label my_var: String) {
my_var
}
fn my_top_level_fun_with_defaults(my_label my_var = "maybe future Gleam") {
my_var
}
pub fn main() {
let my_anon_fun_without_label = my_top_level_fun_with_label // type is: fn(String) -> String
my_anon_fun_without_label("This could not be set via my_label")
let my_anon_fun_without_defaults = my_top_level_fun_with_defaults // type is: fn(String) -> String
my_anon_fun_without_defaults("This has to be specified")
} To reiterate and extend the previously suggested constraints:
|
Beta Was this translation helpful? Give feedback.
-
Also unless I am missunderstanding, to keep the defaults when working with anons, one could wrap I think: fn x(i: Int, my_flag b = True) {
case my_flag {
True -> i * 2
False -> i + 2
}
}
pub fn main() {
let y = fn(i) { x(i) } // fn(Int) -> Int, with applied default for my_flag
let z = fn(i) { x(i, my_flag: False) } // fn(Int) -> Int setting a value for my_flag
}
// One could also create fn ´x_with_defaults()´ as a companion to ´x()´ at top level,
// so that it becomes easy to have local function with the defaults pre-applied. |
Beta Was this translation helpful? Give feedback.
-
I think optional keyword arguments should not be allowed to be called positionally. fn function(a: Int, b: String = "hello world") {}
fn main() {
function(5, "hello") // this would not be allowed
function(5, b: "hello") // this is ok
} I think this because keyword arguments will have label or argument names that will add a lot of context to what the value is. Forcing users to use these will improve code readability. |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
PythonPython has a long history of default values for parameters. Please, prepare yourself for some horror stories :) Problems:
from typing import Any
_sentinel: Any = object()
def func_with_default_arg(arg: int = _sentinel):
if arg is _sentinel:
print('arg is deprecated!')
arg = 0
... I am not sure about how this can be approached in Gleam (
def func(arg=[]):
arg.append(1)
return arg
a = func()
assert a == [1]
b = func()
assert b == [1, 1]
assert a == [1, 1] Fix: def func(arg=None):
if arg is None:
arg = []
arg.append(1)
return arg It should not be a problem for Gleam, since everything is immutable. But still: thinking about when default arguments are evaluated is a good idea. It should probably be: every time a function is called, just to be safe: considering the fact that there are different compilation backends. And they can grow in the future.
def some(x = 1, y = 2, /):
print(x, y)
some() # no way to pass `y` without passing `x` In this case all positional-or-named parameters will also require to have default values.
Let's say you have a function like: __all__ = ['parse_and_log']
def parse_and_log(obj, verbose = False):
res = _somehow_parse_the_object(obj)
if verbose:
print(res)
return res So, you cannot simply change This is also a point to think about in Gleam.
from collections.abc import Callable
def with_default(x: int = 0) -> int:
return x
a: Callable[[], int] = with_default
a() # ok
b: Callable[[int], int] = with_default
b(1) # ok
def some(y=0):
... And then you want to add a parameter without a default value, some regular required parameter. How would you do that? def some(x, y=0):
... ^ Is a breaking change, because now users that used to call def some(*, x, y=0): # or `def some(x, *, y=0):`, both are fine
... GleamI am not a big fan of default parameter values (as you can see from the first part of my post), but they might be helpful in some case. What would I expect from them?
This way annonymous functions would not be allowed to have default values automatically, because they can't have labeled parameters.
fn x(a arg: Int = 0) -> Int {
arg
}
pub fn main() {
let a: fn(Int) -> Int = x
let b: fn() -> Int = x
} My attempt to answer open questions
pub fn add(a: Int, b: Int = 1) -> Int {
a + b
}
pub fn main() {
let f = add // What is the type of `f`?
} The type would be:
My suggestion is above.
Please, don't add purity checks just for that 🙏 Here's my suggested alternative to: pub fn write(name, message, to sink = open_log_file(name)) {
todo
} Becomes pub type SinkSpec {
DefaultSink
FileSink(name: String)
// StdOutSink
// etc
}
pub fn write(name, message, to sink: SinkSpec = DefaultSink) {
case sink {
DefaultSink -> "default io"
FileSink(name) -> "fileio: " <> name
}
} |
Beta Was this translation helpful? Give feedback.
-
I think default values are a bad feature, especially for Gleam. Gleam optimizes so much for explicitness over implicitness (which is why many of us like it), so I do not think adding a feature that gives a convenient shortcut to implicit behavior makes sense for the language. I think default arguments are in the "easy now, can be really painful and unsustainable in the future" category of features. Other items in that category might be: implicit error propagation, allowing for variables / function parameters to be multiple types, runtime type checking / casting, variable mutability (in high level languages), and there are probably more. Gleam works hard to avoid things like this (and as such is very sustainable and refactorable), so I don't think adding default function arguments fits with the Gleam ethos. I also think Gleam has good alternatives already in place (easy syntax for the builder pattern, the I have written and worked with default function arguments in a couple different languages, and every time I did it made the code base more of a pain to work with in the long run. |
Beta Was this translation helpful? Give feedback.
-
Here's some initial thoughts on default/optional arguments.
I suspect that this would improve to the ergonomics of Gleam a lot, making some slightly tedious boilerplate avoidable.
Proposal
Default arguments in Gleam would make it so some arguments can be omitted to function calls, instead using a default value specified in the function definition.
Constructors would also accept default arguments.
The default argument value can be any expression, enabling function calls to be used.
Here the
open_log_file
function would be called each time thewrite
function is called without a second argument.In this case we would need to have the compiler track default arguments that call functions and disallow them in constant expressions.
Like labelled arguments defaults are not part of the type system, so can only be used when referring to functions directly.
Prior art
This is inspired by the default arguments of Elixir, Scala, Ruby and OCaml.
Questions
Degrading to anonymous functions
Like labels optional arguments are a compile time feature for named functions. What should happen when a function with optional arguments is assign to a variable and degrades to an anonymous function?
What expressions are permitted in anonymous functions?
Can it be anything? Or does it have to be pure? Or a constant?
When can an argument have a default?
My initial thought was that optional arguments must come after mandatory arguments. How would this interact with labelled arguments?
Defaults that refer to earlier argument values
It could be possible to use other arguments in the expression for an existing default
Here
name
is used in the default ofsink
. This could be powerful/useful, but is it desirable? We could disallow this and enable it later without any breaking change.Is this syntax good?
Largely just copied Scala here. Other ideas welcome!
Beta Was this translation helpful? Give feedback.
All reactions