Existe uma maneira pitônica de dissociar a funcionalidade opcional do objetivo principal de uma função?

11

Contexto

Suponha que eu tenha o seguinte código 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_functionaqui está simplesmente analisando cada um dos elementos da nslista e dividindo-os pela metade 3 vezes, acumulando os resultados. A saída da execução desse script é simplesmente:

2.0

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

Agora, digamos que (por qualquer motivo, talvez depuração ou log), eu gostaria de exibir algum tipo de informação sobre as etapas intermediárias que example_functionestão sendo executadas. Talvez eu reescrevesse essa função em algo assim:

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

que agora, quando chamado com os mesmos argumentos de antes, gera o seguinte:

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

Isso atinge exatamente o que eu pretendia. No entanto, isso contraria um pouco o princípio de que uma função deve fazer apenas uma coisa, e agora o código para example_functioné ligeiramente mais longo e mais complexo. Para uma função tão simples, isso não é um problema, mas, no meu contexto, tenho funções bastante complicadas que se chamam, e as instruções de impressão geralmente envolvem etapas mais complicadas do que as mostradas aqui, resultando em um aumento substancial na complexidade do meu código (por exemplo, das minhas funções, havia mais linhas de código relacionadas ao log do que linhas relacionadas à sua real finalidade!).

Além disso, se eu decidir mais tarde que não quero mais nenhuma declaração de impressão em minha função, teria que passar example_functione excluir todas asprint instruções manualmente, juntamente com quaisquer variáveis ​​relacionadas a essa funcionalidade, um processo que é tedioso e com erros. -propenso.

A situação fica ainda pior se eu gostaria de sempre ter a possibilidade de imprimir ou não imprimir durante a execução da função, levando-me a declarar duas funções extremamente semelhantes (uma com as printinstruções e outra sem), o que é terrível para manter ou para definir algo como:

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

o que resulta em uma função inchada e (espero) desnecessariamente complicada, mesmo no caso simples de nossa example_function.


Questão

Existe uma maneira pitônica de "dissociar" a funcionalidade de impressão da funcionalidade original do example_function?

De maneira mais geral, existe uma maneira pitônica de dissociar a funcionalidade opcional do objetivo principal de uma função?


O que eu tentei até agora:

A solução que encontrei no momento está usando retornos de chamada para a dissociação. Por exemplo, pode-se reescrever o example_functionseguinte:

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, em seguida, definindo uma função de retorno de chamada que execute a funcionalidade de impressão que eu desejar:

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

e chamando example_functionassim:

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

que então gera:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

Isso dissocia com êxito a funcionalidade de impressão da funcionalidade básica de example_function. No entanto, o principal problema dessa abordagem é que a função de retorno de chamada só pode ser executada em uma parte específica do example_function(neste caso, logo após reduzir pela metade o número atual), e toda a impressão deve ocorrer exatamente lá. Às vezes, isso força o design da função de retorno de chamada a ser bastante complicado (e impossibilita a realização de alguns comportamentos).

Por exemplo, se alguém gostaria de obter exatamente o mesmo tipo de impressão que eu fiz na parte anterior da pergunta (mostrando qual número está sendo processado, juntamente com as respectivas metades correspondentes), o retorno de chamada resultante seria:

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)

que resulta exatamente na mesma saída de antes:

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

mas é difícil escrever, ler e depurar.

JLagana
fonte
6
consulte a python loggingmódulo
Chris_Rands
@ Chris_Rands está certo .. use o módulo de log .. dessa forma, você pode ativar e desativar o log .. use o seguinte link. stackoverflow.com/questions/2266646/…
Yatish Kadam
2
Não vejo como o loggingmódulo ajudaria aqui. Embora minha pergunta use printinstruções ao configurar o contexto, na verdade, estou procurando uma solução para dissociar qualquer tipo de funcionalidade opcional do objetivo principal de uma função. Por exemplo, talvez eu queira uma função para plotar as coisas à medida que é executada. Nesse caso, acredito que o loggingmódulo nem seria aplicável.
JLagana #
3
O @Pythonic é um adjetivo que descreve a sintaxe / estilo / estrutura / uso do python para sustentar a filosofia do Python. Esta não é uma regra sintática ou de design, mas uma abordagem que precisa ser mantida com responsabilidade para produzir uma base de código python limpa e sustentável. No seu caso, ter poucas linhas de instruções de rastreio ou impressão adiciona valores à manutenção e, em seguida, é necessário; não seja duro consigo mesmo. Considere qualquer uma das abordagens acima mencionadas que você acha ideal.
Nair
11
Esta questão é muito ampla. Podemos ser capazes de abordar questões específicas (como loggingdemonstram as sugestões de uso ), mas não como separar o código arbitrário.
chepner 31/10/19

