Dla osób przesiadających się z C++ czy Javy to jedna z bardziej szokujących właściwości Pythona (a także Perla i paru innych języków skryptowych): kod programu można zmienić w locie. Można - w szczególności - dopisać albo podmienić metody istniejącej klasy czy funkcje zadanego modułu.
Technika ryzykowna, łatwo prowadząca do powstawania niezrozumiałego kodu i trudnych do interpretacji błędów. Ale zarazem bardzo przydatna, zwłaszcza gdy trzeba zmodyfikować zachowanie jakiejś biblioteki w miejscu, którego jej autor nie przewidział (np. ostatnio przypomniałem ją sobie chcąc zmienić sposób w jaki Mercurial obsługuje hasła HTTP i SMTP). Monkey-patching bardzo często przydaje się też przy testach, pozwalając zamienić prawdziwy kod komunikacyjny czy bazodanowy zaślepkami.
W tym artykule o kilku możliwych sposobach zapisywania tej operacji.
Przykładowy problem
Wszystkie autentyczne przykłady jakie mi przychodzą do głowy są dość
złożone, dlatego posłużę się sztucznym. Wyobraźmy sobie, że
chcemy wpłynąć na działanie funkcji urlopen
- dokładniej, chcemy by
nie zwracała ona nagłówków HTTP związanych z cacheowaniem (powiedzmy,
bo jakaś inna biblioteka, której używamy, korzysta z urlopen
i
błędnie się zachowuje, gdy napotka te nagłówki). Po krótkim
przeglądzie kodu zauważamy, że nagłówki są zapisywane przy pomocy
funkcji addheader
klasy httplib.HTTPMessage
.
Chcielibyśmy ją nadmazać ale w naturalny sposób zrobić tego nie możemy,
standardowe biblioteki tworzą explicite obiekty klasy HTTPMessage
i nie ma jak podstawić za nią podklasy.
Bezpośrednio
Prosty bezpośredni zapis może wyglądać tak jak niżej. Po prostu
przepisujemy sobie atrybuty klasy httplib.HTTPMessage
, traktując
ją jak normalny obiekt.
from urllib import urlopen import httplib httplib.HTTPMessage.skipped_count = 0 orig_addheader = httplib.HTTPMessage.addheader def my_addheader(self, key, value): if key in ['cache-control', 'expires', 'x-cache', 'pragma']: print "skipping", key, value self.skipped_count = self.skipped_count + 1 else: orig_addheader(self, key, value) httplib.HTTPMessage.addheader = my_addheader httplib.HTTPMessage.skipped_headers = lambda self: self.skipped_count conn = urlopen("http://www.onet.pl") msg = conn.info() print msg.skipped_headers()
Główna wada powyższego: brutalna operacja jaką jest przepisywanie istniejącego kodu jest tu stosunkowo słabo widoczna. Dlatego dość popularnych jest kilka syntaksów pozwalających zapisywać ją bardziej explicite.
Moduł partial
Pierwszym jest moduł partial. Po jego zainstalowaniu:
$ easy_install partial
przepisywanie klas realizujemy dziedzicząc nową klasę (o nieistotnej
nazwie) z partial
oraz z modyfikowanej klasy. Może to
wyglądać tak:
from partial import partial, replace from urllib import urlopen import httplib orig_addheader = httplib.HTTPMessage.addheader class PatchedHttpMessage(partial, httplib.HTTPMessage): skipped_count = 0 def skipped_headers(self): return self.skipped_count @replace def addheader(self, key, value): if key in ['cache-control', 'expires', 'x-cache', 'pragma']: print "skipping", key, value self.skipped_count = self.skipped_count + 1 else: orig_addheader(self, key, value) conn = urlopen("http://www.onet.pl") msg = conn.info() print msg.skipped_headers()
Istotna zaleta: partial
pilnuje, by nadmazane zostało tylko to,
co nadmazać naprawdę chcemy. Jeśli dodawana metoda nie jest oznaczona
dekoratorem @replace
a ma nazwę zgodną z metodą patchowanej klasy,
zostanie zgłoszony błąd.
Dekoratory Guido van Rossuma
Guido van Rossum swego czasu zaproponował dekorator monkeypatch_method
oraz metaklasę monkeypatch_class
. Szczególnie estetycznie wygląda to pierwsze rozwiązanie:
from urllib import urlopen import httplib def monkeypatch_method(cls): def decorator(func): setattr(cls, func.__name__, func) return func return decorator orig_addheader = httplib.HTTPMessage.addheader httplib.HTTPMessage.skipped_count = 0 @monkeypatch_method(httplib.HTTPMessage) def skipped_headers(self): return self.skipped_count @monkeypatch_method(httplib.HTTPMessage) def addheader(self, key, value): if key in ['cache-control', 'expires', 'x-cache', 'pragma']: print "skipping", key, value self.skipped_count = self.skipped_count + 1 else: orig_addheader(self, key, value) conn = urlopen("http://www.onet.pl") msg = conn.info() print msg.skipped_headers()
Wadą jest tylko brak syntaksu do dodawania atrybutu.
Zapis z monkeypatch_class
jest nieco brzydszy, bo wymusza
deklarowanie w używanym kodzie metaklasy (wspominany wyżej moduł
partial
działa podobnie ale ukrywa te szczegóły
pod maską dziedziczenia):
from urllib import urlopen import httplib def monkeypatch_class(name, bases, namespace): assert len(bases) == 1, "Exactly one base class required" base = bases[0] for name, value in namespace.iteritems(): if name != "__metaclass__": setattr(base, name, value) return base orig_addheader = httplib.HTTPMessage.addheader httplib.HTTPMessage.skipped_count = 0 class PatchedHTTPMessage(httplib.HTTPMessage): __metaclass__ = monkeypatch_class skipped_count = 0 def skipped_headers(self): return self.skipped_count def addheader(self, key, value): if key in ['cache-control', 'expires', 'x-cache', 'pragma']: print "skipping", key, value self.skipped_count = self.skipped_count + 1 else: orig_addheader(self, key, value) conn = urlopen("http://www.onet.pl") msg = conn.info() print msg.skipped_headers()
Zaleta obu snippetów: są bardzo krótkie, można je po prostu umieścić w własnym kodzie, nie trzeba tworzyć dodatkowych modułów czy dystrybuować dodatkowych bibliotek.
Bardziej dopracowane monkeypatch_method
Nieco dłuższe monkeypatch_method
zaproponował Robert Brewer. Tutaj dodatkową
zaletą jest zapamiętywanie nadmazanej metody, do której można się odwołać
(przy pomocy atrybutu _old_oryginalna_nazwa
- który jest, na wszelki
wypadek, listą).
from urllib import urlopen import httplib def monkeypatch_method(cls): """Add the decorated method to the given class; replace as ed. If the named method already exists on the given class, it will be replaced, and a reference to the old method appended to a at cls._old_{name}. If the "_old_{name}" attribute already ts and is not a list, KeyError is raised. """ def decorator(func): fname = func.__name__ old_func = getattr(cls, fname, None) if old_func is not None: # Add the old func to a list of old funcs. old_ref = "_old_%s" % fname old_funcs = getattr(cls, old_ref, None) if old_funcs is None: setattr(cls, old_ref, []) elif not isinstance(old_funcs, list): raise KeyError("%s.%s already exists." % (cls.__name__, old_ref)) getattr(cls, old_ref).append(old_func) setattr(cls, fname, func) return func return decorator httplib.HTTPMessage.skipped_count = 0 @monkeypatch_method(httplib.HTTPMessage) def skipped_headers(self): return self.skipped_count @monkeypatch_method(httplib.HTTPMessage) def addheader(self, key, value): if key in ['cache-control', 'expires', 'x-cache', 'pragma']: print "skipping", key, value self.skipped_count = self.skipped_count + 1 else: print "using", key, value self._old_addheader[0](self, key, value) conn = urlopen("http://www.onet.pl") msg = conn.info() print msg.skipped_headers()
Modyfikacja modułów
Dekoratory monkeypatch_method
równie dobrze jak dla klas działają także dla modułów, pozwalając podmieniać funkcje globalne. Trzeba jedynie jako parametr podać moduł zamiast klasy.
Prosty przykład (zamieniamy funkcję urlopen
w module urllib
na inną):
import urllib def monkeypatch_method(cls): def decorator(func): setattr(cls, func.__name__, func) return func return decorator @monkeypatch_method(urllib) def urlopen(self): raise Exception("Simulating HTTP failure") conn = urllib.urlopen("http://www.onet.pl")
Posłowie
Kończąc, chciałbym przypomnieć, że wszystkie powyższe rozwiązania należy stosować w ostateczności - a nie jako normalną technikę kodowania. Niemal zawsze wymuszają uzależnianie kodu od wewnętrznych szczegółów implementacyjnych modyfikowanego modułu i są wrażliwe na jego zmiany.
Ale czasem trzeba.