-
Notifications
You must be signed in to change notification settings - Fork 265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Idea: compose as an operator #523
Comments
I have a working typescript version of this (with left-to-right composition). Something analogous would be possible in typed Python, but with the added bonus of decorators and operator overloading to clean up the syntax. Here's the typescript version:
|
I tried, but I couldn't get the type inference to work at all. X = TypeVar('X')
Y = TypeVar('Y')
Z = TypeVar('Z')
class composable:
"""
Decorator to compose functions with the | operator.
"""
def __init__(self, _func: Callable[[X], Y]):
self._func: Callable[[X], Y] = _func
def __call__(self, arg: X) -> Y:
return self._func(arg)
def __or__(
self: Callable[[X], Y], other: Callable[[Y], Z]
) -> Callable[[X], Z]:
def composed(arg: X) -> Z:
return other(self._func(arg))
return composable(composed)
@composable
def to_int(string_: str) -> int:
return int(string_)
@composable
def to_str(int_: int) -> str:
return str(int_)
new_func = to_int | to_str
# expected (int_: int) -> string_: str
# result (_p0: X@__call__) -> Y@__call__ lol |
@cardoso-neto you can make this work by turning from __future__ import annotations # I added this line
# this import allows us to write composable[X, Z] (without quotes)
# instead of "composable[X, Z]" (with quotes)
# as the return type from composable.__or__
from typing import TypeVar, Callable, Generic
X = TypeVar('X')
Y = TypeVar('Y')
Z = TypeVar('Z')
class composable(Generic[X, Y]):
"""
Decorator to compose functions with the | operator.
"""
def __init__(self, _func: Callable[[X], Y]):
self._func: Callable[[X], Y] = _func
def __call__(self, arg: X) -> Y:
return self._func(arg)
def __or__(self, other: Callable[[Y], Z]) -> composable[X, Z]: # I changed this line
def composed(arg: X) -> Z:
return other(self._func(arg))
return composable(composed)
@composable
def float_to_int(float_: float) -> int:
return int(float_)
@composable
def int_to_str(int_: int) -> str:
return str(int_)
float_to_str = float_to_int | int_to_str
# float_to_int: composable[float, int]
# int_to_str: composable[int, str]
# float_to_str: composable[float, str] |
Epic. You fixed it. Ok, now type inference works through the whole chain. |
@cardoso-neto I think that the only way to preserve the entire function type structure is to use from __future__ import annotations
from typing import Callable, Generic, TypeVar
from typing_extensions import ParamSpec
Y = TypeVar('Y')
Z = TypeVar('Z')
P = ParamSpec('P')
class composable(Generic[P, Y]):
"""
Decorator to compose functions with the | operator.
"""
def __init__(self, _func: Callable[P, Y]):
self._func: Callable[P, Y] = _func
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Y:
return self._func(*args, **kwargs)
def __or__(self, other: Callable[[Y], Z]) -> composable[P, Z]:
def composed(*args: P.args, **kwargs: P.kwargs) -> Z:
return other(self(*args, **kwargs))
return composable(composed)
@composable
def float_and_str_to_int(float_: float, str_: str) -> int:
return int(float_ + float(str_))
@composable
def int_to_str(int_: int) -> str:
return str(int_)
weird_func = float_and_str_to_int | int_to_str
# float_and_str_to_int: composable[(float, str), int]
# int_to_str: composable[(int), str]
# weird_func: composable[(float, str), str] Note that As to the function documentation, I'm sure there are many solutions out there that will allow @composable
def f(int_: int) -> str:
'''Converts ints to strs'''
return str(int_)
@composable
def g(str_: str) -> None:
'''Prints strings'''
print(g)
# the composable object copies the decorated function docstring:
f.__doc__ # "Converts ints to strs"
g.__doc__ # "Prints strings"
# however, what should we do with the composition?
(f | g).__doc__ # ??? what should we do here?
# __doc__ should *not* be "Converts ints to strs", because this is not what
# it does; nor should it be "Prints strings" Perhaps a combination of both, like what is already done here in toolz? I would suggest using from toolz.functoolz import compose
class composable(Generic[P, Y]):
...
def __or__(self, other: Callable[[Y], Z]) -> composable[P, Z]:
return compose(other, self) |
I've been thinking about this and, though it is a bit niche, if the left function returned a tuple we could have a separate operator that also unpacked its returns so they'd work on a right function that had more than a single required parameter. Rough sketch: ...
def starcompose(self, other: Callable[[Y], Z]) -> composable[P, Z]:
def composed(*args: P.args, **kwargs: P.kwargs) -> Z:
return other(*self(*args, **kwargs)) # unpacking, splatting, starring, asterisking, discombobulating
return composable(composed)
... Or, more inline with toolz/funcy: # (snippet stolen from Suor/funcy#62) def unpack(func):
@functools.wraps(func)
def wrapper(arguments):
return func(*arguments)
return wrapper And then def starcompose(self, other: Callable[[Y], Z]) -> composable[P, Z]:
return compose(unpack(other), self) Though, we'd have to figure out types for that, I'm sure it wouldn't be a problem for you. |
@eriknw is there any interest in having this feature (I mean the |
Hey, thanks for the ping and offer @ruancomelli! Sorry for my delay in seeing this (new job and email workflow; my filters have been fixed). I think composability is theoretically interesting. And fun. I'm curious, though: how useful will it be in practice? When would you have used it? What API do you suggest?
Yeah, I think there's enough interest in this to get it into Regarding typing: I think we'll want to add typing to |
Hi, @eriknw! I'm so sorry for the long delay. I guess I didn't have time to reply when I first saw it, and then I ended up completely forgetting about it 😬
I don't think I have ever needed this myself, it's easy enough to write The big benefit I see for using the pipe operator is that it is possible to get full type-correctness given the current Python typing environment (we still need def compose2(f: Callable[P, R1], g: Callable[[R1], R2) -> Callable[P, R2]:
def _composed(*args: P.args, **kwargs: P.kwargs) -> R2:
return g(f(*args, **kwargs))
return _composed This function has all the type information we need. This also means that chaining multiple calls to f: Callable[P, R1]
g: Callable[R1, R2]
h: Callable[R2, R3]
f | g # Callable[P, R2]
g | h # Callable[R1, R3]
f | g | h # Callable[P, R3]
g | f # Ooops, incompatible types! In contrast, there is currently no way for us to annotate This leaves us with three ways for composing functions: k = compose(f, g, h) # not type-correct in the general case
k = compose2(f, compose2(g, h)) # type-correct, but not ergonomic - imagine 3 or 5 functions?
k = f | g | h # type-correct and clean
I propose adding a
I don't think so -
If we implement this the way I suggested, no further changes are required here since
I have no preference here - I would personally default to not doing it. But the change would be as simple as making all of those classes derive from
I would guess that any alternative implementations would have the same type-safety/limitations as this one. The implementation from my previous comment (#523 (comment)) is enough to make sure we keep all type information that we need. In particular,
Awesome! I can write a PR for it this weekend. Let me know if you disagree with any of the points above.
You promised!! 😆
If we don't have class composable(Generic[P, Y]):
...
def __or__(self, other: Callable[[Y], Z]) -> composable[P, Z]:
return compose(other, self) as class composable(Generic[Y]):
...
def __or__(self, other: Callable[[Y], Z]) -> composable[Z]:
return compose(other, self) The huge downside of doing this is that we no longer know what parameters |
I just wrote a PR for adding this feature to toolz 😁 |
A thought: using This is probably a non-issue, but I wanted to mention it so others can give it a think-over too.... For example, let's say I use the @composable
class Foo:
... Now Also, the overload of Normally we can just say "then don't do that", but
So I see approximately three reasonable options:
|
Continuing the above and replying to #531 (comment) : For what it's worth, in my library where I sketched out a solution for this, here's how I solved it:
But... in my library this kind of split made more sense because I was trying to thoroughly foresee and handle the decorator and wrapper use-cases as well as possible. That's probably beyond the scope that For example, my library used (click to expand details) Vaguely relevant aside, I also sketched out how I would do
|
One neat thing that caused me to revive it is that just one tiny change made it magically elegantly combine with stuff like import operator
import toolz
from compose_operator import composable
curry = composable(toolz.curry)
add = curry(operator.add)
(add(1) | float | str)(1) # returns "2.0" It doesn't have type hints yet - it would probably be trivial to add type hints for the non- (If type-checking matters more than the operator syntactic sugar for you, my I'll promote |
I wanted to put forward an idea - could functoolz benefit from a compose "operator"? The API I have in mind is the following:
One benefit to this would be that I think this would be easier to add types for. (See: python/mypy#8449)
If this meets with approval, I would be happy to attempt a PR.
The text was updated successfully, but these errors were encountered: