web-dev-qa-db-fra.com

Python meilleure pratique du décorateur, en utilisant une classe vs une fonction

Comme je l'ai compris, il y a deux façons de faire un décorateur Python, soit pour utiliser le __call__ d'une classe ou pour définir et appeler une fonction comme décorateur. Quels sont les avantages/inconvénients de ces méthodes? Y a-t-il une méthode préférée?

Exemple 1

class dec1(object):
    def __init__(self, f):
        self.f = f
    def __call__(self):
        print "Decorating", self.f.__name__
        self.f()

@dec1
def func1():
    print "inside func1()"

func1()

# Decorating func1
# inside func1()

Exemple 2

def dec2(f):
    def new_f():
        print "Decorating", f.__name__
        f()
    return new_f

@dec2
def func2():
    print "inside func2()"

func2()

# Decorating func2
# inside func2()
55
olofom

Il est assez subjectif de dire s'il y a des "avantages" à chaque méthode.

Cependant, une bonne compréhension de ce qui se passe sous le capot rendrait naturel le choix du meilleur choix pour chaque occasion.

Un décorateur (parler de décorateurs de fonctions) est simplement un objet appelable qui prend une fonction comme paramètre d'entrée. Python a sa conception plutôt intéressante qui permet de créer d'autres types d'objets appelables, en plus des fonctions - et on peut l'utiliser pour créer du code plus maintenable ou plus court à l'occasion.

Les décorateurs ont été ajoutés en arrière Python 2.3 comme "raccourci syntaxique" pour

def a(x):
   ...

a = my_decorator(a)

En plus de cela, nous appelons généralement les décorateurs des "callables" qui seraient plutôt des "usines de décorateurs" - lorsque nous utilisons ce type:

@my_decorator(param1, param2)
def my_func(...):
   ...

l'appel est fait à "my_decorator" avec param1 et param2 - il retourne ensuite un objet qui sera appelé à nouveau, cette fois avec "my_func" comme paramètre. Donc, dans ce cas, techniquement le "décorateur" est tout ce qui est retourné par le "my_decorator", ce qui en fait une "fabrique de décorateurs".

Désormais, les décorateurs ou les "usines de décorateurs", comme décrit, doivent généralement conserver un certain état interne. Dans le premier cas, la seule chose qu'il garde est une référence à la fonction d'origine (la variable appelée f dans vos exemples). Une "usine de décoration" peut vouloir enregistrer des variables d'état supplémentaires ("param1" et "param2" dans l'exemple ci-dessus).

Cet état supplémentaire, dans le cas des décorateurs écrits en tant que fonctions, est conservé dans des variables au sein des fonctions englobantes et accessible en tant que variable "non locale" par la fonction wrapper réelle. Si l'on écrit une classe appropriée, elles peuvent être conservées en tant que variables d'instance dans la fonction décoratrice (qui sera considérée comme un "objet appelable", pas une "fonction") - et leur accès est plus explicite et plus lisible.

Donc, dans la plupart des cas, c'est une question de lisibilité que vous préfériez une approche ou l'autre: pour les décorateurs courts et simples, l'approche fonctionnelle est souvent plus lisible qu'une approche écrite en classe - tandis que parfois plus élaborée - en particulier une "décorator factory" profitera pleinement des conseils "flat is better than nested" Fore Python coding.

Considérer:

def my_dec_factory(param1, param2):
   ...
   ...
   def real_decorator(func):
       ...
       def wraper_func(*args, **kwargs):
           ...
           #use param1
           result = func(*args, **kwargs)
           #use param2
           return result
       return wraper_func
   return real_decorator

contre cette solution "hybride":

class MyDecorator(object):
    """Decorator example mixing class and function definitions."""
    def __init__(self, func, param1, param2):
        self.func = func
        self.param1, self.param2 = param1, param2

    def __call__(self, *args, **kwargs):
        ...
        #use self.param1
        result = self.func(*args, **kwargs)
        #use self.param2
        return result

def my_dec_factory(param1, param2):
    def decorator(func):
         return MyDecorator(func, param1, param2)
    return decorator

mise à jour: Formes de décorateurs "de classe pure" manquantes

Maintenant, notez que la méthode "hybride" prend le "meilleur des deux mondes" en essayant de garder le code le plus court et le plus lisible. Une "fabrique de décorateurs" complète définie exclusivement avec des classes aurait besoin de deux classes ou d'un attribut "mode" pour savoir si elle a été appelée pour enregistrer la fonction décorée ou pour appeler réellement la fonction finale:

class MyDecorator(object):
   """Decorator example defined entirely as class."""
   def __init__(self, p1, p2):
        self.p1 = p1
        ...
        self.mode = "decorating"

   def __call__(self, *args, **kw):
        if self.mode == "decorating":
             self.func = args[0]
             self.mode = "calling"
             return self
         # code to run prior to function call
         result = self.func(*args, **kw)
         # code to run after function call
         return result

@MyDecorator(p1, ...)
def myfunc():
    ...

Et enfin un décorateur pur, "col blanc" défini avec deux classes - peut-être en gardant les choses plus séparées, mais en augmentant la redondance à un point on ne peut pas dire qu'il est plus maintenable:

class Stage2Decorator(object):
    def __init__(self, func, p1, p2, ...):
         self.func = func
         self.p1 = p1
         ...
    def __call__(self, *args, **kw):
         # code to run prior to function call
         ...
         result = self.func(*args, **kw)
         # code to run after function call
         ...
         return result

class Stage1Decorator(object):
   """Decorator example defined as two classes.

   No "hacks" on the object model, most bureacratic.
   """
   def __init__(self, p1, p2):
        self.p1 = p1
        ...
        self.mode = "decorating"

   def __call__(self, func):
       return Stage2Decorator(func, self.p1, self.p2, ...)


@Stage1Decorator(p1, p2, ...)
def myfunc():
    ...

Mise à jour 2018

J'ai écrit le texte ci-dessus il y a quelques années. Je suis venu récemment avec un modèle que je préfère en raison de la création de code qui est "plus plat".

L'idée de base est d'utiliser une fonction, mais de renvoyer un objet partial de lui-même s'il est appelé avec des paramètres avant d'être utilisé comme décorateur:

from functools import wraps, partial

def decorator(func=None, parameter1=None, parameter2=None, ...):

   if not func:
        # The only drawback is that for functions there is no thing
        # like "self" - we have to rely on the decorator 
        # function name on the module namespace
        return partial(decorator, parameter1=parameter1, parameter2=parameter2)
   @wraps(func)
   def wrapper(*args, **kwargs):
        # Decorator code-  parameter1, etc... can be used 
        # freely here
        return func(*args, **kwargs)
   return wrapper

Et c'est tout - les décorateurs écrits en utilisant ce modèle peuvent décorer une fonction tout de suite sans être "appelés" d'abord:

@decorator
def my_func():
    pass

Ou personnalisé avec des paramètres:

@decorator(parameter1="example.com", ...):
def my_func():
    pass

** 2019 - ** Avec Python 3.8 et paramètres positionnels uniquement, ce dernier modèle deviendra encore meilleur, car l'argument func peut être déclaré uniquement positionnel et nécessite les paramètres d'être nommé;

def decorator(func=None, /, parameter1=None, parameter2=None, ...):
52
jsbueno

Je suis principalement d'accord avec jsbueno: il n'y a pas qu'une seule bonne façon. Ça dépend de la situation. Mais je pense que def est probablement mieux dans la plupart des cas, parce que si vous allez en classe, la plupart du "vrai" travail se fera en __call__ en tous cas. De plus, les callables qui ne sont pas des fonctions sont assez rares (à l'exception notable de l'instanciation d'une classe), et les gens ne s'attendent généralement pas à cela. En outre, les variables locales sont généralement plus faciles à suivre pour les variables par rapport aux variables d'instance, simplement parce qu'elles ont une portée plus limitée, bien que dans ce cas, les variables d'instance ne soient probablement utilisées que dans __call__ (avec __init__ en les copiant simplement à partir des arguments).

Je dois cependant être en désaccord avec son approche hybride. C'est un design intéressant, mais je pense que cela va probablement confondre la merde de vous ou de quelqu'un d'autre qui l'examine quelques mois plus tard.

Tangente: que vous optiez pour une classe ou une fonction, vous devez utiliser functools.wraps , qui lui-même est destiné à être utilisé comme décorateur (nous devons aller plus loin!) comme ceci:

import functools

def require_authorization(f):
    @functools.wraps(f)
    def decorated(user, *args, **kwargs):
        if not is_authorized(user):
            raise UserIsNotAuthorized
        return f(user, *args, **kwargs)
    return decorated

@require_authorization
def check_email(user, etc):
    # etc.

Cela fait que decorated ressemble à check_email par exemple. en changeant c'est func_name attribut.

Quoi qu'il en soit, c'est généralement ce que je fais et ce que je vois faire autour de moi, sauf si je veux une usine de décoration. Dans ce cas, j'ajoute juste un autre niveau de def:

def require_authorization(action):
    def decorate(f):
        @functools.wraps(f):
        def decorated(user, *args, **kwargs):
            if not is_allowed_to(user, action):
                raise UserIsNotAuthorized(action, user)
            return f(user, *args, **kwargs)
        return decorated
    return decorate

Soit dit en passant, je serais également contre une utilisation excessive des décorateurs, car ils peuvent rendre très difficile le suivi des traces de pile.

Une approche pour gérer les traces de pile hideuses consiste à avoir pour politique de ne pas modifier substantiellement le comportement du décoré. Par exemple.

def log_call(f):
    @functools.wraps(f)
    def decorated(*args, **kwargs):
        logging.debug('call being made: %s(*%r, **%r)',
                      f.func_name, args, kwargs)
        return f(*args, **kwargs)
    return decorated

Une approche plus extrême pour garder vos traces de pile saines consiste pour le décorateur à renvoyer le décoré non modifié, comme suit:

import threading

DEPRECATED_LOCK = threading.Lock()
DEPRECATED = set()

def deprecated(f):
    with DEPRECATED_LOCK:
        DEPRECATED.add(f)
    return f

@deprecated
def old_hack():
    # etc.

Ceci est utile si la fonction est appelée dans un cadre qui connaît le décorateur deprecated. Par exemple.

class MyLamerFramework(object):
    def register_handler(self, maybe_deprecated):
        if not self.allow_deprecated and is_deprecated(f):
            raise ValueError(
                'Attempted to register deprecated function %s as a handler.'
                % f.func_name)
        self._handlers.add(maybe_deprecated)
9
allyourcode

Il existe deux implémentations de décorateur différentes. L'un d'eux utilise une classe comme décorateur et l'autre utilise une fonction comme décorateur. Vous devez choisir la mise en œuvre préférée pour vos besoins.

Par exemple, si votre décorateur fait beaucoup de travail, vous pouvez utiliser la classe comme décorateur, comme ceci:

import logging
import time
import pymongo
import hashlib
import random

DEBUG_MODE = True

class logger(object):

        def __new__(cls, *args, **kwargs):
                if DEBUG_MODE:
                        return object.__new__(cls, *args, **kwargs)
                else:
                        return args[0]

        def __init__(self, foo):
                self.foo = foo
                logging.basicConfig(filename='exceptions.log', format='%(levelname)s %   (asctime)s: %(message)s')
                self.log = logging.getLogger(__name__)

        def __call__(self, *args, **kwargs):
                def _log():
                        try:
                               t = time.time()
                               func_hash = self._make_hash(t)
                               col = self._make_db_connection()
                               log_record = {'func_name':self.foo.__name__, 'start_time':t, 'func_hash':func_hash}
                               col.insert(log_record)
                               res = self.foo(*args, **kwargs)
                               log_record = {'func_name':self.foo.__name__, 'exc_time':round(time.time() - t,4), 'end_time':time.time(),'func_hash':func_hash}
                               col.insert(log_record)
                               return res
                        except Exception as e:
                               self.log.error(e)
                return _log()

        def _make_db_connection(self):
                connection = pymongo.Connection()
                db = connection.logger
                collection = db.log
                return collection

        def _make_hash(self, t):
                m = hashlib.md5()
                m.update(str(t)+str(random.randrange(1,10)))
                return m.hexdigest()
1
Denis

J'oserai proposer une approche différente du problème près de sept ans après le début de la question. Cette version n'est décrite dans aucune des réponses précédentes (très sympa!).

Les plus grandes différences entre l'utilisation de classes et de fonctions en tant que décorateurs sont déjà très bien décrites ici. Par souci d'exhaustivité, je reviendrai brièvement sur ce point, mais pour être plus pratique, je vais utiliser un exemple concret.

Supposons que vous souhaitiez écrire un décorateur pour mettre en cache le résultat des fonctions "pures" (sans effets secondaires, donc la valeur de retour est déterministe, compte tenu des arguments) dans un service de cache.

Voici deux décorateurs équivalents et très simples pour ce faire, dans les deux versions (fonctionnelle et orientée objet):

import json
import your_cache_service as cache

def cache_func(f):
    def wrapper(*args, **kwargs):
        key = json.dumps([f.__name__, args, kwargs])
        cached_value = cache.get(key)
        if cached_value is not None:
            print('cache HIT')
            return cached_value
        print('cache MISS')
        value = f(*args, **kwargs)
        cache.set(key, value)
        return value
    return wrapper

class CacheClass(object):
    def __init__(self, f):
        self.orig_func = f

    def __call__(self, *args, **kwargs):
        key = json.dumps([self.orig_func.__name__, args, kwargs])
        cached_value = cache.get(key)
        if cached_value is not None:
            print('cache HIT')
            return cached_value
        print('cache MISS')
        value = self.orig_func(*args, **kwargs)
        cache.set(key, value)
        return value

Je suppose que c'est assez facile à comprendre. C'est juste un exemple stupide! Je saute toute la gestion des erreurs et les cas Edge pour plus de simplicité. Vous ne devriez pas de toute façon ctrl + c/ctrl + v code de StackOverflow, non? ;)

Comme on peut le constater, les deux versions sont essentiellement les mêmes. La version orientée objet est un peu plus longue et plus verbeuse que la version fonctionnelle, car nous devons définir des méthodes et utiliser la variable self, mais je dirais qu'elle est légèrement plus lisible. Ce facteur devient vraiment important pour les décorateurs plus complexes. Nous verrons cela dans un instant.

Les décorateurs ci-dessus sont utilisés comme ceci:

@cache_func
def test_one(a, b=0, c=1):
    return (a + b)*c

# Behind the scenes:
#     test_one = cache_func(test_one)

print(test_one(3, 4, 6))
print(test_one(3, 4, 6))

# Prints:
#     cache MISS
#     42
#     cache HIT
#     42

@CacheClass
def test_two(x, y=0, z=1):
    return (x + y)*z

# Behind the scenes:
#     test_two = CacheClass(test_two)

print(test_two(1, 1, 569))
print(test_two(1, 1, 569))

# Prints:
#     cache MISS
#     1138
#     cache HIT
#     1138

Mais disons maintenant que votre service de cache prend en charge la définition du TTL pour chaque entrée de cache. Vous devrez définir cela au moment de la décoration. Comment faire?

L'approche fonctionnelle traditionnelle serait d'ajouter une nouvelle couche wrapper qui renvoie un décorateur configuré (il y a de plus belles suggestions dans les autres réponses à cette question):

import json
import your_cache_service as cache

def cache_func_with_options(ttl=None):
    def configured_decorator(*args, **kwargs):
        def wrapper(*args, **kwargs):
            key = json.dumps([f.__name__, args, kwargs])
            cached_value = cache.get(key)
            if cached_value is not None:
                print('cache HIT')
                return cached_value
            print('cache MISS')
            value = f(*args, **kwargs)
            cache.set(key, value, ttl=ttl)
            return value
        return wrapper
    return configured_decorator

