Au menu du jour : {R6} — Partie 1

Chose promise, chose due : il est temps que nous parlions de R6.

Comme nous vous le disions dans notre billet sur useR!2017, {R6} est aujourd’hui le package le plus téléchargé, si l’on se fie aux chiffres de la rdocumentation. À l’heure où nous écrivons ces lignes, le record du « last month download » en détenu par le 20 juin, avec un 46 452 téléchargements en une journée. Oui, rien que ça.

À titre de comparaison, le second package le plus populaire, {XML}, atteint un record de 21 390 sur les trente derniers jours… le 20 juin également (il faut que nous lançions une investigation sur cette coïncidence…). Il faut avouer que, en empruntant ce code à Yihui, on retrouve pas moins de 103 packages qui dépendent de {R6}. Et parmi eux {shiny}, ou encore {dplyr}, qui import le package de Winston Chang à chaque installation. Ce qui peut vite envoyer voler le compteur !

Et donc, promesse de notre billet sur Bruxelles : nous allions approfondir {R6}, et vous en dire plus sur l’un des packages les plus populaires de l’univR. C’est chose faite, et c’est ici que ça se passe !

R6 ?

Avant toute chose, commençons par le commencement : {R6} est un système de programmation orientée objet, et cela peut vous paraître étrangement différent de votre manière habituelle de coder. Vous ne connaissez par encore la POO ? Alors vous avez manqué le coche, mais il est encore temps de lire notre billet sur le sujet 😉

R et la POO

En R, il existe pas moins de 9 systèmes de POO…

  • R5
  • mutatr

Deux projets expérimentaux, qui n’ont pas été poussés jusqu’en production.

  • R.OO

Projet existant depuis plusieurs années, mais qui n’a pas vraiment rencontré le succès.

  • proto

A connu son heure de gloire, parce qu’utilisé dans les premières versions de {ggplot2}.

  • OOP

N’est plus maintenu.

  • S3

Hérité de S (précurseur de R), ce système existe depuis les années 80. Très simple, il n’implémente qu’un seul feature : la possibilité de définir des fonctions qui se changent de comportement selon la classe de l’objet sur lesquelles elles sont appelées. C’est pourquoi, par exemple, summary() renvoie des résultats différents si l’objet est un data.frame, un lm, un vecteur de caractères…

  • S4

Hérité de la 4e version de S. Version enrichie de S3.

  • ReferenceClasses

Système inspiré des langages orientés comme Java. S’est fait voler la vedette par {R6} ces dernières années.

  • R6

R6, jeune prodige de notre liste, partage beaucoup de features avec RC. Pourquoi un nouveau système ? De l’aveu du créateur, R6 a été pensé pour se concentrer sur la performance, la légèreté des objets, ainsi qu’un héritage stable entre packages.

R6 et la référence sémantique

« Quoi ? Qu’est-ce que c’est encore que ce terme capillotracté ? ». Eh bien, lecteur, il s’agit de la façon dont R crée une « copie » des objets. Il existe deux grandes méthodes :

  • copy by value : la valeur de l’objet est dupliquée avec le nouvel objet. Autrement dit, chaque fois que vous copiez un objet par valeur, une nouvelle case est ouverte en mémoire.

  • copy by reference : seulement le pointeur est copié, pas la valeur de l’objet. Tous les pointeurs font référence au même espace en mémoire.

Par défaut, R fait de la copie par valeur. Autrement dit, la valeur est stockée deux fois. Le point positif étant qu’en agissant sur l’élément copié, vous n’écrasez pas l’élément d’origine.

a <- c(1:5)
a[1]
[1] 1

b <- a
b[1] <- 2811
b[1]
[1] 2811 
a[1]
[1] 1

Les environnements, de leur côté, travaillent par copie par référence. Autrement dit, en copiant des environnements, les symboles au sein de chaque copie pointent vers la même valeur en mémoire.

Un concept qui va être plus simple par l’exemple :

