Skip to content
← Wszystkie wpisy
7 min czytania Dawid Skłodowski

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

Ruby

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.