How does functools partial do what it does?
Roughly, partial
does something like this (apart from keyword args support etc):
def partial(func, *part_args):
def wrapper(*extra_args):
args = list(part_args)
args.extend(extra_args)
return func(*args)
return wrapper
So, by calling partial(sum2, 4)
you create a new function (a callable, to be precise) that behaves like sum2
, but has one positional argument less. That missing argument is always substituted by 4
, so that partial(sum2, 4)(2) == sum2(4, 2)
As for why it's needed, there's a variety of cases. Just for one, suppose you have to pass a function somewhere where it's expected to have 2 arguments:
class EventNotifier(object):
def __init__(self):
self._listeners = []
def add_listener(self, callback):
''' callback should accept two positional arguments, event and params '''
self._listeners.append(callback)
# ...
def notify(self, event, *params):
for f in self._listeners:
f(event, params)
But a function you already have needs access to some third context
object to do its job:
def log_event(context, event, params):
context.log_event("Something happened %s, %s", event, params)
So, there are several solutions:
A custom object:
class Listener(object):
def __init__(self, context):
self._context = context
def __call__(self, event, params):
self._context.log_event("Something happened %s, %s", event, params)
notifier.add_listener(Listener(context))
Lambda:
log_listener = lambda event, params: log_event(context, event, params)
notifier.add_listener(log_listener)
With partials:
context = get_context() # whatever
notifier.add_listener(partial(log_event, context))
Of those three, partial
is the shortest and the fastest.
(For a more complex case you might want a custom object though).
What exactly is the optimization `functools.partial` is making?
The following arguments actually apply only to CPython, for other Python implementations it could be completely different. You actually said your question is about CPython but nevertheless I think it's important to realize that these in-depth questions almost always depend on implementation details that might be different for different implementations and might even be different between different CPython versions (for example CPython 2.7 could be completely different, but so could be CPython 3.5)!
Timings
First of all, I can't reproduce differences of 15% or even 20%. On my computer the difference is around ~10%. It's even less when you change the lambda
so it doesn't have to look up add
from the global scope (as already pointed out in the comments you can pass the add
function as default argument to the function so the lookup happens in the local scope).
from functools import partial
def add(x, y, z, a):
return x + y + z + a
def max_lambda_default(lst):
return max(lst , key=lambda a, add=add: add(10, 20, 30, a))
def max_lambda(lst):
return max(lst , key=lambda a: add(10, 20, 30, a))
def max_partial(lst):
return max(lst , key=partial(add, 10, 20, 30))
I actually benchmarked these:
from simple_benchmark import benchmark
from collections import OrderedDict
arguments = OrderedDict((2**i, list(range(2**i))) for i in range(1, 20))
b = benchmark([max_lambda_default, max_lambda, max_partial], arguments, "list size")
%matplotlib notebook
b.plot_difference_percentage(relative_to=max_partial)
Possible explanations
It's very hard to find the exact reason for the difference. However there are a few possible options, assuming you have a CPython version with compiled _functools
module (all desktop versions of CPython that I use have it).
As you already found out the Python version of partial
will be significantly slower.
partial
is implemented in C and can call the function directly - without intermediate Python layer1. Thelambda
on the other hand needs to do a Python level call to the "captured" function.partial
actually knows how the arguments fit together. So it can create the arguments that are passed to the function more efficiently (it just concatenats the stored argument tuple to the passed in argument tuple) instead of building a completely new argument tuple.In more recent Python versions several internals were changed in an effort to optimize function calls (the so called FASTCALL optimization). Victor Stinner has a list of related pull requests on his blog in case you want to find out more about it.
That probably will affect both the
lambda
and thepartial
but again becausepartial
is a C function it knows which one to call directly without having to infer it likelambda
does.
However it's very important to realize that creating the partial
has some overhead. The break-even point is for ~10 list elements, if the list is shorter, then the lambda
will be faster.
Footnotes
1 If you call a function from Python it uses the OP-code CALL_FUNCTION
which is actually a wrapper (that's what I meant with Python layer) around the PyObject_Call*
(or FASTCAL) functions. But it also includes creating the argument tuple/dictionary. If you call a function from a C function you can avoid this thin wrapper by directly calling the PyObject_Call*
functions.
In case you're interested about the OP-Codes, you can dis
assemble the function:
import dis
dis.dis(max_lambda_default)
0 LOAD_GLOBAL 0 (max)
2 LOAD_FAST 0 (lst)
4 LOAD_GLOBAL 1 (add)
6 BUILD_TUPLE 1
8 LOAD_CONST 1 (<code object <lambda>>)
10 LOAD_CONST 2 ('max_lambda_default.<locals>.<lambda>')
12 MAKE_FUNCTION 1 (defaults)
14 LOAD_CONST 3 (('key',))
16 CALL_FUNCTION_KW 2
18 RETURN_VALUE
Disassembly of <code object <lambda>>:
0 LOAD_FAST 1 (add) <--- (2)
2 LOAD_CONST 1 (10)
4 LOAD_CONST 2 (20)
6 LOAD_CONST 3 (30)
8 LOAD_FAST 0 (a)
10 CALL_FUNCTION 4 <--- (1)
12 RETURN_VALUE
As you can see the CALL_FUNCTION
op code (1) is actually in there.
As an aside: The LOAD_FAST
(2) is responsible for the performance difference between the lambda_default
and the lambda
without default (which has to resort to a slower lookup). That's because loading a name actually starts by checking the local scope (the function scope), in the case of add=add
the add function is in the local scope, so it can make a faster lookup. If you don't have it in the local scope it will check each surrounding scope until it finds the name and it only stops when it reaches the global scope. And that lookup is done every time the lambda
is called!
The rationale of `functools.partial` behavior
Using partial
with a Positional Argument
f = partial(bar, 3)
By design, upon calling a function, positional arguments are assigned first. Then logically, 3
should be assigned to a
with partial
. It makes sense to remove it from the signature as there is no way to assign anything to it again!
when you have f(a=2, b=6)
, you are actually doing
bar(3, a=2, b=6)
when you have f(2, 2)
, you are actually doing
bar (3, 2, 2)
We never get rid of 3
For the new partial function:
- We can't give
a
a different value with another positional argument - We can't use the keyword
a
to assign a different value to it as it is already "filled"
If there is a parameter with the same name as the keyword, then the argument value is assigned to that parameter slot. However, if the parameter slot is already filled, then that is an error.
I recommend reading the function calling behavior section of pep-3102 to get a better grasp of this matter.
Using partial
with a Keyword Argument
f = partial(bar, b=3)
This is a different use case. We are applying a keyword argument to bar
.
You are functionally turning
def bar(a, b):
...
into
def f(a, *, b=3):
...
where b
becomes a keyword-only argument
instead of
def f(a, b=3):
...
inspect.signature
correctly reflects a design decision of partial
. The keyword arguments passed to partial
are designed to append additional positional arguments (source).
Note that this behavior does not necessarily override the keyword arguments supplied with f = partial(bar, b=3)
, i.e., b=3
will be applied regardless of whether you supply the second positional argument or not (and there will be a TypeError
if you do so). This is different from a positional argument with a default value.
>>> f(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given
where f(1, 2)
is equivalent to bar(1, 2, b=3)
The only way to override it is with a keyword argument
>>> f(2, b=2)
An argument that can only be assigned with a keyword but positionally? This is a keyword-only argument. Thus (a, *, b=3)
instead of (a, b=3)
.
The Rationale of Non-default Argument follows Default Argument
f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)' # whaaa?! non-default argument follows default argument?
- You can't do
def bar(a=3, b)
.a
andb
are so calledpositional-or-keyword arguments
. - You can do
def bar(*, a=3, b)
.a
andb
arekeyword-only arguments
.
Even though semantically, a
has a default value and thus it is optional, we can't leave it unassigned because b
, which is a positional-or-keyword argument
needs to be assigned a value if we want to use b
positionally. If we do not supply a value for a
, we have to use b
as a keyword argument
.
Checkmate! There is no way for b
to be a positional-or-keyword argument
as we intended.
The PEP for positonal-only arguments also kind of shows the rationale behind it.
This also has something to do with the aforementioned "function calling behavior".
partial
!= Currying & Implementation Details
partial
by its implementation wraps the original function while storing the fixed arguments you passed to it.
IT IS NOT IMPLEMENTED WITH CURRYING. It is rather partial application instead of currying in the sense of functional programming. partial
is essentially applying the fixed arguments first, then the arguments you called with the wrapper:
def __call__(self, /, *args, **keywords):
keywords = {**self.keywords, **keywords}
return self.func(*self.args, *args, **keywords)
This explains f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'
.
See also: Why is partial
called partial
instead of curry
Under the Hood of inspect
The outputs of inspect is another story.
inspect
itself is a tool that produces user-friendly outputs. For partial()
in particular (and partialmethod()
, similarly), it follows the wrapped function while taking the fixed parameters into account:
if isinstance(obj, functools.partial):
wrapped_sig = _get_signature_of(obj.func)
return _signature_get_partial(wrapped_sig, obj)
Do note that it is not inspect.signature
's goal to show you the actual signature of the wrapped function in the AST.
def _signature_get_partial(wrapped_sig, partial, extra_args=()):
"""Private helper to calculate how 'wrapped_sig' signature will
look like after applying a 'functools.partial' object (or alike)
on it.
"""
...
So we have a nice and ideal signature for f = partial(bar, 3)
but get f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'
in reality.
Follow-up
If you want currying so badly, how do you implement it in Python, in the way which gives you the expected TypeError
?
Python decorators using functools.partial.. Where does func come from?
First, run_n gets called with func=None
and n=2
. Since func
is None
, it will take the first branch of the if statement. partial
then creates, and returns, an anonymous function that behaves as if it was defined like this:
def anonymous(*args, **kwargs):
return run_n(*args, count=2, **kwargs)
Then that function gets called with the function being defined (i.e, the function printing "hello") as its first argument. This immediately calls
run_n(<func-printing-hello>,count=2)
This time, run_n
has been given a func
argument, and returns the wrapper function that calls func-printing-hello n times. This wrapper, eventually, is what gets assigned to the global func
name.
Using functools partial with multiple arguments
Can I recommend the use of lambda
for your case.
f1 = lambda u : func(u,v,w,x, alpha = 4, beta = 5)
f2 = lambda v, x: func(u,v,w,x, alpha = 4, beta = 5)
f3 = lambda v, alpha: func(u,v,w,x, alpha = alpha, beta = 5)
The usage is the same as what you wanted:
f1(u) #or f(v),f(w)
f2(v,x) #or all other variable combinations
f3(v,alpha) # a combination of args and kwargs
functools.partial vs normal Python function
Functions do have information about what arguments they accept. The attribute names and data structures are aimed at the interpreter more than the developer, but the info is there. You'd have to introspect the .func_defaults
and .func_code.co_varnames
structures (and a few more besides) to find those details.
Using the inspect.getargspec()
function makes extracting the info a little more straightforward:
>>> import inspect
>>> h = lambda x : int(x,base=2)
>>> inspect.getargspec(h)
ArgSpec(args=['x'], varargs=None, keywords=None, defaults=None)
Note that a lambda
produces the exact same object type as a def funcname():
function statement produces.
What this doesn't give you, is what arguments are going to be passed into the wrapped function. That's because a function has a more generic use, while functools.partial()
is specialised and thus can easily provide you with that information. As such, the partial
tells you that base=2
will be passed into int()
, but the lambda
can only tell you it receives an argument x
.
So, while a functools.partial()
object can tell you what arguments are going to be passed into what function, a function
object can only tell you what arguments it receives, as it is the job of the (potentially much more complex) expressions that make up the function body to do the call to a wrapped function. And that is ignoring all those functions that don't call other functions at all.
Related Topics
Loop That Also Accesses Previous and Next Values
How to Get My Program to Sleep for 50 Milliseconds
How to Limit Concurrency with Python Asyncio
Create a "With" Block on Several Context Managers
Windows Is Not Passing Command Line Arguments to Python Programs Executed from the Shell
Adding a Module (Specifically Pymorph) to Spyder (Python Ide)
Sorting Columns in Pandas Dataframe Based on Column Name
Converting Epoch Time into the Datetime
Error: " 'Dict' Object Has No Attribute 'Iteritems' "
Converting Dict to Ordereddict
How to Obtain the Element-Wise Logical Not of a Pandas Series
Pandas Convert Dataframe to Array of Tuples
How to Convert 'Binary String' to Normal String in Python3
How to Quantify Difference Between Two Images
Pandas Dataframe Stored List as String: How to Convert Back to List