# Création d'un nouvel environnement

envnmta <- new.env()

# Création de a dans envnmta
envnmta$a <- LETTERS
envnmta$a[1]
[1] "A"

# Copie de b, se faisant par référence 
envnmtb <- envnmta

# Remplacement du premier élément de a dans envnmtb
envnmtb$a[1] <- "PLOP"
envnmtb$a[1]
[1] "PLOP"

# Que se passe-t-il si l'on jette un oeil à a dans envnmta ?
envnmta$a[1]
[1] "PLOP"

Eh oui, avec les environnements, la copie se fait par référence — donc ici envnmta et envnmtb pointent vers le même espace mémoire qui contient les mêmes valeurs. Agir sur l’un transforme aussi l’autre. L’avantage ? Si vous avez besoin de manipuler de grosses bases de données, elles ne sont inscrites en mémoire qu’une seule fois, pas copiée à chaque nouvelle instanciation d’un objet.

C’est sur cette fonctionnalité de R que repose {R6} : en créant des classes puis des objets qui copient par référence plutôt que par valeur, ce package promet des objets plus légers, et donc du code plus rapide. Tout en reposant sur une écriture simple à prendre en main.

R6, en pratique

Choisir {R6} vous permet de travailler avec des objets qui contiendront à la fois des fonctions et des données. Comme une liste, mais en beaucoup plus structurée, efficace et portable.

Mais bref, assez parlé théorie, passons maintenant à la pratique. Montons petit à petit une classe R6.

Première étape : le nom

Par convention, le nom de votre classe est en « UpperCamelCase ».

library(R6)
tkr <- R6Class("Team")

Et voilà, vous avez votre classe {R6}. Vous pouvez maintenant retourner à vos activités habituelles… Eh non, vous vous en doutez, on ne va pas s’arrêter là 😉

Secundo : public

Une fois votre nom trouvé (et c’est bien souvent le plus dur), nous pouvons entrer dans le vif du sujet.

 

Commençons par la liste des éléments publics, qui peuvent être (comme nous l’avons dit plus haut), des données ou des fonctions.

team <- R6Class("Team", 
               public = list(
                 Mail = "[email protected]", 
                 Phone = "01.85.09.14.03",
                 contact = function(){
                   print(data.frame(Mail = self$Mail, 
                                    Phone = self$Phone))
                 }
               ))

Note : vous avez peut-être remarqué, ici, l’utilisation de self$. Cette appellation est indispensable pour faire référence, à l’intérieur de la classe, aux éléments publics internes (du moins pour les classes portables, mais nous n’entrerons pas dans ces détails aujourd’hui).

Nous voilà maintenant avec une classe R6 (très) basique, qui nous servira de super-classe, ou classe parente, pour créer de nouveaux objets. Comment ? En appelant la méthode $new() après le nom.

thinkr <- team$new()

Ensuite, nous pouvons accéder aux différents éléments / fonctions de la même manière : en utilisant nom_objet $ nom_element. Par exemple :

thinkr$Mail
[1] "[email protected]"

thinkr$contact()
            Mail          Phone
1 [email protected] 01.85.09.14.03

Troisièmement : initialize

Bon, c’est bien, mais pas très portable comme classe : comment faire si je veux créer une nouvelle équipe plutôt que celle de ThinkR ? Avec initialize !

À chaque appelle de la méthode $new(), la fonction contenue dans l’élément initialize, s’il existe, sera exécutée. Il suffit d’instancier des objets Mail et Phone NULL, qui seront remplis par l’utilisateur lors de l’appelle à new().

team <- R6Class("Team", 
                public = list(
                  Mail = NULL, 
                  Phone = NULL,
                  contact = function(){
                    print(data.frame(Mail = self$Mail, 
                                     Phone = self$Phone))
                  }, 
                  initialize = function(Mail, Phone){
                    self$Mail <- Mail
                    self$Phone <- Phone
                  }
                ))

