Existe-t-il un moyen pythonique de dissocier les fonctionnalités facultatives de l'objectif principal d'une fonction?

11

Le contexte

Supposons que j'ai le code Python suivant:

def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        for _ in range(n_iters):
            number = halve(number)
        sum_all += number
    return sum_all


ns = [1, 3, 12]
print(example_function(ns, 3))

example_functionici, il suffit de parcourir chacun des éléments de la nsliste et de les diviser par 3, tout en accumulant les résultats. Le résultat de l'exécution de ce script est simplement:

2.0

Puisque 1 / (2 ^ 3) * (1 + 3 + 12) = 2.

Maintenant, disons que (pour une raison quelconque, peut-être le débogage ou la journalisation), je voudrais afficher un certain type d'informations sur les étapes intermédiaires que example_functionprend. Peut-être que je réécrirais alors cette fonction en quelque chose comme ceci:

def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        print('Processing number', number)
        for i_iter in range(n_iters):
            number = number/2
            print(number)
        sum_all += number
        print('sum_all:', sum_all)
    return sum_all

qui maintenant, lorsqu'il est appelé avec les mêmes arguments que précédemment, génère ce qui suit:

Processing number 1
0.5
0.25
0.125
sum_all: 0.125
Processing number 3
1.5
0.75
0.375
sum_all: 0.5
Processing number 12
6.0
3.0
1.5
sum_all: 2.0

Cela réalise exactement ce que je voulais. Cependant, cela va un peu à l'encontre du principe selon lequel une fonction ne devrait faire qu'une seule chose, et maintenant le code de example_functionest légèrement plus long et plus complexe. Pour une fonction aussi simple, ce n'est pas un problème, mais dans mon contexte, j'ai des fonctions assez compliquées qui s'appellent les unes les autres, et les instructions d'impression impliquent souvent des étapes plus compliquées que celles présentées ici, ce qui entraîne une augmentation substantielle de la complexité de mon code (pour un de mes fonctions, il y avait plus de lignes de code liées à la journalisation que de lignes liées à son objectif réel!).

De plus, si je décide plus tard que je ne veux plus imprimer d'instructions dans ma fonction, je devrais parcourir example_functionet supprimer toutes les printinstructions manuellement, ainsi que toutes les variables liées à cette fonctionnalité, un processus à la fois fastidieux et erroné. -enclin.

La situation devient encore pire si je souhaite toujours avoir la possibilité d'imprimer ou de ne pas imprimer pendant l'exécution de la fonction, ce qui m'amène à déclarer deux fonctions extrêmement similaires (une avec les printinstructions, une sans), ce qui est terrible pour la maintenance, ou pour définir quelque chose comme:

def example_function(numbers, n_iters, debug_mode=False):
    sum_all = 0
    for number in numbers:
        if debug_mode:
            print('Processing number', number)
        for i_iter in range(n_iters):
            number = number/2
            if debug_mode:
                print(number)
        sum_all += number
        if debug_mode:
            print('sum_all:', sum_all)
    return sum_all

ce qui se traduit par une fonction gonflée et (espérons-le) inutilement compliquée, même dans le cas simple de notre example_function.


Question

Existe-t-il un moyen pythonique de "découpler" la fonctionnalité d'impression de la fonctionnalité d'origine du example_function?

Plus généralement, existe-t-il un moyen pythonique de dissocier les fonctionnalités facultatives de l'objectif principal d'une fonction?


Ce que j'ai essayé jusqu'à présent:

La solution que j'ai trouvée pour le moment utilise des rappels pour le découplage. Par exemple, on peut réécrire example_functioncomme ceci:

def example_function(numbers, n_iters, callback=None):
    sum_all = 0
    for number in numbers:
        for i_iter in range(n_iters):
            number = number/2

            if callback is not None:
                callback(locals())
        sum_all += number
    return sum_all

puis en définissant une fonction de rappel qui exécute la fonctionnalité d'impression que je souhaite:

def print_callback(locals):
    print(locals['number'])

et appeler example_functioncomme ça:

ns = [1, 3, 12]
example_function(ns, 3, callback=print_callback)

qui sort ensuite:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

Cela dissocie avec succès la fonctionnalité d'impression de la fonctionnalité de base de example_function. Cependant, le principal problème avec cette approche est que la fonction de rappel ne peut être exécutée que sur une partie spécifique de example_function(dans ce cas, juste après la réduction de moitié du nombre actuel), et toute l'impression doit se produire exactement là. Cela oblige parfois la conception de la fonction de rappel à être assez compliquée (et rend certains comportements impossibles à réaliser).

