Curring in Python and why it sucks

katunilya

Ilya Katun

Posted on May 24, 2022

Curring in Python and why it sucks

What is curring?

Curring is a functional technic (or feature) that allows developers to pass arguments to the functions partially. Let's look at the example.

Example

Imagine we have function decode that converts str to bytes with some encoding.

Yep, I know that there is built-in str.decode method, but for simplicity and some non-imaginary add example let's look at this one

def decode(encoding: str, s: str) -> bytes:
    return s.decode(encoding)
Enter fullscreen mode Exit fullscreen mode

This function is executed like decode("UTF-8", some_str) all the time. I'm sure that you would mostly use one encoding ("UTF-8" in particular) so this "UTF-8" argument would be everywhere. One day tired of writing it all the time you decide to refactor that and write another function:

def decode_utf_8(s: str) -> bytes:
    return decode("UTF-8", s)
Enter fullscreen mode Exit fullscreen mode

It is actually just a shortcut, but unknowingly you used curring (but not in the way real functional languages do)! Curring is about passing some of the arguments to the function to get another function that just waits when you pass left arguments.

So if function has 3 arguments and you pass only 2, than instead of raising exception you actually get function that accepts only one left argument.

What if Python supported curring?

If Python would support this feature natively than you could've written something like:

# this does not work!!!
decode_utf_8 = decode("UTF-8")  # type: Callable[[str], bytes]
Enter fullscreen mode Exit fullscreen mode

Looks great to me, but to be honest I don't see any possibility this might actually become real in Python3.X (maybe in Python4 some day, but who knows?) due to backward compatibility issues.

But there are a few legal ways of curring functions in Python right now and right there.

Option 1: functools.partial

With functools.partial I could've written decode_utf_8 like so:

from functools import partial

decode_utf_8 = partial(decode, "UTF-8")

decode_utf_8("hello")  # b"hello"
Enter fullscreen mode Exit fullscreen mode

Option 2: toolz.curry (better one)

Another option is provided by great package toolz

from toolz import curry

@curry
def decode(encoding: str, s: str) -> bytes:
    return s.decode(encoding)

decode_utf_8 = decode("UTF-8")
Enter fullscreen mode Exit fullscreen mode

This option looks much-much better as it seems like curring is natively supported with just one decorator.

But why it sucks?

Python is dynamically typed language and after using partial or @curry you can't really get type hint for your code editor. All your attempts to keep code clean and typed via writing type hints and using static type checkers like mypy are lost in vain. VS Code for example tells that decode_utf_8 has type partial or curry instead of actual Callable[[str], bytes].

This makes it hard to use, because you need to remember by yourself what arguments you need to pass to actually execute function. Also it's not always clear what to do with optional arguments. Functions with arbitrary arguments can't be obviously curried too.

Is there any hack?

Well, I've been looking for one and made this simple workaround:

from typing import (
    Callable,
    Concatenate,
    ParamSpec,
    TypeVar,
)

P = ParamSpec("P")
A1 = TypeVar("A1")
AResult = TypeVar("AResult")


def hof1(func: Callable[Concatenate[A1, P], AResult]):
    """Separate first argument from other."""

    def _wrapper(arg_1: A1) -> Callable[P, AResult]:
        def _func(*args: P.args, **kwargs: P.kwargs) -> AResult:
            return func(arg_1, *args, **kwargs)

        return _func

    return _wrapper

# also hof2 and hof3 decorator for separating first 2 and 3
# arguments correspondingly

@hof1
def decode(encoding: str, s: str) -> bytes:
    return s.decode(encoding)

decode_utf_8 = decode("UTF-8")
# but you can't do this:
decode("UTF-8", "hello")  # raises error
Enter fullscreen mode Exit fullscreen mode

With this decorator you must know beforehand what arguments you might want to pass first and what arguments you gonna pass to actually execute function. This is a serious limit, but it helps me save my type hints which I consider more important than easier syntax.

More extensive example:

from toolz import pipe


@hof1
def encode(encoding: str, b: bytes) -> str:
    return b.encode(encoding)


@hof1
def split(sep: str, s: str) -> Iterable[str]:
    return s.split(sep)

@hof1
def cmap(
   mapper: Callable[[X], Y], iterable: Iterable[X]
) -> Iterable[Y]:
    return map(mapper, iterable)

query = pipe(
    b"name=John&age=32",
    encode("UTF-8"),
    split("&"),
    cmap(split("=")),
    dict,
)

print(query)  # {"name": "John", "age": "32"}

# same without hof1
query = pipe(
    b"name=John&age=32",
    lambda b: b.encode("UTF-8"),
    lambda s: s.split("&"),
    lambda ss: map(lambda s: s.split("="), ss),
    dict,
)
Enter fullscreen mode Exit fullscreen mode

What do you think about curring and such workaround? Share your thoughts and questions in the comments!

馃挅 馃挭 馃檯 馃毄
katunilya
Ilya Katun

Posted on May 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related