Gibt es eine pythonische Möglichkeit, optionale Funktionen vom Hauptzweck einer Funktion zu entkoppeln?

11

Kontext

Angenommen, ich habe den folgenden Python-Code:

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_functionHier gehen Sie einfach jedes der Elemente in der nsListe durch und halbieren sie dreimal, während Sie die Ergebnisse akkumulieren. Die Ausgabe dieses Skripts lautet einfach:

2.0

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

Nehmen wir nun an, ich möchte (aus irgendeinem Grund, vielleicht zum Debuggen oder Protokollieren) Informationen zu den Zwischenschritten anzeigen, die example_functiondas Unternehmen ausführt. Vielleicht würde ich diese Funktion dann in so etwas umschreiben:

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

Wenn jetzt mit denselben Argumenten wie zuvor aufgerufen wird, wird Folgendes ausgegeben:

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

Damit wird genau das erreicht, was ich beabsichtigt habe. Dies widerspricht jedoch ein wenig dem Prinzip, dass eine Funktion nur eines tun sollte, und jetzt ist der Code für example_functionetwas länger und komplexer. Für eine so einfache Funktion ist dies kein Problem, aber in meinem Kontext habe ich ziemlich komplizierte Funktionen, die sich gegenseitig aufrufen, und die Druckanweisungen umfassen oft kompliziertere Schritte als hier gezeigt, was zu einer erheblichen Zunahme der Komplexität meines Codes führt (zum einen) Von meinen Funktionen gab es mehr Codezeilen im Zusammenhang mit der Protokollierung als Zeilen im Zusammenhang mit dem eigentlichen Zweck!).

Wenn ich später beschließe, dass ich keine Druckanweisungen mehr in meiner Funktion haben möchte, müsste ich außerdem example_functionalle printAnweisungen zusammen mit allen Variablen, die mit dieser Funktionalität zusammenhängen, manuell durchgehen und löschen. Dies ist ein Prozess, der sowohl langwierig als auch fehlerhaft ist -anfällig.

Die Situation wird noch schlimmer, wenn ich während der Funktionsausführung immer die Möglichkeit haben möchte, zu drucken oder nicht zu drucken, was mich dazu veranlasst, entweder zwei extrem ähnliche Funktionen zu deklarieren (eine mit den printAnweisungen, eine ohne), was für die Wartung schrecklich ist, oder etwas definieren wie:

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

was zu einer aufgeblähten und (hoffentlich) unnötig komplizierten Funktion führt, selbst im einfachen Fall unserer example_function.


Frage

Gibt es eine pythonische Möglichkeit, die Druckfunktionalität von der ursprünglichen Funktionalität der zu "entkoppeln"? example_function ?

Gibt es allgemein eine pythonische Möglichkeit, optionale Funktionen vom Hauptzweck einer Funktion zu entkoppeln?


Was ich bisher versucht habe:

Die Lösung, die ich im Moment gefunden habe, besteht darin, Rückrufe für die Entkopplung zu verwenden. Zum Beispiel kann man Folgendes umschreiben example_function:

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

und dann Definieren einer Rückruffunktion, die die gewünschte Druckfunktion ausführt:

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

und so anrufen example_function:

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

welches dann ausgibt:

0.5
0.25
0.125
1.5
0.75
0.375
6.0
3.0
1.5
2.0

Dadurch wird die Druckfunktionalität erfolgreich von der Basisfunktionalität von entkoppelt example_function. Das Hauptproblem bei diesem Ansatz besteht jedoch darin, dass die Rückruffunktion nur an einem bestimmten Teil der ausgeführt werden kannexample_function (in diesem Fall direkt nach der Halbierung der aktuellen Nummer) ausgeführt werden kann und der gesamte Druck genau dort stattfinden muss. Dies erzwingt manchmal, dass das Design der Rückruffunktion ziemlich kompliziert ist (und macht es unmöglich, einige Verhaltensweisen zu erreichen).

Wenn man zum Beispiel genau die gleiche Art des Druckens wie in einem vorherigen Teil der Frage erzielen möchte (wobei angegeben wird, welche Nummer verarbeitet wird, zusammen mit den entsprechenden Halbierungen), wäre der resultierende Rückruf:

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)

was zu genau der gleichen Ausgabe wie zuvor führt:

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

Aber es ist ein Schmerz zu schreiben, zu lesen und zu debuggen.

JLagana
quelle
6
logging
Schauen
@Chris_Rands ist richtig. Verwenden Sie das Protokollierungsmodul. Auf diese Weise können Sie die Protokollierung ein- und ausschalten. Verwenden Sie den folgenden Link. stackoverflow.com/questions/2266646/…
Yatish Kadam
2
Ich sehe nicht, wie das loggingModul hier helfen würde. Obwohl meine Frage printbeim Einrichten des Kontexts Anweisungen verwendet , suche ich tatsächlich nach einer Lösung, wie jede Art von optionaler Funktionalität vom Hauptzweck einer Funktion entkoppelt werden kann. Zum Beispiel möchte ich vielleicht, dass eine Funktion Dinge zeichnet, während sie ausgeführt wird. In diesem Fall glaube ich, dass das loggingModul nicht einmal anwendbar wäre.
JLagana
3
@Pythonic ist ein Adjektiv, das die Python-Syntax / den Stil / die Struktur / die Verwendung beschreibt, um die Philosophie von Python aufrechtzuerhalten. Dies ist keine syntaktische oder Entwurfsregel, sondern ein Ansatz, der verantwortungsbewusst eingehalten werden muss, um eine saubere und wartbare Python-Codebasis zu erstellen. In Ihrem Fall werden durch wenige Zeilen mit Trace- oder Druckanweisungen Werte für die Wartbarkeit hinzugefügt. Sei nicht hart zu dir. Betrachten Sie einen der oben genannten Ansätze, die Sie für ideal halten.
Nair
1
Diese Frage ist zu weit gefasst. Wir sind möglicherweise in der Lage, bestimmte Fragen zu beantworten (wie die zu verwendenden Vorschläge loggingzeigen), aber nicht, wie beliebiger Code getrennt werden kann.
Chepner

Antworten:

4

Wenn Sie Funktionen außerhalb der Funktion benötigen, um Daten innerhalb der Funktion zu verwenden, muss innerhalb der Funktion ein Nachrichtensystem vorhanden sein, das dies unterstützt. Daran führt kein Weg vorbei. Lokale Variablen in Funktionen sind von außen vollständig isoliert.

Das Protokollierungsmodul ist ziemlich gut darin, ein Nachrichtensystem einzurichten. Es ist nicht nur auf das Ausdrucken der Protokollnachrichten beschränkt - mit benutzerdefinierten Handlern können Sie alles tun.

Das Hinzufügen eines Nachrichtensystems ähnelt Ihrem Rückrufbeispiel, mit der Ausnahme, dass die Stellen, an denen die "Rückrufe" (Protokollierungshandler) verarbeitet werden, an einer beliebigen Stelle im example_function (durch Senden der Nachrichten an den Protokollierer) angegeben werden können. Alle Variablen, die von den Protokollierungshandlern benötigt werden, können beim Senden der Nachricht angegeben werden (Sie können sie weiterhin verwenden locals(), es ist jedoch am besten, die benötigten Variablen explizit zu deklarieren).

Ein neues example_functionkönnte aussehen wie:

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

Dies gibt drei Orte an, an denen die Nachrichten verarbeitet werden könnten. Dies allein example_functionwird nichts anderes als die Funktionalität des Selbst example_functionbewirken. Es wird nichts ausgedruckt oder andere Funktionen ausgeführt.

Um dem Funktionen zusätzliche Funktionen example_functionhinzuzufügen, müssen Sie dem Logger Handler hinzufügen.

Wenn Sie beispielsweise die gesendeten Variablen ausdrucken möchten (ähnlich wie in Ihrem debuggingBeispiel), definieren Sie den benutzerdefinierten Handler und fügen ihn dem example_functionLogger hinzu:

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

Wenn Sie die Ergebnisse in einem Diagramm darstellen möchten, definieren Sie einfach einen anderen Handler:

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

Sie können beliebige Handler definieren und hinzufügen. Sie sind völlig unabhängig von der Funktionalität der example_functionund können nur die Variablen verwenden, die example_functionsie ihnen geben.

Obwohl die Protokollierung als Messagingsystem verwendet werden kann, ist es möglicherweise besser, auf ein vollwertiges Messagingsystem wie PyPubSub umzusteigen , damit die tatsächliche Protokollierung, die Sie möglicherweise durchführen , nicht beeinträchtigt wird:

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
quelle
Danke für die Antwort, RPalmer. Der Code, den Sie mit dem loggingModul bereitgestellt haben, ist in der Tat besser organisiert und wartbarer als der von mir vorgeschlagene Code printund die ifAnweisungen. Die Druckfunktionalität wird jedoch nicht von der Hauptfunktionalität der Funktion entkoppelt example_function. Das heißt, das Hauptproblem, example_functionzwei Dinge gleichzeitig erledigt zu haben, bleibt bestehen, was den Code komplizierter macht, als ich es gerne hätte.
JLagana
Vergleichen Sie dies zum Beispiel mit meinem Rückrufvorschlag. Wenn Sie Rückrufe verwenden, gibt es example_functionjetzt nur noch eine Funktion, und das Drucken (oder eine andere Funktion, die wir gerne hätten) findet außerhalb davon statt.
JLagana
Hallo @JLagana. My example_functionist von der Druckfunktion entkoppelt - die einzige zusätzliche Funktion, die der Funktion hinzugefügt wird, ist das Senden der Nachrichten. Es ähnelt Ihrem Rückrufbeispiel, sendet jedoch nur bestimmte Variablen, die Sie möchten, und nicht alle locals(). Es liegt an den Protokollhandlern (die Sie an einer anderen Stelle an den Protokollierer anhängen), die zusätzlichen Funktionen (Drucken, Zeichnen usw.) auszuführen. Sie müssen überhaupt keine Handler anhängen. In diesem Fall passiert beim Senden der Nachrichten nichts. Ich habe meinen Beitrag aktualisiert, um dies klarer zu machen.
RPalmer
Ich stehe korrigiert da, Ihr Beispiel hat die Druckfunktionalität von der Hauptfunktionalität von entkoppelt example_function. Vielen Dank, dass Sie es jetzt besonders deutlich gemacht haben! Diese Antwort gefällt mir sehr gut. Der einzige Preis, der gezahlt wird, ist die zusätzliche Komplexität der Nachrichtenübermittlung, die, wie Sie bereits erwähnt haben, unvermeidlich zu sein scheint. Vielen Dank auch für den Verweis auf PyPubSub, der mich dazu brachte, das Beobachtermuster nachzulesen .
JLagana
1

Wenn Sie sich nur an Druckanweisungen halten möchten, können Sie einen Dekorateur verwenden, der ein Argument hinzufügt, mit dem der Druck zur Konsole ein- und ausgeschaltet wird.

Hier ist ein Dekorateur, der verbose=Falsejeder Funktion das Nur- Schlüsselwort-Argument und den Standardwert hinzufügt und die Dokumentzeichenfolge und Signatur aktualisiert. Wenn Sie die Funktion unverändert aufrufen, wird die erwartete Ausgabe zurückgegeben. Wenn Sie die Funktion mit verbose=Trueaufrufen, werden die print-Anweisungen aktiviert und die erwartete Ausgabe zurückgegeben. Dies hat den zusätzlichen Vorteil, dass nicht jedem Druck ein if debug:Block vorangestellt werden muss .

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

Wenn Sie Ihre Funktion jetzt einpacken, können Sie die Druckfunktionen mithilfe von aktivieren / deaktivieren 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

Beispiele:

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

Bei der Inspektion example_functionwird auch die aktualisierte Dokumentation angezeigt. Da Ihre Funktion keine Dokumentzeichenfolge hat, ist es genau das, was sich im Dekorator befindet.

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 Bezug auf die Codierungsphilosophie. Eine Funktion zu haben, die keine Nebenwirkungen hat, ist ein funktionales Programmierparadigma. Python kann eine funktionale Sprache sein, ist jedoch nicht ausschließlich so konzipiert. Ich gestalte meinen Code immer mit Blick auf den Benutzer.

Wenn das Hinzufügen der Option zum Drucken der Berechnungsschritte für den Benutzer von Vorteil ist, ist daran NICHTS falsch. Vom Standpunkt des Designs aus werden Sie nicht mehr in der Lage sein, die Druck- / Protokollierungsbefehle irgendwo hinzuzufügen.