Respostas:

4

Se você precisar de uma funcionalidade externa à função para usar dados de dentro da função, será necessário haver algum sistema de mensagens dentro da função para suportar isso. Não há como contornar isso. Variáveis ​​locais em funções são totalmente isoladas do exterior.

O módulo de registro é muito bom em configurar um sistema de mensagens. Não se restringe apenas à impressão das mensagens de log - usando manipuladores personalizados, você pode fazer qualquer coisa.

A adição de um sistema de mensagens é semelhante ao seu exemplo de retorno de chamada, exceto que os locais onde os 'retornos de chamada' (manipuladores de log) são manipulados podem ser especificados em qualquer lugar dentro do example_function (enviando as mensagens para o logger). Quaisquer variáveis ​​necessárias aos manipuladores de log podem ser especificadas quando você envia a mensagem (você ainda pode usarlocals() lo, mas é melhor declarar explicitamente as variáveis ​​necessárias).

Um novo example_functionpode se parecer com:

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

Isso especifica três locais onde as mensagens podem ser tratadas. Por si só, isso example_functionnão fará outra coisa senão a funcionalidade doexample_function próprio. Não imprimirá nada ou fará qualquer outra funcionalidade.

Para adicionar funcionalidade extra ao example_function , você precisará adicionar manipuladores ao criador de logs.

Por exemplo, se você quiser imprimir algumas variáveis ​​enviadas (semelhante ao seu debuggingexemplo), defina o manipulador personalizado e adicione-o ao example_functioncriador de logs:

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 você deseja plotar os resultados em um gráfico, basta definir outro manipulador:

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

Você pode definir e adicionar os manipuladores que desejar. Eles serão totalmente separados da funcionalidade do example_function, e só podem usar as variáveis ​​que os example_functionfornece.

Embora o registro possa ser usado como um sistema de mensagens, talvez seja melhor mudar para um sistema de mensagens completo, como o PyPubSub , para que não interfira em nenhum registro real que você possa estar fazendo:

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
fonte
Obrigado pela resposta, RPalmer. O código que você forneceu usando o loggingmódulo é realmente mais organizado e sustentável do que o que eu propus usar printe ifinstruções. No entanto, não separa a funcionalidade de impressão da principal funcionalidade da example_functionfunção. Ou seja, o principal problema de example_functionfazer duas coisas ao mesmo tempo ainda permanece, tornando o código mais complicado do que eu gostaria que fosse.
JLagana #
Compare isso com, por exemplo, minha sugestão de retorno de chamada. Usando retornos de chamada, example_functionagora só há uma funcionalidade, e o material de impressão (ou qualquer outra funcionalidade que gostaríamos de ter) acontece fora dela.
JLagana #
Oi @JLagana. Meu example_functionestá dissociado da funcionalidade de impressão - a única funcionalidade adicionada à função é enviar as mensagens. É semelhante ao seu exemplo de retorno de chamada, exceto que ele envia apenas variáveis ​​específicas desejadas, e não todas locals(). Cabe aos manipuladores de log (que você anexa ao logger em outro lugar) executar a funcionalidade extra (impressão, gráficos, etc.). Você não precisa anexar nenhum manipulador; nesse caso, nada acontecerá quando as mensagens forem enviadas. Atualizei minha postagem para deixar isso mais claro.
RPalmer 5/11
Estou corrigido, seu exemplo dissociou a funcionalidade de impressão da funcionalidade principal de example_function. Obrigado por torná-lo mais claro agora! Gosto muito dessa resposta, o único preço pago é a complexidade adicional da transmissão de mensagens, que, como você mencionou, parece inevitável. Obrigado também pela referência ao PyPubSub, que me levou a ler sobre o padrão do observador .
JLagana
1

Se você deseja manter apenas as instruções de impressão, pode usar um decorador que adicione um argumento que ativa / desativa a impressão no console.

Aqui está um decorador que adiciona o argumento somente palavra-chave e o valor padrão de verbose=Falsequalquer função, atualiza a sequência de caracteres e a assinatura. Chamar a função como está retorna a saída esperada. Chamar a função com verbose=Trueativará as instruções de impressão e retornará a saída esperada. Isso tem o benefício adicional de não precisar preceder todas as impressões com um if debug:bloco.

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

