Skip to main content

Automatic currying, uncurrying and application of functions and methods

Project description

Features

  • Decorators for functions, methods and class methods.
  • Supports positional, positional-only, positional-variable, keyword, keyword-only, and keyword-variable arguments.
  • Accepts too few argument.
  • Accepts too many arguments, storing them for the next resulting function that wants them.
  • Automatically applies the underlying callable when all the necessary arguments have been passed.
  • Automatically re-curries/re-applies when the result of the callable is itself callable.
  • Picklable (no lambdas).
  • Flat (curry(curry(f)) is simplified to curry(f)).
  • Inspection-friendly: implements __signature__, __name__, __str__, etc.

Context

Mathematically, Python can be approached as a Closed Monoidal Category.

  • Category: Python’s functions and other callables can be composed.
  • Monoidal: We can put two python things together to form a new python thing (lists, dictionaries, tuples, class attributes, etc.).
  • Closed: Pythons’s functions (callables) are first-class, meaning they can be treated like any other non-callable Python thing.

All closed monoidal categories are also Cartesian closed categories, and all Cartesian closed categories have currying.

In simple terms, currying takes a function that takes two arguments and turns it into a function of one argument that returns a function of one argument:

(a, b) -> c  ===curry==>  a -> (b -> c)

The opposite operation is called un-currying, and lets you call a function with more arguments than it requires.

Python is not limited to unary functions and pairs of arguments, Python’s callable objects accept posititional arguments, keyword arguments, and even variable arguments. Python has functions, but also methods, some of which can be class methods that are in fact implemented as descriptors. Furthermore, Python can do introspection, so not only should curried callables work, they should also maintain signatures, names, docstrings, etc. To complicate things even more, we should be able to pickle curried callables and have weakrefs to them.

The yummycurry package aims at providing a simple and reliable way to automatically curry, uncurry and apply (call) any Python callable.

Walkthrough

Decorator or simple function

The function yummycurry.curry can be used as a decorator or as a function:

from yummycurry import curry

def dbl(x):
    return x * 2
dbl = curry(dbl)  # As a function.

@curry  # As a decorator.
def inc(x):
    return x + 1

Too few arguments

A trivial use of curry is to call a function with fewer arguments than it requires.

We can see it the other way around and design with curry in mind, in order to define functions that take more parameters than they actually need. It is common to see function composition implemented as such:

def compose(f, g):
    return lambda x: f(g(x))

One severe problem is that lambdas cannot be pickled, which prevents them from being shared easily in a multiprocessing environment. Another problem is the lack of useful __doc__ and __name__ which make introspection, documentation and printing/logging difficult. Finally they are difficult to read. As a rule of thumb, lambdas should not escape the scope in which they are defined.

We can avoid returning lambdas by making compose take a third argument and relying on curry to wait for it:

@curry
def compose(f, g, x):
    """Composition of unary functions."""
    # No need to return a lambda, ``curry`` takes care of it.
    return f(g(x))

dbl_inc = compose(dbl, inc)
assert dbl_inc(10) == 22

# Function composition is associative: as long as the order or the leaves
# is preserved, the way that the tree forks does not matter.
pipeline_1 = compose(compose(dbl, compose(inc, dbl)), compose(inc, inc))
pipeline_2 = compose(compose(compose(compose(dbl, inc), dbl), inc), inc)
assert pipeline_1(10) == 2 * (1 + 2 * (1 + 1 + 10))
assert pipeline_2(10) == 2 * (1 + 2 * (1 + 1 + 10))

This version of compose, which relies on curry, has no lambdas and is therefore picklable. We will see later that curry preserves the name, documentation, and even the signature of the underlying callable. Those are also features of functools.partial, so yummycurry brings no surprise there.

Automatic application, re-currying and uncurrying

With functools.partial, there are two explicit phases:

  1. The currying phase: create a partial object by setting some of, or all, the arguments.
  2. The application phase: apply the partial object by calling it with all the remaining arguments, even if there are actually no remaining arguments.

Example:

from functools import partial

def cool(x, y, z):
    return x * 100 + y * 10 + z

p = partial(cool, 1, 2, 3)  # Phase 1: explicit currying.
result = p()  # Phase 2: explicit application, even if there are no arguments.
assert result == 123

If we want to curry again we have to be explicit:

p = partial(cool, 1)  # Explicit currying.
p = partial(p, 2)  # Explicit currying, again.
result = p(3)  # Explicit application.
assert result == 123

Automatic application

With yummycurry, function application is automated:

p = curry(cool, 1)
p = p(2)
result = p(3)
assert result == 123

To achieve this, yummycurry inspects its underlying callable (in our case cool) and compares its signature with the arguments that have been provided so far. If the arguments satisfy the signature of the underlying callable, then it is automatically applied, otherwise yummycurry returns a new callable that waits for more arguments: it re-curries itself.

Automatic application stops when the result is not callable. This means that curry accepts non-callable objects; it just returns them untouched:

s = "Don't call us, we'll call you"
assert curry(s) == s

@curry
def actually_constant():
    return 123

assert actually_constant == 123

Automatic re-currying

Not only does yummycurry re-curries its underlying callable when it needs more arguments, but it also automatically curries any callable resulting from its application.

If a callable f0 returns a callable f1 that is not explicitly curried, then curry(f0) will automatically curry f1:

def f0(x:int):  # Uncurried
    def f1(y:int, z:int) -> int:  # Uncurried
        return x*100 + y*10 + z
    return f1

# Without currying, this is the only thing that works:
assert f0(1)(2, 3) == 123

try:
    assert f0(1)(2)(3) == 123
except TypeError:
    pass  # The result of f0(1) is not curried so f0(1)(2) is incorrect.

# If we curry f0, then its result ``f0(1)`` is automatically curried:
f0 = curry(f0)
assert f0(1)(2)(3) == 123  # Now it works.

The process continues: if curry(f1) returns a callable f2 then it gets curried as well. The process stops when the result of a function is not callable. In this example, the number 123 is not callable so the automatic currying and application stops.

When currying, we wish to always preserve f(x, y) == f(x)(y). There are cases in which this symmetry cannot be preserved: when f accepts a variable-argument parameter (like *args or **kwargs), or when a parameter has a default value. This will be addressed later in this document.

Automatic uncurrying

Unlike functools.partial and many other Python packages that ship a currying function, yummycurry accepts arguments even when they do not match any parameter of the curried callable.

If a curried function f0 is called with too many arguments, and if its result is a function f1, then f1 is automatically called with the arguments that f0 did not use.

From a mathematical point of view, it is not currying but uncurrying:

a -> (b -> c)  ===uncurry==>  (a, b) -> c

Indeed, by accepting more arguments than necessary, yummycurry effectively turns a function-returning-function (a -> (b -> c)) into a function of several parameters ((a, b) -> c).

The process repeats itself automatically until it runs out of arguments or the result is not callable:

def one_param_only(x):
    def i_eat_leftovers(y):
        return x + y
    return i_eat_leftovers

try:
    greeting = one_param_only('hello ', 'world')
except TypeError:
    pass  # We knew it would not work.

With yummycurry you can call a one-parameter function with more than one argument. In our example, one_param_only does not use 'world', so curry passes it to the result of one_param_only, which is a i_eat_leftovers closure:

greet = curry(one_param_only)
greeting = greet('hello ', 'world')
assert greeting == 'hello world'

Until now, we have always called curry or @curry with a single argument: the callable to curry. However, it is possible to give more arguments to curry; they will simply be passed to the underlying callable.

The three following snippets are equivalent:

greet = curry(one_param_only)
greeting = greet('hello ', 'world')
assert greeting == 'hello world'

greet = curry(one_param_only, 'hello ')
greeting = greet('world')
assert greeting == 'hello world'

greeting = curry(one_param_only, 'hello ', 'world')
assert greeting == 'hello world'

It is an error to have left-over arguments when the automatic application stops:

# Good:
assert curry(inc, 123) == 124

# Bad:
try:
    curry(inc, 123, 456, x=789)
except TypeError:
    pass

It raises TypeError: left-over arguments at the end of evaluation: *(456,), **{'x':789}.

In that example, inc(123) returns the integer 124 which is not callable and does not know what to do with the extra arguments. Instead of letting Python return its typical TypeError: 'int' object is not callable, yummycurry gives an error message that lists the leftover parameters, which helps with debugging.

Keyword arguments

In addition to positional parameters, Python also has keyword parameters.

One can use yummycurry and keyword arguments when the order of the positional parameters is inconvenient (except for positional-only parameters in Python >=3.8 which will never accept being fed by a keyword argument):

@curry
def list_map(f, iterable):
    return list(map(f, iterable))

