Esiste un modo pitonico per separare la funzionalità opzionale dallo scopo principale di una funzione?


11

Contesto

Supponiamo che io abbia il seguente codice Python:

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_functionqui sta semplicemente esaminando ciascuno degli elementi nsnell'elenco e dimezzandoli 3 volte, accumulando i risultati. L'output dell'esecuzione di questo script è semplicemente:

2.0

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

Ora, supponiamo che (per qualsiasi motivo, forse il debug o la registrazione), vorrei visualizzare un tipo di informazioni sui passaggi intermedi che example_functionsta eseguendo. Forse vorrei quindi riscrivere questa funzione in qualcosa del genere:

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

che ora, quando viene chiamato con gli stessi argomenti di prima, genera quanto segue:

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

Questo raggiunge esattamente quello che intendevo. Tuttavia, questo va un po 'contro il principio che una funzione dovrebbe fare solo una cosa, e ora il codice per example_functionè leggermente più lungo e più complesso. Per una funzione così semplice questo non è un problema, ma nel mio contesto ho funzioni abbastanza complicate che si chiamano a vicenda e le istruzioni di stampa spesso implicano passaggi più complicati di quelli mostrati qui, con conseguente aumento sostanziale della complessità del mio codice (per uno delle mie funzioni c'erano più righe di codice relative alla registrazione di quante fossero le righe relative al suo scopo reale!).

Inoltre, se in seguito decido che non voglio più alcuna istruzione di stampa nella mia funzione, dovrei passare attraverso example_functioned eliminare tutte le printistruzioni manualmente, insieme a tutte le variabili correlate a questa funzionalità, un processo che è sia noioso che di errore -prone.

La situazione peggiora ancora se vorrei avere sempre la possibilità di stampare o non stampare durante l'esecuzione della funzione, portandomi a dichiarare due funzioni estremamente simili (una con le printistruzioni, una senza), che è terribile da mantenere, oppure per definire qualcosa come:

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

che si traduce in una funzione gonfia e (si spera) inutilmente complicata, anche nel nostro semplice caso example_function.


Domanda

Esiste un modo pitonico per "disaccoppiare" la funzionalità di stampa dalla funzionalità originale di example_function?

Più in generale, esiste un modo pitonico per separare la funzionalità opzionale dallo scopo principale di una funzione?


Quello che ho provato finora:

La soluzione che ho trovato al momento sta usando i callback per il disaccoppiamento. Ad esempio, si può riscrivere in example_functionquesto modo:

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

e quindi definire una funzione di callback che esegue qualunque funzionalità di stampa che voglio:

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

e chiamando example_functioncosì:

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

che quindi genera:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

Questo disaccoppia con successo la funzionalità di stampa dalla funzionalità di base di example_function. Tuttavia, il problema principale con questo approccio è che la funzione di callback può essere eseguita solo in una parte specifica del example_function(in questo caso subito dopo aver dimezzato il numero corrente), e tutta la stampa deve avvenire esattamente lì. Questo a volte costringe il design della funzione di callback ad essere abbastanza complicato (e rende impossibile ottenere alcuni comportamenti).

Ad esempio, se si desidera ottenere esattamente lo stesso tipo di stampa che avevo fatto in una parte precedente della domanda (mostrando quale numero viene elaborato, insieme alle sue corrispondenti metà) il callback risultante sarebbe:

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)

che si traduce esattamente nello stesso output di prima:

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

ma è una seccatura scrivere, leggere e debug.


6
controlla il loggingmodulo python
Chris_Rands

@Chris_Rands ha ragione .. usa il modulo di registrazione .. in questo modo puoi accendere e spegnere la registrazione .. usa il seguente link. stackoverflow.com/questions/2266646/...
Yatish Kadam

2
Non vedo come il loggingmodulo possa aiutare qui. Sebbene la mia domanda utilizzi le printdichiarazioni durante l'impostazione del contesto, in realtà sto cercando una soluzione su come disaccoppiare qualsiasi tipo di funzionalità opzionale dallo scopo principale di una funzione. Ad esempio, forse voglio una funzione per tracciare le cose mentre corre. In tal caso, credo che il loggingmodulo non sarebbe nemmeno applicabile.
JLagana,

3
@Pythonic è un aggettivo che descrive la sintassi / stile / struttura / utilizzo di Python per confermare la filosofia di Python. Questa non è una regola sintattica o di progettazione, piuttosto un approccio che deve essere sostenuto responsabilmente per produrre una base di codice Python pulita e gestibile. Nel tuo caso, avere poche righe di istruzioni trace o print aggiunge valori alla manutenibilità e poi lo ha; non essere duro con te stesso. Prendi in considerazione uno degli approcci di cui sopra che ritieni ideale.
Nair,

