¿Existe una manera pitónica de desacoplar la funcionalidad opcional del propósito principal de una función?

11

Contexto

Supongamos que tengo el siguiente código de 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_functionaquí simplemente pasa por cada uno de los elementos de la nslista y los divide por la mitad 3 veces, mientras acumula los resultados. El resultado de ejecutar este script es simplemente:

2.0

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

Ahora, digamos que (por cualquier motivo, tal vez depuración o registro), me gustaría mostrar algún tipo de información sobre los pasos intermedios que example_functionestá tomando. Quizás entonces reescribiría esta función en algo como esto:

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 ahora, cuando se llama con los mismos argumentos que antes, genera lo siguiente:

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

Esto logra exactamente lo que pretendía. Sin embargo, esto va un poco en contra del principio de que una función solo debe hacer una cosa, y ahora el código example_functiones un poco más largo y complejo. Para una función tan simple, esto no es un problema, pero en mi contexto tengo funciones bastante complicadas que se llaman entre sí, y las declaraciones de impresión a menudo implican pasos más complicados que los que se muestran aquí, lo que resulta en un aumento sustancial en la complejidad de mi código (para uno ¡De mis funciones había más líneas de código relacionadas con el registro que líneas relacionadas con su propósito real!).

Además, si luego decido que ya no quiero imprimir ninguna declaración en mi función, tendría que revisar example_functiony eliminar todas las printdeclaraciones manualmente, junto con cualquier variable relacionada con esta funcionalidad, un proceso que es tedioso y erróneo -propenso.

La situación empeora aún más si quisiera tener siempre la posibilidad de imprimir o no imprimir durante la ejecución de la función, lo que me lleva a declarar dos funciones extremadamente similares (una con las printdeclaraciones, una sin), que es terrible para mantener, o 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

lo que resulta en una función hinchada y (con suerte) innecesariamente complicada, incluso en el caso simple de nuestro example_function.


Pregunta

¿Existe una forma pitónica de "desacoplar" la funcionalidad de impresión de la funcionalidad original de la example_function?

En términos más generales, ¿hay una manera pitónica de desacoplar la funcionalidad opcional del propósito principal de una función?


Lo que he probado hasta ahora:

La solución que he encontrado en este momento es usar devoluciones de llamada para el desacoplamiento. Por ejemplo, uno puede reescribir lo example_functionsiguiente:

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

y luego definiendo una función de devolución de llamada que realice la funcionalidad de impresión que desee:

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

y llamando example_functionasí:

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

que luego produce:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

Esto desacopla con éxito la funcionalidad de impresión de la funcionalidad base de example_function. Sin embargo, el principal problema con este enfoque es que la función de devolución de llamada solo se puede ejecutar en una parte específica de example_function(en este caso, justo después de reducir a la mitad el número actual), y toda la impresión tiene que suceder exactamente allí. Esto a veces obliga al diseño de la función de devolución de llamada a ser bastante complicado (y hace que algunos comportamientos sean imposibles de lograr).

Por ejemplo, si a uno le gustaría lograr exactamente el mismo tipo de impresión que hice en una parte anterior de la pregunta (que muestra qué número se está procesando, junto con sus mitades correspondientes) la devolución de llamada resultante sería:

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 da como resultado exactamente el mismo resultado que 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

pero es un dolor de escribir, leer y depurar.

JLagana
fuente
66
echa un vistazo al loggingmódulo de Python
Chris_Rands
@Chris_Rands tiene razón ... use el módulo de registro ... de esa manera puede activar y desactivar el registro ... use el siguiente enlace. stackoverflow.com/questions/2266646/…
Yatish Kadam
2
No veo cómo el loggingmódulo ayudaría aquí. Aunque mi pregunta usa printdeclaraciones cuando configuro el contexto, en realidad estoy buscando una solución sobre cómo desacoplar cualquier tipo de funcionalidad opcional del propósito principal de una función. Por ejemplo, tal vez quiero una función para trazar las cosas mientras se ejecuta. En ese caso, creo que el loggingmódulo ni siquiera sería aplicable.
JLagana
3
@Pythonic es un adjetivo que describe la sintaxis / estilo / estructura / uso de Python para mantener la filosofía de Python. Esta no es una regla sintáctica o de diseño, sino un enfoque que debe mantenerse responsablemente para producir una base de código de Python limpia y mantenible. En su caso, tener pocas líneas de declaraciones de rastreo o impresión agrega valores a la capacidad de mantenimiento y luego tenerlo; no seas duro contigo mismo. Considere cualquiera de los enfoques antes mencionados que considere ideal.
Nair
1
Esta pregunta es demasiado amplia. Es posible que podamos abordar preguntas específicas (como lo loggingdemuestran las sugerencias de uso ), pero no cómo separar el código arbitrario.
chepner

Respuestas:

4

Si necesita funcionalidad fuera de la función para usar datos desde dentro de la función, entonces debe haber algún sistema de mensajería dentro de la función para admitir esto. No hay forma de evitar esto. Las variables locales en funciones están totalmente aisladas del exterior.

El módulo de registro es bastante bueno para configurar un sistema de mensajes. No solo se limita a imprimir los mensajes de registro: con los controladores personalizados, puede hacer cualquier cosa.

Agregar un sistema de mensajes es similar a su ejemplo de devolución de llamada, excepto que los lugares donde se manejan las 'devoluciones de llamada' (controladores de registro) se pueden especificar en cualquier lugar dentro del example_function (enviando los mensajes al registrador). Las variables que necesitan los manejadores de registro se pueden especificar cuando envía el mensaje (aún puede usar locals(), pero es mejor declarar explícitamente las variables que necesita).

Un nuevo example_functionpodría verse así:

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

Esto especifica tres ubicaciones donde se pueden manejar los mensajes. Por sí solo, esto example_functionno hará nada más que la funcionalidad de example_functionsí mismo. No imprimirá nada ni realizará ninguna otra funcionalidad.

Para agregar funcionalidad adicional al example_function, deberá agregar controladores al registrador.

Por ejemplo, si desea imprimir algunas de las variables enviadas (similar a su debuggingejemplo), defina el controlador personalizado y agréguelo al example_functionregistrador:

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 desea trazar los resultados en un gráfico, simplemente defina otro controlador:

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

Puede definir y agregar los controladores que desee. Estarán totalmente separados de la funcionalidad de example_function, y solo pueden usar las variables que example_functionles da.

Aunque el registro se puede usar como un sistema de mensajería, podría ser mejor pasar a un sistema de mensajería completo, como PyPubSub , para que no interfiera con ningún registro real que pueda estar haciendo:

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
fuente
Gracias por la respuesta, RPalmer. El código que proporcionó usando el loggingmódulo es de hecho más organizado y mantenible que lo que propuse usar printy ifdeclaraciones. Sin embargo, no desacopla la funcionalidad de impresión de la funcionalidad principal de la example_functionfunción. Es decir, el problema principal de tener que example_functionhacer dos cosas a la vez sigue siendo, lo que hace que su código sea más complicado de lo que me gustaría que fuera.
JLagana
Compare esto con, por ejemplo, mi sugerencia de devolución de llamada. Al usar las devoluciones de llamada, example_functionahora solo tiene una funcionalidad, y las cosas de impresión (o cualquier otra funcionalidad que nos gustaría tener) sucede fuera de ella.
JLagana
Hola @JLagana My example_functionestá desacoplado de la funcionalidad de impresión: la única funcionalidad agregada a la función es enviar los mensajes. Es similar a su ejemplo de devolución de llamada, excepto que solo envía variables específicas que desea, en lugar de todas locals(). Depende de los manejadores de registros (que adjuntas al registrador en otro lugar) hacer la funcionalidad adicional (impresión, gráficos, etc.). No necesita adjuntar ningún controlador, en cuyo caso no pasará nada cuando se envíen los mensajes. He actualizado mi publicación para aclarar esto.
RPalmer
Estoy corregido, su ejemplo desacopla la funcionalidad de impresión de la funcionalidad principal de example_function. ¡Gracias por dejarlo más claro ahora! Realmente me gusta esta respuesta, el único precio que se paga es la complejidad adicional de pasar mensajes, lo que, como mencionó, parece inevitable. Gracias también por la referencia a PyPubSub, que me llevó a leer sobre el patrón de observación .
JLagana
1

Si desea seguir con solo las declaraciones de impresión, puede usar un decorador que agrega un argumento que enciende / apaga la impresión en la consola.

Aquí hay un decorador que agrega el argumento de solo palabras clave y el valor predeterminado de verbose=Falsecualquier función, actualiza la cadena de documentos y la firma. Llamar a la función tal cual devuelve el resultado esperado. Llamar a la función con verbose=Trueactivará las declaraciones de impresión y devolverá el resultado esperado. Esto tiene el beneficio adicional de no tener que prefacio cada impresión con un if debug:bloque.

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

Ajustar su función ahora le permite activar / desactivar las funciones de impresión 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

Ejemplos:

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

Cuando inspeccione example_function, verá también la documentación actualizada. Como su función no tiene una cadena de documentación, es justo lo que está en el 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.

En cuanto a la filosofía de codificación. Tener una función que no incurre en efectos secundarios es un paradigma de programación funcional. Python puede ser un lenguaje funcional, pero no está diseñado para ser exclusivamente de esa manera. Siempre diseño mi código con el usuario en mente.

Si agregar la opción de imprimir los pasos de cálculo es un beneficio para el usuario, entonces NO HAY nada malo en hacerlo. Desde el punto de vista del diseño, se quedará atascado agregando los comandos de impresión / registro en alguna parte.

James
fuente
Gracias por la respuesta, James. El código provisto es, de hecho, más organizado y mantenible que el que propuse, que utiliza printy ifdeclaraciones. Además, se las arregla para desacoplar parte de la funcionalidad de impresión de example_functionla funcionalidad principal, lo cual fue muy agradable (también me gustó que el decorador se agregue automáticamente a la cadena de documentos, un buen toque). Sin embargo, no desacopla completamente la funcionalidad de impresión de la funcionalidad principal de example_function: todavía tiene que agregar las printdeclaraciones y la lógica que lo acompaña al cuerpo de la función.
JLagana
Compare esto con, por ejemplo, mi sugerencia de devolución de llamada. Usando devoluciones de llamada, example_function ahora solo tiene una funcionalidad, y las cosas de impresión (o cualquier otra funcionalidad que nos gustaría tener) sucede fuera de ella.
JLagana
Por último, estamos de acuerdo en que si imprimir los pasos de cálculo es un beneficio para el usuario, entonces me quedaré atrapado agregando los comandos de impresión en alguna parte. Sin embargo, quiero que estén fuera del example_functioncuerpo del cuerpo, de modo que su complejidad solo esté asociada a la complejidad de su funcionalidad principal. En mi aplicación de la vida real de todo esto, tengo una función principal que ya es significativamente compleja. Agregar declaraciones de impresión / trazado / registro a su cuerpo hace que se convierta en una bestia que ha sido bastante difícil de mantener y depurar.
JLagana
1

Puede definir una función que encapsule la debug_modecondición y pasar la función opcional deseada y sus argumentos a esa función (como se sugiere aquí ):

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

Tenga en cuenta que debug_modeobviamente se le debe haber asignado un valor antes de llamar DEBUG.

Por supuesto, es posible invocar funciones que no sean print.

También podría ampliar este concepto a varios niveles de depuración utilizando un valor numérico para debug_mode.

Gerd
fuente
Gracias por la respuesta, Gerd. De hecho, su solución elimina la necesidad de ifdeclaraciones en todas partes y también facilita la activación y desactivación de la impresión. Sin embargo, no desacopla la funcionalidad de impresión de la funcionalidad principal de example_function. Compare esto con, por ejemplo, mi sugerencia de devolución de llamada. Usando devoluciones de llamada, example_function ahora solo tiene una funcionalidad, y las cosas de impresión (o cualquier otra funcionalidad que nos gustaría tener) sucede fuera de ella.
JLagana
1

He actualizado mi respuesta con una simplificación: la función example_functionpasa una única devolución de llamada o enlace con un valor predeterminado de modo que example_functionya no necesita probar para ver si se pasó o no:

hook=lambda *args, **kwargs: None

Lo anterior es una expresión lambda que devuelve Noney example_functionpuede llamar a este valor predeterminado hookcon cualquier combinación de parámetros posicionales y de palabras clave en varios lugares dentro de la función.

En el siguiente ejemplo, sólo estoy interesado en las "end_iteration"y los "result"eventos.

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

Huellas dactilares:

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 función de enlace puede ser tan simple o tan elaborada como desee. Aquí está haciendo una verificación del tipo de evento y haciendo una impresión simple. Pero podría obtener una loggerinstancia y registrar el mensaje. Puede tener toda la riqueza del registro si lo necesita, pero simplicidad si no lo necesita.

Boo Boo
fuente
Gracias por la respuesta, Ronald. La idea de extender la idea de devolución de llamada para ejecutar devoluciones de llamada en diferentes partes de la función (y pasarles una variable de contexto) parece, de hecho, la mejor manera de hacerlo. Hace que sea mucho más fácil escribir devoluciones de llamada y a un precio razonable en complejidad adicional example_function.
JLagana
Buen toque con el valor predeterminado; es una forma sencilla de eliminar muchas ifdeclaraciones :)
JLagana