Par exemple, si l'on souhaite obtenir exactement le même type d'impression que je l'ai fait dans une partie précédente de la question (montrant quel numéro est en cours de traitement, avec ses moitiés correspondantes), le rappel résultant serait:

def complicated_callback(locals):
    i_iter = locals['i_iter']
    number = locals['number']
    if i_iter == 0:
        print('Processing number', number*2)
    print(number)
    if i_iter == locals['n_iters']-1:
        print('sum_all:', locals['sum_all']+number)

ce qui donne exactement la même sortie que précédemment:

Processing number 1.0
0.5
0.25
0.125
sum_all: 0.125
Processing number 3.0
1.5
0.75
0.375
sum_all: 0.5
Processing number 12.0
6.0
3.0
1.5
sum_all: 2.0

mais est pénible à écrire, à lire et à déboguer.

JLagana
la source
6
consultez le loggingmodule python
Chris_Rands
@Chris_Rands a raison .. utilisez le module de journalisation .. de cette façon vous pouvez activer et désactiver la journalisation .. utilisez le lien suivant. stackoverflow.com/questions/2266646/…
Yatish Kadam
2
Je ne vois pas comment le loggingmodule pourrait aider ici. Bien que ma question utilise des printinstructions lors de la configuration du contexte, je cherche en fait une solution pour dissocier tout type de fonctionnalité facultative de l'objectif principal d'une fonction. Par exemple, je veux peut-être qu'une fonction trace les choses en cours d'exécution. Dans ce cas, je pense que le loggingmodule ne serait même pas applicable.
JLagana
3
@Pythonic est un adjectif qui décrit la syntaxe / style / structure / utilisation de python pour soutenir la philosophie de Python. Ce n'est pas une règle de syntaxe ou de conception, mais plutôt une approche qui doit être respectée de manière responsable pour produire une base de code python propre et maintenable. Dans votre cas, le fait de disposer de quelques lignes d'instructions de trace ou d'impression ajoute des valeurs à la maintenabilité. ne soyez pas dur avec vous-même. Considérez l'une des approches susmentionnées qui vous semble idéale.
Nair
1
Cette question est trop large. Nous pourrions être en mesure de répondre à des questions spécifiques (comme le loggingmontrent les suggestions d'utilisation ), mais pas comment séparer le code arbitraire.
chepner

Réponses:

4

Si vous avez besoin de fonctionnalités en dehors de la fonction pour utiliser des données de l'intérieur de la fonction, il doit y avoir un système de messagerie à l'intérieur de la fonction pour prendre en charge cela. Il n'y a aucun moyen de contourner cela. Les variables locales dans les fonctions sont totalement isolées de l'extérieur.

Le module de journalisation est assez bon pour mettre en place un système de messagerie. Il ne s'agit pas seulement d'imprimer les messages du journal - en utilisant des gestionnaires personnalisés, vous pouvez tout faire.

L'ajout d'un système de messagerie est similaire à votre exemple de rappel, sauf que les endroits où les «rappels» (gestionnaires de journalisation) sont traités peuvent être spécifiés n'importe où à l'intérieur de example_function (en envoyant les messages à l'enregistreur). Toutes les variables nécessaires aux gestionnaires de journalisation peuvent être spécifiées lorsque vous envoyez le message (vous pouvez toujours les utiliser locals(), mais il est préférable de déclarer explicitement les variables dont vous avez besoin).

Un nouveau example_functionpourrait ressembler à:

import logging

# Helper function
def send_message(logger, level=logging.DEBUG, **kwargs):
  logger.log(level, "", extra=kwargs)

# Your example function with logging information
def example_function(numbers, n_iters):
    logger = logging.getLogger("example_function")
    # If you have a logging system set up, then we don't want the messages sent here to propagate to the root logger
    logger.propagate = False
    sum_all = 0
    for number in numbers:
        send_message(logger, action="processing", number=number)
        for i_iter in range(n_iters):
            number = number/2
            send_message(logger, action="division", i_iter=i_iter, number=number)
        sum_all += number
        send_message(logger, action="sum", sum=sum_all)
    return sum_all

Cela spécifie trois emplacements où les messages pourraient être traités. En soi, cela example_functionne fera rien d'autre que la fonctionnalité de example_functionlui - même. Il n'imprimera rien ni ne fera aucune autre fonctionnalité.

Pour ajouter des fonctionnalités supplémentaires à example_function, vous devrez ajouter des gestionnaires à l'enregistreur.

Par exemple, si vous souhaitez effectuer une impression des variables envoyées (similaire à votre debuggingexemple), vous définissez le gestionnaire personnalisé et l'ajoutez à l' example_functionenregistreur:

class ExampleFunctionPrinter(logging.Handler):
    def emit(self, record):
        if record.action == "processing":
          print("Processing number {}".format(record.number))
        elif record.action == "division":
          print(record.number)
        elif record.action == "sum":
          print("sum_all: {}".format(record.sum))

example_function_logger = logging.getLogger("example_function")
example_function_logger.setLevel(logging.DEBUG)
example_function_logger.addHandler(ExampleFunctionPrinter())

Si vous souhaitez tracer les résultats sur un graphique, définissez simplement un autre gestionnaire:

class ExampleFunctionDivisionGrapher(logging.Handler):
    def __init__(self, grapher):
      self.grapher = grapher

    def emit(self, record):
      if record.action == "division":
        self.grapher.plot_point(x=record.i_iter, y=record.number)

example_function_logger = logging.getLogger("example_function")
example_function_logger.setLevel(logging.DEBUG)
example_function_logger.addHandler(
    ExampleFunctionDivisionGrapher(MyFancyGrapherClass())
)

Vous pouvez définir et ajouter les gestionnaires que vous souhaitez. Ils seront totalement distincts de la fonctionnalité de la example_function, et ne peuvent utiliser que les variables que la example_functionleur donne.

Bien que la journalisation puisse être utilisée comme système de messagerie, il pourrait être préférable de passer à un système de messagerie à part entière, tel que PyPubSub , afin qu'il n'interfère pas avec la journalisation réelle que vous pourriez effectuer:

from pubsub import pub

# Your example function
def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        pub.sendMessage("example_function.processing", number=number)
        for i_iter in range(n_iters):
            number = number/2
            pub.sendMessage("example_function.division", i_iter=i_iter, number=number)
        sum_all += number
        pub.sendMessage("example_function.sum", sum=sum_all)
    return sum_all

# If you need extra functionality added in, then subscribe to the messages.
# Otherwise nothing will happen, other than the normal example_function functionality.
def handle_example_function_processing(number):
    print("Processing number {}".format(number))

def handle_example_function_division(i_iter, number):
    print(number)

def handle_example_function_sum(sum):
    print("sum_all: {}".format(sum))

pub.subscribe(
    "example_function.processing",
    handle_example_function_processing
)
pub.subscribe(
    "example_function.division",
    handle_example_function_division
)
pub.subscribe(
    "example_function.sum",
    handle_example_function_sum
)
RPalmer
la source
Merci pour la réponse, RPalmer. Le code que vous avez fourni en utilisant le loggingmodule est en effet plus organisé et maintenable que ce que j'ai proposé d'utiliser printet les ifdéclarations. Cependant, il ne dissocie pas la fonctionnalité d'impression de la fonctionnalité principale de la example_functionfonction. Autrement dit, le principal problème d'avoir à example_functionfaire deux choses à la fois reste toujours, ce qui rend son code plus compliqué que ce que je voudrais qu'il soit.
JLagana
Comparez cela avec par exemple ma suggestion de rappel. L'utilisation des rappels example_functionn'a désormais qu'une seule fonctionnalité et l'impression (ou toute autre fonctionnalité que nous aimerions avoir) se produit en dehors de celle-ci.
JLagana
Salut @JLagana. My example_functionest découplé de la fonctionnalité d'impression - la seule fonctionnalité ajoutée à la fonction est l'envoi des messages. Il est similaire à votre exemple de rappel, sauf qu'il n'envoie que les variables spécifiques que vous souhaitez, plutôt que toutes les variables locals(). C'est aux gestionnaires de journaux (que vous attachez à l'enregistreur ailleurs) de faire les fonctionnalités supplémentaires (impression, représentation graphique, etc.). Vous n'avez pas besoin de joindre de gestionnaire du tout, auquel cas rien ne se passera lorsque les messages seront envoyés. J'ai mis à jour mon message pour que cela soit plus clair.
RPalmer
Je suis corrigé, votre exemple a dissocié la fonctionnalité d'impression de la fonctionnalité principale de example_function. Merci d'avoir rendu cela plus clair maintenant! J'aime vraiment cette réponse, le seul prix à payer est la complexité supplémentaire de la transmission des messages qui, comme vous l'avez mentionné, semble inévitable. Merci également pour la référence à PyPubSub, qui m'a amené à lire sur le modèle d'observateur .
JLagana
1

Si vous souhaitez vous en tenir à des instructions d'impression, vous pouvez utiliser un décorateur qui ajoute un argument qui active / désactive l'impression sur la console.

Voici un décorateur qui ajoute l'argument de mot clé uniquement et la valeur par défaut de verbose=False à n'importe quelle fonction, met à jour la docstring et la signature. L'appel de la fonction telle quelle renvoie la sortie attendue. L'appel de la fonction avec verbose=Trueactivera les instructions d'impression et renverra la sortie attendue. Cela a l'avantage supplémentaire de ne pas avoir à faire précéder chaque impression d'un if debug:bloc.

from functools import wraps
from inspect import cleandoc, signature, Parameter
import sys
import os

def verbosify(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        def toggle(*args, verbose=False, **kwargs):
            if verbose:
                _stdout = sys.stdout
            else:
                _stdout = open(os.devnull, 'w')
            with redirect_stdout(_stdout):
                return func(*args, **kwargs)
        return toggle(*args, **kwargs)
    # update the docstring
    doc = '\n\nOption:\n-------\nverbose : bool\n    '
    doc += 'Turns on/off print lines in the function.\n '
    wrapper.__doc__ = cleandoc(wrapper.__doc__ or '\n') + doc
    # update the function signature to include the verbose keyword
    sig = signature(func)
    param_verbose = Parameter('verbose', Parameter.KEYWORD_ONLY, default=False)
    sig_params = tuple(sig.parameters.values()) + (param_verbose,)
    sig = sig.replace(parameters=sig_params)
    wrapper.__signature__ = sig
    return wrapper

Envelopper votre fonction vous permet désormais d'activer / désactiver les fonctions d'impression à l'aide de verbose.

@verbosify
def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        print('Processing number', number)
        for i_iter in range(n_iters):
            number = number/2
            print(number)
        sum_all += number
        print('sum_all:', sum_all)
    return sum_all

Exemples:

example_function([1,3,12], 3)
# returns:
2.0

example_function([1,3,12], 3, verbose=True)
# returns/prints:
Processing number 1
0.5
0.25
0.125
sum_all: 0.125
Processing number 3
1.5
0.75
0.375
sum_all: 0.5
Processing number 12
6.0
3.0
1.5
sum_all: 2.0
2.0

Lorsque vous inspectez example_function , vous verrez également la documentation mise à jour. Puisque votre fonction n'a pas de docstring, c'est juste ce qu'il y a dans le décorateur.

help(example_function)
# prints:
Help on function example_function in module __main__:

example_function(numbers, n_iters, *, verbose=False)
    Option:
    -------
    verbose : bool
        Turns on/off print lines in the function.

En termes de philosophie de codage. Avoir une fonction qui n'entraîne pas d'effets secondaires est un paradigme de programmation fonctionnelle. Python peut être un langage fonctionnel, mais il n'est pas conçu pour être exclusivement de cette façon. Je conçois toujours mon code en pensant à l'utilisateur.

Si l'ajout de l'option pour imprimer les étapes de calcul est un avantage pour l'utilisateur, il n'y a RIEN de mal à le faire. Du point de vue de la conception, vous allez être coincé avec l'ajout des commandes d'impression / journalisation quelque part.

James
la source
Merci pour la réponse, James. Le code fourni est en effet plus organisé et maintenable que celui que j'ai proposé, qu'il utilise printet ifénonce. De plus, il parvient à découpler une partie de la fonctionnalité d'impression de example_functionla fonctionnalité principale de, ce qui était très agréable (j'ai aussi aimé que le décorateur ajoute automatiquement à la docstring, nice touch). Cependant, il ne dissocie pas complètement la fonctionnalité d'impression de la fonctionnalité principale de example_function: vous devez toujours ajouter les printinstructions et toute logique qui l'accompagne au corps de la fonction.
JLagana
Comparez cela avec par exemple ma suggestion de rappel. À l'aide des rappels, example_function n'a désormais qu'une seule fonctionnalité, et l'impression (ou toute autre fonctionnalité que nous aimerions avoir) se produit en dehors de celle-ci.
JLagana
Enfin, nous convenons que si l'impression des étapes de calcul est un avantage pour l'utilisateur, alors je serai coincé avec l'ajout des commandes d'impression quelque part. Je veux cependant qu'ils soient en dehors du example_functioncorps du, pour que sa complexité ne reste associée qu'à la complexité de sa fonctionnalité principale. Dans mon application réelle de tout cela, j'ai une fonction principale qui est déjà considérablement complexe. Ajouter des instructions d'impression / traçage / journalisation à son corps en fait une bête qui a été assez difficile à maintenir et à déboguer.
JLagana
1

Vous pouvez définir une fonction encapsulant la debug_modecondition et transmettre la fonction facultative souhaitée et ses arguments à cette fonction (comme suggéré ici ):

def DEBUG(function, *args):
    if debug_mode:
        function(*args)

def example_function(numbers, n_iters):
    sum_all = 0
    for number in numbers:
        DEBUG(print, 'Processing number', number)
        for i_iter in range(n_iters):
            number = number/2
            DEBUG(print, number)
        sum_all += number
        DEBUG(print, 'sum_all:', sum_all)
    return sum_all

ns = [1, 3, 12]
debug_mode = True
print(example_function(ns, 3))

Notez que vous debug_modedevez évidemment avoir reçu une valeur avant d'appeler DEBUG.

Il est bien sûr possible d'invoquer des fonctions autres que print.

Vous pouvez également étendre ce concept à plusieurs niveaux de débogage en utilisant une valeur numérique pour debug_mode.

Gerd
la source
Merci pour la réponse, Gerd. En effet, votre solution élimine le besoin de ifdéclarations partout, et facilite également l'activation et la désactivation de l'impression. Cependant, il ne dissocie pas la fonctionnalité d'impression de la fonctionnalité principale de example_function. Comparez cela avec par exemple ma suggestion de rappel. À l'aide des rappels, example_function n'a désormais qu'une seule fonctionnalité, et l'impression (ou toute autre fonctionnalité que nous aimerions avoir) se produit en dehors de celle-ci.
JLagana
1

J'ai mis à jour ma réponse avec une simplification: la fonction example_functionreçoit un seul rappel ou un crochet avec une valeur par défaut telle qu'elle example_functionn'a plus besoin de tester pour voir si elle a été transmise ou non:

hook=lambda *args, **kwargs: None

Ce qui précède est une expression lambda qui renvoie Noneet example_functionpeut appeler cette valeur par défaut pourhook avec n'importe quelle combinaison de paramètres de position et de mot-clé à divers endroits de la fonction.

Dans l'exemple ci-dessous, je ne suis intéressé que par les événements "end_iteration"et "result".

def example_function(numbers, n_iters, hook=lambda *args, **kwargs: None):
    hook("init")
    sum_all = 0
    for number in numbers:
        for i_iter in range(n_iters):
            hook("start_iteration", number)
            number = number/2
            hook("end_iteration", number)
        sum_all += number
    hook("result", sum_all)
    return sum_all

if __name__ == '__main__':
    def my_hook(event_type, *args):
        if event_type in ["end_iteration", "result"]:
            print(args[0])

    print('sum = ', example_function([1, 3, 12], 3))
    print('sum = ', example_function([1, 3, 12], 3, my_hook))

Tirages:

sum =  2.0
0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0
sum =  2.0

La fonction crochet peut être aussi simple ou élaborée que vous le souhaitez. Ici, il vérifie le type d'événement et effectue une simple impression. Mais il pourrait obtenir une loggerinstance et enregistrer le message. Vous pouvez avoir toute la richesse de la journalisation si vous en avez besoin mais la simplicité si vous n'en avez pas.

Booboo
la source
Merci pour la réponse, Ronald. L'idée d'étendre l'idée de rappel pour exécuter des rappels dans différentes parties de la fonction (et de leur passer une variable de contexte) semble en effet la meilleure façon de procéder. Il rend beaucoup plus facile d'écrire des rappels et à un prix raisonnable dans une complexité supplémentaire example_function.
JLagana
Belle touche avec la valeur par défaut; c'est un moyen simple de supprimer un grand nombre de ifdéclarations :)
JLagana