Skip to content

Composing 0-argument functions #2365

@MajorDallas

Description

@MajorDallas

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:

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: <Nothing> or <Some(2)>

# TypeError: compose.<locals>.<lambda>() 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:

# 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)
    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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions