Gewährleistung von sauberem Code: Ein Blick auf Python, parametrisiert

Veröffentlicht: 2022-03-11

In diesem Beitrag werde ich über das sprechen, was ich für die wichtigste Technik oder das wichtigste Muster bei der Erstellung von sauberem, pythonischem Code halte – nämlich Parametrisierung. Dieser Beitrag ist für dich, wenn:

  • Sie sind relativ neu in der ganzen Sache mit Entwurfsmustern und vielleicht etwas verwirrt von langen Listen von Musternamen und Klassendiagrammen. Die gute Nachricht ist, dass es für Python wirklich nur ein Entwurfsmuster gibt, das Sie unbedingt kennen müssen. Noch besser, Sie kennen es wahrscheinlich bereits, aber vielleicht nicht alle Möglichkeiten, wie es angewendet werden kann.
  • Sie sind von einer anderen OOP-Sprache wie Java oder C# zu Python gekommen und möchten wissen, wie Sie Ihr Wissen über Entwurfsmuster aus dieser Sprache in Python übersetzen können. In Python und anderen dynamisch typisierten Sprachen sind viele Muster, die in statisch typisierten OOP-Sprachen üblich sind, „unsichtbar oder einfacher“, wie der Autor Peter Norvig es ausdrückte.

In diesem Artikel untersuchen wir die Anwendung der „Parametrisierung“ und wie sie sich auf Mainstream-Entwurfsmuster beziehen kann, die als Dependency Injection , Strategy , Template Method , Abstract Factory , Factory Method und Decorator bekannt sind. In Python erweisen sich viele davon als einfach oder werden dadurch überflüssig, dass Parameter in Python aufrufbare Objekte oder Klassen sein können.

Parametrierung ist der Prozess, Werte oder Objekte, die in einer Funktion oder Methode definiert sind, zu nehmen und sie zu Parametern für diese Funktion oder Methode zu machen, um den Code zu verallgemeinern. Dieser Vorgang wird auch als „Extract Parameter“-Refactoring bezeichnet. In gewisser Weise handelt dieser Artikel von Entwurfsmustern und Refactoring.

Der einfachste Fall von Python parametrisiert

Für die meisten unserer Beispiele verwenden wir das Turtle-Modul der Unterrichtsstandardbibliothek, um einige Grafiken zu erstellen.

Hier ist ein Code, der mit turtle ein 100x100-Quadrat zeichnet:

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

Angenommen, wir möchten jetzt ein Quadrat mit einer anderen Größe zeichnen. Ein sehr unerfahrener Programmierer wäre an dieser Stelle versucht, diesen Block zu kopieren, einzufügen und zu modifizieren. Offensichtlich wäre es eine viel bessere Methode, zuerst den Code zum Zeichnen des Quadrats in eine Funktion zu extrahieren und dann die Größe des Quadrats zu einem Parameter für diese Funktion zu machen:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

So können wir jetzt mit draw_square Quadrate beliebiger Größe zeichnen. Das ist alles, was die wesentliche Technik der Parametrisierung ausmacht, und wir haben gerade die erste Hauptverwendung gesehen – die Eliminierung der Copy-Paste-Programmierung.

Ein unmittelbares Problem mit dem obigen Code ist, dass draw_square von einer globalen Variablen abhängt. Dies hat viele schlimme Folgen und es gibt zwei einfache Möglichkeiten, es zu beheben. Die erste wäre, dass draw_square die Turtle -Instanz selbst erstellt (was ich später besprechen werde). Dies ist möglicherweise nicht wünschenswert, wenn wir für alle unsere Zeichnungen eine einzige Turtle verwenden möchten. Im Moment verwenden wir also einfach wieder die Parametrisierung, um turtle zu einem Parameter für draw_square zu machen:

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

Dies hat einen ausgefallenen Namen – Dependency Injection. Es bedeutet nur, dass, wenn eine Funktion ein Objekt benötigt, um ihre Arbeit zu erledigen, wie z. B. draw_square eine Turtle benötigt, der Aufrufer dafür verantwortlich ist, dieses Objekt als Parameter zu übergeben. Nein, wirklich, wenn Sie jemals neugierig auf die Python-Abhängigkeitsinjektion waren, das ist es.

Bisher haben wir uns mit zwei sehr grundlegenden Verwendungen befasst. Die wichtigste Beobachtung für den Rest dieses Artikels ist, dass es in Python eine Vielzahl von Dingen gibt, die zu Parametern werden können – mehr als in einigen anderen Sprachen – und das macht es zu einer sehr mächtigen Technik.

Alles, was ein Objekt ist

In Python können Sie diese Technik verwenden, um alles zu parametrisieren, was ein Objekt ist, und in Python sind die meisten Dinge, auf die Sie stoßen, tatsächlich Objekte. Das beinhaltet:

  • Instanzen von integrierten Typen, wie die Zeichenfolge "I'm a string" und die Ganzzahl 42 oder ein Wörterbuch
  • Instanzen anderer Typen und Klassen, z. B. ein datetime.datetime Objekt
  • Funktionen und Methoden
  • Integrierte Typen und benutzerdefinierte Klassen

Die letzten beiden sind diejenigen, die am überraschendsten sind, besonders wenn Sie aus anderen Sprachen kommen, und sie bedürfen weiterer Diskussion.

Funktioniert als Parameter

Die Funktionsanweisung in Python macht zwei Dinge:

  1. Es erstellt ein Funktionsobjekt.
  2. Es erstellt einen Namen im lokalen Geltungsbereich, der auf dieses Objekt zeigt.

Mit diesen Objekten können wir in einer REPL spielen:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

Und wie allen Objekten können wir anderen Variablen Funktionen zuweisen:

 > >> bar = foo > >> bar() 'Hello from foo'

Beachten Sie, dass bar ein anderer Name für dasselbe Objekt ist, also hat es dieselbe interne Eigenschaft __name__ wie zuvor:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

Aber der entscheidende Punkt ist, dass, da Funktionen nur Objekte sind, überall dort, wo eine Funktion verwendet wird, es sich um einen Parameter handeln könnte.

Nehmen wir also an, wir erweitern unsere Funktion zum Zeichnen von Quadraten oben, und jetzt möchten wir beim Zeichnen von Quadraten manchmal an jeder Ecke anhalten – ein Aufruf von time.sleep() .

Aber angenommen, wir wollen manchmal nicht pausieren. Der einfachste Weg, dies zu erreichen, wäre das Hinzufügen eines pause Parameters, vielleicht mit einem Standardwert von Null, so dass wir standardmäßig nicht pausieren.

Später stellen wir jedoch fest, dass wir an den Ecken manchmal eigentlich etwas ganz anderes machen wollen. Vielleicht möchten wir an jeder Ecke eine andere Form zeichnen, die Stiftfarbe ändern usw. Wir könnten versucht sein, viele weitere Parameter hinzuzufügen, einen für jede Sache, die wir tun müssen. Eine viel schönere Lösung wäre jedoch, zuzulassen, dass jede Funktion als auszuführende Aktion übergeben wird. Als Standard erstellen wir eine Funktion, die nichts tut. Wir sorgen auch dafür, dass diese Funktion die lokalen turtle und size akzeptiert, falls sie erforderlich sind:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

Oder wir könnten etwas Cooleres machen, wie z. B. rekursiv kleinere Quadrate an jeder Ecke zeichnen:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

Illustration von rekursiv gezeichneten kleineren Quadraten, wie oben im parametrisierten Python-Code gezeigt

Natürlich gibt es davon Variationen. In vielen Beispielen würde der Rückgabewert der Funktion verwendet werden. Hier haben wir einen imperativeren Programmierstil, und die Funktion wird nur wegen ihrer Nebeneffekte aufgerufen.

In anderen Sprachen…

Mit erstklassigen Funktionen in Python ist dies sehr einfach. In Sprachen, denen sie fehlen, oder einigen statisch typisierten Sprachen, die Typsignaturen für Parameter erfordern, kann dies schwieriger sein. Wie würden wir das machen, wenn wir keine erstklassigen Funktionen hätten?

Eine Lösung wäre, draw_square in eine Klasse SquareDrawer :

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

Jetzt können wir SquareDrawer und eine at_corner -Methode hinzufügen, die das tut, was wir brauchen. Dieses Python-Muster ist als Template-Methodenmuster bekannt – eine Basisklasse definiert die Form der gesamten Operation oder des Algorithmus, und die abweichenden Teile der Operation werden in Methoden eingefügt, die von Unterklassen implementiert werden müssen.

Während dies in Python manchmal hilfreich sein kann, ist es oft viel einfacher, den Variantencode in eine Funktion zu ziehen, die einfach als Parameter übergeben wird.

Eine zweite Möglichkeit, wie wir dieses Problem in Sprachen ohne erstklassige Funktionen angehen könnten, besteht darin, unsere Funktionen als Methoden innerhalb von Klassen zu verpacken, wie folgt:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

Dies wird als Strategiemuster bezeichnet. Auch dies ist sicherlich ein gültiges Muster für die Verwendung in Python, insbesondere wenn die Strategieklasse tatsächlich eine Reihe verwandter Funktionen enthält und nicht nur eine. Oft ist jedoch alles, was wir wirklich brauchen, eine Funktion, und wir können aufhören, Klassen zu schreiben.

Andere Callables

In den obigen Beispielen habe ich darüber gesprochen, Funktionen als Parameter an andere Funktionen zu übergeben. Alles, was ich geschrieben habe, galt jedoch tatsächlich für jedes aufrufbare Objekt. Funktionen sind das einfachste Beispiel, aber wir können auch Methoden betrachten.

Angenommen, wir haben eine Liste foo :

 foo = [1, 2, 3]

foo hat jetzt eine ganze Reihe von Methoden angehängt, wie zum Beispiel .append() und .count() . Diese „gebundenen Methoden“ können herumgereicht und wie Funktionen verwendet werden:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

Zusätzlich zu diesen Instanzmethoden gibt es andere Arten von aufrufbaren Objekten staticmethods und classmethods , Instanzen von Klassen, die __call__ implementieren, und Klassen/Typen selbst.

Klassen als Parameter

In Python sind Klassen „erstklassig“ – sie sind Laufzeitobjekte, genau wie Diktate, Zeichenfolgen usw. Dies mag noch seltsamer erscheinen, als dass Funktionen Objekte sind, aber zum Glück ist es tatsächlich einfacher, diese Tatsache zu demonstrieren als für Funktionen.

Die class-Anweisung, mit der Sie vertraut sind, ist eine nette Art, Klassen zu erstellen, aber es ist nicht die einzige Möglichkeit – wir können auch die Version von type mit drei Argumenten verwenden. Die folgenden beiden Anweisungen machen genau dasselbe:

 class Foo: pass Foo = type('Foo', (), {})

Beachten Sie in der zweiten Version die zwei Dinge, die wir gerade getan haben (die bequemer mit der class-Anweisung erledigt werden):

  1. Auf der rechten Seite des Gleichheitszeichens haben wir eine neue Klasse mit dem internen Namen Foo erstellt. Dies ist der Name, den Sie zurückerhalten, wenn Sie Foo.__name__ .
  2. Mit der Zuweisung haben wir dann einen Namen im aktuellen Gültigkeitsbereich erstellt, Foo, der auf das gerade erstellte Klassenobjekt verweist.

Wir haben die gleichen Beobachtungen für das gemacht, was die Funktionsanweisung tut.

Die wichtigste Erkenntnis hier ist, dass Klassen Objekte sind, denen Namen zugewiesen werden können (dh die in eine Variable eingefügt werden können). Überall dort, wo Sie eine verwendete Klasse sehen, sehen Sie eigentlich nur eine verwendete Variable. Und wenn es eine Variable ist, kann es ein Parameter sein.

Wir können das in eine Reihe von Verwendungen unterteilen:

Klassen als Fabriken

Eine Klasse ist ein aufrufbares Objekt, das eine Instanz von sich selbst erstellt:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

Und als Objekt kann es anderen Variablen zugewiesen werden:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

