Czy istnieje pythonowy sposób na oddzielenie opcjonalnej funkcjonalności od głównego celu funkcji?

11

Kontekst

Załóżmy, że mam następujący kod 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_functiontutaj po prostu przeglądam każdy z elementów na nsliście i dzielimy je na trzy razy, jednocześnie kumulując wyniki. Wynikiem uruchomienia tego skryptu jest po prostu:

2.0

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

Teraz powiedzmy, że (z dowolnego powodu, być może debugowania lub logowania), chciałbym wyświetlić pewne informacje na temat kroków pośrednich, które example_functionpodejmujesz. Może wtedy przepisałbym tę funkcję na coś takiego:

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

który teraz wywoływany z tymi samymi argumentami, co poprzednio, wyświetla następujące wyniki:

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

Osiąga to dokładnie to, co zamierzałem. Jest to jednak trochę sprzeczne z zasadą, że funkcja powinna robić tylko jedną rzecz, a teraz kod dlaexample_function jest nieco dłuższy i bardziej złożony. W przypadku tak prostej funkcji nie stanowi to problemu, ale w moim kontekście mam dość skomplikowane funkcje wywołujące się nawzajem, a instrukcje drukowania często wymagają bardziej skomplikowanych kroków niż pokazano tutaj, co powoduje znaczny wzrost złożoności mojego kodu (na przykład z moich funkcji było więcej linii kodu związanych z logowaniem niż linii związanych z jego faktycznym przeznaczeniem!).

Ponadto, jeśli później zdecyduję, że nie chcę już żadnych instrukcji drukowania w mojej funkcji, musiałbym ręcznie przejrzeć example_functioni usunąć wszystkie printinstrukcje, wraz ze wszystkimi zmiennymi związanymi z tą funkcją, co jest zarówno żmudne, jak i błędne -skłonny.

Sytuacja staje się jeszcze gorsza, jeśli chciałbym zawsze mieć możliwość drukowania lub nie drukowania podczas wykonywania funkcji, co prowadzi mnie do zadeklarowania dwóch bardzo podobnych funkcji (jednej z printinstrukcjami, drugiej bez), co jest straszne dla utrzymania, lub zdefiniować coś takiego:

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

co powoduje rozdętą i (miejmy nadzieję) niepotrzebnie skomplikowaną funkcję, nawet w naszym prostym przypadku example_function.


Pytanie

Czy istnieje pythonowy sposób na „oddzielenie” funkcji drukowania od oryginalnej funkcji example_function ?

Mówiąc bardziej ogólnie, czy istnieje pythonowy sposób na oddzielenie opcjonalnej funkcjonalności od głównego celu funkcji?


Co próbowałem do tej pory:

Rozwiązaniem, które znalazłem w tej chwili, jest użycie wywołań zwrotnych do oddzielenia. Na przykład można przepisać w example_functionten sposób:

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

a następnie zdefiniowanie funkcji zwrotnej, która wykonuje dowolną funkcję drukowania, którą chcę:

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

i dzwoniąc w example_functionten sposób:

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

który następnie generuje:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

To skutecznie oddziela funkcję drukowania od podstawowej funkcji example_function. Jednak głównym problemem związanym z tym podejściem jest to, że funkcję zwrotną można uruchomić tylko w określonej częściexample_function (w tym przypadku zaraz po zmniejszeniu o połowę bieżącego numeru), a całe drukowanie musi odbywać się właśnie tam. Czasami wymusza to dość skomplikowane projektowanie funkcji zwrotnej (i uniemożliwia osiągnięcie niektórych zachowań).

Na przykład, jeśli ktoś chciałby uzyskać dokładnie ten sam typ wydruku, co ja w poprzedniej części pytania (pokazując, który numer jest przetwarzany wraz z odpowiadającymi mu połówkami), wynikowe wywołanie zwrotne byłoby:

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)

co daje dokładnie taką samą wydajność jak poprzednio:

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

ale trudno jest pisać, czytać i debugować.

JLagana
źródło
6
sprawdź loggingmoduł python
Chris_Rands
@Chris_Rands ma rację .. użyj modułu logowania .. w ten sposób możesz włączać i wyłączać logowanie .. użyj następującego linku. stackoverflow.com/questions/2266646/…
Yatish Kadam
2
Nie rozumiem, jak loggingmoduł mógłby tutaj pomóc. Mimo że moje pytanie używa printoświadczeń podczas konfigurowania kontekstu, tak naprawdę szukam rozwiązania, w jaki sposób oddzielić dowolny typ opcjonalnej funkcjonalności od głównego celu funkcji. Na przykład może chcę, aby funkcja wykreśliła rzeczy podczas działania. W takim przypadku uważam, że loggingmoduł nie miałby nawet zastosowania.
JLagana
3
@Pythonic to przymiotnik opisujący składnię / styl / strukturę / użycie Pythona w celu podtrzymania filozofii Pythona. Nie jest to reguła składniowa ani projektowa, a raczej podejście, które należy odpowiedzialnie realizować, aby stworzyć czystą i łatwą do utrzymania bazę kodową Pythona. W twoim przypadku, mając kilka wierszy instrukcji śledzenia lub instrukcji drukowania, dodajesz wartości do łatwości konserwacji, a następnie je posiadasz; nie bądź dla siebie trudny. Rozważ dowolne z wyżej wymienionych podejść, które uważasz za idealne.
Nair
1
To pytanie jest zbyt ogólne. Możemy być w stanie odpowiedzieć na konkretne pytania (jak loggingpokazują sugestie użycia ), ale nie możemy oddzielić dowolnego kodu.
chepner

Odpowiedzi:

4

Jeśli potrzebujesz funkcji spoza funkcji, aby korzystać z danych z wnętrza funkcji, musi istnieć jakiś system przesyłania wiadomości wewnątrz funkcji, aby to obsługiwać. Nie można tego obejść. Zmienne lokalne w funkcjach są całkowicie odizolowane od zewnątrz.

Moduł logowania jest całkiem dobry w konfigurowaniu systemu komunikatów. Nie ogranicza się tylko do drukowania komunikatów dziennika - za pomocą niestandardowych programów obsługi możesz zrobić wszystko.

Dodanie systemu komunikatów jest podobne do przykładu wywołania zwrotnego, z tym wyjątkiem, że miejsca, w których obsługiwane są „wywołania zwrotne” (procedury obsługi rejestrowania), można określić w dowolnym miejscu example_function (wysyłając wiadomości do programu rejestrującego). Wszelkie zmienne, które są wymagane przez procedury obsługi rejestrowania, można określić podczas wysyłania wiadomości (nadal można jej używać)locals() , ale najlepiej jest jawnie zadeklarować potrzebne zmienne).

Nowy example_functionmoże wyglądać następująco:

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

Określa trzy lokalizacje, w których można obsługiwać wiadomości. Na własną rękę, to example_functionnie będzie coś innego niż funkcjonalności zrobić example_functionsama. Nie wydrukuje niczego ani nie wykona żadnej innej funkcji.

Aby dodać dodatkową funkcjonalność do example_function, musisz dodać programy obsługi do rejestratora.

Na przykład, jeśli chcesz wydrukować wysłane zmienne (podobnie jak w twoim debuggingprzykładzie), zdefiniuj niestandardową procedurę obsługi i dodaj ją do programu example_functionrejestrującego:

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

Jeśli chcesz wykreślić wyniki na wykresie, po prostu zdefiniuj inny moduł obsługi:

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

Możesz zdefiniować i dodać dowolne programy obsługi. Będą one całkowicie oddzielone od funkcji example_functioni będą mogły używać tylko zmiennych, które example_functionim daje.

