Milky
Milky

Reputation: 99

How to declare a function with a default parameter before a non-default parameter in Python?

I'd like to declare a function with a non-default (mandatory) parameter preceded by a default parameter, just as in the built-in range function: range(start, end, step)

Is there anyway to do that? When I try to define a function that way I get a SyntaxError as in def example(a = 0, b)

Upvotes: 1

Views: 970

Answers (3)

paxton4416
paxton4416

Reputation: 565

In Python, positional arguments must come before keyword arguments – it's just a rule of the language. That said, there are a few ways (that I wouldn't necessarily recommend) that you could write a function to get around this.

This is essentially how the built-in range function does what you're describing:

def range(*args):
    # default values
    start = 0
    step = 1

    n_args = len(args)
    if not 0 < n_args < 4:
        raise TypeError(f'range expected 1 arguments, got {n_args}')
    elif n_args == 1:
        stop = args[0]
    elif n_args == 2:
        start, stop = args
    else:
        start, stop, step = args
    
    # etc...

It requires arguments be passed in a specific order, then determines what variables to assign values to based on the number of arguments passed.

Or, consider the following:

def example(a=10, b=None):
    assert b is not None, "argument 'b' is required"
    # etc...

This is an even uglier solution, because:

  1. you must pass b as a keyword argument, not a positional argument
  2. The function signature doesn't give the user any indication that b is required – it looks like it's optional, but the function will fail if it's not explicitly passed.
  3. You'd have to set the parameter's "invalid" value to something you're 100% certain no one would ever want to actually pass to that argument.

Unless there's a really compelling reason not to, it's best to stick to the usual format (positional arguments first, optionally followed by collecting leftover *args, then keyword arguments, optionally followed by collecting leftover **kwargs). It's how people expect to use a function and will make your code easier to both use and read. If there really is a compelling reason to deviate from that, mimic the setup of range.

edit:

re: your question about "correcting" the function signature to show certain parameter names instead of *args, the example above massively simplifies how the range built-in works. In reality, it's implemented in C and its signature/docstring are set here. It's more analogous to a class that defines a __call__ method that gets run inside the constructor than a regular function.

Manually overriding an object's signature would be tricky (not to mention a whole lot of extra steps to avoid using the "normal" parameter order) -- you're much better off just putting the "correct" parameter names in the docstring.

There are some really, really hacky ways you could do it. E.g., you could wrap the function in a decorator that uses something like inspect.Signature to modify the function object. Or maybe you could convert it to a class, have it inherit from some parent class, then use a combination of __init_subclass__ in the parent and __new__ in the child to intercept and modify the arguments passed to the constructor. But these solutions are so far off in left field that it's worth asking why there isn't a simpler way to solve this problem. In my opinion, it's because this simply isn't something you want to be doing in the first place.

Upvotes: 2

ShadowRanger
ShadowRanger

Reputation: 155507

You can't. The way range's parameters are defined is a feature of range being a built-in and using a different means of parsing arguments, roughly equivalent to receiving *args, **kwargs and hand-parsing them (it rejects non-empty kwargs manually, but still has to accept them due to the conventions of CPython's C API). If you want to do it yourself, you can, but Python won't help:

def myrange(*args):
    if not args or len(args) > 3:
        raise TypeError("Expected 1-3 args")
    if len(args) == 1:
        start, step = 0, 1
        stop, = args
    else:
        start, stop, step = (args + (1,))[:3]  # Stupid trick to reduce number of cases

    # Actual work done here

Upvotes: 0

Ted Klein Bergman
Ted Klein Bergman

Reputation: 9766

You can't. Default arguments must be last in the argument list. If you want different behavior, then you'll have to do the validation inside the function.

def primes_in_interval(start=2, end=None):
    assert(end is not None)
    # Rest of code.

primes_in_interval(end=10)

You could do something like this to get the calling convention you're looking for.

def primes_in_interval(end_or_start, end=None):
    if end is None:
        start = 2
        end   = end_or_start
    else:
        start = end_or_start
        end   = end
    # Rest of code.

primes_in_interval(10)      # Start will be 2 and end 10
primes_in_interval(8, 16)   # Start will be 8 and end 16

Upvotes: 0

Related Questions