primes = [2, 3, 5, 7]

over_primes = list_map(iterable=primes)

assert over_primes(inc) == [3, 4, 6, 8]

Conflicts between keyword and positional arguments

Keyword arguments and positional arguments can fight over names. The curry function is designed to break whenever Python would break (with error messages close to the original ones).

  • For example, if a positional-only parameter (Python >=3.8) is fed by a keyword argument, both curry and undecorated functions raise TypeError.
  • If a positional-or-keyword parameter is fed both by a positional and a keyword argument, TypeError is raised.
@curry
def give_name(who, name, verbose=False):
    if verbose:
        print('Hello', name)
    new_who = {**who, 'name':name}
    return new_who

@curry
def create_genius(iq: int, best_quality:str, *, verbose=False):
    you = dict(iq = 50, awesome_at=best_quality)
    if iq > you['iq']:
        you['iq'] = iq
        if verbose:
            print('Boosting your iq to', iq)
    else:
        if verbose:
            print('You are already smart enough')
    return give_name(you)

Consider the following call:

dear_reader = create_genius('spitting fire', name='Darling', iq=160, verbose=True)

That call raises TypeError: multiple values for argument 'iq', as it would if it were not decorated with @curry. It would have been possible to make curry detect that iq is passed as a keyword, and conclude that 'spitting fire' should go to best_quality, but this would make the decorated and undecorated versions behave differently. Indeed, Python complains in this situation for the undecorated function. In order to be transparent and predictable, curry complains as well.

One could think that doing it in two steps would resolve the ambiguity:

smart = create_genius(name='Darling', iq=160, verbose=True)
dear_reader = smart('spitting fire')

but it does not, which is a good thing. In this case, the signature of smart is (best_quality: str), and we properly call it with a string. Nevertheless it still raises the same TypeError about iq having more than one value. This is by design. The order of the keyword arguments, and the number of calls that sets them, should not matter. If it breaks in one case, it should breaks in all cases. Otherwise that is a debugging nightmare.

Two exceptions to this rule: variable-argument parameters (*args and **kwargs), and parameters with default values. As shown later in this document, those break the symmetry.

There are many ways to fix this call. For example, if we insist in passing name and iq as keywords, then it is necessary to pass best_quality as a keyword as well to remove all ambiguity. This can be done in any order, in as many calls as wanted:

dear_reader = create_genius(
    best_quality='spitting fire',
    name='Darling',
    iq=160,
    verbose=True
)

# ... equivalent to ...

smart = create_genius(name='Darling', iq=160, verbose=True)
dear_reader = smart(best_quality='spitting fire')

Keyword arguments are used only once

If you run the code above, you will notice that setting verbose=True makes create_genius print something. However, give_name does not print anything. This happens because curry uses arguments only once. When create_genius returns the give_name function, the verbose argument has already been consumed.

Variable positional and keyword arguments

If a callable has a variable-argument parameter, whether positional or keyword, then it will take all the available arguments and will not pass them down the call chain:

@curry
def greedy(x, *args):
    if args:
        print('I am stealing your', args)
    def starving(y):
        return x + y
    return starving

assert greedy(10)(1) == 11

Here, greedy is satisfied with one argument (even if it could take more) so it executes and returns the starving closure which takes 1. Because of this, we break the general rule-of-thumb that f(x)(y) == f(x, y). Indeed:

try:
    assert greedy(10, 1) == 11
except AssertionError:
    pass

Here, greedy takes the 1 it its *args, it even brags about it with its print statement. Then, satisfied, it executes. The result is the starving closure. That closure does not receive any argument to feed its parameter so it cannot execute, it remains callable, it is not an integer and therefore is not equal to 11. There is no workaround, one must give starving its own argument:

assert greedy(10, 1000, 2000, 3000, 4000)(1) == 11

The same rule applies for variable-keyword-argument parameters:

@curry
def black_hole(mass, **slurp):
    def hawking_radiation(*, bleep):
        return 'tiny {}'.format(bleep)

    return hawking_radiation

assert black_hole(10, bleep='proton', curvature='thicc')(bleep='neutrino') == 'tiny neutrino'

Here, the black hole swallowed our bleeping proton, so the Hawking radiation requires that we specify a new bleep.

Be careful: currying a function that takes only variable arguments will execute it immediately since its signature is satisfied by getting nothing at all.

