Obiekty w Rubim, część 2: singletony, moduły, miksiny
Posted by Jacek Galanciak on Oct 29 2007
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
iallocate
- 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ą metodyrequire
- 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:
- metody oryginalnej klasy
- metody wmiksowane
- metody przodka
- 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.