1
Questa domanda è troppo ampia. Potremmo essere in grado di rispondere a domande specifiche (come loggingdimostrano i suggerimenti da utilizzare ), ma non come separare il codice arbitrario.
Chepner,

Risposte:


4

Se hai bisogno di funzionalità esterne alla funzione per utilizzare i dati dall'interno della funzione, allora ci deve essere un sistema di messaggistica all'interno della funzione per supportare questo. Non c'è modo di aggirarlo. Le variabili locali nelle funzioni sono totalmente isolate dall'esterno.

Il modulo di registrazione è abbastanza bravo a configurare un sistema di messaggi. Non si limita solo alla stampa dei messaggi di registro: utilizzando i gestori personalizzati, puoi fare qualsiasi cosa.

L'aggiunta di un sistema di messaggistica è simile al tuo esempio di callback, tranne per il fatto che i luoghi in cui vengono gestiti i "callback" (gestori di log) possono essere specificati ovunque all'interno example_function (inviando i messaggi al logger). Tutte le variabili necessarie ai gestori della registrazione possono essere specificate quando si invia il messaggio (è comunque possibile utilizzare locals(), ma è meglio dichiarare esplicitamente le variabili necessarie).

Un nuovo example_functionpotrebbe apparire come:

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

Questo specifica tre posizioni in cui è possibile gestire i messaggi. Di per sé, questo example_functionnon farà altro che la funzionalità di example_functionse stesso. Non stamperà nulla o farà qualsiasi altra funzionalità.

Per aggiungere funzionalità extra a example_function, sarà necessario aggiungere gestori al logger.

Ad esempio, se si desidera eseguire una stampa delle variabili inviate (simile al proprio debuggingesempio), si definisce il gestore personalizzato e lo si aggiunge al example_functionlogger:

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())

Se vuoi tracciare i risultati su un grafico, allora basta definire un altro gestore:

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())
)

È possibile definire e aggiungere qualsiasi gestore si desideri. Saranno totalmente separati dalla funzionalità di example_functione possono usare solo le variabili che gli example_functiondanno.

Sebbene la registrazione possa essere utilizzata come sistema di messaggistica, potrebbe essere meglio passare a un sistema di messaggistica completo, come PyPubSub , in modo che non interferisca con alcuna registrazione effettiva che potresti eseguire:

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
)

Grazie per la risposta, RPalmer. Il codice che hai fornito utilizzando il loggingmodulo è in effetti più organizzato e gestibile di quello che ho proposto di utilizzare printe le ifdichiarazioni. Tuttavia, non disaccoppia la funzionalità di stampa dalla funzionalità principale della example_functionfunzione. Cioè, il problema principale di dover example_functionfare due cose contemporaneamente rimane ancora, rendendo il suo codice più complicato di quello che vorrei che fosse.
JLagana,

Confronta questo con ad esempio il mio suggerimento di richiamata. Utilizzando i callback, example_functionora ha solo una funzionalità e le cose di stampa (o qualunque altra funzionalità che vorremmo avere) avvengono al di fuori di essa.
JLagana,

Ciao @JLagana. My example_functionè disaccoppiato dalla funzionalità di stampa: l'unica funzionalità aggiunta alla funzione è l'invio dei messaggi. È simile al tuo esempio di callback, tranne per il fatto che invia solo variabili specifiche che desideri, anziché tutte locals(). Spetta ai gestori dei registri (che si collegano al registratore da qualche altra parte) fare le funzionalità extra (stampa, rappresentazione grafica, ecc.). Non è necessario allegare alcun gestore, nel qual caso non accadrà nulla quando i messaggi vengono inviati. Ho aggiornato il mio post per renderlo più chiaro.
RPalmer,

Sono corretto, il tuo esempio ha disaccoppiato la funzionalità di stampa dalla funzionalità principale di example_function. Grazie per averlo reso più chiaro ora! Mi piace molto questa risposta, l'unico prezzo che viene pagato è la maggiore complessità del passaggio di messaggi, che, come hai già detto, sembra inevitabile. Grazie anche per il riferimento a PyPubSub, che mi ha portato a leggere sul modello di osservatore .
JLagana,

1

Se si desidera attenersi solo alle istruzioni di stampa, è possibile utilizzare un decoratore che aggiunge un argomento che attiva / disattiva la stampa sulla console.

Ecco un decoratore che aggiunge l'argomento solo parola chiave e il valore predefinito di verbose=False a qualsiasi funzione, aggiorna il docstring e la firma. Chiamando la funzione così com'è si restituisce l'output previsto. Chiamare la funzione con verbose=Trueattiverà le istruzioni di stampa e restituirà l'output previsto. Ciò ha l'ulteriore vantaggio di non dover eseguire la prefazione di ogni stampa con un if debug:blocco.

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

L'avvolgimento della funzione ora consente di attivare / disattivare le funzioni di stampa mediante 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

Esempi:

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

Quando controlli example_function , vedrai anche la documentazione aggiornata. Poiché la tua funzione non ha una docstring, è proprio ciò che è nel decoratore.

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.

In termini di filosofia del codice. Avere una funzione che non comporta effetti collaterali è un paradigma di programmazione funzionale. Pitone può essere un linguaggio funzionale, ma non è progettato per essere esclusivamente così. Progetto sempre il mio codice pensando all'utente.

Se l'aggiunta dell'opzione per stampare le fasi del calcolo è un vantaggio per l'utente, allora NULLA è sbagliato. Dal punto di vista del design, rimarrai bloccato con l'aggiunta dei comandi di stampa / registrazione da qualche parte.


Grazie per la risposta, James. Il codice fornito è in effetti più organizzato e gestibile di quello che ho proposto, che usa printe ifafferma. Inoltre, riesce effettivamente a separare parte della funzionalità di stampa dalla example_functionfunzionalità principale, il che è stato molto bello (mi è anche piaciuto che il decoratore aggiungesse automaticamente al tocco, bel tocco). Tuttavia, non disaccoppia completamente la funzionalità di stampa dalla funzionalità principale di example_function: devi ancora aggiungere le printdichiarazioni e qualsiasi logica di accompagnamento al corpo della funzione.
JLagana,

Confronta questo con ad esempio il mio suggerimento di richiamata. Usando i callback, example_function ora ha solo una funzionalità e le cose di stampa (o qualunque altra funzionalità che vorremmo avere) avviene al di fuori di essa.
JLagana,

Infine, concordiamo sul fatto che se la stampa dei passaggi di calcolo è un vantaggio per l'utente, rimarrò bloccato con l'aggiunta dei comandi di stampa da qualche parte. Voglio comunque che siano al di fuori del example_functioncorpo del corpo, in modo che la sua complessità rimanga associata solo alla complessità della sua funzionalità principale. Nella mia applicazione reale di tutto ciò, ho una funzione principale che è già significativamente complessa. L'aggiunta di istruzioni di stampa / stampa / registrazione al suo corpo lo rende una bestia che è stata piuttosto difficile da mantenere e eseguire il debug.
JLagana,

1

È possibile definire una funzione che incapsula la debug_modecondizione e passare la funzione opzionale desiderata e i suoi argomenti a quella funzione (come suggerito qui ):

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))

Nota che debug_mode ovviamente è stato assegnato un valore prima di chiamare DEBUG.

È ovviamente possibile invocare funzioni diverse da print .

È inoltre possibile estendere questo concetto a diversi livelli di debug utilizzando un valore numerico per debug_mode.


Grazie per la risposta, Gerd. In effetti, la tua soluzione elimina la necessità di ifdichiarazioni ovunque e semplifica anche l'attivazione e la disattivazione della stampa. Tuttavia, non disaccoppia la funzionalità di stampa dalla funzionalità principale di example_function. Confronta questo con ad esempio il mio suggerimento di richiamata. Usando i callback, example_function ora ha solo una funzionalità e le cose di stampa (o qualunque altra funzionalità che vorremmo avere) avviene al di fuori di essa.
JLagana,

1

Ho aggiornato la mia risposta con una semplificazione: alla funzione example_functionviene passato un singolo callback o hook con un valore predefinito tale che example_functionnon è più necessario testare per vedere se è stato passato o meno:

hook=lambda *args, **kwargs: None

Quanto sopra è un'espressione lambda che restituisce Noneeexample_function può chiamare questo valore predefinito per hookcon qualsiasi combinazione di parametri posizionali e di parole chiave in vari punti all'interno della funzione.

Nell'esempio seguente, sono interessato solo agli eventi " "end_iteration"e "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))

stampe:

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 funzione hook può essere semplice o elaborata come vuoi. Qui sta facendo un controllo del tipo di evento e sta eseguendo una semplice stampa. Ma potrebbe ottenere loggerun'istanza e registrare il messaggio. Puoi avere tutta la ricchezza di registrazione se ne hai bisogno, ma semplicità se non lo fai.


Grazie per la risposta, Ronald. L'idea di estendere l'idea di callback per eseguire callback in diverse parti della funzione (e passare loro una variabile di contesto) sembra davvero il modo migliore di procedere. Rende molto più facile scrivere callback e ad un prezzo ragionevole in complessità aggiunta a example_function.
JLagana,

Bel tocco con il valore predefinito; è un modo semplice per rimuovere molte ifdichiarazioni :)
JLagana,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.