Chociaż rejestrowania można używać jako systemu przesyłania wiadomości, lepiej jest przejść do w pełni rozwiniętego systemu przesyłania wiadomości, takiego jak PyPubSub , aby nie zakłócał żadnego rzeczywistego rejestrowania:

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
źródło
Dzięki za odpowiedź, RPalmer. Kod podany przy użyciu loggingmodułu jest rzeczywiście bardziej zorganizowany i łatwiejszy w utrzymaniu niż to, co zaproponowałem przy użyciu printi ifinstrukcji. Jednak nie oddziela funkcji drukowania od głównej funkcjonalności example_functionfunkcji. Oznacza to, że główny problem example_functionzrobienia dwóch rzeczy naraz wciąż pozostaje, co sprawia, że ​​jego kod jest bardziej skomplikowany niż chciałbym.
JLagana,
Porównaj to na przykład z moją sugestią oddzwonienia. Korzystanie z oddzwaniania example_functionma teraz tylko jedną funkcję, a drukowanie (lub jakakolwiek inna funkcja, którą chcielibyśmy mieć) dzieje się poza nią.
JLagana,
Cześć @JLagana. My example_functionjest oddzielony od funkcji drukowania - jedyną dodatkową funkcją tej funkcji jest wysyłanie wiadomości. Jest podobny do przykładu wywołania zwrotnego, z tym wyjątkiem, że wysyła tylko określone zmienne, które chcesz, a nie wszystkie locals(). Dodatkowe funkcje (drukowanie, tworzenie wykresów itp.) Należy do programów obsługi dziennika (które podłączasz do rejestratora w innym miejscu). W ogóle nie musisz dołączać żadnych programów obsługi, w którym to przypadku nic się nie stanie po wysłaniu wiadomości. Zaktualizowałem mój post, aby to wyjaśnić.
RPalmer,
Poprawiłem się, twój przykład oddzielił funkcjonalność drukowania od głównej funkcjonalności example_function. Dzięki za wyjaśnienie teraz! Naprawdę podoba mi się ta odpowiedź, jedyną płaconą ceną jest dodatkowa złożoność przekazywania wiadomości, która, jak wspomniałeś, wydaje się nieunikniona. Dziękuję również za odniesienie do PyPubSub, które doprowadziło mnie do odczytania wzorca obserwatora .
JLagana,
1

Jeśli chcesz trzymać się tylko instrukcji drukowania, możesz użyć dekoratora, który dodaje argument, który włącza / wyłącza drukowanie na konsoli.

Oto dekorator, który dodaje argument tylko do słowa kluczowego i domyślną wartość verbose=Falsedowolnej funkcji, aktualizuje dokumentację i podpis. Wywołanie funkcji „tak jak jest” zwraca oczekiwane wyjście. Wywołanie funkcji z verbose=Truespowoduje włączenie instrukcji print i zwrócenie oczekiwanego wyniku. Ma to tę dodatkową zaletę, że nie trzeba poprzedzać każdego wydruku if debug:blokiem.

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

Zawijanie funkcji umożliwia teraz włączanie / wyłączanie funkcji drukowania za pomocą 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

Przykłady:

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

Podczas inspekcji example_functionzobaczysz również zaktualizowaną dokumentację. Ponieważ twoja funkcja nie ma dokumentów, jest to tylko to, co jest w dekoratorze.

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.

Pod względem filozofii kodowania. Funkcjonowanie, które nie powoduje efektów ubocznych, jest funkcjonalnym paradygmatem programowania. Python może być językiem funkcjonalnym, ale nie został zaprojektowany wyłącznie w taki sposób. Zawsze projektuję kod z myślą o użytkowniku.

Jeśli dodanie opcji drukowania kroków obliczeniowych jest korzyścią dla użytkownika, to nie ma w tym nic złego. Z punktu widzenia projektowania, utkniesz z dodawaniem gdzieś poleceń drukowania / logowania.

