Zagadnienia zaawansowane
Poprzedni rozdział | Następny rozdział
W niniejszym rozdziale przedstawiamy zaawansowane techniki programowania w języku Ruby. Należy podkreślić, że techniki te nie powinny być nadużywane – w szczególności jeśli tylko możemy rozwiązać dany problem uciekając się do podstawowych metod wykorzystywanych w językach obiektowych (np. dziedziczenia), to powinniśmy to zrobić właśnie w ten sposób.
Opisane tutaj techniki są niezwykle potężne, ale używane nieroztropnie mogą przyczynić się do tego, że kod programu będzie mniej zrozumiały (co nigdy nie jest pożądane), a w najgorszych przypadkach doprowadzić do powstawania zupełnie nieprzewidywalnych błędów (co może prowadzić do katastrofy). Dlatego jeszcze raz zalecamy roztropne korzystanie z omówionych technik.
Zamrażanie obiektów
Istnieje również mechanizm języka, który w pewnym stopniu pozwala ograniczyć negatywne
skutki niechcianych modyfikacji (zarówno klas i obiektów). Wywołanie na dowolnym
obiekcie metody freeze
(zwane zamrożeniem) powoduje, że staje się on niemodyfikowalny. Wywołanie jakiejkolwiek metody powodującej zmianę stanu
zamrożonego obiektu, spowoduje wystąpienie wyjątku TypeError
.
Dotyczy to również modyfikacji, które nie wyglądają jak wywołania metod (np. otwarcie
klasy i dodanie lub zmodyfikowanie metody). Istnieje również metoda frozen?
, która
zwraca wartość true
, jeśli dany obiekt jest zamrożony. Obiekt, który uległ zamrożeniu
nie może być przywrócony do zwykłego stanu w żaden sposób.
Negatywną konsekwencją stosowanie tego mechanizmu jest m.in. to, że nie można modyfikować wartości zmiennych klasowych, jeśli dana klasa została zamrożona.
Introspekcja
TODO
Dynamiczne wywoływanie metod
Jedną z własności języków skryptowych (nie tylko Ruby) jest możliwość wywoływania
metod, których nazwa nie jest znana w trakcie pisania danego fragmentu kodu.
W Ruby do tego celu służy właśnie metoda send
, zdefiniowana w klasie Object
.
Metoda ta jako pierwszy parametr przyjmuje nazwę metody do wywołania w postaci
łańcucha znaków lub symbolu. Pozostałe parametry przekazane zostaną do wywoływanej
metody:
class Sesame def open_sesame!(password) if password == "Abrakadabra" "gold, silver and rock n' roll" end end end sesame = Sesame.new sesame.send(:open_sesame!, "Abrakadabra") #=> "gold, silver and rock n' roll"
W powyższym przykładzie zilustrowane jest wykorzystanie metody send
, do wywołania
metody open_sesame!
z parametrem password
równym “Abrakadabra”. W tym wypadku nic
nie stało na przeszkodzie, aby na obiekcie sesame
wywołać bezpośrednio metodę
open_sesame!
.
- kiedy nazwa metody jest konstruowana w trakcie wykonania programu
- kiedy metoda jest konstruowana w trakcie wykonania programu
Dynamiczne nazwy
Dobrą ilustracją pierwszego przypadku jest np. wykorzystanie informacji pochodzących z introspekcja. Wyobraźmy sobie, że chcielibyśmy wywołać na danym obiekcie wszystkie metody zaczynające się na wybraną literę, albo wszystkie metody odczytu atrybutów, dla których zdefiniowane są również metody ich ustawienia. Możemy wtedy wykorzystać następujący kod:
class Dynamic def for_letter(letter, *args) self.class.methods.select{|m| m =~ /\A#{letter}/}.each{|m| send(m, *args)} end def writeable_attributes self.class.methods.select{|m| m=~ /=\Z/}.collect{|m| [m,send(m.sub(/=\Z/,""))]} end end
W powyższym przykładzie w klasie Dynamic
definiowane są dwie metody for_letter
oraz
writeable_attributes
. Pierwsza z nich pozwala wywołać wszystkie metody zaczynając
się na daną literę (lub ciąg liter) z argumentami przekazanymi do metody for_letter
.
Zastosowanie takiej techniki byłoby trudne w zwykłych aplikacjach, ze względu na konieczność
dostosowania atrybutów do wszystkich potencjalnie wywoływanych metody.
Znacznie ciekawsza jest metoda writeable_attributes
, która zwraca tablicę par: [ _nazwa
atrybutu, wartość_ ] dla wszystkich atrybutów, dla których istnieją metody pozwalające
na ustawienie wartości atrybutu. Nie trudno wyobrazić sobie zastosowanie takiej
metody np. w aplikacji z graficznym interfejsem użytkownika, która pozwalałaby
modyfikować wartość dowolnego atrybutu, do którego dostęp zagwarantowany został w publicznym
interfejsie klasy danego obiektu. Przy drobnej modyfikacji obiekt ten nie musiałby
dziedziczyć z klasy Dynamic
(wystarczyłoby, że wspomniana metoda przyjmowałaby go
jako swój argument). Widzimy zatem jak w bardzo prosty sposób można by w Ruby zaimplementować
niektóre z zaawansowanych mechanizmów zarządzania języka Java (JMX).
Dynamiczne metody
Drugi przypadek dotyczy sytuacji, w której nie tylko nazwa metody nie jest znana w trakcie tworzenia kodu, ale nawet sama metoda nie jest zdefiniowana. Jak zostanie to przedstawione w następnym rozdziale, metody w Ruby mogą być tworzone w trakcie wykonania programu (Biorąc pod uwagę fakt, że podstawowa implementacja języka bazuje na interpreterze, nie jest to nic zaskakującego. Z drugiej jednak strony powstaje coraz więcej implementacji, w których kod Ruby jest kompilowany przed wykonaniem.), zatem może zdarzyć się sytuacja, w której chcielibyśmy wywołać taką metodę. W tym wypadku nie ma innej możliwości jak wywołanie jej poprzez nazwę.
Stosowny przykład realizujący ten schemat zostanie przedstawiony w rozdziale następnym.
Dynamiczne modyfikowanie klas
W języku Ruby, w przeciwieństwie do takich języków jak C++ czy Java, interfejs danej klasy nie musi być określony w jednym miejscu. Pozwala to na określanie go w wielu niezależnych plikach. Aby dodać nową metodę do wcześniej zdefiniowanej klasy wystarczy otworzyć tę klasę i napisać definicję nowej metody. Mechanizm ten nie pozwala na zmianę hierarchii dziedziczenia danej klasy, więc należy w definicji wskazać właściwą klasę nadrzędną.
class Base end class Inherited < Base def foo "foo" end end object = Inherited.new object.foo # "foo" object.bar # NoMethodError: undefined method `bar' for... class Inherited < Base def bar "bar" end end object.bar # "bar"
W powyższym przykładzie zdefiniowana jest klasa Base
oraz klasa Inherited
,
która dziedziczy z Base
. Pierwotna definicja klasy Inherited
składa się tylko z jednej metody: foo
. Wywołanie metody bar
, która
nie została zdefiniowana za pierwszym razem, skutkuje wystąpieniem
wyjątku NoMethodError
. Nic nie stoi jednak na przeszkodzie aby
rozszerzyć interfejs definiowany przez klasę Inherited
i dodać
metodą bar
. Wtedy wywołanie tej metody na obiektach tej
klasy nie spowoduje błędu. Być może zastanawiające jest to, że ta
nowa funkcjonalność będzie występować również w obiektach, które
zostały stworzone przed jej dodaniem. Jeśli jednak przypomnimy
sobie poprzedni rozdział, gdzie wyraźnie było napisane, że
obiekty posiadają stan, a metody związane są z klasami, to
zachowanie to nie powinno wymagać dalszego wyjaśnienia.
Na pierwszy rzut oka technika ta wydaje się przydatna jedynie wtedy, gdy definicja naszej klasy rozrasta się nadmiernie i chcemy umieścić ją w kilku plikach. Oczywiście nic nie stoi na przeszkodzie aby właśnie wtedy ją stosować. Tym niemniej gdyby chodziło tylko o to zastosowanie, nie opisywalibyśmy go w tym rozdziale.
Po pierwsze należy wskazać, że można ponownie definiować metody już wcześniej zdefiniowane, a ponadto można redefiniować klasy wbudowane!
Pierwsze właściwość sama w sobie wydaje się zbędna. Po co mielibyśmy redefiniować metody, które sami zdefiniowaliśmy? Lepiej w tym wypadku byłoby od razu poprawić pierwotną definicję, a nie tworzyć kod, który staje się trudny w utrzymaniu. Ponadto zachowanie programu zależy od tego, która z definicji zostanie zinterpretowana jako ostania, gdyż to ona będzie wykorzystana w następnych wywołaniach, zatem będzie ono zależało od kolejności wczytania plików.
Dopiero w połączeniu z drugą własnością, czyli możliwością redefiniowana klas wbudowanych nabiera ona praktycznego znaczenia. Oczywiście osoby, które pierwszy raz spotykają się z takimi olbrzymi możliwościami modyfikacji języka, mogą obawiać się, że te własności są potencjalnie bardzo destrukcyjne. Należy przyznać, że owszem – nierozsądne ich stosowanie może prowadzić do bardzo dziwnych i nieprzewidywalnych błędów. Jak jednak pisaliśmy na początku tego rozdziału, wszystkie opisane tutaj techniki programistyczne powinny być stosowane wyłącznie w dobrze uzasadnionych przypadkach i tylko przez doświadczonych programistów. Co więcej – jeśli przyjmiemy zasadę, że nie modyfikujemy zachowania metod w klasach wbudowanych, a jedynie rozszerzamy ich funkcjonalność, to ryzyko wystąpienia dziwnych błędów znacznie się zmniejsza.
Przykładem takiego bezpiecznego rozszerzenia mogłoby być dodanie
do klasy String nowej metody hl
, która dodawałaby do łańcucha
znaków sekwencje modyfikujące sposób wyświetlania łańcucha, czy
to w postaci tagów HTML czy też znaków sterujących w terminalach
uniksowych:
class String def hl(tag="em",hlstr=nil) if hlstr self.gsub(/#{hlstr}/,"<#{tag}>#{hlstr}</#{tag}>") else "<#{tag}>#{self}</#{tag}>" end end end "Ala ma kota".hl("b","ma").hl("p") #=> "<p>Ala <b>ma</b> kota</p>"
W powyższym przykładzie do klasy String
dodawana jest metoda hl
,
która pozwala w prosty sposób formatować łańcuchy znaków za pomocą
tagów HTML. W przypadku, gdy nie jest przekazany żaden argument do
tej metody, cały łańcuch formatowany jest za pomocą tagu em
.
Jeśli podany jest pierwszy parametr, to jest on traktowany jako
nazwa znacznika, za pomocą którego formatowany jest łańcuch.
Drugi parametr wykorzystywany jest jako podłańcuch, do którego
ma być zawężone formatowanie.
Dlaczego funkcjonalność ta została dodana w klasie String
a nie
jakiejś klasie, która by z niej dziedziczyła? Oczywiście w językach,
w których nie można modyfikować klas wbudowanych byłoby to jedyne
sensowne rozwiązanie. Tym niemniej w Ruby można w tym celu właśnie
rozszerzyć klasę String
– widzimy, że wprowadzona modyfikacja skutkuje
tym, że wywołania hl
mogą być łączone, bez potrzeby opakowywanie
ich wyniku w jakąś dodatkową klasę. Rozwiązanie takie jest przejrzyste,
a ponadto raczej nie powinno prowadzić do “dziwnych błędów”, gdyż
wyłącznie rozszerzyliśmy, a nie zmodyfikowaliśmy interfejs oraz
zachowanie tej klasy.
W dalszej części rozdziały (przy okazji omówienia techniki aliasowania) przedstawiony zostanie jeszcze bardziej zaawansowany przykład modyfikowania klas wbudowanych, który jednak jest już dużo bardziej ryzykowny, gdyż skutkuje zmianą ich zachowania. Nadal jednak technika ta może być wykorzystywana w aplikacjach prototypowych, gdzie ilość kodu jest niewielka, a pożytki przeważają nad zagrożeniami.
Brakujące metody i stałe
Bardzo interesującą techniką zaawansowanego programowania jest redefiniowanie
metod method_missing
oraz const_missing
. Pierwsza z nich wywoływana
jest jeśli żadna z klas i modułów stowarzyszonych z danym obiektem
nie implementuje metody o danej nazwie. Metoda const_missing
wywoływana jest zaś wtedy, gdy w kodzie pojawia się odwołanie do stałej,
która nie została zdefiniowana.
method_missing
Scenariusz wykorzystania metody method_missing
jest dokładnie odwrotny
do wykorzystania metody send
(co nie znaczy, że nie mogą być one
wykorzystywane razem). W przypadku tej pierwszej, w trakcie pisania
programu znana jest nazwa wywoływanej metody, ale metoda ta nie jest
w ogóle definiowana! Jak zatem można wykorzystać metodę, która nie posiada
definicji? Otóż metoda taka nie posiada definicji w zwykłym tego słowa
znaczeniu, tzn. składającej się ze słowa def
, nazwy metody, jej listy
parametrów oraz ciała.
W metodzie method_missing
(zazwyczaj) definiowane jest tylko ciało tej metody.
W sygnaturze metody method_missing
pojawiają się dwa argumenty – pierwszy
to nazwa metody, której definicja nie została znaleziona, natomiast
drugi przechwytuje wszystkie parametry przekazane do nieistniejącej
metody. Na podstawie tych informacji możliwe jest zasymulowanie zachowania,
którego można by się spodziewać po brakującej metodzie.
W najprostszym przypadku można po prostu wypisać komunikat informujący, że dana metoda nie istnieje (w domyślnej implementacji rzucany jest wyjątek NameError):
class Empty def method_missing(name, *arg) "Brak metody '#{name}', do której przekazano argumenty [#{arg.join(", ")}]" end end ala = Empty.new ala.ma_kota?("Mamrota") #=> "Brak metody 'ma_kota?', do której przekazano argumenty [Mamrota]"
W powyższym przykładzie metoda method_missing
powoduje zwrócenie komunikatu
mówiącego, że dana metoda nie istnieje.
Oczywiście takie zastosowanie, choć interesujące, jest jednak mało przydatne (z jednym wyjątkiem – przyjaznych interaktywnych interpreterów, które zamiast generować wyjątki, informują użytkownika, że gdzieś popełnił błąd).
Znacznie ciekawsze zastosowanie tej techniki można znaleźć w różnych bibliotekach, które pozwalają na bardziej obiektową interakcję z obiektami, które z założenia nie są obiektowe (np. relacyjne bazy danych) czy też posiadają rozbudowany interfejs, którego przepisywanie na modłę obiektową byłoby czasochłonne (np. tagi języka HTML).
Oba przytoczone rozwiązania wykorzystywane są w praktyce. Pierwsze stosowane jest w bibliotece ActiveRecord i pozwala np. definiować kryteria wyszukiwania obiektów w bazie danych w “bardziej obiektowy” sposób. Zamiast przekazywać je jako tablicę asocjacyjną postaci nazwa atrybutu => kryterium, można wywołać metodę, w której nazwie pojawią się nazwy atrybutów, a kryteria są przekazywane jako jej parametry:
# zwykły kod apples = Apple.find(:all, :conditions => {:color => "red"}) # bardziej obiektowy kod apples = Apple.find_all_by_color("red")
Należy zwrócić uwagę, że metoda find_all_by_color
nie jest zdefiniowana
w klasie Apple
. Odpowiednie wywołanie do bazy danych realizowane
jest na podstawie nazwy metody – jest ona de facto przkształcana
w wywołanie zbliżone do pierwszego. Nie trzeba chyba nikogo przekonywać,
że drugi zapis jest znacznie czytelniejszy.
Drugie rozwiązanie stosowane jest natomiast w bibliotece szablonów
stron internetowych .haml
Jedną z ciekawych jej własności jest to, że
klasa (HTML-owy atrybut class
) danego elementu może pojawić się
po nazwie tagu:
div.left.highlight p.first To jest pierwszy akapit p.normal To jest drugi akapit
W powyższym przykładzie definiowana jest sekcja (div
), która
będzie posiadała atrybut class
o wartościach left
oraz highlight
.
Wewnątrz tej sekcji pojawiają się zaś dwa paragrafy, spośród których pierwszy
posiada klasę first
, zaś drugi normal
. Zapis stosowane w .haml-u jest
bardzo czytelny, a implementacja tego mini-języka opiera się m.in.
właśnie o metodę method_missing
zdefiniowaną dla znaczników HTML.
Na pierwszy rzut oka technika ta może wydawać się mało przydatna, ale po bliższym jej poznaniu, a także po zapoznaniu się z niektórymi bibliotekami napisanymi w Ruby, okazuje się, że pozwala ona niesamowicie poprawić czytelność kodu. Jest to jeden z powodów, dla których Ruby często wykorzystywany jest do tworzenia tzw. DSL-i, czyli języków specyficznych dla danej dziedziny zastosowań.
const_missing
Metoda const_missing
, choć wykorzystywana znacznie rzadziej,
również może mieć ciekawe zastosowania. Jedno z najbardziej oczywistych
polega na redefinicji tej metody w klasie Module
(gdzie jest ona
zdefiniowana pierwotnie) w celu ładowania plików zawierających definicje
klas, które nie zostały explicite załadowane. Jeśli tylko wiadomo jaka
jest ścieżka dostępu do tych plików oraz stosowana jest np. konwencja,
w myśl której klasa o nazwie NazwaKlasy
zdefiniowana jest w pliku
nazwa_klasy.rb
, to można bardzo łatwo zaimplementować mechanizm
auto-ładowania potrzebnych plików.
Aliasy metod
Polecenie alias
jest niewątpliwie znane wszystkim użytkownikom
systemów uniksopodobnych – pozwala ono na wprowadzanie innych
nazw dla już zdefiniowanych poleceń. Podobne jest działanie
słowa kluczowego alias
w języku Ruby – dzięki niemu można nadać
nową nazwę metodzie, która została zdefiniowana wcześniej.
Aby utworzyć alias jakiejś metody, należy w definicji klasy
wprowadzić słowo kluczowe alias
, po nim nazwę (w postaci symbolu)
tworzonego aliasu, dalej nazwę (również w postaci symbolu) już
zdefiniowanej metody, dla której tworzona jest nazwa alternatywna.
class String alias :dlugosc :length end "abc".dlugosc #=> 3
W powyższym przykładzie w klasie String
tworzony jest alias dlugosc
dla
metody length
. Wywołanie metody dlugosc
na dowolnym obiekcie tej
klasy jest tożsame z wywołanie metody length
.
Przedstawione zastosowanie mechanizmu aliasowania, polegające na
rozszerzeniu interfejsu klasy o metody, które dla niektórych
programistów będą bardziej oczywiste (np. size
vs. length
),
choć interesujące, nie jest aż tak przydatne, jak mogłoby to się wydawać
na pierwszy rzut oka. Przez niektórych programistów (np. zakorzenionych
w pythonowej filozofii, w myśl której “powinien istnieć tylko jeden
oczywisty sposób na zrobienie tego”) ta właściwość języka Ruby może
wydawać się nawet zdecydowanie niepożądana, gdyż powoduje zaśmiecenie
interfejsu metodami, których działanie jest identyczne.
Po części zgadzamy się z tą krytyką, tym bardziej, że stworzenie aliasu dla danej metody nie powoduje, że nazwy te są całkowicie utożsamione - zdefiniowanie metody o nazwie takiej jak alias innej metody powoduje, że powstają dwie zupełne odrębne metody:
class String alias :dlugosc :length end "abc".dlugosc #=> 3 "abc".length #=> 3 class String def dlugosc "długość jest nieznana" end end "abc".dlugosc #=> "długość jest nieznana" "abc".length #=> 3
W powyższym przykładzie widać dokładnie, że pomimo wcześniejszego zaliasowania
metody length
za pomocą nazwy dlugosc
, zdefiniowanie tej drugiej metody
powoduje, że każda z metod daj inny wynik.
Czy zatem istnieje racjonalne uzasadnienie dla istnienia techniki
aliasowania metod? Otóż tak! Aliasy przydają się jeśli modyfikujemy
działanie jakiejś metody, a jednocześnie chcielibyśmy móc odwołać się
do jej wcześniejszej implementacji. Programiści wychowani
w starej szkole dobrego OOP realizowaliby przedstawiony scenariusz
poprzez stworzenie klasy dziedziczącej z danej klasy, w której
pojawiłaby się nowa, rozszerzona implementacja starej metody, a
stara metoda byłaby wywołana z wykorzystaniem słowa kluczowego super
.
Tym niemniej wszyscy Ci, którzy zetknęli się z programowaniem zorientowanym
aspektowo, wiedzą, że ten mechanizm nie zawsze się sprawdza. Jeśli np.
chcielibyśmy dziedziczyć z oryginalnej klasy, to oczywiści nie
mamy dostępu do modyfikacji, która pojawia się w nowej klasie:
class Base def foo "foo" end end class Inherited1 < Base def foo "you said " + foo end end class Inherited2 < Base end in1 = Inherited1.new in1.foo #=> "you said foo" in2 = Inherited2.new in2.foo #=> "foo"
W powyższym przykładzie zilustrowany jest problem, o którym mowa. Jeśli
chcemy nasze rozszerzenie wprowadzić w metodzie foo
,
aby zostało ono wykorzystane, wszystkie klasy dziedziczące z Base
musiałyby
teraz dziedziczyć z Inherited1
. Nie zawsze jest to jednak możliwe.
M.in. stąd wziął się pomysł na programowanie aspektowe, dzięki któremu
można modyfikować zachowanie pewnych metod na dowolnym poziomie hierarchii
dziedziczenia, w taki sposób, aby zmiany te były propagowane
na wszystkie klasy poniżej tego poziomu.
Wykorzystując mechanizm aliasowania języka Ruby, przedstawiony problem możemy rozwiązać w sposób następujący:
class Base def foo "foo" end end class Inherited < Base end in = Inherited.new in.foo #=> "foo" class Base alias :old_foo :foo def foo "you said " + old_foo end end in.foo #=> "you said foo"
Oczywiście sceptycy mogą zapytać – dlaczego nie zmodyfikowano oryginalnej
metody foo
? Najprostsza odpowiedź brzmi – ponieważ nie było takiej
możliwości. Kod klasy Base
może pochodzić z zewnątrz i ulegać zmianie,
zatem modyfikacja tej metody mogłaby zostać usunięta przy aktualizacji
danej biblioteki (biblioteki napisane w czystym Ruby zawierają zawsze
jego kod źródłowy, zetem taka modyfikacja jest zazwyczaj możliwa,
lecz z oczywistych względów, niezalecana).
Niemożność modyfikacji oryginalnego kodu ma jeszcze większe znaczenie w odniesieniu do klas wbudowanych języka. O ile moglibyśmy zmodyfikować ich kod na własnej maszynie (co IMHO zakrawałoby na dziwactwo, żeby nie powiedzieć szaleństwo), o tyle nie mamy takiej możliwości, jeśli uruchamiamy nasz kod na maszynie, nad którą nie mamy pełnej kontroli. Jednakże dzięki mechanizmowi pozwalającemu na rozszerzanie klas wbudowanych, możemy dokonywać modyfikacji tych klas w sposób bardziej cywilizowany.
Pozostaje pytanie – kiedy taka technika faktycznie powinna być stosowana. Jak wspominaliśmy wielokrotnie w niniejszym rozdziale – wszystkich prezentowanych tutaj metod programowania należy używać z rozwagą. Możemy jednak wskazać dwa scenariusze, kiedy zastosowanie tej techniki wydaje się dobrze uzasadnione:- przypadki znane z AOP – dodawanie logowania, transakcji, etc.
- prototypowanie – badanie przypadków granicznych, stosowanie klas wbudowanych jako prototypów
Zastosowanie: AOP
Pierwszy przypadek najlepiej zilustrować jako rozwinięcie ostatniego
przykładu. Załóżmy, że mamy klasę Account
, która posiada metodę
transfer(amount, to)
, pozwalającą na przekazanie pewnej kwoty
pieniędzy na inne konto. W trakcie rozwoju aplikacji okazało się,
że wszystkie wywołania tej metody muszą być logowane, na potrzeby audytu.
Niestety wspomniana klasa nie jest naszym własnym produktem i wiadomo,
że może ulec dalszym zmianom. Rozwiązaniem problemu jest stworzenie
rozszerzenia tej klasy:
# oryginalny kod, którego nie możemy zmodyfikować class Account attr_accessor :balance def transfer(amount, to) Transaction.start do self.balance -= amount to.balance += amount end end end # nasze rozszerzenie klasy Account class Account alias :old_transfer :transfer def transfer(amount, to) @logger.log("From balance: #{self.balance}, to balance: #{to.balance}") old_transfer(amount, to) @logger.log("From balance: #{self.balance}, to balance: #{to.balance}") end end
W powyższym przykładzie dodanie logowania sprowadza się do
dodania aliasu dla metody transfer
oraz otoczenia oryginalnego
wywołania odpowiednimi wywołaniami do loggera. Oczywiści przedstawiony
jest jedynie szkic rozwiązania (koniczne byłoby wcześniejsze ustawienie
wartości zmiennej instancyjnej @logger
, etc.).
Widzimy zatem jak w łatwy sposób można dodać niektóre z mechanizmów AOP do naszych programów napisanych w Ruby.
Zastosowanie: prototypowanie
Drugi przypadek choć rzadziej stosowany, może być równie cenny, w szczególności w początkowej fazie rozwoju nowego projektu. Zwykle wtedy zależy nam bardziej na określeniu trudności implementacji i wstępnej weryfikacji nowego pomysłu, aniżeli na stworzeniu całkowicie stabilnego i wydajnego rozwiązania. Możliwość zmodyfikowania wbudowanych klas jest wtedy nieoceniona – zamiast tworzyć własne klasy, które swoją funkcjonalnością byłyby zbliżone do klas wbudowanych, możemy po prostu nieco “przerobić” te klasy na własne potrzeby.
Jednym z ciekawych sposobów wykorzystania tej techniki właśnie w fazie
prototypowania, miałem do czynienia w projekcie z zakresu NLP.
Jak powszechnie wiadomo, przy przetwarzaniu języka naturalnego, takiego
jak język polski, bardzo istotne są operacje na łańcuchach znaków.
Z drugiej strony – ze względu na fleksję występującą w naszym języku,
zastosowanie surowej klasy String
jest niemożliwe. W szczególności
dwa słowa mogą być identyczne, nawet jeśli ich formy w postaci łańcucha
znaków różnią się (“kot”, “kota”, “kotu”, “koty”... to formy jednego leksemu).
Poprawne rozwiązanie tego problemu powinno oczywiście sprowadzać się
do napisania nowej klasy, np. Lexeme
, która odpowiadałaby
temu uogólnionemu pojęciu napisu i zawierałaby informacje o jego odmianie.
Takie rozwiązanie właśnie zastosowałem. Jednak szybko okazało się,
że w bibliotece zawierającej odmienione formy słów, nie występują
wszystkie słowa pojawiające się w analizowanych tekstach.
Pierwsza myśl polegała na tym, aby dla tych słów również tworzyć
odpowiednie instancje klasy Lexeme
. Niestety takie rozwiązanie
wymagałoby dużych modyfikacji tej klasy – w zasadzie każda metoda
powinna zostać przerobiona w taki sposób, aby uwzględniała sytuację,
w której dane słowo występuje lub nie występuje w wspomnianej bibliotece.
Co więcej – okazało się, że jedyna metoda, która będzie wykorzystywana
dla tych “fałszywych” leksemów to metoda porównująca ==(other)
.
Znacznie prostsze wydało się przerobienie oryginalnej metody porównującej
klasy String
, tak by ona akceptowała również obiekty klasy Lexeme
!
To rozwiązanie zastosowałem właśnie w swoim prototypowym projekcie:
class Lexeme # metoda sprawdzająca czy leksem występuje w danej formie def has_form?(form) #implementacja end def ==(other) return self.lexeme_id == other.lexeme_id if other.is_a?(Lexeme) return self.has_form?(other) if other.is_a?(String) false end end # modyfikacja klasy String, aby == było relacją symetryczną class String alias :old_equals :== def ==(other) return other == self if other.is_a?(Lexeme) old_equals(other) end end
Dzięki takiemu rozwiązaniu mogę np. przechowywać obiekty klas
Lexeme
oraz String
w jednej tablicy i porównywać je miedzy
sobą, nie przejmując się czy są to instancje należące do tych
samych czy też różnych klas. Nie muszę chyba podkreślać jak
bardzo ułatwia to tworzenie prototypu!
Klasy i metody typu singleton
W poprzednim rozdziale przedstawione zostały podstawowe zagadnienia związane z obiektowością: relacje pomiędzy klasami i obiektami, dostępem do atrybutów, widocznością metod, etc. Chociaż nie wspomniano nic o znanych z Javy interfejsach i klasach abstrakcyjnych (które w Ruby nie występują), to przedstawiony obraz mógł wydawać się pełny.
Okazuje się jednak, że zagadnienie obiektowości w Ruby jest nieco
bardziej złożone. Co więcej – aby faktycznie zrozumieć jak działają
podstawowe składniki tego język, czyli klasy, nie sposób nie poruszyć
zagadnienia tzw. klas i metod typu singleton
.
Ponieważ zagadnienie to jest dosyć złożone, a jego zrozumienie jest kluczowe dla wykorzystania pełnego potencjału języka, poświęcimy mu trochę więcej miejsca.
Metody typu singleton
Zacznijmy od kwestii najprostszej – metod singletownowych. Tym mianem określa się metody, które definiowane są dla pojedynczych obiektów. Tak! Okazuje się, że w Ruby można dodać metodę do pojedynczego obiektu. Można to zrobić na trzy sposoby:
my_string = "trzy" class << my_string def to_i 3 end end my_string.to_i #=> 3 "trzy".to_i #=> 0
Pierwszy sposób polega na wykorzystaniu słowa kluczowego class
,
po którym pojawia się operator <<
, a po nim zmienna, która
odnosi się do obiektu, do którego dodawana jest metoda typu singleton
.
Drugi sposób polega na użyciu nazwy zmiennej przed nazwą dodawanej metody:
my_string = "trzy" def my_string.to_i 3 end my_string.to_i #=> 3
Trzeci sposób polega na wykorzystaniu metody instance_eval
:
my_string = "trzy" my_string.instance_eval do def to_i 3 end end my_string.to_i #=> 3
Metoda instance_eval
akceptuje zarówno blok kodu jak i łańcuch znaków.
Efekt działania powyższych konstrukcji jest taki sam – metoda (lub metody)
zdefiniowane jako metody typu singleton, dołączane są tylko do wybranego
obiektu. Nie wpływają one na żaden inny obiekt danej klasy. Dlatego też
w powyższych przykładach tylko łańcuch “trzy” ukrywający się pod zmienną
my_string
przy zamianie na wartość całkowitą, dawał wartość 3. Pozostałe
łańcuchy, chociaż nawet wyglądały identycznie, przy wywołaniu tej metody
zachowywały się w sposób zdefiniowany w klasie String
, tzn. jeśli nie
reprezentowały wartości całkowitej, przy wywołaniu metody to_i
zwracały
wartość 0.
- czy są jakieś ograniczenia jego stosowania?
- do czego może się przydać?
- jak jego działanie ma się do tezy, że obiekty posiadają tylko stan, a metody mogą być związane tylko z klasami?
Ograniczenia
Pierwsze poważne ograniczenie jego stosowania dotyczy klas Fixnum
oraz Symbol
– obiekty tych klasy nie mogą posiadać metod typu singleton.
Drugie ograniczenie dotyczy serializowania (za pomocą metody to_yaml
) –
obiekt posiadający metody tego rodzaju po serializacji i deserializacji
zostanie ich pozbawiony.
Zastosowania
Odpowiedź na drugie pytanie mogłaby być bardzo długa, dlatego przytaczamy zaledwie kilka przykładów zastosowania tej techniki. W dalszej części rozdziału postaramy się omówić niektóre z nich nieco szerzej.- Tworzenie testów akceptacyjnych w języku zbliżonym do języka naturalnego –
dzięki możliwości dodawania metod do pojedynczych obiektów, mamy pełną swobodę
w ich nazywaniu. Nie muszą być to nazwy, które mają sens w kontekście
klasy, a jedynie wybranego obiektu. Możemy np. w naszym teście do obiektu
michał
dodać metodękup_pocztowki
i wywoływać ją wielokrotnie w różnych kontekstach. Znacząco poprawi to czytelność takiego testu. - Zmiana zachowania pojedynczych obiektów – najlepiej technika ta sprawdza
się w sytuacji, w której mamy jeden specyficzny obiekt, który powinien być
traktowany inaczej np. dla metody
to_yaml
. Aby zmodyfikować działanie tej metody dla tego obiektu, musielibyśmy stworzyć dla niego osobną klasę. W Ruby możemy skorzystać właśnie z metod typu singleton. - Prototypowanie – technika ta przydaje się również w tym przypadku i można
podejrzewać, że jest nawet bezpieczniejsza, niż wspomniana wcześniej
technika polegająca na modyfikacji klas wbudowanych. W odniesieniu
do wcześniejszego przykładu z klasami
Lexeme
iString
: zamiast modyfikować metodę==
w całej klasieString
, moglibyśmy ją zmodyfikować tylko dla tych obiektów, które trafiają do tablicy przechowującej leksemy i łańcuchy znaków. - Metody singleton mają też istotne znaczenie w kontekście metaprogramowania.
Klasy typu singleton
Zanim jednak wyjaśnimy na czym polega rola metod singletonowych w metaprogramowaniu, musimy odpowiedzieć na pytanie trzecie: jak można utrzymać tezę o niezależności obiektów od metod, jeśli można związać metodę z pojedynczym obiektem?
Odpowiedź jest w zasadzie oczywista – metody typu singleton nie są związane z obiektami, a ze specjalnymi klasami singletonowymi. Wspomnieliśmy już wielokrotnie, że tylko klasy mogą “posiadać” metody, a zwykłe obiekty mogą mieć tylko stan przechowywany w atrybutach. Zatem realizacja mechanizmu metod singletonowych opiera się o to, że w momencie dodanie do obiektu takiej metody tworzona jest nowa klasa, która związana jest tylko z tym obiektem. Klasa ta pojawia się najniżej w hierarchii klas tego obiektu – zatem wszystkie zdefiniowane w niej metody mają pierwszeństwo wobec metod zdefiniowanych w pozostałych klasach należących do hierarchii, w szczególności klasy, która jest “oficjalną” klasą danego obiektu.
Klasy singletonowe nazywane są czasami metaklasami lub klasami wirtualnymi.
Obie nazwy nie oddają jednak za bardzo ich specyfiki. Przedrostek meta
jest zazwyczaj zarezerwowany dla pojęć, które służą do opisu innych
pojęć, w tym wypadku byłyby to klasy stosowane do opisu klas. Oczywiście
w Ruby istnieje taka klasa i nazywa się… Class
.
Pojęcie klasy wirtualne kojarzy się natomiast z wywołaniami wirtualnymi, a w Ruby wszystkie wywołania metod są wirtualne (w sensie terminologii stosowanej w języku C++), zatem ten kwalifikator też nie wydaje się najwłaściwszy.
Skoro uporaliśmy się z kwestiami terminologicznymi, możemy przystąpić do odkrywania ostatnich tajemnic języka Ruby. Możemy zadać pytanie: czy klasy również posiadają swoje klasy singletonowe? Odpowiedź jest dosyć oczywista i brzmi: tak. Ponieważ klasy są obiektami, to jako obiekty, również mogą posiadać metody typu singleton.
Zaraz, zaraz, skoro tak, to definicje metod klasowych są tożsame z
definicjami metod typu singleton! Dokładnie tak – można powiedzieć,
że definicje metod klasowych są definicjami metod singletonowych
dla obiektów poszczególnych klas. Powtórzmy to jeszcze raz: metody
klasowe typu Figure.instances
, są zdefiniowane w _klasie singletonowej
obiektu kryjącego się pod stałą Figure
._ Żeby zobaczyć to naocznie
możemy przywołać definicję z poprzedniego rozdziału:
class Figure @@instances = 0 def Figure.instances @@instances end end
Przy omówieniu metod klasowych wspomnieliśmy, że występują trzy ich postaci, ale trzecia zostanie omówiona później. Teraz jest to już oczywiste:
class Figure @@instances class << self def instances @@instances end end end
W powyższym kodzie metoda instances
definiowana jest jako singletonowa
metoda obiektu, kryjącego się pod słowem kluczowym self
, czyli obiektu
będącego klasą Figure
.
Na pierwszy rzut oka całe to zamieszanie z klasami i metodami typu singleton
wydaje się tylko zbędną komplikacją, która obciąża umysł programisty.
W tym oglądzie jest trochę racji, dlatego też w poprzednim rozdziale
pominęliśmy te skomplikowane zagadnienia. Tym niemniej zobligowani jesteśmy
do wyjaśnienia, jaki zysk może być z takiej komplikacji? Otóż klasy
i metody typu singleton odgrywają zasadniczą rolę w metaprogramowaniu,
czyli programowaniu, którego przedmiotem są nie obiekty (czyli instancje
klas), ale same klasy (czyli instancje klasy Class
). Zagadnienie
to omówione jest szerzej w następnym rozdziale.
Poprzedni rozdział | Następny rozdział