Magie met Modules

13 July 2009 16:24 Iain Hecker Ruby

Ruby is een zeer krachtige programmeertaal. In deze blogpost wil ik wat dieper duiken in Modules. We gaan kijken naar modules als namespaces, als mixins om code te delen en naar een wat geavanceerdere methode om mixins te gebruiken door het gedrag van objecten aan te passen.

Modules als namespaces

In Ruby worden modules gebruikt als namespaces. Alles dat binnen een module gedefinieerd wordt zit in die namespace. Dat is dus met methoden en constanten. Klassen en modules zijn “constanten”(1) en worden dus ook genamespaced. Deze kunnen benaderd worden met de dubbele-dubbelepunt notatie.

module Math
  FAKE_PI = 22.0 / 7.0
 
  class World
    def self.hello
      "hello world 123" # (3)
    end
  end
end
 
Math::FAKE_PI # => 3.14285714285714
Math::World.hello # => "hello world 123"

Mixins

De manier om code herbruikbaar te maken in Ruby is door middel van modules(3). Modules zijn een krachtiger alternatief dan class inheritance(4). Laten we eerst eens bekijken hoe we methoden kunnen gebruiken. Modules worden meestal “ingemixt” in een klasse om te gebruiken. Dat kan met de methode include

module HelloWorld
  def hello
    "hello world"
  end
end
 
class World
  include HelloWorld
end
 
the_world = World.new
the_world.hello # => "hello world"

Methodes gedefinieerd in een module worden bij een include dus beschikbaar als instance methods. Maar hoe werkt dit? Door include wordt er door Ruby een anonieme klasse (ook wel ghost class, singleton of eigenklass genoemd) gemaakt die de superklasse is van World. Deze anonieme klasse heeft een pointer naar de methoden van de module, zodat deze methoden door overerving beschikbaar zijn. Ruby zal deze anonieme klasse nooit direct tonen, maar in feite ziet het er zo uit, als we de anonieme klasse “X” zouden noemen:

class X < Object
  def hello
    "hello world"
  end
end
 
class World < X
  include HelloWorld
end
 
the_world = World.new
the_world.hello # => "hello world"

De anonieme klasse erft over van de klasse waar de originele klasse van overerft (in dit geval dus Object, wat immers impliciet de superklasse is van alles). In feite bereik je hiermee “multiple inheritance”. Het is interessant om op te merken dat de methode hello niet echt in deze anonieme klasse zit, maar dat de anonieme klasse alleen naar de module HelloWorld blijft verwijzen. Als je de module later nog aanpast, zijn deze wijzigingen dus in alle klassen nog steeds beschikbaar.

Maar soms wil je class methods(5) toevoegen. Dit gaat met de methode extend:

module HelloWorld
  def hello
    "hello world"
  end
end
 
class World
  extend HelloWorld
end
 
World.hello # => "hello world"

Dit werkt vergelijkbaar met include, maar deze zal op het object zelf gelden waar het op geëxtend wordt. Het object waarin dat gebeurt is World, een object van de klasse Class. De klasse zelf wordt dus uitgebreid met een anonieme klasse. Als we deze “Y” noemen, ziet dat er als volgt uit:

module HelloWorld
  def hello
    "hello world"
  end
end
 
class Y < Class
  include HelloWorld
end
 
World = Y.new # een nieuwe instantie van Class is een klasse
World.hello # => "hello world"

Als dit nieuw voor je is, is dit behoorlijk pittig om door te krijgen. Onderaan deze post staan een aantal links, als je meer wilt lezen over hoe modules in elkaar te zitten. Om verder te gaan met deze blogpost is het verder voornamelijk belangrijk om te weten dat je met include instance methods toevoegt en met extend class methods toevoegt.

Hook methods

Als we aan een klasse zowel class methods als instance methods willen toevoegen zouden we zowel include en extend moeten gebruiken. Dat zou dan als volgt gaan:

module HelloWorld
 
  module ClassMethods
    def hello
      "hello from every world"
    end
  end
 
  def goodbye
    "goodbye from this particular world"
  end
 
end
 
class World
  include HelloWorld
  extend HelloWorld::ClassMethods
end
 
World.hello # => "hello from every world"
World.new.goodbye # => "goodbye from this particular world"

Dit werkt prima, maar is wat vervelend. Er zijn twee regels nodig om alle functionaliteit van de module HelloWorld te krijgen. Dit kan makkelijk vergeten worden als iemand anders deze module gebruikt. Gelukkig biedt Ruby handige “hook methods”.

Voor include is er de methode included. Vanuit deze methode zouden we de extend kunnen uitvoeren zodat dit niet meer in onze klasse gedaan hoeft te worden.

module HelloWorld
 
  def self.included(klasse)
    klasse.extend(ClassMethods)
  end
 
  module ClassMethods
    def hello
      "hello from every world"
    end
  end
 
  def goodbye
    "goodbye from this particular world"
  end
 
end
 
class World
  include HelloWorld
end
 
World.hello # => "hello from every world"
World.new.goodbye # => "goodbye from this particular world"

Er bestaat ook de methode extended, die vergelijkbaar werkt voor de methode extend.

Macros

Na deze enorme berg aan theorie wordt het hoog tijd om eens naar wat praktische toepassingen te gaan kijken. Van Ruby on Rails kennen we de declaratieve methodes om eigenschappen van een klasse te definiëren. Iedereen die weleens naar Ruby on Rails heeft gekeken heeft weleens zulke code gezien:

class Post < ActiveRecord::Base
  belongs_to :author
  has_many :comments
  validates_presence_of :title
end

De methoden belongs_to, has_many en validates_presence_of worden ook wel macro’s genoemd. Dit is niks meer dan het aanroepen dan class methods, die in dit geval bestaan omdat Post inherit van de klasse ActiveRecord::Base. Dit zou er zo uit kunnen zien:

class ActiveRecord::Base
  def self.belongs_to(model_name, options = {})
    # ...
  end
end

Dit kunnen we ook met modules. Stel dat we read only velden willen definiëren.

module ReadonlyFields
 
  def self.included(klasse)
    klasse.extend(ClassMethods)
  end
 
  module ClassMethods
 
    attr_accessor :_readonly_fields
 
    def readonly_fields(*fields)
      self._readonly_fields = fields
    end
 
  end
 
end
 
class Post < ActiveRecord::Base
  include ReadonlyFields
  readonly_fields :title, :author_id
end

De asterisk in de parameterlijst van de methode readonly_fields geeft aan dat alle argumenten in een array gestopt worden. Deze array wordt opgeslagen in de klasse (Post dus). Maar goed, ze zijn nu dus nu geregistreerd, maar laten we nu de implementatie als voorbeeld er nog eens bij geven.

We moeten het model een error laten geven als een van deze velden veranderen. ActiveRecord houdt voor ons bij wat de verschillen zijn, dus kunnen we makkelijk zien of de waarden veranderd zijn. ActiveRecord heeft een methode genaamd changes die een hash met veranderde veldnamen en de daadwerkelijke verandering teruggeeft. We zijn alleen geinteresseerd in de keys, de veldnamen.

We registreren tijdens de include een ActiveRecord hook method, genaamd validate_on_update waarmee extra methodes toegevoegd kunnen worden zodra het object opgeslagen wordt. Ik gebruik validate_on_update omdat de waarde in eerste instantie wel opgeslagen moet kunnen worden.

module ReadonlyFields
 
  def self.included(klasse)
    klasse.extend(ClassMethods)
    klasse._readonly_fields = []
    klasse.validate_on_update :readonly_fields_cannot_change
  end
 
  module ClassMethods
 
    attr_accessor :_readonly_fields
 
    def readonly_fields(*fields)
      self._readonly_fields = fields
    end
 
  end
 
  def readonly_fields_cannot_change
    changes.each_key do |field|
      if self.class._readonly_fields.include? field.to_sym
        errors.add(field, :readonly)
      end
    end
  end
 
end

Natuurlijk is er nog wat te verbeteren in deze code, maar voor deze demonstratie laat het aardig zien wat er mogelijk is.

Afronding

We gaan het nog even afmaken. Eerst willen we dat we niet in elk model de ReadonlyFields-module hoeven toe te voegen. Dit kan door de module te includen in ActiveRecord::Base zelf. Zet daarom deze code in een initializer van Rails, zodat deze uitgevoerd wordt zodra Rails start.

ActiveRecord::Base.instance_eval { include ReadonlyFields }

Helaas is include een private method. Dat is te omzeilen door bijvoorbeeld instance_eval of send te gebruiken. Hoe dit werkt valt buiten de scope van deze blogpost.

Laten we nog wat tests schrijven, in RSpec wel te verstaan:

describe ReadonlyFields do
 
  before do
    @post = Post.create!(:title => "foo")
  end
 
  it "should not allow updating the title" do
    @post.title = "bar"
    @post.should have(1).error_on(:title)
  end
 
  it "should allow updating without changing the title" do
    @post.title = "foo"
    @post.should have(:no).errors_on(:title)
  end
 
end

Conclusie

Modules in Ruby zijn erg krachtig. Ze maken het erg gemakkelijk code te delen tussen verschillende klassen en objecten. Modules kunnen veel meer dan alleen methoden toevoegen en dingen in een namespace plaatsen, maar kunnen ook op zeer essentiele wijze de werking van een klasse beinvloeden. Het is dus van groot belang dat je ze goed test.

Gooi brokken code dus eens in een module, verwerk ze tot gem of plugin en deel ze eens met de wereld en/of met je collega’s. Deel eens wat vaak voorkomende validaties bijvoorbeeld. Go wild!

Meer informatie

Footnotes

  1. Niks is ooit echt constant in Ruby.
  2. In Ruby wordt het resultaat van het laatste statement als return waarde gebruikt. Explicite returns zijn dus zelden nodig.
  3. Het is netter om inheritance te gebruiken voor dingen die ook echt een subsoort zijn van de superklasse. Een mens is een subsoort van zoogdier dus daar zou inheritance gerechtvaardigd zijn. Maar als er verder geen reden is om zoogdieren te instantieren, en je het alleen introduceert om functionaliteit te delen met andere zoogdieren, wordt dat in Ruby als slecht design ervaren. In Ruby zijn er geen abstracte klassen.
  4. Voor de niet-rubyisten: extends is in Ruby het kleiner-dan-teken. Je kan in Ruby modules en klassen ook echt met elkaar vergelijken. class A < B; end geeft inderdaad aan dat A < B en B > A en natuurlijk A == A.
  5. Class methods heten in Java static methods.

3 reacties »

  1. Really good work about this website was done. Keep trying more – thanks!

    Yahoouj February 23, 2010 2:02

  2. [...] Veel mogelijkheden om code te herbruiken (zie bijv. mijn post over modules) [...]

    Qu’est-ce que c’est Rubyesque? | Finalist Developers Blog April 12, 2010 11:06

  3. [...] Als je niet precies weet wat mixin’s in Ruby zijn, lees er dan meer over in de blogpost van Iain Hecker over mixins: blog.finalist.nl/2009/07/13/magie-met-modules/#more-852 [...]

    DSL’s en Ruby | Finalist Developers Blog July 23, 2010 9:58

Reageer

RSS feed for comments on this post · TrackBack URI