Wczoraj przeprowadzałem rozmowę kwalifikacyjną na stanowisko programisty Ruby w firmie tutaj w Denver, kiedy mój rozmówca postawił mi dobre wyzwanie metaprogramistyczne: poprosił mnie o napisanie automatycznego audytora metod.
Opisał metodę klasową, która pobierałaby nazwę metody instancji i generowała automatyczny, rejestrowany ślad za każdym razem, gdy wywoływana jest metoda docelowa. Coś w tym stylu:
To dałoby dane wyjściowe, które wyglądałyby tak:
Wymyśliłem rozwiązanie, które działało, ale wymagało kilku potencjalnie niebezpiecznych hacków do Object#send
. Przed przedstawieniem mojego rozwiązania, powiedziałem „to prawdopodobnie zły pomysł, ale…” i miałem rację. Dobre pomysły rzadko zaczynają się w ten sposób.
Rozwiązanie, które zaproponował wiązało się z użyciem często pomijanego Module#prepend
i pomyślałem, że jest na tyle fajne, że warto o nim napisać.
Strategia polegała na użyciu zachowania Module#prepend
do stworzenia dynamicznie generowanej metody wrappera dla Messenger#share
.
W tym wrapperze, będziemy mieli jeden komunikat „Performing” wystrzelony przed wykonaniem ciała metody, samo wykonanie metody, a następnie końcowy komunikat „Exiting” po pomyślnym zakończeniu wykonania metody.
Aby zrozumieć jak używać Module#prepend
do tego celu, musimy najpierw dobrze zrozumieć jak działa dziedziczenie metod w Rubim.
Łańcuch przodków
Każdy obiekt w Rubim znajduje się na końcu tak zwanego łańcucha przodków. Jest to lista obiektów przodków, po których dany obiekt dziedziczy. Ruby używa tego łańcucha przodków do określenia, która wersja metody (jeśli w ogóle) zostanie wykonana, gdy obiekt otrzyma wiadomość.
Możesz zobaczyć drzewo przodków dla dowolnego obiektu wywołując Module#ancestors
. Na przykład, oto łańcuch przodków dla naszej klasy Messenger
:
Gdy #include
lub #prepend
moduł do klasy, Ruby dokonuje zmian w łańcuchu przodków tej klasy, zmieniając, które metody są znajdowane i w jakiej kolejności. Dużą różnicą pomiędzy #include
a #prepend
jest to, gdzie ta zmiana jest dokonywana. Stwórzmy moduł o nazwie Auditable
, który (w końcu) będzie zawierał kod, który robi naszą magię audytu:
Jeśli użyjemy Module#include
do zaimportowania (obecnie nieistniejących) metod z Auditable
, Ruby wciśnie ten moduł jako przodka naszej klasy Messenger
. Zobacz sam:
Gdy wywołamy metodę w Messenger
, Ruby spojrzy na to, co jest zdefiniowane w klasie Messenger
i – jeśli nie znajdzie metody, która pasuje do wywołania – wspina się w górę łańcucha przodków do Auditable
, aby poszukać ponownie. Jeśli nie znajdzie tego, czego szuka na Auditable
, przechodzi do Object
i tak dalej.
To jest #include
. Jeśli zamiast tego użyjemy Module#prepend
do zaimportowania zawartości modułu, uzyskamy zupełnie inny efekt:
#prepend
czyni Messenger
przodkiem Auditable
. Zaraz. Co?
To może wyglądać tak, że Messenger
jest teraz nadklasą Auditable
, ale to nie jest dokładnie to, co się tutaj dzieje. Instancje klasy Messenger
są nadal instancjami klasy Messenger
, ale Ruby będzie teraz szukał metod do użycia dla Messenger
w module Auditable
przed szukaniem ich w samej klasie Messenger
.
I właśnie to, przyjaciele, wykorzystamy do zbudowania tego audytora: jeśli stworzymy metodę o nazwie Auditable#share
, Ruby znajdzie ją zanim znajdzie Messenger#share
. Możemy wtedy użyć wywołania super
w Auditable#share
, aby uzyskać dostęp (i wykonać!) do oryginalnej metody zdefiniowanej na Messenger
.
Sam moduł
Właściwie nie będziemy tworzyć metody o nazwie Auditable#share
. Dlaczego nie? Ponieważ chcemy, aby było to elastyczne narzędzie. Jeśli na sztywno zakodujemy metodę Auditable#share
, będziemy mogli jej używać tylko w metodach, które implementują metodę #share
. Albo co gorsza, będziemy musieli ponownie zaimplementować ten sam wzorzec audytora dla każdej metody, którą kiedykolwiek będziemy chcieli audytować. Nie dziękuję.
Więc zamiast tego, zdefiniujemy naszą metodę dynamicznie i metodę klasy audit_method
do jej odpalenia:
Gdy audit_method
jest wywoływane w klasie implementującej, w module Auditable
tworzona jest metoda o takiej samej nazwie jak metoda do audytu. W naszym przypadku, zostanie utworzona metoda o nazwie Auditable#share
. Jak wspomniano, Ruby znajdzie tę metodę zanim znajdzie oryginalną metodę w Messenger
, ponieważ poprzedzamy moduł Auditable
w klasie implementującej.
To oznacza, że możemy użyć superwywołania, aby dotrzeć do łańcucha przodków i wykonać Messenger#send
. Kiedy to zrobimy, przekażemy argumenty, które zebraliśmy (*arguments
) również w górę łańcucha.
Po wywołaniu oryginalnej metody, drukujemy nasz komunikat o wyjściu i nazywamy to dniem. Dobra robota, gang!
Bring it all Together
Teraz to tylko kwestia prepend
dodania tego modułu do naszej Messenger
klasy i powinniśmy być gotowi do pracy:
A good golly it works:
Wpływ na to jest ogromny dla audytu, ale jest więcej do tej sztuczki. Możesz użyć prependingu do zmiany zachowania obiektów bez zmiany samych obiektów. Jest to łatwy do przystosowania i przejrzysty sposób na tworzenie komponentów wyższego rzędu w Rubim. Testowanie wydajności, obsługa błędów. Możesz tutaj zrobić wiele.
Wnioski
Moduły Rubiego są bardziej skomplikowane niż większość ludzi myśli. Nie jest to tak proste jak „zrzucenie” kodu z jednego miejsca w drugie, a zrozumienie tych różnic odblokowuje kilka naprawdę zgrabnych narzędzi do twojego paska narzędzi.
Możesz zauważyć, że mówiłem dziś tylko o Module#include
i Module#prepend
, a nie poruszyłem tematu Module#extend
. To dlatego, że działa on zupełnie inaczej niż jego kuzyni. Wkrótce napiszę dogłębne wyjaśnienie Module#extend
, aby uzupełnić ten zestaw.
Jak na razie, jeśli chcesz dowiedzieć się więcej, polecam przeczytanie modułów Rubiego: Include vs Prepend vs Extend autorstwa Léonarda Hetscha. To było naprawdę pomocne w złożeniu tego wszystkiego razem.