Rozszerzanie obiektów w czasie wykonania w Ruby
Wzorzec projektowy w Ruby pozwalający rozszerzać obiekty o nowe zachowania w czasie wykonania, przy użyciu modułów i klas singletonowych — na przykładzie postaci z gry RPG.
Ruby to język dynamiczny, który wspiera wiele sposobów organizowania logiki. Możemy korzystać z dziedziczenia klas i/lub komponować nasze klasy, dołączając wybrane moduły (miksiny).

Możemy definiować i usuwać metody w locie. Możemy nawet używać metod, które tak
naprawdę nie są zdefiniowane (przy użyciu method_missing). Kolejną potężną
funkcją jest możliwość rozszerzania obiektu o nowe metody w czasie wykonania,
poprzez dołączanie modułów do klasy lub klasy singletonowej (jeśli chcemy
rozszerzyć tylko jedną instancję).
Aby zaprezentować ten wzorzec projektowy, załóżmy, że chcemy stworzyć aplikację, która będzie zabawiać naszych użytkowników — grę RPG, której akcja toczy się w świecie fantasy.
Dla uproszczenia zamodelujemy klasę postaci. Gracz może wybrać jedną z sześciu różnych ras: krasnolud, elf, gnom, hobbit, człowiek lub ogr. Musi też wybrać profesję swojej postaci spośród: kapłan, programista, kowal, złodziej, wojownik lub czarodziej.
Nasza bazowa klasa postaci będzie miała publiczną metodę greeting, której wynik
zależy od rasy i profesji postaci. Aby to zilustrować, zacznijmy od testu:
# spec/character_spec.rb
require 'spec_helper'
require 'character'
describe Character do
describe '#greeting' do
it 'works for ogre warrior' do
ogre = Character.new(:race => 'ogre', :occupation => 'warrior')
ogre.greeting.should == 'Grumph! I will kill you!'
end
it 'works for elven wizard' do
elf = Character.new(:race => 'elf', :occupation => 'wizard')
elf.greeting.should == 'Heil! Did you see my staff?'
end
it 'works for human programmer' do
human = Character.new(:race => 'human', :occupation => 'programmer')
human.greeting.should == 'Good Day. Do you know Ruby?'
end
end
end
Character#greeting składa się z dwóch części. Pierwsza zależy od tego, jak dana
rasa wykonuje powitanie. Na przykład ogr powie Grumph!. Druga część zależy od
profesji postaci, np. programista zapyta o Ruby. Łącząc powyższe, „ogr
programista” przywita Cię słowami: Grumph! Do you know Ruby?.
Mając testy w stanie niezaliczonym, pomyślmy o kilku możliwych implementacjach.
Najprostszym sposobem na zaliczenie testów jest stworzenie tylko jednej klasy —
Character — złożonej z wielu instrukcji if (lub case), z których każda
modyfikuje wynik powitania. Jest jednak prawdopodobne, że w przyszłości pojawią
się kolejne atrybuty, co doprowadzi do skomplikowanej logiki trudnej w
utrzymaniu.
Inne podejście mogłoby rozdzielić logikę na klasy, z których każda dziedziczy po
klasie Character. To rozwiązanie jest ładnie wspierane w Rails przez Single Table
Inheritance (STI). Sprawdza się ono dobrze dla jednej warstwy podziału. My jednak
chcemy rozdzielić logikę zarówno według rasy, jak i profesji. Doprowadziłoby to
do dwóch warstw i 36 klas, takich jak: OgreProgrammer, OgrePriest,
GnomeThief, HobbitWizard itd. A liczba ta rośnie wraz z dodawaniem kolejnych
warstw. Moglibyśmy skończyć z tysiącami klas typu FemaleYoungWoodenElfArcher!
Rozwiązanie, które chcę przedstawić, wykorzystuje rozszerzanie w czasie wykonania w Ruby. Tworzymy moduł dla każdej rasy i profesji oraz trochę logiki, która spaja to wszystko razem.
Zacznijmy od klasy Character:
# lib/character.rb
class Character
include Character::Race
include Character::Occupation
def greeting
"#{race_greeting} #{occupation_greeting}"
end
def race_greeting
raise 'Not implemented'
end
def occupation_greeting
raise 'Not implemented'
end
end
Klasa Character implementuje greeting, które zależy od dwóch innych metod:
race_greeting i occupation_greeting. Oczekujemy, że te dwie metody zostaną
zaimplementowane w dołączonych modułach. Są one również zdefiniowane w samej
klasie Character, ale zgłaszają błąd, aby zasygnalizować, że powinny być
zdefiniowane gdzie indziej.
Kontynuujmy implementacją modułów dołączonych do klasy Character — Race i Occupation:
# lib/character/race.rb
class Character
module Race
def initialize(options = {})
@race = options[:race]
include_race
super if defined? super
end
def race_module
ActiveSupport::Inflector.constantize("Character::Race::#{@race.capitalize}")
end
private
def include_race
singleton_class = class << self; self; end
singleton_class.send(:include, race_module)
end
end
end
# lib/character/occupation.rb
class Character
module Occupation
def initialize(options = {})
@occupation = options[:occupation]
include_occupation
super if defined? super
end
def occupation_module
ActiveSupport::Inflector.constantize("Character::Occupation::#{@occupation.capitalize}")
end
private
def include_occupation
singleton_class = class << self; self; end
singleton_class.send(:include, occupation_module)
end
end
end
Te dwa moduły wyglądają podobnie i można je zrefaktoryzować, ale zajmiemy się tym później. Na razie przyjrzyjmy się modułowi Occupation.
Metoda initialize ustawia zmienną instancji @occupation, a następnie wywołuje
include_occupation, które dołącza wybraną profesję do klasy singletonowej
obiektu (oznacza to, że moduł jest dostępny tylko dla tego obiektu, a nie dla
wszystkich obiektów Character). Metoda occupation_module zwraca moduł do
dołączenia (przy użyciu constantize z ActiveSupport).
Na koniec wywołanie super w initialize wywołuje initialize w każdym innym
module/klasie poprzez dziedziczenie. Jest to ważne, ponieważ wywołuje nie tylko
initialize klasy Character, ale także initialize zdefiniowane we wszystkich
modułach dołączonych przed opisywanym. Zapewnia to wywołanie zarówno
initialize zdefiniowanego w module Race, jak i w module Occupation. Moduł Race
działa w ten sam sposób.
Ostatnią rzeczą, którą musimy zaimplementować, są moduły dla każdej rasy i profesji. Ponieważ są dość podobne, wymienię tylko kilka z nich:
# lib/character/race/ogre.rb
class Character
module Race
module Ogre
def race_greeting
'Grumph!'
end
end
end
end
# lib/character/occupation/programmer.rb
class Character
module Occupation
module Programmer
def occupation_greeting
'Do you know Ruby?'
end
end
end
end
Zaimplementowanie wszystkich wymaganych modułów i dołączenie ActiveSupport w klasie Character sprawia, że nasze testy przechodzą.
# lib/character.rb
require 'rubygems'
require 'active_support'
Zmiana istniejących obiektów w czasie wykonania
Do tej pory zaimplementowaliśmy strukturę, która pozwala ustawić rasę i profesję
postaci podczas tworzenia obiektu, za pomocą metody new. Nie spełnia to jednak
naszej potrzeby — musimy mieć możliwość zmiany profesji i rasy istniejącej
postaci (to pewien rodzaj magii) w czasie wykonania. Można to łatwo osiągnąć,
ulepszając nasze moduły. Najpierw napiszmy kilka testów:
# spec/character_spec.rb
describe Character do
context 'attribute readers' do
it 'are being set during initialization' do
ogre = Character.new(:race => 'ogre', :occupation => 'warrior')
ogre.race.should == 'ogre'
ogre.occupation.should == 'warrior'
end
it 'are changeable' do
character = Character.new(:race => 'ogre', :occupation => 'warrior')
character.race = 'elf'
character.occupation = 'smith'
character.race.should == 'elf'
character.occupation.should == 'smith'
end
end
end
Aby to zaliczyć, musimy dodać dwie metody do modułów Race (i Occupation):
# lib/character.rb
class Character
module Race
def self.included(base)
base.send(:attr_reader, :race)
end
def race=(value)
@race = value
include_race
end
# Reszta kodu pominięta dla przejrzystości.
end
end
Pierwsza metoda jest wywoływana, gdy moduł zostaje dołączony do innego modułu lub
klasy (przechowywanej w zmiennej base — w naszym przypadku to klasa Character).
Ustawia ona reader atrybutu dla zmiennej race w klasie Character.
Druga metoda to writer atrybutu, który przypisuje wartość do zmiennej instancji obiektu, a następnie dołącza odpowiedni moduł rasy.
Pobawmy się tym jeszcze bardziej
Aby skomplikować sprawę, zaimplementujmy dialekt mowy gnomów. Gnomy są bardzo
inteligentne i mają wiele do powiedzenia, więc ich dialekt powinien mówić
szybciej. Gnomy pomijają pauzy między słowami i zastępują je specjalnymi
akcentami. Powiedzą na przykład: HowAreYouDoing? zamiast How are you doing?.
Aby przedstawić nasze potrzeby, zacznijmy od modyfikacji testów postaci:
# spec/character_spec.rb
describe Character do
describe '#greeting' do
it 'works for gnome programmer' do
gnome = Character.new(:race => 'gnome', :occupation => 'programmer')
gnome.greeting.should == 'GutenTag.DoYouKnowRuby?'
end
end
end
Aby to zaimplementować, musimy ponownie zmodyfikować klasę Character:
# lib/character.rb
class Character
include Character::Race
include Character::Occupation
def greeting
if race_module.methods.include?(:race_modifier)
race_module.race_modifier(clean_greeting)
else
clean_greeting
end
end
def clean_greeting
"#{race_greeting} #{occupation_greeting}"
end
# Reszta kodu pominięta dla przejrzystości
end
Teraz, jeśli race_modifier zostało zaimplementowane w race_module, to zostanie
użyte; w przeciwnym razie zwracane jest niezmodyfikowane clean_greeting. Na
koniec implementujemy race_modifier w module Gnome:
# lib/character/race/gnome.rb
class Character
module Race
module Gnome
def race_greeting
'Guten Tag.'
end
def self.race_modifier(value)
value.split(' ').map(&:capitalize).join
end
end
end
end
Ten prosty przykład modyfikacji mowy gnomów pokazuje, jak łatwo i czysto można rozszerzać logikę przy użyciu tego wzorca rozszerzania w czasie wykonania.
Trochę refaktoryzacji
Do tej pory mamy sporo duplikacji kodu. Moduły Race i Occupation wyglądają niemal identycznie. Możemy temu zaradzić, tworząc moduł Common dołączany do modułów Race i Occupation:
# lib/character/common.rb
module Common
def self.included(base)
base_name = base.name.split('::').last.downcase
base.send(:attr_reader, base_name.to_sym)
base.class_eval <<-EOS
def initialize(options = {})
@#{base_name} = options[:#{base_name}]
include_#{base_name}
super if defined? super
end
def #{base_name}=(value)
@#{base_name} = value
include_#{base_name}
end
def #{base_name}_module
ActiveSupport::Inflector.constantize("Character::#{base_name.capitalize}::\#{@#{base_name}.capitalize}")
end
private
def include_#{base_name}
singleton_class = class << self; self; end
singleton_class.send(:include, #{base_name}_module)
end
EOS
end
end
Ten kod wykorzystuje hook included, aby odczytać nazwę dołączającego modułu, a
następnie ustawia reader atrybutu (o tej nazwie) i definiuje jego metody (przy
użyciu class_eval).
Podsumowanie
Kompletny kod z tego wpisu można znaleźć tutaj: github.com/dawid-sklodowski/runtime-extends.