Decorator Operator

Specification

The decorator operator, as the name implies, enables the use of decorators inline. The syntax is as follows:

value @func_or_decorator

Comparing it with the traditional decorator, it works as follows:

@decorator
def func():
    ...

# Using decorator operator:
def func():
    ...
func = func @decorator  # or func @= decorator

This diverges from the traditional decorator in that value does not necessarily have to be a function (although it can be). Essentially, value @func_or_decorator is equivalent to func_or_decorator(value).

The code of val = func(val)(or val = val @func) can be substituted with @=. Special treatment using __imatmul__ is not necessary.

val = map(str, 10 @range)
val @= list  # same with `val = list(val)`
print(val)
# output: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

Applications

When Multiple Functions Are Overlapped

Flat is better than nested. However, there are many cases where multiple functions are overlapped. In this case, using the decorator operator can be an intuitive alternative.

def joiner(delimiter: str):
    return lambda iterable: delimiter.join(str(i) for i in iterable)

print(joiner("|")(list(range(10))))

# Using decorator operator:
10 @range @list @joiner("|") @print
# output: 0|1|2|3|4|5|6|7|8|9

When Argument Variable and Assigned Variable Are Overlapped

The decorator operator can also be used very effectively by applicating @= when the var = func(var) form is used.

# from https://discuss.python.org/t/apply-operator-f-x-means-f-x/32834/7
import numpy as np

arr = np.array(...)
arr = np.mean(arr)
arr = np.sqrt(arr)
arr = np.log(arr)

# Using decorator operator:
arr = np.array(...)
arr = arr @np.mean  # same with arr = arr @np.mean
arr = arr @np.sqrt
arr = arr @np.log

# Using in-place decorator operator:
arr = np.array(...)
arr @= np.mean
arr @= np.sqrt
arr @= np.log

Complex Function Execution

In complex function execution, you can largely eliminate parentheses and reduce the chance of getting lost in the swamp of parentheses.

# from https://discuss.python.org/t/apply-operator-f-x-means-f-x/32834/19
result = collections.OrderedDict(
    map(
        lambda x: (x[0],x),
        self.get_buy_operations_with_adjusted(sorted(items))
    )
)

# Using decorator operator:
result = map(
    lambda x: (x[0], x),
    items @sorted @self.get_buy_operations_with_adjusted
) @collections.OrderedDict

Pipelining

# from https://discuss.python.org/t/apply-operator-f-x-means-f-x/32834/6
result = data
result @= filterpipe(lambda age: age >= 25)
result @= selectpipe("name", "score")
result @= arrangepipe("score", order="desc")

Decorating Functions Already Defined

You can also use it when the place where the function is declared and the place where the decorator can be applied are different, or when you want to apply the decorator only once.

# from https://stackoverflow.com/questions/28245450/statement-decorators
import requests

def retry(count: int):
    def inner(f):
        def wrapper(*args, **kwargs):
            for i in range(count):
                try:
                    return f(*args, **kwargs)
                except Exception:
                    if i + 1 >= count:
                        raise
        return wrapper
    return inner

# post-define decorating
requests.get @= retry(3)
res = requests.get(unstable_source)

# instant decorating
res = (requests.get @retry(3))(unstable_source)

Broadcasting

In numpy.array or pandas.Series etc, broadcasting is used for operators. The disadvantage is that broadcasting does not apply to functions, but the decorator operator is an operator and can therefore be overloaded.

Callables and arrays are completely separate types, so this feature can be clearly distinguished from matrix multiplication.

import numpy as np
arr = np.array(10 @range @list)
print(arr - 1)  # [-1  0  1  2  3  4  5  6  7  8] (broadcasted)
print(str(arr))  # [0 1 2 3 4 5 6 7 8 9] (Not broadcasted)
print(arr @str)  # will be ['0' '1' '2' '3' '4' '5' '6' '7' '8' '9'], same with np.array([str(i) for i in arr])

Of course, to support this, agreement with the library developers is necessary.

Features

Very Easy to Implement

The entire implementation is as follows:

from typing import Callable

class DecoratorOperatorMeta(type):
    __matmul__ = lambda cls, other: other(cls)
    __rmatmul__ = lambda cls, other: cls(other)


class pipeline[T, U, V](metaclass=DecoratorOperatorMeta):
    """A decorator for implementing decorator operator on Python."""

    def __init__(self, f: Callable[[U], T]) -> None:
        self.func = f

    def __call__(self, *args, **kwargs) -> T:
        return self.func(*args, **kwargs)

    def __matmul__(self, other: Callable[[Callable[[U], T]], V]) -> V:
        """__matmul__ is not needed if decorator operator is being default.

        pipeline_func @no_pipeline_func
        ==> no_pipeline_func(pipeline_func)
        """
        return other(self.func)

    def __rmatmul__(self, other: U) -> T:
        """
        argument @pipeline_func
        ==> pipeline_func(argument)
        """
        return self.func(other)