A quebra da sua função agora permite ativar / desativar as funções de impressão usando 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

Exemplos:

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 você inspeciona example_function, também verá a documentação atualizada. Como sua função não possui uma sequência de caracteres, é exatamente o que está no decorador.

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.

Em termos de filosofia de codificação. Ter uma função que não produza efeitos colaterais é um paradigma de programação funcional. Python pode ser uma linguagem funcional, mas não foi projetada para ser exclusivamente dessa maneira. Eu sempre desenvolvo meu código com o usuário em mente.

Se adicionar a opção de imprimir as etapas de cálculo for um benefício para o usuário, não há NADA errado nisso. Do ponto de vista do design, você ficará preso ao adicionar os comandos de impressão / registro em algum lugar.

James
fonte
Obrigado pela resposta, James. O código fornecido é realmente mais organizado e sustentável do que o que propus, que usa printe ifafirmações. Além disso, ele consegue desacoplar parte da funcionalidade da impressão da example_functionfuncionalidade principal, o que foi muito bom (eu também gostei que o decorador anexasse automaticamente à docstring, toque agradável). No entanto, ele não dissocia totalmente a funcionalidade de impressão da funcionalidade principal de example_function: você ainda precisa adicionar as printinstruções e qualquer lógica que acompanha o corpo da função.
JLagana #
Compare isso com, por exemplo, minha sugestão de retorno de chamada. Usando retornos de chamada, example_function agora possui apenas uma funcionalidade, e o material de impressão (ou qualquer outra funcionalidade que gostaríamos de ter) acontece fora dele.
JLagana #
Por fim, concordamos que, se a impressão das etapas de cálculo for um benefício para o usuário, continuarei adicionando os comandos de impressão em algum lugar. No entanto, eu quero que eles estejam fora do example_functioncorpo do, para que sua complexidade permaneça associada apenas à complexidade de sua funcionalidade principal. Na minha aplicação na vida real de tudo isso, tenho uma função principal que já é significativamente complexa. A adição de instruções de impressão / plotagem / registro em seu corpo faz com que ele se torne um animal que tem sido bastante desafiador para manter e depurar.
JLagana #
1

Você pode definir uma função que encapsule a debug_modecondição e passar a função opcional desejada e seus argumentos para essa função (conforme sugerido aqui ):

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

Observe que debug_modeobviamente deve ter sido atribuído um valor antes de chamar DEBUG.

É claro que é possível invocar funções diferentes de print.

Você também pode estender esse conceito para vários níveis de depuração usando um valor numérico para debug_mode.

Gerd
fonte
Obrigado pela resposta, Gerd. Na verdade, sua solução elimina a necessidade de ifdeclarações em todo o lugar e também facilita a ativação e desativação da impressão. No entanto, não separa a funcionalidade de impressão da funcionalidade principal de example_function. Compare isso com, por exemplo, minha sugestão de retorno de chamada. Usando retornos de chamada, example_function agora possui apenas uma funcionalidade, e o material de impressão (ou qualquer outra funcionalidade que gostaríamos de ter) acontece fora dele.
JLagana # 0519
1

Atualizei minha resposta com uma simplificação: a função example_functionrecebe um único retorno de chamada ou gancho com um valor padrão que example_functionnão precisa mais testar para verificar se foi aprovada ou não:

hook=lambda *args, **kwargs: None

A descrição acima é uma expressão lambda que retorna Nonee example_functionpode chamar esse valor padrão para hookqualquer combinação de parâmetros posicionais e de palavras-chave em vários locais da função.

No exemplo abaixo, estou interessado apenas nos eventos " "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))

Impressões:

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

A função de gancho pode ser tão simples ou elaborada quanto você desejar. Aqui, é feita uma verificação do tipo de evento e uma impressão simples. Mas poderia obter uma loggerinstância e registrar a mensagem. Você pode ter toda a riqueza do registro, se precisar, mas a simplicidade, se não precisar.

Booboo
fonte
Obrigado pela resposta, Ronald. A idéia de estender a idéia de retorno de chamada para executar retornos de chamada em diferentes partes da função (e passar uma variável de contexto para eles) parece ser o melhor caminho a percorrer. Torna muito mais fácil escrever retornos de chamada e a um preço razoável para aumentar a complexidade example_function.
JLagana #
Toque agradável com o valor padrão; é uma maneira simples de remover um monte de ifdeclarações :)
JLagana