Um auf unser obiges Schildkrötenbeispiel zurückzukommen, besteht ein Problem bei der Verwendung von Schildkröten zum Zeichnen darin, dass die Position und Ausrichtung der Zeichnung von der aktuellen Position und Ausrichtung der Schildkröte abhängen und sie auch in einem anderen Zustand belassen können, der möglicherweise nicht hilfreich ist für der Anrufer. Um dies zu lösen, könnte unsere Funktion draw_square eine eigene Schildkröte erstellen, sie an die gewünschte Position verschieben und dann ein Quadrat zeichnen:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Allerdings haben wir jetzt ein Anpassungsproblem. Angenommen, der Aufrufer wollte einige Attribute der Schildkröte festlegen oder eine andere Art von Schildkröte verwenden, die dieselbe Schnittstelle, aber ein spezielles Verhalten hat?

Wir könnten dies wie zuvor mit Abhängigkeitsinjektion lösen – der Aufrufer wäre für die Einrichtung des Turtle -Objekts verantwortlich. Aber was ist, wenn unsere Funktion manchmal viele Schildkröten für verschiedene Zeichenzwecke erstellen muss oder wenn sie vielleicht vier Fäden mit jeweils einer eigenen Schildkröte starten möchte, um eine Seite des Quadrats zu zeichnen? Die Antwort ist einfach, die Turtle-Klasse zu einem Parameter für die Funktion zu machen. Wir können ein Schlüsselwortargument mit einem Standardwert verwenden, um die Dinge für Anrufer, denen es egal ist, einfach zu halten:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Um dies zu verwenden, könnten wir eine make_turtle Funktion schreiben, die eine Schildkröte erstellt und modifiziert. Angenommen, wir möchten die Schildkröte beim Zeichnen von Quadraten ausblenden:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

Oder wir könnten Turtle , um dieses Verhalten zu integrieren, und die Unterklasse als Parameter übergeben:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

In anderen Sprachen…

Einigen anderen OOP-Sprachen, wie Java und C#, fehlen erstklassige Klassen. Um eine Klasse zu instanziieren, müssen Sie das Schlüsselwort new gefolgt von einem tatsächlichen Klassennamen verwenden.

Diese Einschränkung ist der Grund für Muster wie die abstrakte Fabrik (die die Erstellung einer Reihe von Klassen erfordert, deren einzige Aufgabe es ist, andere Klassen zu instanziieren) und das Factory-Methodenmuster. Wie Sie sehen, geht es in Python nur darum, die Klasse als Parameter herauszuziehen, da eine Klasse ihre eigene Factory ist.

Klassen als Basisklassen

Angenommen, wir erstellen Unterklassen, um dieselbe Funktion zu verschiedenen Klassen hinzuzufügen. Zum Beispiel wollen wir eine Turtle -Unterklasse, die bei ihrer Erstellung in ein Protokoll schreibt:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

Aber dann machen wir genau dasselbe mit einer anderen Klasse:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

Die einzigen Dinge, die zwischen diesen beiden variieren, sind:

  1. Die Basisklasse
  2. Der Name der Unterklasse – aber das interessiert uns nicht wirklich und könnte ihn automatisch aus dem Attribut __name__ der Basisklasse generieren.
  3. Der Name, der im debug -Aufruf verwendet wird – aber auch hier könnten wir diesen aus dem Namen der Basisklasse generieren.

Was können wir angesichts zweier sehr ähnlicher Code-Bits mit nur einer Variante tun? Genau wie in unserem allerersten Beispiel erstellen wir eine Funktion und ziehen den Variantenteil als Parameter heraus:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

Hier haben wir eine Demonstration erstklassiger Klassen:

  • Wir haben eine Klasse an eine Funktion übergeben und dem Parameter einen konventionellen Namen cls gegeben, um den Konflikt mit dem Schlüsselwort class zu vermeiden (Sie werden auch sehen class_ und klass für diesen Zweck verwendet werden).
  • Innerhalb der Funktion haben wir eine Klasse erstellt – beachten Sie, dass jeder Aufruf dieser Funktion eine neue Klasse erstellt.
  • Wir haben diese Klasse als Rückgabewert der Funktion zurückgegeben.