James
źródło
Dzięki za odpowiedź, James. Dostarczony kod jest rzeczywiście bardziej zorganizowany i łatwiejszy w utrzymaniu niż ten, który zaproponowałem, który używa printi ifoświadczeń. Co więcej, udaje mu się oddzielić część funkcjonalności drukowania od example_functiongłównej funkcjonalności, co było bardzo miłe (podobało mi się również, że dekorator automatycznie dołącza się do docstring, miły akcent). Jednak nie oddziela w pełni funkcji drukowania od głównej funkcjonalności example_function: nadal musisz dodać printinstrukcje i wszelką towarzyszącą logikę do ciała funkcji.
JLagana,
Porównaj to na przykład z moją sugestią oddzwonienia. Korzystając z wywołań zwrotnych, funkcja example_funkcja ma teraz tylko jedną funkcję, a drukowanie (lub jakakolwiek inna funkcja, którą chcielibyśmy mieć) dzieje się poza nią.
JLagana,
Wreszcie, zgadzamy się, że jeśli wydrukowanie kroków obliczeniowych jest korzyścią dla użytkownika, utknę w dodawaniu gdzieś poleceń drukowania. Chcę jednak, aby znajdowały się poza example_functionciałem, aby jego złożoność pozostała jedynie związana ze złożonością jego głównej funkcjonalności. W moim rzeczywistym zastosowaniu tego wszystkiego mam główną funkcję, która jest już znacznie złożona. Dodanie instrukcji drukowania / kreślenia / rejestrowania do jego ciała sprawia, że ​​staje się bestią, której utrzymanie i debugowanie jest dość trudne.
JLagana,
1

Możesz zdefiniować funkcję enkapsulującą debug_modewarunek i przekazać żądaną funkcję opcjonalną i jej argumenty do tej funkcji (zgodnie z sugestią tutaj ):

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

Pamiętaj, że debug_modeoczywiście przed wywołaniem musiała zostać mu przypisana wartość DEBUG.

Oczywiście można wywoływać funkcje inne niż print.

Możesz także rozszerzyć tę koncepcję na kilka poziomów debugowania, używając wartości liczbowej dla debug_mode.

Gerd
źródło
Dzięki za odpowiedź, Gerd. Rzeczywiście Twoje rozwiązanie eliminuje potrzebę stosowania ifwyciągów w dowolnym miejscu, a także ułatwia włączanie i wyłączanie drukowania. Jednak nie oddziela funkcji drukowania od głównej funkcjonalności example_function. Porównaj to na przykład z moją sugestią oddzwonienia. Korzystając z wywołań zwrotnych, funkcja example_funkcja ma teraz tylko jedną funkcję, a drukowanie (lub jakakolwiek inna funkcja, którą chcielibyśmy mieć) dzieje się poza nią.
JLagana
1

Zaktualizowałem moją odpowiedź z uproszczeniem: funkcja example_functionprzechodzi pojedyncze wywołanie zwrotne lub przechwytuje z wartością domyślną, tak że example_functionnie trzeba już sprawdzać, czy została ona przekazana, czy nie:

hook=lambda *args, **kwargs: None

Powyższe jest wyrażeniem lambda, które zwraca Nonei example_functionmoże wywoływać tę wartość domyślną dla hookdowolnej kombinacji parametrów pozycyjnych i słów kluczowych w różnych miejscach funkcji.

W poniższym przykładzie interesują mnie tylko wydarzenia „ "end_iteration"i "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))

Wydruki:

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

Funkcja zaczepu może być tak prosta lub tak rozbudowana, jak tylko chcesz. Tutaj sprawdza typ zdarzenia i wykonuje prosty wydruk. Ale może uzyskać loggerinstancję i zapisać komunikat. Możesz mieć całe bogactwo logowania, jeśli potrzebujesz, ale prostotę, jeśli nie.

Boo Boo
źródło
Dzięki za odpowiedź, Ronald. Pomysł rozszerzenia idei wywołania zwrotnego w celu wykonania wywołań zwrotnych w różnych częściach funkcji (i przekazania do nich zmiennej kontekstowej) wydaje się być najlepszym sposobem. To znacznie ułatwia pisanie oddzwaniania i za rozsądną cenę, co dodatkowo komplikuje example_function.
JLagana
Miły akcent z wartością domyślną; to prosty sposób na usunięcie wielu ifwyciągów :)
JLagana,