Zapewnienie czystego kodu: spojrzenie na Pythona, sparametryzowane
Opublikowany: 2022-03-11W tym poście opowiem o tym, co uważam za najważniejszą technikę lub wzorzec w tworzeniu czystego kodu w Pythonie — a mianowicie o parametryzacji. Ten post jest dla Ciebie, jeśli:
- Jesteś stosunkowo nowy w temacie wzorców projektowych i być może nieco oszołomiony długimi listami nazw wzorców i diagramami klas. Dobrą wiadomością jest to, że tak naprawdę istnieje tylko jeden wzorzec projektowy, który koniecznie musisz znać w Pythonie. Co więcej, prawdopodobnie już to znasz, ale być może nie wszystkie sposoby, w jakie można go zastosować.
- Przyszedłeś do Pythona z innego języka OOP, takiego jak Java lub C# i chcesz wiedzieć, jak przetłumaczyć swoją wiedzę o wzorcach projektowych z tego języka na Python. W Pythonie i innych dynamicznie typowanych językach wiele wzorców powszechnych w statycznie typowanych językach OOP jest „niewidocznych lub prostszych”, jak to ujął autor Peter Norvig.
W tym artykule omówimy zastosowanie „parametryzacji” i sposób, w jaki może ona odnosić się do wzorców projektowych głównego nurtu, znanych jako wstrzykiwanie zależności , strategia , metoda szablonowa , fabryka abstrakcyjna , metoda fabryczna i dekorator . W Pythonie wiele z nich okazuje się prostych lub niepotrzebnych, ponieważ parametry w Pythonie mogą być obiektami lub klasami, które można wywoływać.
Parametryzacja to proces pobierania wartości lub obiektów zdefiniowanych w funkcji lub metodzie i nadawania im parametrów do tej funkcji lub metody w celu uogólnienia kodu. Proces ten jest również znany jako refaktoryzacja „wyodrębniania parametrów”. W pewnym sensie ten artykuł dotyczy wzorców projektowych i refaktoryzacji.
Najprostszy przypadek sparametryzowanego Pythona
W większości naszych przykładów użyjemy modułu instruktażowego żółwia biblioteki standardowej do wykonania niektórych grafik.
Oto kod, który narysuje kwadrat 100x100 za pomocą turtle
:
from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)
Załóżmy, że teraz chcemy narysować kwadrat o innym rozmiarze. W tym momencie bardzo młodszy programista pokusiłby się o skopiowanie i wklejenie tego bloku i modyfikację. Oczywiście znacznie lepszą metodą byłoby najpierw wyodrębnienie kodu rysowania kwadratów do funkcji, a następnie uczynienie z rozmiaru kwadratu parametru tej funkcji:
def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)
Możemy więc teraz rysować kwadraty o dowolnym rozmiarze za pomocą draw_square
. To wszystko, co dotyczy podstawowej techniki parametryzacji, a my właśnie widzieliśmy pierwsze główne zastosowanie — eliminację programowania kopiuj-wklej.
Bezpośrednim problemem z powyższym kodem jest to, że draw_square
zależy od zmiennej globalnej. Ma to wiele złych konsekwencji i istnieją dwa proste sposoby, aby to naprawić. Pierwszym z nich byłoby utworzenie przez draw_square
samej instancji Turtle
(co omówię później). Może to nie być pożądane, jeśli chcemy używać jednego Turtle
do wszystkich naszych rysunków. Na razie po prostu ponownie użyjemy parametryzacji, aby uczynić turtle
parametrem draw_square
:
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)
Ma to wymyślną nazwę — wstrzykiwanie zależności. Oznacza to po prostu, że jeśli funkcja potrzebuje jakiegoś obiektu do wykonania swojej pracy, na przykład draw_square
potrzebuje Turtle
, wywołujący jest odpowiedzialny za przekazanie tego obiektu jako parametru. Nie, naprawdę, jeśli kiedykolwiek byłeś ciekaw wstrzykiwania zależności Pythona, to jest to.
Do tej pory mieliśmy do czynienia z dwoma bardzo podstawowymi zastosowaniami. Kluczową obserwacją w dalszej części tego artykułu jest to, że w Pythonie istnieje wiele rzeczy, które mogą stać się parametrami — więcej niż w niektórych innych językach — i to sprawia, że jest to bardzo potężna technika.
Wszystko, co jest przedmiotem
W Pythonie możesz użyć tej techniki do sparametryzowania wszystkiego, co jest obiektem, aw Pythonie większość napotkanych rzeczy to w rzeczywistości obiekty. To zawiera:
- Instancje typów wbudowanych, takich jak ciąg
"I'm a string"
i liczba całkowita42
lub słownik - Instancje innych typów i klas, np. obiekt
datetime.datetime
- Funkcje i metody
- Wbudowane typy i niestandardowe klasy
Ostatnie dwa są najbardziej zaskakujące, zwłaszcza jeśli pochodzisz z innych języków i wymagają one dalszej dyskusji.
Funkcje jako parametry
Instrukcja function w Pythonie robi dwie rzeczy:
- Tworzy obiekt funkcji.
- Tworzy nazwę w zakresie lokalnym, która wskazuje na ten obiekt.
Możemy bawić się tymi obiektami w REPL:
> >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'
I tak jak wszystkie obiekty, możemy przypisać funkcje do innych zmiennych:
> >> bar = foo > >> bar() 'Hello from foo'
Zauważ, że bar
to inna nazwa tego samego obiektu, więc ma taką samą wewnętrzną właściwość __name__
jak poprzednio:
> >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>
Ale najważniejsze jest to, że ponieważ funkcje są tylko obiektami, gdziekolwiek zobaczysz używaną funkcję, może to być parametr.
Załóżmy więc, że rozszerzyliśmy naszą funkcję rysowania kwadratów powyżej, a teraz czasami, gdy rysujemy kwadraty, chcemy zatrzymać się w każdym rogu — wywołanie time.sleep()
.
Ale przypuśćmy, że czasami nie chcemy się zatrzymać. Najprostszym sposobem na osiągnięcie tego byłoby dodanie parametru pause
, być może z domyślną wartością zero, aby domyślnie nie pauzować.
Jednak później odkrywamy, że czasami naprawdę chcemy zrobić coś zupełnie innego na rogach. Być może chcemy narysować inny kształt w każdym rogu, zmienić kolor pisaka itp. Możemy pokusić się o dodanie o wiele więcej parametrów, po jednym dla każdej rzeczy, którą musimy zrobić. Jednak o wiele lepszym rozwiązaniem byłoby zezwolenie na przekazanie dowolnej funkcji jako działania do wykonania. Domyślnie stworzymy funkcję, która nic nie robi. Sprawimy również, że ta funkcja będzie akceptowała lokalne parametry turtle
i size
, na wypadek gdyby były wymagane:
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)
Lub możemy zrobić coś fajniejszego, na przykład rekursywnie rysować mniejsze kwadraty w każdym rogu:
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)
Istnieją oczywiście odmiany tego. W wielu przykładach zostanie użyta wartość zwracana przez funkcję. Tutaj mamy bardziej imperatywny styl programowania, a funkcja jest wywoływana tylko ze względu na jej skutki uboczne.
W innych językach…
Posiadanie funkcji pierwszej klasy w Pythonie sprawia, że jest to bardzo łatwe. W językach, w których ich brakuje, lub w niektórych statycznie typowanych językach, które wymagają podpisów typów dla parametrów, może to być trudniejsze. Jak byśmy to zrobili, gdybyśmy nie mieli funkcji pierwszej klasy?
Jednym z rozwiązań byłoby przekształcenie draw_square
w klasę 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
Teraz możemy podklasę SquareDrawer
i dodać metodę at_corner
, która robi to, czego potrzebujemy. Ten wzorzec Pythona jest znany jako wzorzec metody szablonu — klasa bazowa definiuje kształt całej operacji lub algorytmu, a warianty operacji są umieszczane w metodach, które muszą być zaimplementowane przez podklasy.
Chociaż może to być czasami pomocne w Pythonie, przeciągnięcie kodu wariantu do funkcji, która jest po prostu przekazywana jako parametr, często będzie znacznie prostsze.
Drugim sposobem, w jaki możemy podejść do tego problemu w językach bez funkcji pierwszej klasy, jest zawinięcie naszych funkcji jako metod wewnątrz klas, na przykład:
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())
Jest to znane jako wzorzec strategii. Ponownie, jest to z pewnością prawidłowy wzorzec do użycia w Pythonie, zwłaszcza jeśli klasa strategii faktycznie zawiera zestaw powiązanych funkcji, a nie tylko jedną. Jednak często wszystko, czego naprawdę potrzebujemy, to funkcja i możemy przestać pisać klasy.
Inne Callables
W powyższych przykładach mówiłem o przekazywaniu funkcji do innych funkcji jako parametrów. Jednak wszystko, co napisałem, było w rzeczywistości prawdą dla każdego obiektu wywoływalnego. Funkcje są najprostszym przykładem, ale możemy również rozważyć metody.
Załóżmy, że mamy listę foo
:
foo = [1, 2, 3]
foo
ma teraz dołączoną całą masę metod, takich jak .append()
i .count()
. Te „metody powiązane” mogą być przekazywane i używane jak funkcje:
> >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]
Oprócz tych metod instancji istnieją inne typy obiektów wywoływanych — metody statyczne i classmethods
klas , instancje klas implementujących staticmethods
oraz __call__
klasy/typy.

