Welcome, guest | Sign In | My Account | Store | Cart

Improved handling of mutable and deferred default arguments. After the recipe you'll find an explanation of what that means.

Works for 2.7 with minor tweaks (getfullargspec --> getargspec).

Python, 140 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""smart_default_arguments module

DEFERRED is the singleton to use in place of None as the sentinel
for deferred default arguments.

"""

import inspect


# mutable default arguments

def recalculate_defaults(f, newdefaults):
    argspec = inspect.getfullargspec(f)

    # take care of defaults

    args = argspec.args
    defaults = list(argspec.defaults)
    num_bare_args = len(args) - len(defaults)

    for i in range(len(args)-num_bare_args):
        defaults[i] = newdefaults.pop(args[i+num_bare_args], defaults[i])

    bare_args = args[:num_bare_args]
    while bare_args:
        arg = bare_args.pop()
        if arg in newdefaults:
            defaults = [newdefaults.pop(arg)] + defaults
        else:
            break

    # take care of kwonly defaults

    kwonly = argspec.kwonlyargs[:]
    kwdefaults = argspec.kwonlydefaults
    num_bare_kwonly = len(kwonly) - len(kwdefaults or ())

    for arg in kwonly[num_bare_kwonly:]:
        kwdefaults[arg] = newdefaults.pop(arg, kwdefaults[arg])
    
    bare_kwonly = kwonly[:num_bare_kwonly]
    while bare_kwonly:
        arg = bare_kwonly.pop()
        if arg in newdefaults:
            kwdefaults[arg] = newdefaults.pop(arg)
        else:
            break

    # finish up
    if newdefaults:
        raise TypeError("Unexpected new defaults: %s" % newdefaults)
    return tuple(defaults), kwdefaults


def has_default_arguments(**kwargs):
    """A decorator factory that applies default arguments.

    It handles mutable default arguments, which sets it apart from the
    normal handling of default arguments.

    Trust that kwargs matches parameters of the decorated function.

    """

    def decorator(f):
        f.__defaults__, f.__kwdefaults__ = recalculate_defaults(f, kwargs)
        return f
    return decorator


# deferred default arguments

import functools


class DEFER_DEFAULT_TYPE:
    """Indicates that the default argument should be deferred to the callee."""
DEFERRED = DEFER_DEFAULT_TYPE()


ERROR_MSG = "A DEFERRED object cannot be used for a non-default argument"

def recalculate_arguments(args, kwargs, 
                          spec_args, num_spec_args, num_bare_args, defaults,
                          kwonlyargs, kwonlydefaults):
    args = list(args)
    num_args = len(args)

    # handle argspec.args
    for i in range(num_spec_args):
        arg = spec_args[i]
        if i >= num_bare_args:
            if i < num_args and args[i] is DEFERRED:
                args[i] = defaults[i-num_bare_args]
            elif kwargs.get(arg) is DEFERRED:
                kwargs[arg] = defaults[i-num_bare_args]
            continue
        if i < num_args and args[i] is DEFERRED:
            raise TypeError(ERROR_MSG)
        elif kwargs.get(arg) is DEFERRED:
            raise TypeError(ERROR_MSG)

    # handle argspec.kwonlyargs
    defaults = kwonlydefaults
    for arg in kwonlyargs:
        if kwargs[arg] != DEFERRED:
            continue
        if arg not in kwonlydefaults:
            raise TypeError(ERROR_MSG)
        kwargs[arg] = kwonlydefaults[arg]

    return args, kwargs


def accepts_deferred_defaults(f):
    """A decorator that handles DEFERRED arguments.

    Because this wraps the decorated function with another function,
    performance will take a hit.  However, this is unavoidable since the
    arguments are not known until runtime.  Some effort has been made to
    optimize the new function, though it could certainly be improved.
    
    """

    argspec = inspect.getfullargspec(f)
    spec_args = argspec.args
    num_spec_args = len(spec_args)
    defaults = argspec.defaults
    num_bare_args = num_spec_args - len(defaults)
    kwonlyargs = argspec.kwonlyargs
    kwonlydefaults = argspec.kwonlydefaults

    @functools.wraps(f)
    def newfunc(*args, **kwargs):
        args, kwargs = recalculate_arguments(args, kwargs,
                           spec_args, num_spec_args, num_bare_args, defaults,
                           kwonlyargs, kwonlydefaults)
        return f(*args, **kwargs)
    return newfunc

Python handles default arguments just fine. However, there are two situations that require some extra effort to handle a default argument correctly.

Deferred Default Arguments

If you have a function that calls another, passing through an argument, you likely want the default argument of your function to match that of the called function.

Deferred default arguments is what I call the technique of using a sentinel for the default argument of your function and having the called function translate the sentinel into its own default argument. See my "introduction to deferred default arguments" (recipe 577789) for more information.

This recipe provides a much smoother technique for handling deferred default arguments. The "introduction" recipe requires that you call a special handler function inside each target function, and unnecessarily exposes some of the boilerplate of the solution.

The alternative given here is a decorator. The boilerplate is hidden away and you don't have to make a single change to any function that will be receiving the sentinel argument. Just tack on the decorator and the target function is good to go. See the first example for a demonstration.

Mutable Default Arguments

When a function is defined, any default argument values are evaluated and the resulting objects are bound to the function's __defaults__ and __kwdefaults__. These objects are implicitly passed at each call of the function where the argument is not explicitly passed.

Since the default value is evaluated at definition time and stored away, if a default argument is mutable it will save state between function calls. So if you are expecting that the default value is evaluated at each call, you may be surprised when your changes to that default value are still reflected during your next call.

Currently if you want to avoid any confusion with mutable defaults, you set the default argument to some (immutable) sentinel that indicates the real default argument should be used:

def f(x=None):
    if x is None:
        x = []

There are downsides though:

  • The default argument no longer reflects the expectation for the argument type.
  • You can no longer introspect the default argument.
  • If you want to have the default actually be None, you will have to use some other value for the sentinel. At that point you'll probably then have two different sentinels in use: None and and its surrogate.
  • The real default must be re-evaluated during each call it is used.
  • There are two more [seemingly superfluous] lines cluttering up your function body.

This recipe provides a way to mitigate the surprise and downsides without sacrificing the security of your arguments. You only have to add the decorator from the recipe onto any function for which you want to override (with immutable values) any of the default argument values. Go to the second example to see how it works.

This implementation actually replaces the specified default argument values. Thus you still lose the original value. One alternative would be for the decorator to wrap the function in another that would plug in the new default argument values at each call, as appropriate. The trade-off is in performance, which, for functions, is a big deal.


Example 1

redefining default arguments

deferred default arguments

@accepts_deferred_defaults
def f(x=1):
    return x

def g(x=DEFERRED):
    return f(x)

assert g(2) == 2 == f(2)
assert g() == 1 == f()

Example 2

@has_default_arguments(a=2, x=())
def f(a, x=[]):
    result = 0
    for val in x:
        result += val*a
    return result

assert f() == 0
assert f(x=range(3)) == 6
assert f(1) == 0
assert f(1,range(3)) == 3