Il est utilisé comme ceci:

from time import sleep

@cache_func_with_options(ttl=100)
def test_three(a, b=0, c=1):
    return hex((a + b)*c)

# Behind the scenes:
#     test_three = cache_func_with_options(ttl=100)(test_three)

print(test_three(8731))
print(test_three(8731))
sleep(0.2)
print(test_three(8731))

# Prints:
#     cache MISS
#     0x221b
#     cache HIT
#     0x221b
#     cache MISS
#     0x221b

Celui-ci est toujours correct, mais je dois admettre que, même en tant que développeur expérimenté, je me vois parfois prendre beaucoup de temps pour comprendre des décorateurs plus complexes qui suivent ce modèle. La partie délicate ici est qu'il n'est vraiment pas possible de "désimbriquer" les fonctions, car les fonctions internes ont besoin des variables définies dans la portée des fonctions externes.

La version orientée objet peut-elle aider? Je pense que oui, mais si vous suivez la structure précédente pour celle basée sur la classe, elle se retrouverait avec la même structure imbriquée que la fonctionnelle ou, pire encore, en utilisant des drapeaux pour maintenir l'état de ce que le décorateur fait (pas Agréable).

Ainsi, au lieu de recevoir la fonction à décorer dans la méthode __init__ Et de gérer les paramètres d'habillage et de décorateur dans la méthode __call__ (Ou d'utiliser plusieurs classes/fonctions pour ce faire, ce qui est trop complexe) à mon goût), ma suggestion est de gérer les paramètres du décorateur dans la méthode __init__, de recevoir la fonction dans la méthode __call__ et enfin de gérer l'habillage dans une méthode supplémentaire renvoyée à la fin du __call__.

Cela ressemble à ceci:

import json
import your_cache_service as cache

class CacheClassWithOptions(object):
    def __init__(self, ttl=None):
        self.ttl = ttl

    def __call__(self, f):
        self.orig_func = f
        return self.wrapper

    def wrapper(self, *args, **kwargs):
        key = json.dumps([self.orig_func.__name__, args, kwargs])
        cached_value = cache.get(key)
        if cached_value is not None:
            print('cache HIT')
            return cached_value
        print('cache MISS')
        value = self.orig_func(*args, **kwargs)
        cache.set(key, value, ttl=self.ttl)
        return value

L'utilisation est conforme aux attentes:

from time import sleep

@CacheClassWithOptions(ttl=100)
def test_four(x, y=0, z=1):
    return (x + y)*z

# Behind the scenes:
#     test_four = CacheClassWithOptions(ttl=100)(test_four)

print(test_four(21, 42, 27))
print(test_four(21, 42, 27))
sleep(0.2)
print(test_four(21, 42, 27))

# Prints:
#     cache MISS
#     1701
#     cache HIT
#     1701
#     cache MISS
#     1701

Comme tout est parfait, il y a deux petits inconvénients avec cette dernière approche:

  1. Il n'est pas possible de décorer directement avec @CacheClassWithOptions. Nous devons utiliser la parenthèse @CacheClassWithOptions(), même si nous ne voulons passer aucun paramètre. C'est parce que nous devons d'abord créer l'instance, avant d'essayer de décorer, donc la méthode __call__ Recevra la fonction à décorer, pas dans le __init__. Il est possible de contourner cette limitation, mais c'est très hacky. Mieux vaut simplement accepter que ces parenthèses soient nécessaires.

  2. Il n'y a pas de place évidente pour appliquer le décorateur functools.wraps Sur la fonction encapsulée retournée, ce qui serait une évidence dans la version fonctionnelle. Cela peut être fait facilement, cependant, en créant une fonction intermédiaire dans __call__ Avant de revenir. Il ne semble pas que Nice et il vaut mieux laisser cela de côté si vous n'avez pas besoin des choses agréables que fait functools.wraps.

0
Victor Schröder