Klasy jako parametry
W Pythonie klasy są „pierwszą klasą” — są obiektami czasu wykonywania, takimi jak wersety, ciągi znaków itp. Może się to wydawać jeszcze dziwniejsze niż funkcje będące obiektami, ale na szczęście w rzeczywistości łatwiej jest zademonstrować ten fakt niż w przypadku funkcji.
Instrukcja class, którą znasz, to fajny sposób tworzenia klas, ale nie jest to jedyny sposób — możemy również użyć trzyargumentowej wersji type. Poniższe dwa stwierdzenia robią dokładnie to samo:
class Foo: pass Foo = type('Foo', (), {})
W drugiej wersji zwróć uwagę na dwie rzeczy, które właśnie zrobiliśmy (które wygodniej robi się za pomocą instrukcji class):
- Po prawej stronie znaku równości stworzyliśmy nową klasę o wewnętrznej nazwie
Foo
. To jest imię, które otrzymasz z powrotem, jeśli zrobiszFoo.__name__
. - Za pomocą przypisania utworzyliśmy następnie nazwę w bieżącym zakresie, Foo, która odnosi się do właśnie utworzonego obiektu klasy.
Poczyniliśmy te same obserwacje dotyczące tego, co robi instrukcja funkcji.
Kluczowym spostrzeżeniem jest to, że klasy są obiektami, którym można przypisać nazwy (tj. można je umieścić w zmiennej). Wszędzie, gdzie widzisz używaną klasę, w rzeczywistości widzisz tylko używaną zmienną. A jeśli to zmienna, to może być parametrem.
Możemy podzielić to na kilka zastosowań:
Klasy jako fabryki
Klasa to wywoływalny obiekt, który tworzy swoją instancję:
> >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>
A jako obiekt można go przypisać do innych zmiennych:
> >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>
Wracając do naszego przykładu z żółwiem powyżej, jednym z problemów związanych z używaniem żółwi do rysowania jest to, że pozycja i orientacja rysunku zależą od aktualnej pozycji i orientacji żółwia, a także może pozostawić go w innym stanie, co może być nieprzydatne dla dzwoniący. Aby rozwiązać ten problem, nasza funkcja draw_square
może stworzyć własnego żółwia, przesunąć go w żądane miejsce, a następnie narysować kwadrat:
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)
Jednak teraz mamy problem z dostosowaniem. Załóżmy, że dzwoniący chciał ustawić niektóre atrybuty żółwia lub użyć innego rodzaju żółwia, który ma ten sam interfejs, ale zachowuje się w sposób szczególny?
Moglibyśmy rozwiązać ten problem za pomocą wstrzykiwania zależności, tak jak to robiliśmy wcześniej — wywołujący byłby odpowiedzialny za skonfigurowanie obiektu Turtle
. Ale co, jeśli nasza funkcja czasami potrzebuje zrobić wiele żółwi do różnych celów rysowania, lub jeśli może chce wyrzucić cztery wątki, z których każda ma własnego żółwia, aby narysować jeden bok kwadratu? Odpowiedzią jest po prostu uczynienie z klasy Turtle parametru funkcji. Możemy użyć argumentu słowa kluczowego z wartością domyślną, aby uprościć sprawy dla dzwoniących, których to nie obchodzi:
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)
Aby tego użyć, moglibyśmy napisać funkcję make_turtle
, która tworzy żółwia i modyfikuje go. Załóżmy, że chcemy ukryć żółwia podczas rysowania kwadratów:
def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)
Lub możemy podklasę Turtle
, aby wprowadzić to zachowanie i przekazać podklasę jako parametr:
class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)
W innych językach…
W kilku innych językach OOP, takich jak Java i C#, brakuje klas pierwszej klasy. Aby utworzyć instancję klasy, musisz użyć słowa kluczowego new
, po którym następuje rzeczywista nazwa klasy.
To ograniczenie jest przyczyną wzorców takich jak fabryka abstrakcyjna (która wymaga stworzenia zestawu klas, których jedynym zadaniem jest tworzenie instancji innych klas) oraz wzorca Factory Method. Jak widać, w Pythonie jest to po prostu kwestia wyciągnięcia klasy jako parametru, ponieważ klasa jest własną fabryką.
Klasy jako klasy podstawowe
Załóżmy, że tworzymy podklasy, aby dodać tę samą funkcję do różnych klas. Na przykład, chcemy mieć podklasę Turtle
, która zapisze do dziennika po jego utworzeniu:
import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")
Ale potem robimy dokładnie to samo z inną klasą:
class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")
Jedyne rzeczy, które różnią się między tymi dwoma, to:
- Klasa podstawowa
- Nazwa podklasy — ale tak naprawdę nie dbamy o to i możemy wygenerować ją automatycznie z atrybutu
__name__
klasy bazowej. - Nazwa używana w wywołaniu
debug
— ale znowu możemy wygenerować ją na podstawie nazwy klasy bazowej.
Co możemy zrobić w obliczu dwóch bardzo podobnych fragmentów kodu z tylko jednym wariantem? Tak jak w naszym pierwszym przykładzie, tworzymy funkcję i wyciągamy część wariantową jako parametr:
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)
Tutaj mamy pokaz klas pierwszej klasy:
- Przekazaliśmy klasę do funkcji, nadając parametrowi umowną nazwę
cls
, aby uniknąć kolizji ze słowem kluczowymclass
(zobaczysz też użyte do tego celuclass_
iklass
). - Wewnątrz funkcji stworzyliśmy klasę — zauważ, że każde wywołanie tej funkcji tworzy nową klasę.
- Zwróciliśmy tę klasę jako wartość zwracaną funkcji.
Ustawiliśmy również LoggingThing.__name__
, który jest całkowicie opcjonalny, ale może pomóc w debugowaniu.
Innym zastosowaniem tej techniki jest sytuacja, gdy mamy całą masę funkcji, które czasami chcemy dodać do klasy i możemy chcieć dodać różne kombinacje tych funkcji. Ręczne tworzenie wszystkich różnych kombinacji, których potrzebujemy, może być bardzo nieporęczne.
W językach, w których klasy są tworzone w czasie kompilacji, a nie w czasie wykonywania, nie jest to możliwe. Zamiast tego musisz użyć wzoru dekoratora. Ten wzorzec może być czasami przydatny w Pythonie, ale najczęściej wystarczy użyć powyższej techniki.
Zwykle unikam tworzenia wielu podklas do dostosowywania. Zwykle istnieją prostsze i bardziej Pythonowe metody, które w ogóle nie zawierają klas. Ale ta technika jest dostępna, jeśli jej potrzebujesz. Zobacz także pełne potraktowanie wzoru dekoratora w Pythonie przez Brandona Rhodesa.
Klasy jako wyjątki
Innym miejscem, w którym widzisz używane klasy, jest klauzulaexcept w instrukcji try/ except
/finally. Żadnych niespodzianek dla zgadywania, że możemy też sparametryzować te klasy.
Na przykład poniższy kod implementuje bardzo ogólną strategię podejmowania akcji, która może się nie powieść, i ponawiania próby z wykładniczym wycofywaniem się, aż do osiągnięcia maksymalnej liczby prób:
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)
Wyciągnęliśmy zarówno akcję do wykonania, jak i wyjątki do przechwycenia jako parametry. ParametrExceptions_to_catch może być pojedynczą klasą, taką jak IOError
lub exceptions_to_catch
lub httplib.client.HTTPConnectionError
takich klas. (Chcemy uniknąć klauzul „gołe z wyjątkiem” lub nawet except Exception
, ponieważ wiadomo, że ukrywa on inne błędy programistyczne).
Ostrzeżenia i wnioski
Parametryzacja to potężna technika ponownego wykorzystywania kodu i ograniczania duplikacji kodu. Nie jest pozbawiony pewnych wad. W dążeniu do ponownego wykorzystania kodu często pojawia się kilka problemów:
- Zbyt ogólny lub abstrakcyjny kod, który staje się bardzo trudny do zrozumienia.
- Kod z mnogością parametrów, które zaciemniają ogólny obraz lub wprowadzają błędy, ponieważ w rzeczywistości tylko niektóre kombinacje parametrów są odpowiednio testowane.
- Nieprzydatne łączenie różnych części bazy kodu, ponieważ ich „wspólny kod” został umieszczony w jednym miejscu. Czasami kod w dwóch miejscach jest podobny tylko przypadkowo, a te dwa miejsca powinny być od siebie niezależne, ponieważ mogą wymagać niezależnej zmiany.
Czasami trochę „zduplikowanego” kodu jest znacznie lepsze niż te problemy, więc używaj tej techniki ostrożnie.
W tym poście omówiliśmy wzorce projektowe znane jako wstrzykiwanie zależności , strategia , metoda szablonowa , fabryka abstrakcyjna , metoda fabryczna i dekorator . W Pythonie wiele z nich rzeczywiście okazuje się być prostym zastosowaniem parametryzacji lub jest zdecydowanie niepotrzebnych przez fakt, że parametry w Pythonie mogą być obiektami lub klasami, które można wywoływać. Mamy nadzieję, że pomaga to zmniejszyć obciążenie pojęciowe „rzeczy, które powinieneś wiedzieć jako prawdziwy programista Pythona” i umożliwia pisanie zwięzłego kodu w Pythonie!
Dalsza lektura:
- Wzorce projektowe Pythona: dla eleganckiego i modnego kodu
- Wzorce Pythona: Wzorce projektowe Pythona
- Logowanie w Pythonie: dogłębny samouczek