Wrapping vs. non-wrapping decorators

Regarding the result of “decoration”, there are two types of decorators. Those that wrap the decorated function and those that only register the function in some way, but leave it unchanged.

IMO the difference should be visible. Currently it isn’t:

from somepkg import deco1

@deco1
def function():
    do_something()

Now it is impossible to say if a call of function() will execute some additional code or not.

A decorator is a “syntax sugar” for doing func=decorator(func) immediately after the defintion.
I’d like to discuss if it makes sense to you having also a non-assigning variation of the decorator syntax which guarantees that the original function remains untouched.

Just as an example:

@:atexit.register
def foo(): ...

It seems to me that “will execute some additional code” is only one aspect of how the decorator might behave. For example, some decorators conditionally call the function be decorated. There won’t be a way to use new syntax to capture the full variety of what decorators might do.

To understand what a decorator does, you need to do the same thing you do for any function or class: read the documentation that explains the thing.

7 Likes

I believe the request is for the new syntax to mean

def func(): ...

deco(func) # no assignment there

Please excuse the strange formatting mobile text enty doesn’t work in code blocks

As far as I’m concerned lack of reassignment destroys much of the utility

The new syntax is purely a restriction for something that already has a way

So im opposed to the new syntax

I’m aware that new syntax (for anything) has near zero chance.
I tried to create an example using only type hints, but could make one.

The goal is just to say: “The decorator did not modify this function. You can still use it as before.”

Just check the hints of the decorator. If it is some def deco(callable: T) -> T: … , that should indicate that we return the exact same object, (having a Union would return the exact same object or something else), and anything else would be ‘it could return the same object, don’t be 100% sure about that though’.

This is similar to checking the docs. The information is not where the decorator is used.

Sorry but

does not indicate that deco(callable) is callable, only that type(deco(callable)) is type(callable), i.e. T.

For instance, if you have a decorator @with_logging that adds logging to a function, the function’s type / signature (argument and return types) remain the same, but the function is changed (different identity and code) by adding the logging.

1 Like

TypeVars (like T in this case) can be bound, and that means that I can say that T must be callable. Therefore deco(something_that_works_for_T) will return something callable (and more specifically, T’s Type itself).

Sorry for not being clear about what T does.

In the case of decorators, “read the docs” sometimes turns into “read the source code” for the following information:

  • Is the decorator passing through the incoming callable, returning the identical callable object as is?
  • If the decorator wraps, does it wrap the passed-in callable nicely (are the __name__, __qualname__, etc. set properly?)
  • Does it expect a class? If so, exactly what class does it return? Can I expect isinstance and alike to still work properly afterwards?

There are many other issues to be aware of, and not all of this information can realistically be addressed in the current type system or explained in detail in the docs while being general. This is a consequence of decorators being a convenience shorthand for a call-and-reassign syntax: since functions can just about do anything, decorators can do anything as well. The concept of an API boundary doesn’t apply as cleanly in the case of decorators because the input domain is user-written outside of the decorator designer’s control. And that the decorator syntax looks like that of simple annotations in other languages certainly doesn’t help make its metaprogramming power apparent.

The workings of decorators is one of the few cases where I consider “please consult the course code” to be a satisfactory answer.

This is a documentation issue. Please open an issue for each case where important information is missing.

2 Likes

Decorators are a general purpose metaprogramming mechanism. Wrapping and registering are two use cases, but far from the only ones. Take @property for example. It sort of wraps the function, but does a whole lot more by returning a descriptor that is not directly callable that implements attribute access. Or @overload that registers it, but not in any way that is meaningful during execution and doesn’t wrap it but instead raises an exception. While both of these sort of wrap or sort of register, they demonstrate decorators do not cleanly fit into the two categories you are trying to draw a clear distinction between.

1 Like

The dividing line is a very clear IMO. The decorator either replaces the original definition with a different object or not. Nothing in between.

  1. If the decorator isn’t replacing the decorated function, we know for sure the code will be executed as we see it. My idea was to make this case visible in the code, i.e. that the decoration was applied without assignment. The purpose was to make code reading easier.

  2. If the decorated function was replaced, we need to read the docs, what the decorator does. So, no change here. I find it irrelevant that this case can be further sub-divided into more categories.

What about a decorator that patches the bytecode of the supplied function? That would not technically replace it, but would change its behaviour.

Not mentioned yet is what you will do with this guarantee if you could get it? Is this for people, or type checkers, or something else?

We’ve covered a lot of nuances here, so I still believe there isn’t a strong line to divide decorators into two kinds. But even if we could, I don’t understand what would be useful about the division.

2 Likes

The only purpose is to make the code little bit better readable for humans. I’m sorry if that was not clear from the very beginning.


I was recently in a situation where I would find it helpful. Now I got some feedback and it looks like this situation is not so common as I thought.

1 Like

Id rather propose a extension of literal to typevars

So a decorator[T](func: Literal[T] as R) →R:

Would imply the literal that was passed in gets passed out

@xitop Maybe you could use a custom decorator instead of a new syntax.

def notassign(inner_decorator):
    def decorator(func):
        inner_decorator(func)
        return func
    return decorator


functions = []

import functools
@notassign(functools.cache)
@notassign(functions.append)
def addition(a, b):
    print(a, '+', b)
    return a + b

assert functions == [addition]
addition(3, 5) # will print
addition(3, 5) # will print too
1 Like