Gilliek
5avr/124

La Metaprogrammation en Ruby

Voici (enfin !) mon premier billet en 2012 ! L'année passée, je vous avais promis quelques articles techniques (notamment sur la programmation). Aujourd'hui, c'est chose promise chose due : on va parler de métaprogrammation en Ruby.

Cet article sera accompagné d'un autre qui traitera de la fonction yield(). Et ces deux articles précéderont une série d'autres à propos du célèbre framework Ruby : Ruby On Rails.

Qu'est-ce que la metaprogrammation ?

J'imagine que beaucoup d'entre-vous ont ouvert de grands yeux à la lecture de ce mot barbare : "la métaprogrammation". En fait, cela n'a rien de bien compliqué : La métaprogrammation consiste à écrire des programmes qui décrivent eux-mêmes des programmes. Autrement dit, ça permet (entre autres) de générer du code directement au runtime. Comme vous allez pouvoir le constater, c'est vraiment très puissant.

La définition de Wikipedia énumère les différentes façons de procéder :

  • l'utilisation de générateur de code (ce que j'ai mentionné juste avant)
  • la programmation avec des templates
  • l'utilisation d'un protocole à méta-objets
  • l'utilisation de macros.

A quoi peut bien servir la metaprogrammation ?

La métaprogrammation peut servir à grandement simplifier la lecture du code (notamment via le principe Don't Repeat Yourself) ou dans le cas de Rails à simplifier la vie du développeur.

Deux petits exemples pour la route !

Admettons que l'on fasse un programme qui gère une base de données client et qui manipule donc plein de chaînes de caractères (String) qui peuvent signifier plein de choses (un numéro de téléphone, une adresse email, un numéro client, etc.). Nous aimerions qu'un objet String dispose de méthodes telephone?, email?, no_client? ... et peut-être même d'autres plus tard ! Comment faire ? Vous vous doutez bien de la réponse : avec la métaprogrammation !

Voici le code : (je vous expliquerai juste après son fonctionnement, donc pas de panique ;-) )

  #!/usr/bin/env ruby

class String
        @@string_checks = {
        'phone?' => %r{^0[\d]{2}\/[\d]{3}\.[\d]{2}\.[\d]{2}$},
            'email?' => %r{^.+@.+\.[a-z]{2,4}$},
            'no_client?' => %r{^[A-Z]{2}[\d]{4}-[\d]{4}-[\d]{4}$}
        }
        def method_missing(method_id)
                kind_of_string = method_id.to_s
                if @@string_checks.has_key?(kind_of_string)
                        if self =~ @@string_checks[kind_of_string]
                                true
                        else
                                false
                        end
                else
                        raise NoMethodError,
                                "Undefined method #{method_id} for \"#{self}\":String"
                end
        end
end

def humanize(bool)
        bool ? "Yes !" : "No !"
end

begin
        # Valid
        tel = "022/123.45.67".phone?
        email = "foo@foo.com".email?
        no_client = "CH1234-4242-4242".no_client?

        puts "Is 022/123.45.67 a valid phone number ? #{humanize(tel)}"
        puts "Is foo@foo.com a valid email address ? #{humanize(email)}"
        puts "Is CH1234-4242-4242 a valid client number ? #{humanize(no_client)}"

        puts "#################################################"

        #Invalid
        tel = "0033/123.12.12.12".phone?
        email = "www.foo.com".email?
        no_client = "42".no_client?

        puts "Is 0033/123.12.12.12 a valid phone number ? #{humanize(tel)}"
        puts "Is www.foo.com a valid email address ? #{humanize(email)}"
        puts "Is 42 a valid client number ? #{humanize(no_client)}"
end

Alors qu'est-ce que j'ai fait ?

Vous noterez tout d'abord que je définis une classe String qui est en fait une classe prédéfinie. En effet, en Ruby il est tout à fait possible de rajouter des choses dans les classes prédéfinies.