Wir setzen auch LoggingThing.__name__ , was völlig optional ist, aber beim Debuggen helfen kann.

Eine weitere Anwendung dieser Technik ist, wenn wir eine ganze Reihe von Funktionen haben, die wir manchmal zu einer Klasse hinzufügen möchten, und wir möchten möglicherweise verschiedene Kombinationen dieser Funktionen hinzufügen. Das manuelle Erstellen all der verschiedenen Kombinationen, die wir benötigen, könnte sehr unhandlich werden.

In Sprachen, in denen Klassen zur Kompilierzeit und nicht zur Laufzeit erstellt werden, ist dies nicht möglich. Stattdessen müssen Sie das Decorator-Muster verwenden. Dieses Muster kann manchmal in Python nützlich sein, aber meistens können Sie einfach die obige Technik verwenden.

Normalerweise vermeide ich es eigentlich, viele Unterklassen zum Anpassen zu erstellen. Normalerweise gibt es einfachere und pythonischere Methoden, die überhaupt keine Klassen beinhalten. Aber diese Technik ist verfügbar, wenn Sie sie brauchen. Siehe auch Brandon Rhodes' vollständige Behandlung des Decorator-Musters in Python.

Klassen als Ausnahmen

Eine andere Stelle, an der Klassen verwendet werden, ist in der Klausel except einer try/except/finally-Anweisung. Kein Wunder, dass wir diese Klassen auch parametrisieren können.

Der folgende Code implementiert beispielsweise eine sehr generische Strategie, eine Aktion zu versuchen, die fehlschlagen könnte, und es mit exponentiellem Backoff erneut zu versuchen, bis eine maximale Anzahl von Versuchen erreicht ist:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

Wir haben sowohl die auszuführende Aktion als auch die abzufangenden Ausnahmen als Parameter herausgezogen. Der Parameter exceptions_to_catch kann entweder eine einzelne Klasse wie IOError oder httplib.client.HTTPConnectionError oder ein Tupel solcher Klassen sein. (Wir wollen „nackte Ausnahme“-Klauseln oder sogar eine except Exception vermeiden, da dies bekanntermaßen andere Programmierfehler verbirgt).

Warnungen und Schlussfolgerung

Die Parametrisierung ist eine leistungsstarke Technik zur Wiederverwendung von Code und zur Verringerung der Codeduplizierung. Es ist nicht ohne einige Nachteile. Beim Streben nach Wiederverwendung von Code tauchen oft mehrere Probleme auf:

  • Übermäßig generischer oder abstrahierter Code, der sehr schwer verständlich wird.
  • Code mit einer Vielzahl von Parametern, die das Gesamtbild verschleiern oder Fehler einführen, da in Wirklichkeit nur bestimmte Kombinationen von Parametern ordnungsgemäß getestet werden.
  • Nicht hilfreiche Kopplung verschiedener Teile der Codebasis, weil ihr „gemeinsamer Code“ an einer einzigen Stelle ausgelagert wurde. Manchmal ist der Code an zwei Stellen nur zufällig ähnlich, und die beiden Stellen sollten unabhängig voneinander sein, da sie möglicherweise unabhängig voneinander geändert werden müssen.

Manchmal ist ein bisschen „duplizierter“ Code viel besser als diese Probleme, also verwenden Sie diese Technik mit Vorsicht.

In diesem Beitrag haben wir Entwurfsmuster behandelt, die als Dependency Injection , Strategy , Template Method , Abstract Factory , Factory Method und Decorator bekannt sind. Viele davon entpuppen sich in Python wirklich als einfache Anwendung der Parametrisierung oder werden definitiv dadurch überflüssig, dass Parameter in Python aufrufbare Objekte oder Klassen sein können. Hoffentlich trägt dies dazu bei, die konzeptionelle Last von „Dingen, die Sie als echter Python-Entwickler wissen sollten“ zu verringern, und ermöglicht es Ihnen, prägnanten, pythonischen Code zu schreiben!

Weiterlesen:

  • Python-Entwurfsmuster: Für eleganten und modischen Code
  • Python-Muster: Für Python-Entwurfsmuster
  • Python-Protokollierung: Ein ausführliches Tutorial