dry-python / dry-python/returns

Composing 0-argument functions

Open
#2,365 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
4.4k
Forks
155
Avg merge
2h 27m
Merged PRs (30d)
22

Description

I have a need to set up a function composition which is both lazy and takes zero arguments. Here is a somewhat contrived example from before I realized functions made by `compose` take exactly one argument no matter what:

```python
import operator
import random

import returns.pointfree as fp
from returns.curry import partial
from returns.functions import compose
from returns.maybe import maybe

might_int = lambda: None if random.randint(0, 1) else 1

compose(
maybe(might_int),
pf.map_(partial(operator.add, 1))
)() # Expected: or

# TypeError: compose..() missing 1 required positional argument: 'argument'
```

This example could easily be reworked with `flow`, of course. However, there may be cases where zero-argument functions need to be composed: any instance method with `self` as its only parameter, `Context.get`, partial or curried functions that are being passed around or reused in lots of places for their laziness, or any pattern that relies on "thunks" (trampolines and continuation passing come to mind).

(The actual use-case I have is a bit more complicated, involving decorator factories and such, and while I was typing up a description of it I found that the decorator factory already handles the very specific thing I was trying to do and I didn't need `compose` at all. I wrote that factory almost two years ago and completely forgot 😅 All the same, I feel like this is a gap in `returns`.)

I can think of three ways to accommodate this usage:

```python
# Add a separate function just for this:
def compose0(
first: Callable[[], _SecondType],
second: Callable[[_SecondType], _ThirdType],
) -> Callable[[], _ThirdType]:
return lambda: second(first())

# Or, trade a couple microseconds for some flexibility:
import inspect

def compose[_ThirdType, **P](
first: Callable[P, _SecondType],
second: Callable[[_SecondType], _ThirdType],
) -> Callable[P, _ThirdType]:
param_count = len(inspect.signature(func).parameters) # May not always work; see edit below.
if param_count == 1:
return lambda argument: second(first(argument))
elif param_count == 0:
return lambda: second(first())
else:
# This helps by raising an error sooner, i.e. as soon as `compose` is called rather than when
# the produced lambda is called. This _could_ be the difference between catching a bug
# when the application starts instead of during runtime, having slipped through tests.
raise TypeError("The first function given to compose must be a 0- or 1-argument callable.")

# Or, total flexibility, and probably a strain on type checkers:

def compose[_ThirdType, **P](
first: Callable[P, _SecondType],
second: Callable[[_SecondType], _ThirdType],
) -> Callable[P, _ThirdType]:
def _composed(*args: P.args) -> _ThirdType:
return second(first(*args))

return _composed
```

Edit to add one aside:

There's an additional (if minor) advantage with the form of the third option--that is, returning a `def` function rather than a `lambda` function: Lambdas cannot be pickled as of Python 3.13, but regular functions can be. Most users are probably not bothered by this, admittedly, but it could become a complication for applications that also use eg. Celery, which has the option of using `pickle` as its data transfer protocol between actors.

One more:

I discovered that `inspect.signature` doesn't always play nicely with functions that are implemented in C and don't provide metadata. `ContextVar.get` happens to be one such function. I haven't yet found a good way to check if a function can be inspected this way other than to wrap it in a try/except.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.