Any fool can make things bigger, more complex, and more violent. It takes a touch of genius and a lot of courage to move in the opposite direction. Albert Einstein

o Ruby

method_missing w Rubym - nie pomiń niczego!

4 komentarze | Kategorie: Blog, Techblog | trackback
Tagi:

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 ;-).

Jeśli spodobał Ci się wpis to może umieścisz ten blog w swoim czytniku RSS?

Komentarze

1. avatar icon Seban napisał(a) 26 Mar 2008 o godz. 09:36:

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

2. avatar icon Marcin Urbański napisał(a) 26 Mar 2008 o godz. 10:00:

fajne wprowadzenie do metaprogramowania.

polecam takze:
http://www.apohllo.pl/dydaktyka/ruby/intro/metaprogramowanie

3. avatar icon Zbigniew Jarosik napisał(a) 17 Kwi 2008 o godz. 11:09:

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

4. avatar icon Radarek napisał(a) 17 Kwi 2008 o godz. 11:23:

@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.

Dodaj coś od siebie

Możesz korzystać ze składni Textile.

Pola oznaczone * są wymagane.

Proszę o dodawanie komentarzy związanych z tematem postu, sprawy osobiste proszę załatwiać przez maila bądź gg.

Zastrzegam sobie prawo do moderacji komentarzy (edycja, usuwanie).