It might seem complex, but essentially, only the following one line[1] needs to be added to the implementation of a callable.

__rmatmul__ = lambda self, other: self(other)

Python already has an implementation for the @ operator, so there is no need to change the parser, implement a new magic method, or even reorder the operators.

Intuitive Understanding

Being an extension of the decorator, if you understand decorators, you can readily grasp it.

Adequate Mixing with Regular Function Notation

The decorator operator can blend well with the regular function notation.

"|".join(map(str, list(range(100))))

# Using decorator operator
map(str, 10 @range @list) @"|".join @print
# output: 0|1|2|3|4|5|6|7|8|9

Traditional Decorator Will Be More Explainable

A decorator is a fairly advanced feature that requires a very good understanding of Python’s features, such as closures.

If the decorator operator is implemented, since it can be easily understood even by beginners who have just learned functions, it can reduce the psychological resistance or the barriers to understanding when learning the decorator declaration later.

@deco
def hello():
    ...

# above code is just syntactic sugar for following code:
def hello():
    ...
hello = hello @deco

Alter Implementation of Traditional Decorator

It’s not a monumental alteration, as it fundamentally remains identical, however, the implementation of the traditional decorator needs to be changed from func = decorator(func) to func @= decorator.

Notation

The decorator operator can have two types of notation.

func @decorator: operator and right operand are attached.

  • It can help users who know about decorators to understand intuitively.
  • It can be effectively distinguished from the case where it is used as matrix multiply.
  • You need to explain why there is an exception in the style guideline to newcomers (disadvantage).

func @ decorator: Operator and operand are uniformly spaced.

  • It’s the same usage as other general operators.
  • It’s consistent.

More Than One Way to Do It

This would be the biggest disadvantage of the decorator operator.

It does not offer anything that a regular function cannot do. It’s a kind of syntactic sugar for func(val), just like traditional decorator.

However, PEP 584 explains as follows:

The “Zen of Python” merely expresses a preference for “only one obvious way”:

There should be one-- and preferably only one --obvious way to do it.

The emphasis here is that there should be an obvious way to do “it”.

I dare to think that according to the argument of PEP 584, the decorator operator can be implemented without violating the Zen of Python through a style guideline.

Style Guideline

Here is my proposed style guideline.

Decorator should be used…

  • When it needs one positional parameter.
  • When the output is meaningful (unlike print). However, some instances of pipelining are excluded.

Decorator operator should not be used…

  • With functools.partial only for utilizing the decorator operator.
  • When the traditional decorator can be used.

Examples:

  • All decorators When the traditional decorator is not applicable.
  • Many iterable-based functions (list, iter, sum, max) and many others.
  • Many inner functions (operator.itemgetter, operator.attrgetter etc.)

Try It

You can experience the decorator operator by copying the code in the Very Easy to Implement part and attaching callable @= pipeline to the callable you will use (you can also use DecoratorOperatorMeta in the case of classes). Don’t forget to attach the pipeline decorator to the inner function in the case of inner functions.

str   @= pipeline
range @= pipeline
len   @= pipeline
print @= pipeline
list  @= pipeline
repr  @= pipeline

10 @range @list @repr @print
# output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

map(repr, 10 @range @list) @list
# output: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

More Considerations

The two syntaxes below are some features that I think could be useful when Python starts to focus on implementing functional programming features.
However, you can use the decorator operator effectively without having to implement the features below.

Syntactic Sugar for Partial Function

functools.partial(map, str)
map?(str)

Starred Decorator

map(*(str, range(100)))
(str, range(100)) @@map

Similar Disscussions

Apply operator ( f @ x means f(x) ) - Ideas - Discussions on Python.org
python - Statement decorators - Stack Overflow

Addition

I’m sure this feature has a deep relationship with functional programming language concepts. However, I’m not an expert in functional programming or pipelining. If there’s anything you think I should refer to, please let me know.

Thank you :slight_smile:


  1. Of course, the actual implementation would indeed be more intricate ↩︎

3 Likes

I’m confused as to how this would be any different. You can already explain decorator application just as easily:

@deco
def hello():
    ...

# above code is just syntactic sugar for following code:
def hello():
    ...
hello = deco(hello)

and with the same caveats (that the name hello is not actually ever set to the undecorated function). But inside the decorator - in the decorator implementation - you’re going to have the exact same functionality, and to create a parameterized decorator that calls the original, you’ll still need all the layers of indirection.

Or have I missed something here?

It seems my explanation was insufficient. What I meant was that these two functionalities share the same concept entirely, thus understanding the decorator operator would be beneficial in comprehending traditional decorators as well.
Previously, learning decorators required understanding both the decorator syntax and closures; now, just learning closures will suffice.
Additionally, it aids novice developers, who are still unclear about what decorators are, in conjecturing their functionality.

That’s my point though - you can ALREADY explain existing decorator usage just with a simple assignment and function call. So you’d be adding a new syntax instead of hello = deco(hello) which doesn’t seem to save anything in terms of comprehension - you still need to understand closures to create them (usually, not always - I’ve made decorators that aren’t closures), and now there’s this brand new syntax instead of what’s already used everywhere else.

What’s the gain?

4 Likes

Decorators aren’t conceptually different from other functions; the existing decorator syntax is essentially just a way of calling the decorator function and rebinding the result. So this proposal is really just to add a @ operator to the function type. “The actual implementation” would be in C; it would only be equivalent to the Python code you show.

Not all callables are the same type. Implementing this feature could be achieved in a few different ways:

  • Add __rmatmul__ to all relevant types that are callable. This would at the very least meant type, builtin_function_or_method, function, method, functools.partial, operator.attrgetter, … would all need to gain a new magic method, which would mean this change is distributed throughout the C-implementation and the stdlib. All other third-party callables would also need to add support for this manually or by inheriting a stdlib callable or whatever type.
  • Add __rmatmul__ to object, where it would try to call self.__call__(other) for all objects, meaning that everything now gains this ability automatically. I am not sure if this is a good idea, although I can’t really find any specific issues with this either. It would probably lead to confusing errors.
  • Add this to the interpreters handling of @: If neither side implements __matmul__ or __rmatmul__, and the right-hand-side is callable, call it. This AFAIK would be similar to how iter is currently handled by falling back to __getitem__ and __len__.

All of these ways are IMO quite disruptive for little gain. It also IMO doesn’t really translate to decorators, since decorators put the @func before the thing to operator on, whereas this syntax put’s it afterwards. That’s quite a big difference. In some sense, decorators map better to normal function application than this pipeline syntax.

FWIW, your “prototype” implementing of pipeline also breaks isinstance checks since str and list are no longer types.

If you want to have this kind of feature, a small module that does a bit of black magic to add __rmatmul__ to object would definitely be possible. This would even be reasonably portable across CPython versions.

2 Likes

Upon further consideration, your statement appears to be accurate. The decorator operator seems to provide a good rationale for why the traditional decorator assumes its particular form, rather than helping to understand the traditional decorator itself.

Indeed. I appreciate your clarity.

@MegaIng In my opinion, adding __rmatmul__ to the object seems to be the best approach. __hash__ is automatically enabled or disabled depending on the existence of __eq__. This approach could be applied to decorator operators.

Using @= pipeline to the class is incorrect, but it was a deliberate choice to simplify the code. The accurate code can be written through DecoratorOperatorMeta as follows:

class str(str, metaclass=DecoratorOperatorMeta):
    pass

I personally don’t find the order of decorator operators bothersome, but I can certainly understand that it may be a concern for some people. However, I’m confident that it will become easier to understand once we get used to it.

You are proposing a limited reverse polish notation, which is to say, no parenthesis postfix notation, as a duplicate function call notation for single argument functions. Python bytecode operates this way, without the parameter number limitation. HP calculators allowed 1 or 2 arguments. (Still do, I don’t know if they continue.) They only ever had (have?) a small proportion of the calculator market. The same is true of prefix-only function calling. The may have something to do with subject-verb-object being the most popular sentence type, at least in Europe.

You dismiss the pure duplication too easily. Real syntactic sugar has to have some substantial advantage, which I do not see here.

This has nothing to do with ‘decorators’, which are sugar for a standard deco(func) prefix call, which your proposal dismisses. The decorator advantages, which lead to their acceptance, were a) putting post-processing or wrapper information adjacent to the header instead of at the end, possibly hundreds of lines down and b) not repeating the function name twice more. The latter was especially important for people who have to write long function names (20+ chars) required by an external package and subsequently wrap them for that package.

1 Like

Python developers have adeptly resolved the inherent constraint of the number of function parameters in decorators by utilizing internal functions. Decorator operators are no exception to this accomplishment.

from dataclasses import dataclass

