Asigurarea codului curat: o privire asupra Python, parametrizată
Publicat: 2022-03-11În această postare, voi vorbi despre ceea ce consider a fi cea mai importantă tehnică sau model în producerea de cod curat, Pythonic, și anume, parametrizarea. Această postare este pentru tine dacă:
- Sunteți relativ nou în chestia cu modelele de design și poate puțin dezorientat de listele lungi de nume de modele și diagrame de clasă. Vestea bună este că există într-adevăr un singur model de design pe care trebuie să-l cunoașteți pentru Python. Și mai bine, probabil că o știți deja, dar poate nu toate modurile în care poate fi aplicată.
- Ați ajuns la Python dintr-un alt limbaj OOP, cum ar fi Java sau C# și doriți să știți cum să vă traduceți cunoștințele despre modelele de design din limbajul respectiv în Python. În Python și în alte limbi tipizate dinamic, multe modele comune în limbajele OOP tipizate static sunt „invizibile sau mai simple”, așa cum a spus autorul Peter Norvig.
În acest articol, vom explora aplicarea „parametrizării” și modul în care aceasta se poate asocia cu modelele de design obișnuite cunoscute sub numele de injecție de dependență , strategie , metodă șablon , fabrică abstractă , metodă fabrică și decorator . În Python, multe dintre acestea se dovedesc a fi simple sau nu sunt necesare din cauza faptului că parametrii din Python pot fi obiecte sau clase apelabile.
Parametrizarea este procesul de preluare a valorilor sau obiectelor definite într-o funcție sau a unei metode și de a le transforma în parametri pentru acea funcție sau metodă, pentru a generaliza codul. Acest proces este cunoscut și sub denumirea de refactorizare a „parametrului de extragere”. Într-un fel, acest articol este despre modele de design și refactorizare.
Cel mai simplu caz de Python parametrizat
Pentru cele mai multe dintre exemplele noastre, vom folosi modulul instrucțional standard al bibliotecii testoase pentru a realiza niște grafice.
Iată un cod care va desena un pătrat de 100x100 folosind turtle
:
from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)
Să presupunem că acum vrem să desenăm un pătrat de altă dimensiune. Un programator foarte junior în acest moment ar fi tentat să copieze-lipi acest bloc și să modifice. Evident, o metodă mult mai bună ar fi să extrageți mai întâi codul de desen pătrat într-o funcție și apoi să faceți dimensiunea pătratului un parametru pentru această funcție:
def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)
Deci acum putem desena pătrate de orice dimensiune folosind draw_square
. Asta este tot ce există în tehnica esențială a parametrizării și tocmai am văzut prima utilizare principală - eliminarea programării copy-paste.
O problemă imediată cu codul de mai sus este că draw_square
depinde de o variabilă globală. Acest lucru are o mulțime de consecințe negative și există două moduri ușoare de a o remedia. Prima ar fi ca draw_square
să creeze însăși instanța Turtle
(pe care o voi discuta mai târziu). Acest lucru s-ar putea să nu fie de dorit dacă dorim să folosim o singură Turtle
pentru toate desenele noastre. Deci, pentru moment, pur și simplu vom folosi din nou parametrizarea pentru a face din turtle
un parametru pentru 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)
Acesta are un nume de lux - injecția de dependență. Înseamnă doar că, dacă o funcție are nevoie de un fel de obiect pentru a-și face treaba, cum ar fi draw_square
are nevoie de un Turtle
, apelantul este responsabil pentru transmiterea acelui obiect ca parametru. Nu, într-adevăr, dacă ați fost vreodată curios despre injecția de dependență Python, asta este.
Până acum, ne-am ocupat de două utilizări foarte de bază. Observația cheie pentru restul acestui articol este că, în Python, există o gamă largă de lucruri care pot deveni parametri - mai mult decât în alte limbi - și acest lucru îl face o tehnică foarte puternică.
Tot ceea ce este un obiect
În Python, puteți utiliza această tehnică pentru a parametriza orice este un obiect, iar în Python, majoritatea lucrurilor pe care le întâlniți sunt, de fapt, obiecte. Aceasta include:
- Instanțe de tipuri încorporate, cum ar fi șirul
"I'm a string"
și numărul întreg42
sau un dicționar - Instanțe de alte tipuri și clase, de exemplu, un obiect
datetime.datetime
- Funcții și metode
- Tipuri încorporate și clase personalizate
Ultimele două sunt cele mai surprinzătoare, mai ales dacă vii din alte limbi și au nevoie de mai multe discuții.
Funcționează ca parametri
Declarația funcției din Python face două lucruri:
- Acesta creează un obiect funcțional.
- Acesta creează un nume în domeniul local care indică acel obiect.
Ne putem juca cu aceste obiecte într-un 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 la fel ca toate obiectele, putem atribui funcții altor variabile:
> >> bar = foo > >> bar() 'Hello from foo'
Rețineți că bar
este un alt nume pentru același obiect, deci are aceeași proprietate internă __name__
ca și înainte:
> >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>
Dar punctul crucial este că, deoarece funcțiile sunt doar obiecte, oriunde vezi o funcție folosită, ar putea fi un parametru.
Așadar, să presupunem că ne extindem funcția de desen de pătrate de mai sus și acum, uneori, când desenăm pătrate, vrem să facem o pauză la fiecare colț - un apel la time.sleep()
.
Dar să presupunem că uneori nu vrem să facem o pauză. Cel mai simplu mod de a realiza acest lucru ar fi să adăugați un parametru de pause
, poate cu o valoare implicită de zero, astfel încât implicit să nu facem pauză.
Totuși, descoperim mai târziu că uneori chiar vrem să facem ceva complet diferit la colțuri. Poate că vrem să desenăm o altă formă la fiecare colț, să schimbăm culoarea stiloului etc. S-ar putea să fim tentați să adăugăm mult mai mulți parametri, câte unul pentru fiecare lucru pe care trebuie să-l facem. Cu toate acestea, o soluție mult mai plăcută ar fi să permită transmiterea oricărei funcții ca acțiune de întreprins. Pentru o valoare implicită, vom crea o funcție care nu face nimic. De asemenea, vom face ca această funcție să accepte parametrii de size
și turtle
locale, în cazul în care sunt necesari:
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)
Sau, am putea face ceva mai tare, cum ar fi să desenăm recursiv pătrate mai mici la fiecare colț:
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)
Există, desigur, variații ale acestui lucru. În multe exemple, ar fi folosită valoarea de returnare a funcției. Aici, avem un stil mai imperativ de programare, iar funcția este numită doar pentru efectele sale secundare.
În alte limbi…
Având funcții de primă clasă în Python, acest lucru este foarte ușor. În limbile cărora le lipsesc sau în unele limbi tipizate static care necesită semnături de tip pentru parametri, acest lucru poate fi mai greu. Cum am face asta dacă nu am avea funcții de primă clasă?
O soluție ar fi transformarea draw_square
într-o clasă, 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
Acum putem subclasa SquareDrawer
și adăuga o metodă at_corner
care face ceea ce avem nevoie. Acest model python este cunoscut ca modelul metodei șablon - o clasă de bază definește forma întregii operațiuni sau algoritm, iar porțiunile variante ale operației sunt introduse în metode care trebuie implementate de subclase.
În timp ce acest lucru poate fi uneori util în Python, scoaterea codului variantă într-o funcție care este pur și simplu transmisă ca parametru va fi adesea mult mai simplă.
Un al doilea mod în care am putea aborda această problemă în limbi fără funcții de primă clasă este să ne încheiem funcțiile ca metode în cadrul claselor, astfel:
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())
Acesta este cunoscut sub numele de model de strategie. Din nou, acesta este cu siguranță un model valid de utilizat în Python, mai ales dacă clasa de strategie conține de fapt un set de funcții înrudite, mai degrabă decât una. Cu toate acestea, de multe ori tot ce avem nevoie este o funcție și putem opri să scriem cursuri.
Alte apelabile
În exemplele de mai sus, am vorbit despre trecerea funcțiilor în alte funcții ca parametri. Totuși, tot ce am scris a fost, de fapt, adevărat pentru orice obiect apelabil. Funcțiile sunt cel mai simplu exemplu, dar putem lua în considerare și metode.
Să presupunem că avem o listă foo
:
foo = [1, 2, 3]
foo
are acum o grămadă de metode atașate, cum ar fi .append()
și .count .count()
. Aceste „metode legate” pot fi transmise și utilizate ca funcții:
> >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]
În plus față de aceste metode de instanță, există și alte tipuri de obiecte apelabile — staticmethods
și classmethods
, instanțe de clase care implementează __call__
și clase/tipuri în sine.

Clasele ca parametri
În Python, clasele sunt de „prima clasă” – sunt obiecte de rulare la fel ca dictele, șirurile de caractere etc. Acest lucru ar putea părea chiar mai ciudat decât funcțiile fiind obiecte, dar, din fericire, este de fapt mai ușor să demonstrezi acest fapt decât pentru funcții.
Declarația de clasă cu care sunteți familiarizat este o modalitate frumoasă de a crea clase, dar nu este singura modalitate - putem folosi și versiunea cu trei argumente a tipului. Următoarele două afirmații fac exact același lucru:
class Foo: pass Foo = type('Foo', (), {})
În a doua versiune, rețineți cele două lucruri pe care tocmai le-am făcut (care sunt făcute mai convenabil folosind instrucțiunea de clasă):
- În partea dreaptă a semnului egal, am creat o nouă clasă, cu un nume intern
Foo
. Acesta este numele pe care îl vei primi înapoi dacă faciFoo.__name__
. - Odată cu atribuirea, am creat apoi un nume în domeniul curent, Foo, care se referă la acel obiect de clasă pe care tocmai l-am creat.
Am făcut aceleași observații pentru ceea ce face declarația funcției.
Perspectiva cheie aici este că clasele sunt obiecte cărora li se pot atribui nume (adică, pot fi introduse într-o variabilă). Oriunde vedeți o clasă în uz, de fapt vedeți doar o variabilă în uz. Și dacă este o variabilă, poate fi un parametru.
Îl putem împărți în mai multe utilizări:
Clasele ca fabrici
O clasă este un obiect apelabil care creează o instanță a lui:
> >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>
Și ca obiect, poate fi atribuit altor variabile:
> >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>
Revenind la exemplul nostru de broasca țestoasă de mai sus, o problemă cu utilizarea țestoaselor pentru desen este că poziția și orientarea desenului depind de poziția și orientarea actuală a țestoasei și, de asemenea, o poate lăsa într-o stare diferită, care ar putea fi inutilă pentru apelantul. Pentru a rezolva acest lucru, funcția noastră draw_square
ar putea să-și creeze propria broască țestoasă, să o mute în poziția dorită și apoi să deseneze un pătrat:
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)
Cu toate acestea, acum avem o problemă de personalizare. Să presupunem că apelantul a dorit să seteze unele atribute ale broaștei țestoase sau să folosească un alt tip de țestoasă care are aceeași interfață, dar are un comportament special?
Am putea rezolva acest lucru cu injecția de dependență, așa cum am făcut și înainte - apelantul ar fi responsabil pentru configurarea obiectului Turtle
. Dar dacă funcția noastră trebuie uneori să facă multe broaște țestoase pentru diferite scopuri de desen sau dacă poate vrea să înceapă patru fire, fiecare cu propria țestoasă pentru a desena o parte a pătratului? Răspunsul este pur și simplu de a face din clasa Turtle un parametru al funcției. Putem folosi un argument de cuvânt cheie cu o valoare implicită, pentru a menține lucrurile simple pentru apelanții cărora nu le pasă:
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)
Pentru a folosi acest lucru, am putea scrie o funcție make_turtle
care creează o țestoasă și o modifică. Să presupunem că vrem să ascundem țestoasa când desenăm pătrate:
def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)
Sau am putea subclasa Turtle
pentru a integra acel comportament și a trece subclasa ca parametru:
class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)
În alte limbi…
Câteva alte limbaje OOP, cum ar fi Java și C#, nu au clase de primă clasă. Pentru a instanția o clasă, trebuie să utilizați new
cuvânt cheie urmat de un nume real de clasă.
Această limitare este motivul pentru modele precum fabrica abstractă (care necesită crearea unui set de clase a căror singura sarcină este să instanțieze alte clase) și modelul Metoda fabricii. După cum puteți vedea, în Python, este doar o chestiune de a scoate clasa ca parametru, deoarece o clasă este propria fabrică.
Clasele ca clase de bază
Să presupunem că creăm subclase pentru a adăuga aceeași caracteristică la clase diferite. De exemplu, vrem o subclasă Turtle
care va scrie într-un jurnal atunci când este creată:
import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")
Dar apoi, ne trezim să facem exact același lucru cu o altă clasă:
class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")
Singurele lucruri care variază între acestea două sunt:
- Clasa de bază
- Numele subclasei — dar nu ne pasă de asta și l-am putea genera automat din atributul clasei de bază
__name__
. - Numele folosit în apelul de
debug
, dar din nou, l-am putea genera din numele clasei de bază.
În fața a două fragmente de cod foarte asemănătoare cu o singură variantă, ce putem face? La fel ca în primul nostru exemplu, creăm o funcție și scoatem partea variantă ca parametru:
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)
Aici, avem o demonstrație a claselor de primă clasă:
- Am trecut o clasă într-o funcție, dând parametrului un nume convențional
cls
pentru a evita ciocnirea cu cuvântul cheieclass
(veți vedea, de asemenea,class_
șiklass
folosite în acest scop). - În interiorul funcției, am creat o clasă - rețineți că fiecare apel la această funcție creează o nouă clasă.
- Am returnat acea clasă ca valoare de returnare a funcției.
De asemenea, setăm LoggingThing.__name__
, care este complet opțional, dar poate ajuta la depanare.
O altă aplicație a acestei tehnici este atunci când avem o mulțime de caracteristici pe care uneori dorim să le adăugăm la o clasă și s-ar putea să dorim să adăugăm diferite combinații ale acestor caracteristici. Crearea manuală a tuturor combinațiilor diferite de care avem nevoie ar putea deveni foarte dificilă.
În limbile în care clasele sunt create în timpul compilării, mai degrabă decât în timpul rulării, acest lucru nu este posibil. În schimb, trebuie să utilizați modelul de decorator. Acest model poate fi util uneori în Python, dar în general puteți folosi tehnica de mai sus.
În mod normal, evit de fapt să creez o mulțime de subclase pentru personalizare. De obicei, există metode mai simple și mai Pythonic care nu implică deloc clase. Dar această tehnică este disponibilă dacă aveți nevoie de ea. Vezi, de asemenea, tratarea completă de către Brandon Rhodes a modelului decoratorului în Python.
Clasele ca excepții
Un alt loc în care vezi că sunt folosite clase este în clauza except
a unei instrucțiuni try/except/finally. Nicio surpriză pentru a ghici că putem parametriza și acele clase.
De exemplu, următorul cod implementează o strategie foarte generică de a încerca o acțiune care ar putea eșua și de a reîncerca cu backoff exponențial până când este atins un număr maxim de încercări:
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)
Am scos atât acțiunea de luat, cât și excepțiile de capturat ca parametri. Parametrul exceptions_to_catch
poate fi fie o singură clasă, cum ar fi IOError
sau httplib.client.HTTPConnectionError
, fie un tuplu de astfel de clase. (Vrem să evităm clauzele „bare except” sau chiar except Exception
, deoarece se știe că aceasta ascunde alte erori de programare).
Avertismente și concluzie
Parametrizarea este o tehnică puternică pentru reutilizarea codului și reducerea dublării codului. Nu este lipsit de unele dezavantaje. În căutarea reutilizarii codului, apar adesea câteva probleme:
- Cod excesiv de generic sau abstract care devine foarte greu de înțeles.
- Cod cu o proliferare de parametri care ascunde imaginea de ansamblu sau introduce bug-uri pentru că, în realitate, doar anumite combinații de parametri sunt testate corespunzător.
- Cuplarea inutilă a diferitelor părți ale bazei de cod, deoarece „codul lor comun” a fost inclus într-un singur loc. Uneori, codul din două locuri este similar doar accidental, iar cele două locuri ar trebui să fie independente unul de celălalt, deoarece ar putea fi necesar să se schimbe independent.
Uneori, un pic de cod „duplicat” este mult mai bun decât aceste probleme, așa că utilizați această tehnică cu grijă.
În această postare, am acoperit modele de design cunoscute sub numele de injecție de dependență , strategie , metodă șablon , fabrică abstractă , metodă fabrică și decorator . În Python, multe dintre acestea se dovedesc într-adevăr a fi o simplă aplicație a parametrizării sau cu siguranță nu sunt necesare din cauza faptului că parametrii din Python pot fi obiecte sau clase apelabile. Sperăm că acest lucru ajută la ușurarea încărcăturii conceptuale a „lucrurilor pe care ar trebui să le cunoașteți ca un adevărat dezvoltator Python” și vă permite să scrieți cod Pythonic concis!
Lectură suplimentară:
- Modele de design Python: pentru un cod elegant și la modă
- Modele Python: pentru modele de design Python
- Python Logging: un tutorial aprofundat