colin <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
colin$contact()
             Mail          Phone
1 [email protected] 01.85.09.14.03

Quatro : private

Jusqu’ici, tout va bien. Mais imaginons qu’un esprit malveillant souhaite « écraser » les données de contact, et insérer un faux mail à la place. Il lui suffira de :

colin$Mail <- "[email protected]"
colin$contact()

                Mail          Phone
1 [email protected] 01.85.09.14.03

Bon, avouons-le, ce n’est pas forcément ce que nous voulons. Une fois l’objet créé, les données doivent pouvoir rester en place, ou être modifiées de manière contrôlée. Aussi, il est possible que votre classe ait besoin d’utiliser des informations spécifiques qui ne doivent pas être accessibles de l’extérieur. C’est un processus qu’on appelle l’encapsulation.

Pour cela, direction private, qui peut tout simplement contenir des informations lors de la définition de la classe, ou être remplie à l’instanciation de l’objet.

team <- R6Class("Team", 
                public = list(
                  contact = function(){
                    print(data.frame(Mail = private$privMail, 
                            Phone = private$privPhone))
                    message(private$privCopyright)
                  }, 
                  initialize = function(Mail, Phone){
                    private$privMail <- Mail
                    private$privPhone <- Phone
                  }
                ), 
                private = list(
                  privMail = NULL, 
                  privPhone = NULL, 
                  privCopyright = "Classe créée par ThinkR"
                ))

# Créons une instance 

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
thinkr$contact()
            Mail          Phone
1 [email protected] 01.85.09.14.03
Classe créée par ThinkR

# Et si l'on tente de changer un élément ? 

thinkr$Mail <- "[email protected]"
Error in thinkr$Mail <- "[email protected]" : 
  impossible d'ajouter des liens à un environnement verrouillé

Eh non, cher pirate, tu ne pourras pas nous voler la vedette ! Mais attendez… si j’ai fait une erreur lors de l’instanciation de l’objet… il faut que je recommence tout ?

Et de cinq : l’active binding

Pour l’instant, impossible de gérer l’affichage et la modification des éléments privés :

thinkr$Mail
NULL

Et c’est fait pour : la partie private est destinée à garder des éléments privés, non accessibles de l’extérieur… ou seulement de manière contrôlée. En effet, vous pouvez gérer l’accès aux éléments privés, pour permettre uniquement la lecture (read-only, ou la lecture et l’écriture. Tout cela, grâce à une méthode portant le doux nom d’active binding.

Que recouvre ce terme ? Nous n’allons pas entrer dans la théorie de ce qu’est le binding avec R, mais résumons le en quelques mots : lorsque vous tapez a <- 5, R ouvre une case en mémoire qui contient 5, et attache (bind) le nom a à cette case. On parle de « static binding ». De son côté, l’active binding fait référence au processus d’attachement d’un non à une valeur non fixe, par exemple au résultat d’une fonction. À chaque évaluation du nom, la fonction est exécutée.

Bref, l’active biding se destine à créer des éléments dans votre classe, que vous construisez comme des fonctions, mais qui se comportent comme des données.

Par exemple :

team <- R6Class("Team", 
                public = list(
                  contact = function(){
                    print(data.frame(Mail = private$privMail, 
                            Phone = private$privPhone))
                    message(private$privCopyright)
                  }, 
                  initialize = function(Mail, Phone){
                    private$privMail <- Mail
                    private$privPhone <- Phone
                  }
                ), 
                private = list(
                  privMail = NULL, 
                  privPhone = NULL, 
                  privCopyright = "Classe créée par ThinkR"
                ), 
                active = list(
                  Mail = function(){
                    private$privMail
                  }, 
                  Phone = function(value){
                    if (missing(value)) return(private$privPhone)
                    else private$privPhone <- value
                  }
                )
                )

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")

Mail et Phone ont ici été définis comme des fonctions. Leur particularité est qu’elles seront appelées comme des objets – ici, thinkr$Mail fait référence à l’appel de fonction Mail() de l’élément active de notre class. Pas besoin de parenthèses.

thinkr$Mail
[1] "[email protected]"
thinkr$Mail <- "Pirate"
Error in (function ()  : argument inutilisé (quote("Pirate"))
          
thinkr$Phone
[1] "01.85.09.14.03"
thinkr$Phone <- "Pirate"
thinkr$Phone
[1] "Pirate"

Mail imprime l’élément privé, mais on ne peut pas le modifier.

Phone est un peu plus complexe : fonction avec un paramètre (par convention value), elle renvoie private$privPhone si on l’appelle simplement. Si on lui assigne une valeur, ici thinkr$Phone <- « Pirate », value prend la valeur assignée, et privPhone est remplacée.

Pourquoi est-ce important ? Parce que vous pouvez ainsi protéger l’accès à vos données privées, en n’autorisant l’écriture qu’à certaines conditions. Imaginons par exemple ici, nous pouvons vérifier que le changement est bien un numéro de téléphone sous le format « chiffres séparés par un point ». Pour cela, nous pourrions utiliser :

 

Phone = function(value){
  if (missing(value)) return(private$privPhone)
  else if (stringi::stri_detect_regex(str = value, pattern = "([0-9]{2}\\.?){5}")) {
    private$privPhone <- value
  } else {
    message("Le format du numéro de téléphone est incorrect")
  }
}

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")

thinkr$Phone
[1] "01.85.09.14.03"
thinkr$Phone <- "Pirate"
`Le format du numéro de téléphone n'est pas correct`
thinkr$Phone <- "00.00.00.00.00"
thinkr$Phone
[1] "00.00.00.00.00"

Et pour finir, finalize

Enfin, pour terminer notre session d’aujourd’hui, la méthode finalize. Comme son nom l’indique, cette méthode se lance lorsque vous effacez votre objet R6. Enfin, du moins, lorsque R lance le garbage collector, ou que vous utilisez la fonction gc().

team <- R6Class("Team",
                public = list(
                  contact = function(){
                    print(data.frame(Mail = private$privMail, 
                                     Phone = private$privPhone))
                    message(private$privCopyright)
                  }, 
                  initialize = function(Mail, Phone){
                    private$privMail <- Mail
                    private$privPhone <- Phone
                  }, 
                  finalize = function() {
                    message("Merci d'avoir utilisé notre classe R6 !")
                  }
                ), 
                private = list(
                  privMail = NULL, 
                  privPhone = NULL, 
                  privCopyright = "Classe créée par ThinkR"
                ), 
                active = list(
                  Mail = function(){
                    private$privMail
                  }, 
                  Phone = function(value){
                    if (missing(value)) return(private$privPhone)
                    else if (stringi::stri_detect_regex(str = value, pattern = "([0-9]{2}\\.?){5}")) {
                      private$privPhone <- value
                    } else {
                      message("Le format du numéro de téléphone est incorrect")
                    }
                  }
                  
                  
                )
)

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
thinkr$contact()
rm(thinkr)
gc()
`Merci d'avoir utilisé notre classe R6 !`

À quoi ça sert ? Pas seulement à afficher un message, vous vous en doutez. Avec cette méthode, vous pouvez par exemple déconnecter une base de données, ou une connexion à une API. À savoir que cette méthode est également appelée lors de la fermeture de votre session R.

Fare thee well, R6

Voilà, nous avons fait un premier tour des fonctionnalités proposées par {R6}. Si vous avez bien retenu le titre, cet article est une première partie. Gardez l’oeil ouvert, bientôt nous aborderons des sujets plus avancés comme : l’héritage parent / enfant / grand-parent, classes portables et non portables, clonage, ou encore données partagées… Oui, le programme est bien rempli 😉


À propos de l'auteur

Colin Fay

Colin Fay

Data scientist & R Hacker


Commentaires


À lire également