As mentioned earlier in this document, variable-argument parameters break the general rule of thumb that f(x)(y) == f(x, y).

Inspection and debugging

str

Curried functions are easy on the eyes when given to str. This is achieved by using the __name__ attribute of underlying callables, if they have one:

@curry
def inc(x: int) -> int:
    return x + 1

@curry
def dbl(x: int) -> int:
    return x * 2

def _compose(f: Callable[[int], int], g: Callable[[int], int], x: int) -> int:
    return f(g(x))

compose = curry(_compose)  # __name__ will retain the underscore.

assert str(compose(inc, dbl)) == '_compose(inc, dbl)'  # Note the underscore.
assert str(compose(inc, x=10)) == '_compose(inc, x=10)'

Curried class

Using __repr__ reveals that the composed function is in fact an object of type Curried:

print(repr(compose(inc, x=10))
# Curried(<function _compose at 0x000001F8D864A550>,
# (Curried(<function inc at 0x000001F8D864A430>, (), {},
# <Signature (x: int) -> int>),), {'x': 10},
# <Signature (g: Callable[[int], int]) -> int>)

That Curried object can be deconstructed with the attributes func, args and keywords (same attribute names as functool.partial objects):

i10 = compose(inc, x=10)
assert i10.func == _compose
assert i10.args == (inc,)
assert i10.keywords == dict(x=10)

The Curried object also updates its signature to reflect the parameters that its callable still needs. In our example, the callable i10 (our Curried object), still expects a parameter g which is a function from int to int. The signature can be accessed via the __signature__ attribute, which is of type inspect.Signature:

import inspect

assert i10.__signature__ == inspect.signature(i10)
print(i10.__signature__)  # (g: Callable[[int], int]) -> int

Note that static type checking tools like MyPy are unlikely to understand this, as they look at the code but do not execute it.

Parameters with default values

Under the hood, curry compares the result of inspect.signature to the positional and keyword arguments collected so far. As soon as the function can be called, it is called. This means that curry does not wait when a parameter has a default value:

@curry
def increase(x:int, increment:int=1):
    return x + increment

assert increase(10) == 11  # Does not wait for ``increment``.

assert increase(10, increment=100) == 110

inc_100 = increase(increment=100)
assert inc_100(10) == 110

Parameters with default values break the general rule-of-thumb that f(x, y) == f(x)(y).

Currying classes, class methods and instance methods

Instance and class methods can also be curried:

class Rabbit:
    def __init__(self, ears, tails):
        self._ears = ears
        self._tails = tails

    @curry_method  # Works here like a read-only property
    def ears(self):
        return self._ears

    @curry_method
    def tails(self):
        return self._tails

    @curry_classmethod
    @classmethod
    def breed(cls, rabbit1, rabbit2):
        # Accurate model of rabbit genetics.
        return cls(
            (rabbit1.ears + rabbit2.ears) / 2,  # Yes, floats.
            rabbit1.tails * rabbit2.tails,
        )

    @curry_method
    def jump(self, impulse, target):
        # Does not mean anything, just a demonstration.
        return [impulse, target, 'boing']

thumper = Rabbit(2, 1)
monster = Rabbit(3, 2)

thumperize = Rabbit.breed(thumper)
oh_god_no = thumperize(monster)  # Currying a class method.
assert oh_god_no.ears == 2.5
assert oh_god_no.tails == 2

thumper_jump = thumper.jump('slow')
assert thumper_jump('west') == ['slow', 'west', 'boing']

And of course, you can curry the class itself:

rabbit = curry(Rabbit)
deaf = rabbit(ears=0)
beethoven = deaf(tails=10)  # 5 per hand.
assert beethoven.ears == 0
assert beethoven.tails == 10

Project details


Release history Release notifications

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Files for yummycurry, version 0.1.0
Filename, size File type Python version Upload date Hashes
Filename, size yummycurry-0.1.0-py3-none-any.whl (11.8 kB) File type Wheel Python version py3 Upload date Hashes View hashes
Filename, size yummycurry-0.1.0.tar.gz (19.8 kB) File type Source Python version None Upload date Hashes View hashes

Supported by

Elastic Elastic Search Pingdom Pingdom Monitoring Google Google BigQuery Sentry Sentry Error logging AWS AWS Cloud computing DataDog DataDog Monitoring Fastly Fastly CDN DigiCert DigiCert EV certificate StatusPage StatusPage Status page