# Traditional decorator example

@dataclass
class MyDataclass:
    data: int

@dataclass(unsafe_hash=True, slots=True, kw_only=True)
class MyDataclass:
    data: int

# Decorator operator example

requests.get @= retry(3)
res = requests.get(unstable_source)

# Alternatively, the 'Similarity with Natural Language' section below provides a relevant example.

Similarity with Natural Language

Interestingly, decorator operators enable functions to be executed in a manner that closely resembles natural language. English follows a subject+verb[+object][+modifier](S+V[+O][+M]) structure, and while Python’s function structure is similar, it’s not identical. The verb, object, and modifier are positioned appropriately, but the subject can only follow the function[1].

Decorator operators may assume a form more akin to natural language than functions, as shown below:

subject @verb(object, modifier=...)

For instance, consider the following examples:

sorted @= smart_partial

my_iterable = (random.random() for _ in 10 @range)

# my_iterable is sorted with `lambda x: abs(x - 0.5)` key.
my_iterable @= sorted(key=lambda x: abs(x - 0.5))

print(my_iterable)
# output(example): [0.5539782183769761, 0.3935715075178017, 0.6101377041323496, 0.631808516311081, 0.34539457243787297, 0.32361153092850614, 0.27686779149177143, 0.8051689788456509, 0.943614571438918, 0.019109995811058544]

The structure of the smart_partial used in this code is as follows:

def smart_partial(f: Callable, *, skip_trying: bool = False):
    def wrapper(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except TypeError:
            # Parameter was incomplete
            def inner(*inner_args, **inner_kwargs):
                return f(*inner_args, *args, **(kwargs | inner_kwargs))
            return inner
    return wrapper

smart_partial @= smart_partial

Most well-crafted functions employ a format that places the most critical and essential arguments at the forefront, with secondary and optional arguments trailing. Consequently, many existing functions can use smart_partial to enable them to have the SV[O][M] format.

Of course, this is not a rigid rule that all functions, including decorators, must strictly adhere to.

The decorator operator shares remarkable similarities with the traditional decorator in terms of functionality, purpose, and application, despite its placement not preceding the value or function. Therefore, it can be regarded as a variant or alternative form of the decorator.

The decorator operator, similar to the traditional decorator, has the ability to prevent the redundancy of the value’s (in the case of the traditional decorator, the function) name, but its functionality extends beyond this.

You can consider the following case as an example:

quite_long_variable_name = my_generator(123)
quite_long_variable_name = list(quite_long_variable_name)


# The above code can be abbreviated as follows:
quite_long_variable_name = my_generator(123)
quite_long_variable_name @= list

  1. While classes can be employed, there are circumstances where this is not feasible. ↩︎

The confusion here is that the ‘matmul’ operator (currently only binary) is being mislabeled/relabeled a ‘decorator’ operator. In a decorator statement, the ‘@’ symbol is not an operator, because the statement is not an expression and does not have a value. As I said before, a decorator decorates a def statement header for 2 specific purposes. We could have introduced a new keyword ‘deco’ and made the syntax deco <function>.

In augmented assignment statements with @=, the assignment symbol is augmented with the particular binary operator ‘@’. The result is still an assignment statement, not an expression with value, and ‘op=’ is not an operator in the proper meaning of ‘operator’. Hence, ‘a op= b op= c’ and ‘a @= b @= c’ are syntax errors, whereas ‘a @ b @ c’ may be fine.

Because matrix multiplication is so specialized, we expected that the ‘matmul’ operator would be redefined more often and more wildly than most other operators. requests.get @= retry(3), which is requests.get = requests.get @ retry(3) with requests.get written and evaluated once instead of twice, is one example. But this requires that requests.get have a __matmul__ method or that retry(3) have a __rmatmul__ method.

In decorators, the function does not have a new special methods precisely because ‘@’ is not an operator.

1 Like

The proposed semantics seem identical to a forward pipe operator: x |> f |> g == g(f(x)). Personally I find that framing more intuitive compared to connecting the concept to decorators - which are a very different design pattern regardless of how they’re implemented, IMO.

My 2¢ here is the same as on the f @ x idea - personally I don’t find this kind of syntax useful without language-level support for partial application / currying. It’s too inconvenient to use.

4 Likes

If the term ‘operator’ gives you pause, how about ‘inline decorator’?

To me, it appears to be an excessive concern. In the event that a library has indeed overridden __matmul__/__rmatmul__, they would adjust their code to align with the new Python version. Since inline decorators are only applied to callables, most code can be easily modified to avoid conflicts with the inline decorator through simple adjustments.
If a library isn’t being updated and no longer supports the latest version of Python, one can simply abstain from utilizing the inline decorator. Forgoing the use of the most up-to-date features for the sake of compatibility is a rather common occurrence.

You still haven’t explained how this is an improvement over simple function calls. You can ALREADY do inline decorator application, it uses a form of syntax that is known to even novice Python programmers, and it doesn’t require any advanced concepts that aren’t also required by your proposed syntax. (For example, you need to understand that functions are first-class objects either way.)

  1. Even in the absence of partial or currying, the decorator operator remains advantageous. The examples shown in the Application part of initial post did not utilize either partial or currying.
  2. Features akin to partial or currying can be implemented using decorators. For instance, decorating smart_partial, as introduced in this post, allows for the integration of partial functionality into numerous functions.
  3. Improvements can be made through adding a library. Just as itertools collates utilities for iterables, and contextlib gathers utilities related to the with-statement, adding a library that collects decorator-related utilities would make the decorator operator more convenient.
  4. Built-in functions and standard libraries can progressively add features for the decorator operator. For instance, a feature corresponding to smart_partial could be fundamentally incorporated.
  5. For these reasons, there is no immediate necessity for the implementation of partial or curring at the language level to coincide with the decorator operator. Similar to the way Python initially introduced TypeVar and later supplemented it with the type parameter syntax, Once the decorator operator is initially implemented, additional functionalities could be introduced as developers become more accustomed to it.

Note: The aforementioned smart_partial has the ability to transform a function into a partial function when a function has insufficient arguments. Therefore, it can be used with the decorator operator while maintaining compatibility.

# Applying smart_partial to map
map @= smart_partial

# Using the existing function style: possible
print(tuple(map(lambda x: x ** 2, range(10))))

# Using the decorator style: possible
10 @range @map(lambda x: x ** 2) @tuple @print

# output: (0, 1, 4, 9, 16, 25, 36, 49, 64, 81)

I believe that the Application section has already provided a substantial examples. This post also provides a few more examples.

Your statement is applied equally to many new features. For instance, consider the following two examples:

  • Instead of the assignment expression(:=), you can use the existing assignment statement.
  • Instead of contextlib.suppress, you can use the existing try-except.

Both the assignment expression and contextlib.suppress were not added to solve problems that couldn’t be solved with existing code, but to make code cleaner and simpler.

When teaching the decorator operator to novice developers, there is no need to impart that functions are considered first-class objects. Instead, when learning about first-class objects later, educators could revisit this topic (For example, “Indeed, the decorator operator we previously learned is intrinsically linked to first-class objects!”).

Then they’re extremely weak justifications. You may need to find some stronger ones. Your first example introduces a pipelining concept, which has already been made use of, but usually with the pipe operator; other than that, there’s no way that it’s really a “decorator” feature - you’re just showing function application. Why use @? And the second example has been thrown around periodically, never gets a lot of support, and again, does not explain how this is a decorator instead of regular function application.

(And your OrderedDict example is so convluted that I would reject it out of hand. Use a comprehension instead of that awful map/lambda code. We’re not writing JavaScript here.)

Those are far from equivalent. The assignment expression, like the name suggests, is an expression, where an assignment statement is a statement. (It’s all right there in the name!) What you’re suggesting here is the replacement of one form of expression with another form of expression, so you don’t gain anything there. And contextlib.suppress, unlike a plain try/except statement, can be dynamically added or removed (courtesy of ExitStack); attempting to do so with try/except results in messy, clunky code.

But in a broader sense, this criticism can be applied to EVERY new feature. Python is already a Turing-complete language, and so there is always a previous way to do things. What you have to prove is not whether there is a way to do it already, but what the advantage is.

Why? You still have first-class functions here. But also, you’re teaching that decorators are this special magical thing, instead of just being… functions that do a thing. Tell me, is this a decorator or just a function?

commands = []
def command(f):
    commands.append(f)
    return f

Should it be taught as a function, or as this amazingly unique thing, a decorator? According to your proposal, it is a decorator, which is a special type of “function that receives a function as its sole parameter”. According to existing definitions, it is a function, just like every other function.

Where is the benefit?

Regardless of the naming you choose for the ‘@’ operator, the above code essentially involves pipelining.

Furthermore, the core concept of the Python decorator feature is to provide the ability to modify the behavior of functions or methods at runtime, rather than being used as functions themselves.

@Rosuav I am currently seeking more practical examples where the decorator operator may prove beneficial. Please understand that this may take some time. My apologies.

@elis.byberi I will respond along with the @Rosuav’s reply. Thank you.