Defaults anywhere in positional-only parameters

Defaults can be set anywhere in the positional-only parameters without ambiguity, as long as they keep being filled from the left:

def func(a=0, b, c=2, d, e=4, /):
    ...

# equivalent
func(1, 3)
func(0, 1, 3)
func(0, 1, 2, 3)
func(0, 1, 2, 3, 4)

This is often useful to match Python and mathematical notation, and I have been using various hacks to allow such positional defaults, with good results.[1] It would be nice if Python supported this natively and performantly (since it’s often useful in numerical code).

If nothing else, we could finally have range(start=0, stop, step=1, /).


  1. If you want to try it out, I just bundled one such technique up into a package. ↩︎

What exactly is the algorithm that you are using to assign the arguments to parameters?

For hacking this, there are a number of methods with various performance characteristics. But if you can generate code, the simplest and fastest way is to count the number of given args and jump conditionally:

if len(args) == 2:
    func(defaults[0], args[0], defaults[1], args[1], defaults[2])
elif len(args) == 3:
    func(args[0], args[1], defaults[1], args[2], defaults[2])
elif len(args) == 4:
    func(args[0], args[1], args[2], args[3], defaults[2])
elif len(args) == 5:
    func(args[0], args[1], args[2], args[3], args[4])

PS: If you are asking what algorithm I’m specifically using in my package, it looks at the order in which arguments must be filled, then precomputes for each number of given arguments the decision which arg or default to take. So same as the above, just not dynamically compiled.

PPS: If you only allow defaults on the outside, then the algorithm is trivial (“prepend and append”) and potentially very fast. Maybe that’s good enough?

Without knowing the secret call algorithm, your examples would be ambiguous to many, especially beginners. This would surely be a source of bugs and repeated help questions, here, stackoverflow, and elsewhere. Would you volunteer to spend you life answering them ;-? Note: the PEP template now has an entry for ‘how would this be taught’ to get proposers to think about support costs.

2 Likes

Of course, I meant that they can be parsed unambiguously. And is “the secret call algorithm” the fact that defaulted parameters are filled from the left?

Hey, if that’s what it takes.

“This function has five positional-only arguments: two required ones, and three optional ones. If less than five positional arguments are provided, missing optional arguments are replaced by default values.”

That is all exactly the same as with other default values for parameters.

It took a couple double takes but I think I understand your thought process. I honestly think it’s too confusing.

Thinking that func(a, b) may not be passing a/b as the first 2 args is confusing and makes things more ambiguous.

9 Likes

The same happens with range(5).

In fact, at the C level, defaults on the left exist in a number of places, and are supported by argument clinic. I’m merely asking to

  1. Allow the same in Python function definitions, and
  2. Change Signature to work with them.

In practice, you can make confusing examples, and my initial one was needlessly so in its attempt to demonstrate the lack of ambiguity. But the syntax is powerful in less confusing instances:

def distance(x1=0., x2, /):
    ...

distance(3.)
distance(0., 3.)

Might not be the best example, since any sane distance function will return the same result if you switch the order of arguments. And I think that’s really why this sort of proposal - and you’re far from the first to suggest this - never seems to go anywhere: there just aren’t any compelling examples other than range() itself.

3 Likes

Not in all geometries, e.g. a universe that is not spatially flat. That’s exactly where this proposal is coming from: In the field of cosmology, the distance functions are written on paper as d(z_1, z_2) when binary and d(z) = d(0, z) when unary.

But many other mathematical functions come to mind, usually where this happens to the indices. Off the top of my head, the spherical harmonics _sY_{lm} are defined such that Y_{lm} = {}_0Y_{lm}, which would be nice to write as

def Ylm(s=0, l, m, theta, phi):
    ...

Wouldn’t d(z1, z2) == d(z2, z1)? If so, I’d personally prefer

d(z2, z1=0)

IOW invert the input rather than the language on the basis of aesthetics :slight_smile:

Alternatively, set the other defaults to None (or an alias of None)

d(z1=0, z2=None)

No, the arguments are not always symmetric:

class Cosmology:
    def proper_distance(self, z1=0., z2, /):
        return self.scale_factor(z1) * self.comoving_distance(z1, z2)

And of course there are ways around this, that’s missing the point. The arbitrary defaults, if done at low level, could be useful to get rid of unnecessary input handling for small functions that are supposed to be fast.

For reference, I have managed to bring the performance of my decorator up to par with a plain wrapped(*args, **kwargs) call,[1] but that is still a couple of times slower than not having the decorator at all.

PS: Let me add that by doing the func(arg, other=None) workaround, you have effectively introduced two distinct calling patterns, and now have bigger problems.


  1. By introducing a placeholder for args to be filled in, and precomputing the call pattern for each number of given positional arguments: (defaults[0], ARG, ARG, ...). ↩︎

2 Likes

Frankly, it’s not obvious to me why these arguments would “not always be symmetric,” which itself is a point to consider. Some other considerations:

  • The problem you are proposing is confusing.
  • The solution you propose is confusing.
  • Parameters are already confusing :smiley:

Personally, I think these points are the more obvious “bigger problem.” (I appreciate the link btw. I’ll read it later.) It might be a good proposal, but I have no immediate impulse to write or read intermixed positional and default args. Perhaps others may? GL.

3 Likes

FWIW I like this, but the fact that it can be done with a decorator without changing the language is also nice. I spent years with a @kw_only(n_pos_args) decorator before we got ** syntax, so if I found a compelling use case, I would be happy to use this. Thanks for the implementation!

The use cases that spring to mind for me are also mathematical, where matching the order of free parameters to the order of appearance in an equation is ergonomic, but it is often only the last one or two parameters that don’t have sensible defaults.

My main concern with this, in contrast to keyword-only arguments, is that this technique will produce very fragile function signatures. It’s hard to imagine a procedure for adding/removing/changing parameters without breaking or changing the meaning of some call. I’m not sure you want to build such a footgun directly into the language.

3 Likes

I think your proposition overlaps the signature question raised there.
There is an invariant in both the square-bracket-for-optionals notation and in the native signature syntax, that positional (pos-only or pos-or-kw) parameters are filled from left to right by passed arguments. This proposition would break it.
Even worse, if I understand your proposal correctly, it would make the 3 in foo(3 ... end up in different parameters depending on whether there are other arguments being passed or not.
This just doesn’t happen in Python (apart from range, more or less).
You can make functions that behave differently based on how many arguments you pass, like type for example, but even in that case the first positional arguments ends up bound to the first positional parameter.

As far as I understand your reasons for this feature, it seems to be so that, to say it bluntly, calling str(inspect.signature(foo)) makes its use more directly understandable. That or help(foo) from a REPL.
In my opinion, that equivalence between the accessible signature and the way users are supposed to call your stuff is broken ever since pos-only parameters and **kwargs can exist at the same time for the same callable.
I don’t even understand enough of how your argument-binding would actually work, so I can’t propose an explicit solution code for the OP func. In my opinion there are two pythonic solutions :

  1. like random.randrange does it, with the required args on the left of the defaulted ones (in that case I would have made all three pos-only but whatever), and either re-binding things inside the function, or manipulating required arguments with the publicly-visible name.
  2. stop making functions with left-defaulted parameters. As far as I’ve seen in the stdlib at least, and seeing Raymond’s list which you linked, this is the path which was chosen ever since the range API was settled.

Even in random.randrange, which can be seen parameter-wise as an equivalent of the range builtin, when you compare the body of the function with the line calling it, the arguments come in the same order as they were passed.
Making an API hard to understand for the ones using it is the API developer’s responsability, but at least if you move parameters around in the function, the code doing that is explicit at the top of the function body. This proposition would hide that behavior inside Python’s argument-binding black box, and I don’t think it’s a good thing (“explicit is better than implicit”).

This isn’t really an option when you are writing code for experts in some given field who are not necessarily experts in Python. They expect functions to look like what they know, not what they ought to know. And that use case is quite common, given how much Python is used throughout the sciences.

That seems like a completely backwards argument to me. For example, I often use these defaults to create high-level glue code to a low-level library. Are you saying that this

def nice_interface(x1=0., x2, /):
    return _low_level_function(x1, x2)

is somehow harder to wrap your head around than e.g. these?

def nice_interface(x, other=None, /):
    x1, x2 = (0., x) if other is None else (x, other)
    return _low_level_function(x1, x2)

def nice_interface(x, other=None, /):
    if other is None:
        return _low_level_function(0, x)
    else:
        return _low_level_function(x, other)

The signature of the left-defaulted function would show up correctly in help(), your IDE, etc., while none of the workarounds can even be accurately described by a single signature.

In a way, yes. I’d say no for these very simple examples, but if it were only to be used in such easy cases, it wouldn’t be worth changing the signature syntax, since learning it would be an unworth chore. And since it’s not restricted to those, the examples would be very complicated very fast.

Even the OP example is too complicated for me to understand. You should probably add in comments what the argument-binding ends up being for each example call you added, otherwise it is of little help to understand how you want things to work.

Yes, and I’d go as far as saying that’s not what python’s signatures are meant for (for end-user comprehension). For that you have the docstring. Python chose to have single function declarations instead of overloading like in other languages (there is type-wise overloading in functools, I don’t think it extends to signature shape but if it does it’s another solution to your problem). That philosophy was a clear choice which I believe we have to live with the consequences.

I think this thread would benefit from more examples from existing libraries. So far, we only have range.

2 Likes

But that is exactly the use case. Small, simple signatures with 2 or 3 parameters. (Or do you often use more than that number of positional-only arguments?)

I don’t expect there to be (m)any,[1] because there is currently no good way to do it. For example, in astropy.cosmology, the second signature was given to an entirely separate function. Hence my proposal.


  1. Nevertheless, there are at least two more in the standard library, syslog.syslog() and the late ossaudiodev.open(). ↩︎

Good examples and good point. It would be helpful then to find examples of functions that you think would make use this syntax if there were a good way to do it.

This feature seems to me like the :=, which should be avoided except in the rare cases in which it simplifies code.

I don’t, but you do in the OP, so… Maybe that’s indicative of the worst that could happen if the signature syntax was changed that way ?
If this is only useful for small signatures with at most 3 parameters, I think it wouldn’t be a good change. Saving the one- or three-line ifs you showed in your preceding message at the cost of changing the syntax isn’t worth it. The existing workarounds are good enough not to break some (in my opinion) long-settled invariants.

Not to mention the inspect.signature class and companions, which I happen to be a fan of.
One more invariant this would break, in inspect.signature things, is that currently if you sort a valid signature’s parameters by kind and then by having-a-default-or-not (the order of the kinds being pos-only then pos-or-kw then variadic-pos then kw-only then variadic-kw), you end up with an equivalent signature - it may have reordered kw-only parameters but it doesn’t matter since their ordering doesn’t change anything.
I don’t expect this invariant to last ad vitam æternam, it will likely change as signature syntax will, but I don’t think this change would bring enough good feature to be worth breaking it.

Anyway, in the two stdlib examples you provided, the two-signature documentation is clear enough. I never heard of astropy before, but I’d have implemented the distance function with one required and one optional arguments, and documented it like the two stdlib examples.
In any case, I guess compatibility matters if that module’s API is settled, but otherwise I don’t know of any compelling argument why the two-signature documentation style wouldn’t have been a solution. I don’t think that’s not “a good way to do it”, as you put it.