James
quelle
Danke für die Antwort, James. Der bereitgestellte Code ist in der Tat organisierter und wartbarer als der von mir vorgeschlagene, der verwendet printund ifAnweisungen enthält. Darüber hinaus gelingt es ihm, einen Teil der Funktionalität des Druckens tatsächlich von der Hauptfunktionalität zu entkoppeln example_function, was sehr schön war (mir hat auch gefallen, dass der Dekorateur automatisch an die Dokumentenkette anhängt, nette Geste). Die Druckfunktionalität wird jedoch nicht vollständig von der Hauptfunktionalität entkoppelt example_function: Sie müssen die printAnweisungen und die zugehörige Logik noch zum Funktionskörper hinzufügen .
JLagana
Vergleichen Sie dies zum Beispiel mit meinem Rückrufvorschlag. Bei Verwendung von Rückrufen verfügt example_function nur noch über eine Funktion, und das Druckmaterial (oder eine andere Funktion, die wir gerne hätten) wird außerhalb der Funktion ausgeführt.
JLagana
Schließlich sind wir uns einig, dass ich, wenn das Drucken der Berechnungsschritte für den Benutzer von Vorteil ist, die Druckbefehle nicht irgendwo hinzufügen muss. Ich möchte jedoch, dass sie sich außerhalb des example_functionKörpers des Körpers befinden, damit seine Komplexität nur mit der Komplexität seiner Hauptfunktionalität verbunden bleibt. In meiner realen Anwendung von all dem habe ich eine Hauptfunktion, die bereits sehr komplex ist. Das Hinzufügen von Druck- / Plot- / Protokollierungsanweisungen zu seinem Körper macht ihn zu einem Biest, dessen Wartung und Fehlerbehebung ziemlich schwierig war.
JLagana
1

Sie können eine Funktion definieren, die die debug_modeBedingung kapselt , und die gewünschte optionale Funktion und ihre Argumente an diese Funktion übergeben (wie hier vorgeschlagen ):

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

Beachten Sie, dass debug_modevor dem Aufruf offensichtlich ein Wert zugewiesen worden sein muss DEBUG.

Es ist natürlich möglich, andere Funktionen als aufzurufen print.

Sie können dieses Konzept auch auf mehrere Debug-Ebenen erweitern, indem Sie einen numerischen Wert für verwenden debug_mode.

Gerd
quelle
Danke für die Antwort, Gerd. In der Tat macht Ihre Lösung die Notwendigkeit von ifAussagen überall überflüssig und erleichtert auch das Ein- und Ausschalten des Druckvorgangs. Es entkoppelt jedoch nicht die Druckfunktionalität von der Hauptfunktionalität von example_function. Vergleichen Sie dies zum Beispiel mit meinem Rückrufvorschlag. Bei Verwendung von Rückrufen verfügt example_function nur noch über eine Funktion, und das Druckmaterial (oder eine andere Funktion, die wir gerne hätten) wird außerhalb der Funktion ausgeführt.
JLagana
1

Ich habe meine Antwort mit einer Vereinfachung aktualisiert: Der Funktion example_functionwird ein einzelner Rückruf oder Hook mit einem Standardwert übergeben, sodass example_functionnicht mehr getestet werden muss, ob sie übergeben wurde oder nicht:

hook=lambda *args, **kwargs: None

Das Obige ist ein Lambda-Ausdruck, der diesen Standardwert für eine beliebige Kombination von Positions- und Schlüsselwortparametern an verschiedenen Stellen innerhalb der Funktion zurückgibt Noneund example_functionaufrufen kann hook.

Im folgenden Beispiel interessieren mich nur die "end_iteration"und "result"Ereignisse.

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

Drucke:

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

Die Hook-Funktion kann so einfach oder so aufwendig sein, wie Sie möchten. Hier wird der Ereignistyp überprüft und einfach gedruckt. Es könnte jedoch eine loggerInstanz erhalten und die Nachricht protokollieren. Sie können die Fülle der Protokollierung nutzen, wenn Sie sie benötigen, aber die Einfachheit, wenn Sie dies nicht tun.

Booboo
quelle
Danke für die Antwort, Ronald. Die Idee, die Callback-Idee zu erweitern, um Callbacks in verschiedenen Teilen der Funktion auszuführen (und ihnen eine Kontextvariable zu übergeben), scheint in der Tat der beste Weg zu sein. Es macht es viel einfacher, Rückrufe zu schreiben und zu einem vernünftigen Preis in zusätzlicher Komplexität zu example_function.
JLagana
Nette Berührung mit dem Standardwert; Es ist eine einfache Möglichkeit, viele ifAnweisungen zu entfernen :)
JLagana