Obiekty w Rubim, część 2: singletony, moduły, miksiny

Posted by Jacek Galanciak on

W tej części kursu omówimy wzorzec singletonu, dynamicznie dodawane metody oraz moduły i miksiny w języku Ruby.

Wzorzec singletonu

Założenia są proste: dana klasa może występować w co najwyżej jednym egzemplarzu.

Stwórzmy dla przykładu klasę implementującą konsolę - taką, jaką znamy choćby z Quake'a. Ponieważ w całym silniku gry nie potrzebujemy więcej, niż jednej konsoli - zastosujemy tutaj wzorzec singletonu:

class Console
  def self.instance
    return @@instance if defined? @@instance
    @@instance = new
  end

  private_class_method :new, :allocate
end

c1 = Console.instance
c2 = Console.instance

c1.object_id # => 22691800
c2.object_id # => 22691800

Widać tutaj, że aby zrealizować singleton w Rubim, należy:

  • zablokować metody new i allocate - uczynić je prywatnymi, by mogły być wywoływane tylko w odrębie klasy
  • udostępnić metodę, która zwraca (a wcześniej ewentualnie tworzy) instancję naszego singletonu

Oczywiście Ruby nie byłby sobą, gdyby nie pozwolił nam na realizację tego wszystkiego za pomocą paru stuknięć w klawiaturę :-).

require 'singleton'

class Console
  include Singleton
end

c1 = Console.instance
c2 = Console.instance

c1.object_id # => 22691800
c2.object_id # => 22691800

Metody singletonu

Nazwa może być nieco myląca, gdyż nie do końca jest związana ze wzorcem singletonu.

Ruby pokazuje swoją dynamikę w przykładzie, w którym rozszerzamy wbudowaną w język klasę:

class Integer
  def odd?
    (self % 2) == 1
  end
end

puts 5.odd? # => true
puts 6.odd? # => false
puts 7.odd? # => true

To nie koniec, można posunąć się nawet dalej. Ruby oferuje nam możliwość dopisania metody do egzemplarza dowolnej klasy. Taką metodę nazywamy metodą singletonu.

class Dog
  def bark
    puts "Woof, woof!"
  end

  def method_missing(name)
    puts "Hey, man. I can't #{name}!"
  end
end

Rex, Azor = Dog.new, Dog.new

def Azor.kill
  puts "/me is killing"
end

Rex.kill
Azor.kill
Hey, man. I can't kill!
/me is killing

Metoda method_missing wywoływana jest w momencie, gdy następuje próba wywołania nieistniejącej metody. Więcej takich sztuczek postaram się omówić w kolejnej części tutoriala.

Przestrzenie nazw

…czyli gdy klasa niezbyt dobrze modeluje problem.

Przestrzenie nazw to jedno z dwóch zastosowań modułów (o drugim powiemy za chwilę) w Rubim. Poza umożliwieniem programiście logicznej organizacji struktury projektu, pozwalają dodatkowo uniknąć konfliktów nazw, które tak bardzo potrafią dokuczać w innych językach. O przykłady nietrudno. log(10) może oznaczać zarówno logarytm z dziesięciu, jak i zalogowanie błędu o kodzie 10. Takich niejednoznaczności należy unikać.

Jak?

module MyMath
  # ...
  def self.log(x)
    # ...
  end
end

module CriticalErrorLogger
  def self.log(error_num)
    # ...
  end
end

y = MyMath.log(10) # Logarytm
CriticalErrorLogger.log(10) # Logowanie błędu

Pamiętajmy, że konieczne jest poprzedzenie selfem nazwy metody, by zdefiniować metodę modułu, a nie instancji. Metody instancji mają zastosowanie w miksinach, o których dosłownie za chwilę.

Czasem jednak chcemy napisać program czysto matematyczny, a wtedy rzeczą naturalną jest, że sinus to sin(x) a nie Math.sin(x):

puts Math.sin(Math::PI/2)

include Math
puts sin(PI/2)

Widać tutaj, że includowaniu ulegają nie tylko metody, ale także stałe.

require

Chcąc użyć klas i metod z zewnętrznego pliku, konieczne jest użycie metody require (np. require 'complex' - jeśli chcemy użyć w programie liczb zespolonych). Jak widać, nie ma potrzeby pisania rozszerzenia ładowanego pliku.

A ścieżki?

Są, wszystkie dostępne w magicznej zmiennej $:

irb(main):001:0> $:
=> ["c:/ruby/lib/ruby/site_ruby/1.8", "c:/ruby/lib/ruby/site_ruby/1.8/i386-msvcrt", "c:/ruby/lib/ruby/site_ruby", "c:/ru
by/lib/ruby/1.8", "c:/ruby/lib/ruby/1.8/i386-mswin32", "."

Tablica (a raczej kobyła) z plikami, które includowaliśmy (wiednie lub bezwiednie) dostępna jest pod magiczną nazwą $"

irb(main):002:0> $"
=> ["rbconfig.rb", "rubygems/rubygems_version.rb", "thread.so", "thread.rb", "rbconfig/datadir.rb", "rubygems/user_inter
action.rb", "socket.so", "timeout.rb", "net/protocol.rb", "uri/common.rb", "uri/generic.rb", "uri/ftp.rb", "uri/http.rb"
, "uri/https.rb", "uri/ldap.rb", "uri/mailto.rb", "uri.rb", "net/http.rb", "stringio.so", "yaml/error.rb", "syck.so", "y
aml/ypath.rb", "yaml/basenode.rb", "yaml/syck.rb", "yaml/tag.rb", "yaml/stream.rb", "yaml/constants.rb", "rational.rb",
"date/format.rb", "date.rb", "yaml/rubytypes.rb", "yaml/types.rb", "yaml.rb", "zlib.so", "rubygems/remote_fetcher.rb", "
forwardable.rb", "digest.so", "digest.rb", "digest/sha2.rb", "parsedate.rb", "time.rb", "rubygems/source_index.rb", "rub
ygems/version.rb", "rubygems/specification.rb", "openssl.so", "openssl/bn.rb", "openssl/cipher.rb", "openssl/digest.rb",
"openssl/buffering.rb", "fcntl.so", "openssl/ssl.rb", "openssl/x509.rb", "openssl.rb", "rubygems/gem_openssl.rb", "ruby
gems/security.rb", "rubygems/custom_require.rb", "rubygems.rb", "ubygems.rb", "e2mmap.rb", "irb/init.rb", "irb/workspace
.rb", "irb/context.rb", "irb/extend-command.rb", "irb/output-method.rb", "irb/notifier.rb", "irb/slex.rb", "irb/ruby-tok
en.rb", "irb/ruby-lex.rb", "readline.so", "irb/input-method.rb", "irb/locale.rb", "irb.rb"]`

Ups…

Metoda require powoduje załadowanie pliku docelowego tylko raz. Niestety, przed nieopatrznym powtórzeniem procesu Ruby broni się niezbyt mądrze.

irb(main):001:0> require 'mathn'
=> true
irb(main):002:0> require 'mathn'
=> false
irb(main):003:0> require 'C:/ruby/lib/ruby/1.8/mathn.rb'
C:/ruby/lib/ruby/1.8/mathn.rb:118: warning: already initialized constant Unify
C:/ruby/lib/ruby/1.8/mathn.rb:306: warning: already initialized constant Unify
=> true

W tym przypadku wygląda to niewinnie, ale bardzo łatwo można sobie zrobić tym krzywdę. Prawdopodobnie zostanie to poprawione w przyszłych wersjach, ale póki co - beware!

Miksiny

Drugie zastosowanie modułów. Wspaniała możliwość będąca doskonałym połączeniem zalet dziedziczenia wielobazowego (znanego chociażby z C++) z przejrzystością i jednoznacznością definicji klas.

Prześledźmy poniższy przykład:

module Witness
  def create_witness
    @witness = "Been there. Seen it. Got the scars."
  end

  def has_witness?
    (defined? @witness) ? true : false
  end
end

class Accident
  include Witness

  def to_s
    "An accident with " + (self.has_witness? ? 
            "a witness saying \'#{@witness}\'" : "no witness")
  end
end

acc1, acc2 = Accident.new, Accident.new
acc2.create_witness

puts acc1, acc2
An accident with no witness
An accident with a witness saying 'Been there. Seen it. Got the scars.'

Jak to wszystko funkcjonuje?

  • moduły nigdy nie mają instancji - mogą co najwyżej zostać włączone do klasy; wywołać można jedynie metody modułu (np. Math.sin(0)
  • argumentem metody include jest zawsze nazwa widocznego z danego miejsca modułu; jeśli moduł nie jest widoczny, należy załadować odpowiedni plik za pomocą metody require
  • klasa, do której dołączany jest moduł, przejmuje także jego zmienne instancji, modułu oraz stałe

Pamiętać należy o kolejności poszukiwania metody w momencie wywołania:

  1. metody oryginalnej klasy
  2. metody wmiksowane
  3. metody przodka
  4. metody wmiksowane w przodka

Oczywiście w momencie znalezienia metody, jest ona wywoływana, a samo przeszukiwanie przerywane.

Rubinowe miksiny

Ruby dostarcza parę ciekawych modułów, które wmiksowane są w wiele standardowych klas. Tylko czekają, aż wmiksujemy je i my :). Na przykładzie omówimy Comparable i Enumerable.

Enumerable

Zasada jest prosta. Implementujemy w naszej klasie metodę each, a Ruby zajmuje się resztą. Znaczy się: dopisuje za nas takie metody, jak all?, any?, map, min, sort itp. (ri Enumerable!).

Dla przykładu zaimplementujmy kolekcję, która zawiera ciągi kropek. Ilość kropek kolejnych elementów jest równa wartości kolejnych elementów tablicy podanej do konstruktora.

class Dotter
  include Enumerable

  def initialize(val)
    @collection = val.to_a
  end

  def each
    @collection.each { |e| yield '.'*e }
  end

end

d1 = Dotter.new(2..7)
# ..
# ...
# ....
# .....
# ......
# .......

d2 = Dotter.new([5, 4, 6, 1, 7, 3])
# .....
# ....
# ......
# .
# .......
# ...


d1.all? { |v| v.length > 8} # false
d2.any? { |v| v.length%2} # true
d2.include?('..') # false
d2.max # "......."
d2.min # "."
d2.sort # [".", "...", "....", ".....", 
        # "......", "......."]

Comparable

Użycie tego modułu jest równie proste. Implementujemy metodę <=>, a w efekcie otrzymujemy metody porównania oraz between?.

Dla przypomnienia, metoda <=> zwraca -1, gdy pierwszy element jest mniejszy od drugiego, 0 - gdy oba elementy są równe oraz 1, gdy pierwszy element jest większy od drugiego.

Czas na praktykę. Weźmy przykład z dokumentacji Rubiego. Poza tym, że rozmiar jednak ma znaczenie, jeszcze coś innego jest w nim interesujące.

class SizeMatters
  include Comparable

  attr :str
  def <=>(anOther)
    str.size <=> anOther.str.size
  end

  def initialize(str)
    @str = str
  end

  def inspect
    @str
  end
end

s1 = SizeMatters.new("Z")
s2 = SizeMatters.new("YY")
s3 = SizeMatters.new("XXX")
s4 = SizeMatters.new("WWWW")
s5 = SizeMatters.new("VVVVV")

s1 < s2 # => true
s4.between?(s1, s3) # => false
s4.between?(s3, s5) # => true
[s3, s2, s5, s4, s1].sort # => [Z, YY, XXX, WWWW, VVVVV]

Czy wiesz, że…

Gdy zdefiniujemy pustą klasę:

class Empty
end

e = Empty.new
puts e.methods.sort.join(", ")

==, ===, =~, id , send , class, clone, display, dup, eql?, equal?, extend, freeze, frozen?, gem, hash, id, inspect, instanceeval, instanceof?, instancevariabledefined?, instancevariableget, instancevariableset, instancevariables, isa?, kindof?, method, methods, nil?, objectid, privatemethods, protectedmethods, publicmethods, require, requiregem, respondto?, send, singletonmethods, taguri, taguri=, taint, tainted?, toa, tos, toyaml, toyamlproperties, toyaml_style, type, untaint

zauważamy sporą ilości metod, których nie definiowaliśmy. Tak naprawdę są to metody klas nadrzędnych do naszej: Object, Module, Class.

Za to zamieszanie Ruby jest krytykowany przez wielu sceptyków. Okazuje się, że może być jeszcze gorzej :).

module Kernel
  def are_you_there?
    "Yeah, fuc*in' everywhere!"
  end
end

5.are_you_there? # "Yeah, fuc*in' everywhere!"
"test".are_you_there? # "Yeah, fuc*in' everywhere!"

Publiczna metoda modułu Kernel jest wmiksowana w każdą klasę. Zdefiniowanie metody poza jakąkolwiek klasą definiuje ją w module Kernel. Includowanie modułu poza jakąkolwiek klasą powoduje inkludowanie go do modułu Kernel.

Podany wyżej przykład pokazuje skalę tego, co dopiszemy do Kernela.