method_missing w Rubym - nie pomiń niczego!
4 komentarze | Kategorie: Blog, Techblog | trackbackTagi: metaprogramowanie ruby tips&tricks
Jako, że Ruby jest bardzo dynamicznym językiem, posiada wiele dodatków do klasycznego podejścia programowania OOP. Jednym z takich dodatków jest metoda method_missing. Metoda ta pozwala na przechwycenie wywołania nieistniejącej metody i zareagowania w odpowiedni sposób.
object.should_fail!
Zacznijmy od prostego przypadku. Co się dzieje, gdy wołamy nieistniejącą metodę?
class Happy
end
h = Happy.new
h.say_it
Uruchomienie tego prostego programu owocuje błędem w postaci wyjątku NoMethodError i odpowiednim komunikatem do niego dołączonym.
no_method.rb:5: undefined method `say_it' for # (NoMethodError)
Ponieważ jest to najzwyklejszy wyjątek to nie ma problemu by taki wyjątek przechwycić, co pokazuje kolejny przykład.
class Happy
end
begin
h = Happy.new
h.say_it
rescue NoMethodError => e
puts "Nastąpiła próba wywołania nieistniejącej metody..."
puts "Dodatkowa informacja: #{e.message}"
end
Rezultat
Nastąpiła próba wywołania nieistniejącej metody...
Dodatkowa informacja: undefined method `say_it' for #
Jednakże takie nieinwazyjne zachowanie interpretera pozwala w praktyce na przechwycenie wyjątku, zalogowanie błędu i bezpieczne zakończenie programu (na myśl przychodzą wspomnienia walki w tej kwestii z PHP, które w takim wypadku albo wywalało warning albo co gorzej fatal error, którego nie dało się przechwycić). Wspomniane method_missing pozwala na przechwycenie wywołania metody już na poziomie obiektu a jak się zaraz przekonacie, wcale nie musi to oznaczać błędu (gdyby tak było to po co ta co by nam to dało?). Obrazuje to kolejny przykład.
class Happy
def method_missing(method_name, *args)
puts "Nastąpiła próba wywołania nieistniejącej metody"
puts "Nazwa metody: #{method_name}"
puts "Argumenty (#{args.size}): #{args.inspect}"
end
end
h = Happy.new
h.say_it
h.it_is_weird!(1, "aha!")
h.name = "radarek"
Nastąpiła próba wywołania nieistniejącej metody
Nazwa metody: say_it
Argumenty (0): []
Nastąpiła próba wywołania nieistniejącej metody
Nazwa metody: it_is_weird!
Argumenty (2): [1, "aha!"]
Nastąpiła próba wywołania nieistniejącej metody
Nazwa metody: name=
Argumenty (1): ["radarek"]
Jak widać gdy klasa posiada zdefiniowaną metodę method_missing Ruby wywoła właśnie ją gdy wywołujemy niezdefiniowaną metodę. Jako pierwszy parametr dostaniemy nazwę wołanej metody w postaci symbolu, zaś kolejne parametry możemy zebrać do tablicy za pomocą operatora gwiazdki '*'.
No tak, kolejny zbędny "ficzer"
W tym momencie sporo programistów, zwłaszcza tych nie wychodzących poza jedyne i słuszne języki typowane statycznie (które z reguły takich dodatków nie posiadają, gdyż kompilator nie mógłby wszystkiego sprawdzić i zapewnić o istnieniu danej metody), może zadawać sobie pytanie: "Ale po co coś takiego?".
Przykład pierwszy pokazuje opakowanie hasha za pomocą własnej klasy, do której elementów używamy pełnoprawnych metod dostępowych.
class MyHash
def initialize
@hash = {}
end
def method_missing(method_name, *args)
method_name = method_name.to_s
if method_name.to_s.match(/=$/)
@hash[method_name.to_s[0...-1]] = (args.size < 2 ? args[0] : args)
else
if @hash.has_key?(method_name)
return @hash[method_name]
else
raise NoMethodError, "undefined method `#{method_name}' for #{self}"
end
end
end
end
my_hash = MyHash.new
my_hash.day = "saturday"
my_hash.year = 2008
my_hash.indexes = [1, 2, 3]
puts my_hash.day
puts my_hash.year
puts my_hash.indexes
puts my_hash.name # => NoMethodError
Rezultat
saturday
2008
1
2
3
hash.rb:14:in `method_missing': undefined method `name' for # (NoMethodError)
from hash.rb:28
W ten sposób nasza klasa zachowuje się jak obiekt, którego atrybuty są dynamicznie dodawane. Możemy na przykład wykorzystać to definiując klasę do przechowywania konfiguracji lub podobnych wartości w naszym programie.
Zdefiniowanie method_missing może się czasem okazać tylko połową sukcesu. Metodą spokrewnioną okazuje się respond_to?, która zwraca odpowiednio wartość true/false w zależności czy zadany obiekt odpowiada na zadany komunikat (true) lub nie (false). Zatem dobrze jeszcze zdefiniować tą metodę, adekwatnie do metody method_missing. Przedstawia to następny listing.
class MyHash
def initialize
@hash = {}
end
def method_missing(method_name, *args)
method_name = method_name.to_s
if method_name.to_s.match(/=$/)
@hash[method_name.to_s[0...-1]] = (args.size < 2 ? args[0] : args)
else
if @hash.has_key?(method_name)
return @hash[method_name]
else
raise NoMethodError, "undefined method `#{method_name}' for #{self}"
end
end
end
def respond_to?(method_name)
return @hash.has_key?(method_name.to_s)
end
end
my_hash = MyHash.new
my_hash.day = "saturday"
my_hash.year = 2008
my_hash.indexes = [1, 2, 3]
puts my_hash.respond_to?(:day)
puts my_hash.respond_to?(:year)
puts my_hash.respond_to?(:indexes)
puts my_hash.respond_to?(:name)
Rezultat
true
true
true
false
Railsy też to używają
Jednym z miejsc we frameworku Ruby on Rails, gdzie został wykorzystany ten element języka, są dynamiczne "findery" w klasie modelu. Przykładowo jeśli mam model User z polami name:string, email:string, age:integer i chcę szukać rekordów po polu name i age to mogę to zrobić tak:
# wszystkie zapisy są równoważne
User.find_all_by_login_and_age("tomasz", 40)
User.find_all_by_age_and_login(40, "tomasz")
User.find(:all, :conditions => {:name => "tomasz", :age => 40})
Nic nie stoi na przeszkodzie by dodać własną metodę method_missing do klasy dziedziczącej po ActiveRecord::Base (w końcu to klasa jak każda inna). Należy jednak uważać, by nie zepsuć już wbudowanej funkcjonalności. Musimy zatem poprawnie użyć 'super' i przekazać poprawnie wszystkie parametry.
class User < ActiveRecord::Base
class << self
def method_missing(method_name, *args, &block)
if method_name.to_s.match(/^destroy_/)
method_name = method_name.to_s.sub!(/^destroy/, "find")
users = super(method_name, *args, &block)
users.each do |user|
user.destroy
end
else
super(method_name, *args, &block)
end
end
end
end
User.destroy_all_by_name("tomasz")
Chociaż metody find_* nie obsługują bloków to i tak jawnie go deklaruję i przekazuję (&block). W ten sposób nawet gdy implementacja "railsowa" zmieni się to nie będę musiał zmieniać mojego kodu. Mógłbym także ominąć całkowicie () wraz z parametrami - Ruby w takim wypadku przekazuje wszystkie argumenty oryginalnej metody do tej wołanej przez super.
Co dalej?
Innym bardzo ciekawym przykładem są tzw. buildery, służące do budowania dokumentów strukturalnych, np xml, html. Oto przykład:
require 'rubygems'
require 'builder'
builder = Builder::XmlMarkup.new(:indent => 2)
xml = builder.person do |b|
b.name("Jim")
b.phone("555-1234")
b.class("human")
end
puts xml
<person>
<name>Jim</name>
<phone>555-1234</phone>
<class>human</class>
</person>
Zauważ, że wywołanie metody class zadziałało zgodnie z intuicją, pomimo faktu, że klasa Object posiada już taką metodę. W tym wypadku został zastosowany pewien trick, który polega na usunięciu z danej klasy wszystkich metod instancyjnych, tak by method_missing przechwyciło każde wywołanie.
class BlankSlate
instance_methods.each do |method|
undef_method(method) unless method.match(/^__/)
end
end
puts BlankSlate.instance_methods
Ten kod wymagałby kilku dodatkowych poprawek by obsłużyć wszystkie przypadki (np. to że metodę można dodać później od momentu usunięcia metod), ale w pełni obrazuje ideę stworzenia "czystego" obiektu. Właśni w tym celu Ruby 1.9 posiada klasę BasicObject (nadklasa dla Object), która posiada minimalną ilość metod.
Pokazana technika może być zastosowana także w przypadku proxy, delegacji, buforowania komunikatów, dekoratorów. Języki DSL czerpią garściami z tego typu konstrukcji. Zastosowań jest na prawdę wiele.
Ostatnią rzeczą, na którą chciałbym zwrócić uwagę to fakt, że stosując technikę z method_missing należy dobrze udokumentować co i kiedy on robi (i zwrócić na to uwagę już w opisie klasy).
method_missing należy do tych "ficzerów" Rubiego, które by dobrze i efektywnie używać, trzeba dobrze poznać. To jedna z tych rzeczy, która dobrze użyta pomaga, zaś źle tylko szkodzi. Zatem nie należy nadużywać jej, ale wiedzieć że jest taka możliwość.
Mam nadzieję, że niczego nie pominąłem ;-).
Dobrym materiałem ocierającym się o to co pisałeś jest prezentacja Wiericha z RubyConf 2007. http://rubyconf2007.confreaks.com/d1t1p2_advanced_ruby_class_design.html
fajne wprowadzenie do metaprogramowania.
polecam takze:
http://www.apohllo.pl/dydaktyka/ruby/intro/metaprogramowanie
Jak już do PHP się odnosisz – rzeczywiście, szkoda, że nie rzuca wyjątków w takich sytuacjach. Ale należy pamiętać, że jest dostępna metoda __call, która pozwala obsłużyć takie sytuacje dokładnie w ten sam sposób ;P
@Zbigniew: tak, wiem o __call w PHP. Działa to w zasadzie tak samo, ale w kwestii samego przechwycenia wywołania. Potem jak chcesz przesłać do innego obiektu tą metodę to jest troszkę gorzej. „call_user_func”, „call_user_func_array” nie są tak wygodne jak „send” z Rubiego.