Python
Ce este monkey patching-ul în Python și când este acceptabil
Pe scurt
Monkey patching-ul înseamnă înlocuirea unui atribut al unui modul, al unei clase sau al unui obiect la rulare, din afara codului care l-a definit. Funcționează pentru că însele clasele și modulele sunt obiecte obișnuite, ale căror atribute trăiesc într-un spațiu de nume mutabil, deci atribuirea către CarrierClient.fetch_rate releagă acel nume pentru fiecare instanță — inclusiv pentru cele create înaintea atribuirii. Este acceptabil când modificarea este îngustă, limitată ca domeniu și se anulează singură, ceea ce în practică înseamnă teste care folosesc unittest.mock.patch sau fixtura monkeypatch din pytest, și, ocazional, o cârpeală documentată pentru un defect de la terți pe care nu îl poți repara în amonte. Nu este acceptabil ca înlocuitor pentru derivare, compunere sau un punct de configurare în cod pe care îl deții, pentru că o modificare este invizibilă la locul apelului, iar două modificări ale aceluiași atribut se distrug tăcut una pe alta.
Cum funcționează
class și def sunt instrucțiuni executabile care leagă nume, nu declarații procesate din timp. Un obiect clasă își ține metodele într-un spațiu de nume mutabil, iar când modulul își termină execuția, acel spațiu de nume este un dicționar obișnuit, în care oricine poate scrie. O atribuire făcută mai târziu este imposibil de deosebit de cea făcută de corpul class — Python nu trage nicio linie între definirea unui atribut și înlocuirea lui, ceea ce este tocmai motivul pentru care monkey patching-ul este posibil, în loc să fie o funcționalitate suportată, proiectată de cineva.
Instanțele nu își țin propria copie a unei metode. Căutarea lui client.fetch_rate verifică spațiul de nume al instanței, apoi clasa, apoi restul MRO-ului. Pentru că metoda este găsită pe clasă, înlocuirea ei acolo schimbă comportamentul pentru fiecare obiect care există deja, fără să fie nevoie ca ceva să fie reconstruit sau reimportat.
Modificarea este și globală pentru proces. Fiecare parte a programului care ajunge la atributul modificat primește înlocuirea, inclusiv cod care nu a cerut-o niciodată și nu are cum să observe — un loc de apel se citește exact la fel înainte și după. Acea invizibilitate este obiecția de fond față de tehnică: un cititor care urmărește o eroare vede definiția originală și niciun indiciu că rulează altceva.
Anularea este manuală și acolo o iau razna modificările. Anularea uneia înseamnă salvarea valorii anterioare și repunerea ei, ceea ce funcționează curat doar dacă modificările sunt anulate în ordine exact inversă. Două modificări independente ale aceluiași atribut formează o stivă, iar restaurarea în ordine greșită fie aruncă o modificare vie, fie reinstalează permanent una învechită — fără să se ridice nicio eroare în niciunul dintre cazuri.
Unele atribute nu pot fi modificate deloc. Tipurile implementate în C, printre care str, list și dict, au tabele de atribute imutabile, deci atribuirea către ele ridică TypeError. Acea protecție există pentru că acele tipuri sunt partajate de tot interpretorul, iar o modificare ar ajunge deodată la fiecare bibliotecă din proces.
Cum arată la rulare
Creează un fișier:
touch carrier_rates.py
Rulează-l cu:
python3 carrier_rates.py
Înlocuirea unei metode pe o clasă vie
Cel mai mic caz: schimbi o metodă care ar face un apel de rețea și urmărești cum un obiect creat înainte de schimb preia modificarea.
class CarrierClient:
def fetch_rate(self, weight_kg):
raise RuntimeError("live network call to the carrier API")
client = CarrierClient()
original_fetch_rate = CarrierClient.fetch_rate
def offline_fetch_rate(self, weight_kg):
return round(4.50 + weight_kg * 0.75, 2)
CarrierClient.fetch_rate = offline_fetch_rate
print("instance created before the patch:", client.fetch_rate(12))
print("instance created after the patch: ", CarrierClient().fetch_rate(12))
print("the instance holds no method of its own:", "fetch_rate" in vars(client))
CarrierClient.fetch_rate = original_fetch_rate
try:
client.fetch_rate(12)
except RuntimeError as exc:
print("after restoring:", exc)
instance created before the patch: 13.5
instance created after the patch: 13.5
the instance holds no method of its own: False
after restoring: live network call to the carrier API
clientexista înaintea atribuirii și primește totuși noul comportament, pentru că nu a ținut niciodată unfetch_ratepropriu — a treia linie confirmă că spațiul lui de nume este gol, deci fiecare apel este rezolvat pe clasă, la momentul apelului.- Modificarea este o simplă atribuire. Nimic nu o deosebește de
def-ul original, motiv pentru care clasa nu are cum să o respingă sau să o raporteze. - Salvarea lui
original_fetch_rateînainte de modificare este ce face posibil ultimul pas. Sari peste ea și originalul devine irecuperabil pe toată durata procesului. - Restaurarea pune metoda reală la loc, iar
RuntimeErroro dovedește — aceasta este versiunea manuală a ce automatizează un mecanism de modificare cu domeniu limitat.
Două modificări care se distrug una pe alta
O dimensiune în plus: o a doua modificare, independentă. Fiecare mecanism salvează ce găsește și restaurează ce a salvat, ceea ce este disciplina evidentă — și nu este de ajuns. Înlocuiește fișierul cu:
class ExchangeRates:
def usd_to_eur(self, amount):
return round(amount * 0.92, 2)
true_original = ExchangeRates.usd_to_eur
reporting_saved = ExchangeRates.usd_to_eur
ExchangeRates.usd_to_eur = lambda self, amount: round(amount * 0.90, 2)
print("after the reporting patch:", ExchangeRates().usd_to_eur(100))
audit_saved = ExchangeRates.usd_to_eur
ExchangeRates.usd_to_eur = lambda self, amount: round(amount * 0.85, 2)
print("after the audit patch: ", ExchangeRates().usd_to_eur(100))
ExchangeRates.usd_to_eur = reporting_saved
print("reporting restores first:", ExchangeRates().usd_to_eur(100))
print("audit patch still active?", ExchangeRates().usd_to_eur(100) == 85.0)
ExchangeRates.usd_to_eur = audit_saved
print("audit restores second: ", ExchangeRates().usd_to_eur(100))
print("is the real original back?", ExchangeRates.usd_to_eur is true_original)
after the reporting patch: 90.0
after the audit patch: 85.0
reporting restores first: 92.0
audit patch still active? False
audit restores second: 90.0
is the real original back? False
Ambele mecanisme s-au comportat corect după propria lor logică, iar rezultatul este totuși greșit. Când raportarea a restaurat prima, a scris înapoi originalul adevărat și a șters tăcut modificarea de audit — codul de audit rulează acum pe un tarif pe care nu l-a cerut, fără nicio eroare nicăieri. Apoi auditul a restaurat ce salvase el, adică modificarea de raportare, lăsând instalat permanent un 0.90 învechit și metoda autentică inaccesibilă. Nimic de aici nu este exotic: sunt două bucăți rezonabile de cod care ating un singur atribut mutabil și este motivul pentru care modificarea manuală nu supraviețuiește contactului cu o bază de cod reală.
Forma acceptabilă: cu domeniu limitat și autoanulare
Ultima treaptă este ce face modificarea apărabilă. Un context manager înregistrează valoarea anterioară și o restaurează la ieșire, inclusiv când blocul ridică o excepție. Înlocuiește fișierul cu:
import statistics
from unittest.mock import patch
readings = [18.4, 19.1, 18.9, 21.7]
print("before the block:", statistics.mean(readings))
with patch("statistics.mean", return_value=99.0):
print("inside the block:", statistics.mean(readings))
print("after the block: ", statistics.mean(readings))
try:
str.shout = lambda self: self.upper()
except TypeError as exc:
print("patching a built-in type raises:", type(exc).__name__)
before the block: 19.525
inside the block: 99.0
after the block: 19.525
patching a built-in type raises: TypeError
Modificarea este mărginită de blocul with, iar __exit__ restaurează originalul pe fiecare cale de ieșire — încheiere normală, return sau excepție — deci un test care pică nu poate scurge o funcție modificată în următorul. Aceasta este toată diferența dintre pasul anterior și acesta: durata de viață este exprimată în cod, nu lăsată în seama disciplinei programatorului. Ultima linie arată granița pe care tehnica nu o poate traversa: str este implementat în C, cu un tabel de atribute imutabil, deci interpretorul refuză din start, în loc să lase o modificare să ajungă la fiecare bibliotecă din proces.
Notă: șirul-țintă numește atributul de înlocuit și trebuie să numească locul unde codul testat chiar caută numele — modificarea acolo unde a fost definită o funcție nu are niciun efect asupra unui modul care a legat-o deja sub numele lui propriu.
Cazuri limită și capcane
- Testele sunt cazul limpede acceptabil.
unittest.mock.patchși fixturamonkeypatchdin pytest se anulează automat și sunt limitate la un singur test, deci ambele pericole de mai sus — invizibilitatea și scurgerea — sunt ținute în frâu. Recurge la ele ca să tai apelurile de rețea, ceasurile și aleatorul. - O cârpeală pentru un defect din amonte este acceptabilă când este documentată și îngustă. Modifică o singură funcție, comenteaz-o cu linkul către problemă și cu versiunea vizată, ține-o într-un singur modul evident și șterge-o când apare corectura. Ce face asta apărabil este că într-adevăr nu poți schimba codul, iar alternativa este un fork.
- În cod pe care îl deții, o modificare este un miros de design. Derivarea, compunerea sau un simplu punct de injecție — transmiterea colaboratorului — exprimă toate aceeași substituție la locul apelului, unde un cititor o poate vedea. Recurgerea la o modificare înseamnă de obicei că îmbinarea de care aveai nevoie nu a fost adăugată niciodată.
- Modificarea interiorului unor biblioteci terțe este o bombă cu ceas de versiune. Orice nu se află în API-ul public poate fi redenumit într-o versiune de corecție, iar eșecul sosește la momentul importului, în producție, nu în suita de teste.
- Tipurile predefinite din C refuză să fie modificate, iar asta este o funcționalitate. Există pachete care înfrâng protecția prin API-ul C; folosirea unuia dintre ele pune fiecare bibliotecă din proces pe
str-ul saudict-ul tău modificat.
Rezumat
Monkey patching-ul este o atribuire obișnuită de atribut, îndreptată către o clasă sau un modul definit de altcineva, făcută posibilă de faptul că Python construiește acele spații de nume la rulare și nu le blochează niciodată. Mecanismul este banal; costul este că modificarea este invizibilă la locul apelului, iar durata ei de viață este cea pe care programatorul își amintește să o impună. Acceptă-l acolo unde durata de viață este impusă în locul tău, iar domeniul este un singur test, și tratează-l ca ultimă soluție peste tot altundeva.
- O clasă își ține metodele într-un spațiu de nume mutabil, deci înlocuirea uneia este o atribuire pe care clasa nu o poate detecta și nu o poate refuza.
- Instanțele rezolvă metodele pe clasă, la momentul apelului, deci o modificare schimbă obiecte care există deja.
- O modificare este globală pentru proces și nu lasă nicio urmă la locul apelului, ceea ce o face greu de depanat.
- Salvarea și restaurarea manuală sunt corecte doar dacă modificările se derulează în ordine exact inversă; altfel, o modificare se pierde tăcut, iar originalul s-ar putea să nu se mai întoarcă niciodată.
unittest.mock.patchșimonkeypatchdin pytest mărginesc modificarea la un bloc sau la un test și restaurează pe fiecare cale de ieșire, ceea ce face modificarea acceptabilă în teste.- Tipurile implementate în C nu pot fi modificate, pentru că atributele lor sunt partajate de fiecare bibliotecă din proces.