Il faut ensuite faire attention à la méthode method_missing que j'ai ajouté à la classe String. Que fait cette méthode ? Et bien si vous appelez une méthode non définie d'un objet String, Ruby va jeter un oeil dans cette méthode pour voir si on y définit quand même la méthode.  Et c'est justement là-dessus que je joue en récupérant l'id de la méthode et cherchant l'expression régulière correspondante au nom de la méthode appelée.

Après, je lève simplement une exception NoMethodFound si la méthode appelée n'est pas correct.

Finalement, je fais une méthode humanize ... qui n'a rien de spéciale :-) Elle ne fait que mettre en forme un booléan. Le reste n'est ensuite que des tests que vous pouvez vous amusez à lancer.

N'est-ce pas beau de pouvoir simplement appeler "foo@foo.com".email? pour vérifier s'il s'agit bien d'un email valide ?

Ainsi, il va gérer la méthode dynamiquement et directement au Runtime :-) De plus, si on veut par la suite ajouter d'autres vérifications pour une String, il suffit d'ajouter ce dont on a besoin dans la hash table @@string_checks.

Il y a également d'autres façons d'utiliser la métaprogrammation. On peut générer des méthodes sous forme de chaînes de caractères et utiliser la méthode class_eval pour l'évaluer. Regardons ça avec un petit exemple.

On aimerait créer une méthode attr_accessor_with_backup qui déclarerait un attribut et ses accesseurs correspondant (getter et setter) et qui garderait un backup de la valeur précédente.

 #!/usr/bin/env ruby

class Class
        # Store a backup of the previous value in attr_name_backup
        def attr_accessor_with_backup(attr_name)
          # Make sure it's a String
          attr_name = attr_name.to_s
          # Create a getter for attr_name and attr_name_backup
          attr_reader attr_name
          attr_reader attr_name+"_backup"
          # Generate setter for attr_name
          class_eval %Q{
                  def #{attr_name}=(value)
                        # backup previous value
                        @#{attr_name}_backup = #{attr_name}
                        # set the new value
                        @#{attr_name} = value
                  end
          }
        end
end

class WeNeedBackup
        attr_accessor_with_backup :foo
end

begin
        spock = WeNeedBackup.new
        spock.foo = "42"
        puts "foo => #{spock.foo} and foo_backup => #{spock.foo_backup}"
        spock.foo = "24"
        puts "foo => #{spock.foo} and foo_backup => #{spock.foo_backup}"
        spock.foo = "128"
        puts "foo => #{spock.foo} and foo_backup => #{spock.foo_backup}"
end

Cette fois on ajoute une méthode attr_accessor_with_backup dans la classe Class (classe dont toutes les classes Ruby sont des sous-classes), qui, rappelons-le, fait exactement comme la méthode prédéfinie attr_accessor en plus de stocker un backup de la valeur précédente de l'attribut.

Dans cette méthode on va alors créer des getters pour le nom de l'attribut (attr_name)  et son backup (attr_name_backup) qui contiendra la valeur précédente de attr_name.

Ensuite, il va nous falloir définir dynamiquement un setter pour l'attribut. On va donc utiliser la méthode class_eval et lui donner en argument une chaîne de caractères contenant le code de notre setter. Cette méthode s'occupera de sauvegarder la valeur courante de l'attribut dans l'attribut _backup et de mettre à jour la valeur de l'attribut.

Finalement, notre méthode attr_accessor_with_backup va générer les méthodes nécessaires directement au runtime.  N'est-ce pas monstrueusement puissant ?

Conclusion

Vous l'aurez sans doute constaté, la métaprogrammation permet de faire des tas de choses bien pratiques et peut ainsi simplifier la vie du développeur.

Le framework Rails utilise la métaprogrammation pour simplifier l'écriture du code par le développeur. Par exemple, il génère des helpers pour gérer les chemins aux ressources. Par exemple, pour accéder au controller products, il suffira d'appeler products_path ou products_url si on veut un chemin absolu. Si on veut accéder à l'action new du controller products, il suffira d'appeler new_product_path ou new_product_url. Ne vous inquiétez pas, nous reviendrons dessus beaucoup plus en détail très prochainement lorsque nous aborderons Rails.

