Default parameters in Python are bound at runtime, allowing for late binding

I previously explored the unusual way Python handles default arguments/parameters in [this blog post](a post) (I’m still unsure whether to call them “arguments” or “parameters”!). This behavior stems from default parameters being bound when the function is defined, not when it’s called (i.e., late binding). My post explained how JavaScript, Kotlin, and Ruby differ by evaluating default parameter values during each function call. At the time, I hadn’t grasped the potential of this feature. However, after examining [the documentation](MDN documentation), I discovered that parameters can use previous parameters in its definition:

[``` function greet(name, greeting, message = `${greeting} ${name}`) { return [name, greeting, message]; }

1
2

While [the documentation]([](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters#earlier_parameters_are_available_to_later_default_parameters))[Kotlin documentation](https://kotlinlang.org/docs/functions.html#default-arguments) doesn't emphasize these advanced applications, languages like Kotlin utilize them:

fun read( b: ByteArray, off: Int = 0, len: Int = b.size, ) { /*...*/ }

1
2
3
4

My renewed interest in the capabilities of default parameters was sparked by the recent [proposal]([a draft for a PEP (671)](https://peps.python.org/pep-0671/)) (met with predictable skepticism from some, likely the same crowd that sees no use for optional chaining) to enhance Python's default arguments by making them late-bound and allowing access to other parameters. Impressively, someone had already implemented a version of this in [a library]([late module](https://github.com/neogeny/late)) using decorators.

The method for achieving this in pure Python is straightforward (especially after examining the source code :-D). Since parameters are bound at definition time, we can bind something that generates a value instead of the value itself. To execute this value producer with every function call, we wrap the function with another function responsible for the invocation. Combining this with the powerful capabilities of [inspect(fn).signature() method](https://docs.python.org/3/library/inspect.html#inspect.Signature) completes the implementation. While the _late_ library implementation is impressive, it doesn't appear as versatile as the PEP proposal (or the functionality in JavaScript and Kotlin), lacking the ability for late-bound parameters to depend on other parameters (whether late-bound or not).  After reviewing the _late_ library's source code, I developed my own Python implementation for late-binding (or call-time binding) of default parameters:

`from dataclasses import dataclass from typing import Callable, Any import functools import inspect

@dataclass class LateBound: resolver: Callable

def _invoke_late_bound(callable: Callable, arg_name_to_value: dict[str, Any]) -> Any: """ invokes a callable passing over to it the parameters defined in its signature we obtain those values from the arg_name_to_value dictionary """ expected_params = inspect.signature(callable).parameters.keys() kwargs = {name: arg_name_to_value[name] for name in expected_params } return callable(**kwargs)

def _add_late_bounds(arg_name_to_value: dict[str, Any], late_bounds: list[str, Callable]): “““resolves late-bound values and adds them to the arg_name_to_value dictionary””” for name, callable in late_bounds: val = _invoke_late_bound(callable, arg_name_to_value) #this way one late bound can depend on a previous late boud arg_name_to_value[name] = val

def _resolve_args(target_fn: Callable, *args, **kwargs) -> dict[str, Any]: “““returns a dictionary with the name and value all the parameters (the ones already provided, the calculated latebounds and the normal defaults)””” # dictionary of the arguments and values received by the function at runtime # we use it to be able to calculate late_bound values based on other parameters arg_name_to_value: dict[str, Any] = {} arg_names = list(inspect.signature(target_fn).parameters.keys()) for index, arg in enumerate(args): arg_name_to_value[arg_names[index]] = arg arg_name_to_value = {**arg_name_to_value, **kwargs}

# obtain the values for all default parameters that have not been provided
# we obtain them all here so that late_bounds can depend on other (compile-time or late-bound) default parameters
#late bounds to calculate (were not provided in args-kwargs)
not_late_bounds  = {name: param.default 
    for name, param in inspect.signature(target_fn).parameters.items()
    if not isinstance(param.default, LateBound) and not name in arg_name_to_value
}
arg_name_to_value = {**arg_name_to_value, **not_late_bounds}

list rather than dictionary as order matters (so that a late-bound can depend on a previous late-bound)

late_bounds = [(name, param.default.resolver) 
    for name, param in inspect.signature(target_fn).parameters.items()
    if isinstance(param.default, LateBound) and not name in arg_name_to_value
]

_add_late_bounds(arg_name_to_value, late_bounds) return arg_name_to_value

#decorator function def late_bind(target_fn: Callable | type) -> Callable | type: “““decorates a function enabling late-binding of default parameters for it””” @functools.wraps(target_fn) def wrapper(*args, **kwargs): kwargs = _resolve_args(target_fn, *args, **kwargs) return target_fn(**kwargs)

return wrapper`

1
2

Here's how to use it:

`from datetime import datetime from dataclasses import dataclass from late_bound_default_args import late_bind, LateBound

@late_bind def say_hi(source: str, target: str, greet: str, extra = LateBound(lambda: f"[{datetime.now():%Y-%m-%d_%H%M%S}]"), ): """""" return f"{greet} from {source} to {target}. {extra}"

@late_bind def say_hi2(source: str, target: str, greet: str, extra = LateBound(lambda greet: f"[{greet.upper()}!]"), ): """""" return f"{greet} from {source} to {target}. {extra}"

print(say_hi(“Xuan”, “Francois”, “Bonjour”)) print(say_hi2(“Xuan”, “Francois”, “Bonjour”))

#Bonjour from Xuan to Francois. [2024-02-02_002939] #Bonjour from Xuan to Francois. [BONJOUR!]

access to the “self” parameter in a late-bound method works also fine

@dataclass class Person: name: str birth_place: str

@late_bind def travel(self, by: str, start_city: str = LateBound(lambda self: self.birth_place), to: str = “Paris” ): """ """ return(f"{self.name} is travelling from {start_city} to {to} by {by}")

p1 = Person(“Xuan”, “Xixon”) print(p1.travel(“train”))

Xuan is travelling from Xixon to Paris by train`

1
2

You can find the code on [GitHub]([a gist](https://gist.github.com/XoseLluis/0c388e8a736ccb723b0d1477ec936ccb)).
Licensed under CC BY-NC-SA 4.0
Last updated on Feb 23, 2023 04:25 +0100