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