J'espère que ce billet vous aura été utile ;-)

Commentaires (4) Trackbacks (0)
  1. Merci pour cet article !
    La Metaprogrammation est un outil très puissant, mais également parfois dangereux car ça le code devient plus complexe et ça ajoute de la « magie ». Certains framework comme Rails en abuse (avec grand succès) mais du coup, le framework devient un peu « magique ». Malheureusement il est impossible de faire déjà du code « normal » sans bug, et c’est encore plus vrai lorsque l’on utilise la Metaprogrammation (là je me retient une quote d’once Theo de Raadt sur la virtualisation x86) ! Morale de l’histoire, il faut l’utiliser sagement, quand vraiment le coût (en performance et complexité de code) est justifié.

    Pour compléter ton premier exemple, je me permet de mettre un petit bout de code inspiré:

    #!/usr/bin/env ruby

    class Example
    @@some_static_variable_name = {
    :bang => 'BANG!'
    }
    def method_missing name
    if @@some_static_variable_name.has_key?(name)
    puts @@some_static_variable_name[name]
    else
    raise NoMethodError, "Undefined method #{name} for \"#{self}\":#{self.class}"
    end
    end
    end

    class Better
    def method_missing name
    case name
    when :bang
    puts 'BANG!'
    else
    super
    end
    end
    end

    class EvenBetter
    define_method(:bang) do
    puts 'BANG!'
    end
    end

    Example.new.bang # display "BANG!"
    Better.new.bang # display "BANG!"
    EvenBetter.new.bang # display "BANG!"
    puts Example.new.respond_to?(:bang) # false
    puts Better.new.respond_to?(:bang) # false
    puts EvenBetter.new.respond_to?(:bang) # true

    La class Example est similaire à ce que tu as fait à String.

    La classe Better améliore l’implémentation de cette fonctionalité en deux points. Le premier est qu’elle n’utilise pas de variable statique. Ajouter de nouvelles variables statique à une classe prédéfinie est un peu dangereux, car on risque un clash (avec par exemple, un framework qui utiliserait le même nom de variable statique). Il faut donc le faire qu’en cas de nécessité absolue (à mon sens). Dans ce cas, utiliser un préfix spécifique à ton application (ou bibliothèque) minimise un peu les risques. La deuxième amélioration est qu’elle utiliser « super » pour déléguer l’appel à method_missing() plutôt que de choisir de lever une exception. C’est mieux car dans le cas où method_missing() à été définie dans la class parent de celle où tu ajoutes cette fonctionalité, elle sera complètement ignorée si tu choisis de lever toi-même l’exception.

    La classe EvenBetter améliore encore l’implémentation car elle définie une vraie méthode, plutôt que d’utiliser la magie de Object#method_missing(). Cela permet à la magie de Object#defined_method?() (et d’autres comme Object#methods() ou Object#method()) de fonctionner.

    Merci et continue !

    • [...] oncle* Theo de Raadt. [...]

      Sinon je viens de remarquer qu’en plus, l’implémentation de la class EvenBetter est aussi plus concise et donc simple à comprendre, ce qui est un point assez important à mon sens.

      C’est tout ! :D

    • Pour m’étaler encore plus, l’utilisation de method_missing() est plus judicieuse lorsque tu ouvre tout un « champ » de nouvelle method. À ce moment tu vas étudier le nom de la méthode afin de définir le résultat. Par exemple:

      #!/usr/bin/env ruby

      class Saying
      def method_missing(name)
      if name.to_s =~ /^say_(.+)$/ then $1 else super end
      end
      end

      puts Saying.new.say_hello # display "hello"

      Cet exemple n’est pas réalisable à coup de define_method().

      • Merci pour tes commentaires :-D

        Oui en effet, il faut y aller parcimonieusement avec la metaprogrammation.

        Et merci pour tes exemples. Je complèterai un peu cet article à l’occasion ;-)


Leave a comment

(required)

Aucun trackbacks pour l'instant