Au menu du jour : {R6} — Partie 2

Vous avez lu avec attention notre premier billet sur R6, et vous en voulez encore plus ? C’est normal, nous vous avions promis de continuer à vous parler du package le plus installé de l’univR.

Le programme du jour ouvre l’appétit, donc asseyez-vous et venez découvrir nos plats : clonage et héritage, classes portables et non portables, et données partagées.

Le clonage

Pour créer un nouvel objet {R6}, vous appelez $new() après le nom de la classe, nous avons déjà vu cela dans notre premier billet. Une fois un nouvel objet instancié avec new, vous pouvez créer une copie de cet objet, grâce à la méthode clone(), qui peut se faire de manière classique, ou avec le paramètre deep=TRUE : nous verrons juste après la différence entre ces deux méthodes.

Revenons à notre classe définie dans notre post précédent : team.

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

Une belle classe, cela va sans dire (oui, une classe qui a de la classe). Nous avons un objet thinkr, qui est une instance de team. Maintenant, la grande question : comment créer un nouvel objet depuis un objet déjà instancié ? On pourrait tout simplement penser à la méthode classique, avec un <-.

thinkr_bis <- thinkr
# Changeons le téléphone
thinkr_bis$Phone <- "01.02.03.04.05"
# Retour sur le premier objet
thinkr$Phone
[1] "01.02.03.04.05"

Eh oui, souvenez-vous : la copie des objets {R6} se fait par référence, pas par valeur. Ici, thinkr et thinkr_bis pointent vers le même objet, donc changer un élément de l’un modifie également l’autre. Alors, comment effectuer une copie par valeur ? Direction la méthode clone.

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

thinkr_bis <- thinkr$clone()
thinkr_bis$Phone <- "01."
`Le format du numéro de téléphone est incorrect`
# Oops 

thinkr_bis$Phone <- "01.01.01.01.01"
thinkr_bis$Phone
[1] "01.01.01.01.01"

thinkr$Phone
[1] "01.85.09.14.03"

Ici, il s’agit bien d’un nouvel objet, qui a les mêmes comportements que son clone : on voit par exemple que le changement de numéro reste contrôlé. À noter également : les éléments changés dans thinkr_bis n’affecteront pas thinkr.

Du moins… parce que nous avons cloné une instance qui ne contient que des fonctions et des données. Si notre objet contient d’autres instances {R6}, la musique va être différente :

Team1 <- R6Class("Team1", public = list(mail = "[email protected]"))

TeamClone<- R6Class("TeamClone",
  public = list(
    teambis = NULL,
    initialize = function() self$teambis <- Team1$new()
  )
)

t1 <- TeamClone$new()
t2 <- t1$clone()

t1$teambis$mail
[1] "[email protected]"
t2$teambis$mail
[1] "[email protected]"

t2$teambis$mail <- "[email protected]"
t2$teambis$mail
[1] "[email protected]"
t1$teambis$mail
[1] "[email protected]"

Ici, mail fait référence au même environnement R6 en mémoire. C’est pourquoi le remplacer dans une instance le transforme aussi dans l’autre. Pour empêcher ce comportement, c’est-à-dire effectuer une copie par valeur également de l’élément R6, il faut utiliser la méthode clone(deep = TRUE).

t1 <- TeamClone$new()
t2 <- t1$clone(deep = TRUE)

t1$teambis$mail
[1] "[email protected]"
t2$teambis$mail
[1] "[email protected]"

t2$teambis$mail <- "[email protected]"
t2$teambis$mail
[1] "[email protected]hinkr.fr"
t1$teambis$mail
[1] "[email protected]"

À savoir sur cette méthode clone :

  • Le deep clone ne copie que les éléments R6 (en plus des fonctions et données). Pour copier également les environnements et autres éléments avec références sémantiques, il est indispensable de passer par une méthode personnalisée.
  • Il est possible d’empêcher la « clonabilité » (non, ce mot n’existe pas) de votre objet. Pour cela, il suffit d’ajouter `clonable = FALSE` à votre classe.
  • La méthode `clone` ajoute 49.1 KB à la classe, mais seulement 112 bytes à chaque nouvel objet. Ce qui reste raisonnable en termes d’espace.

L’héritage

Ll’héritage est un concept clé de la programmation orientée objet (mais vous le saviez déjà , parce que vous avez lu notre billet sur le sujet 😉 ). Vous vous doutez que {R6} ne déroge pas à la règle.

Maintenant, on se dit qu’on aimerait bien créer des sous-classes, par exemple une pour définir les membres de l’équipe. Bien sûr, sans faire de copier-coller : certains champs peuvent être partagés par tout le monde, donc autant en profiter !

Pour définir l’héritage, rien de plus simple : nous avons besoin de… inherit = NomDeLObjet !

member <- R6Class("Member", 
                  inherit = team)

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

# Et maintenant, nous pouvons utiliser la fonction contact() définie dans `team`

colin$contact()
             Mail          Phone
1 [email protected] 01.85.09.14.03
`Classe créée par ThinkR`

Le plus étant que les classes enfants peuvent, bien sûr, contenir des éléments supplémentaires, voire supplanter des méthodes des classes parentes.

member <- R6Class("Member", 
                  inherit = team,
                  public = list(
                    contact = function(){
                      print(data.frame(Mail = private$privMail, 
                                       Phone = private$privPhone, 
                                       TwitterTeam = private$privTwitterTeam))
                      message(private$privCopyright)
                    }, 
                    initialize = function(){
                     print("Hello !") 
                    }
                  ), 
                  private = list(
                    privTwitterTeam = "@thinkr_fr",
                    privCopyright = "Classe créée par ThinkR"
                  ), 
                  active = list(
                    TwitterTeam = function(){
                      private$privTwitterTeam
                    }
                  )
)
colin <- member$new()
[1] "Hello !"
colin$TwitterTeam
[1] "@thinkr_fr"

# Mais 
colin$Phone
NULL
# Et pourtant 
colin$Phone <- "01.85.09.14.03"
colin$Phone
[1] "01.85.09.14.03"

Eh oui, ici, notre fonction initialize enfant a pris le dessus sur celle de notre classe parent. En clair : lorsqu’on fait appel à une méthode ou un objet d’une classe, R va chercher dans le scope de votre objet, l’exécuter s’il le trouve, et remonter l’arbre d’héritage si et seulement si il ne trouve pas ce que vous chercher dans l’objet enfant.

Nous sommes ici face à un problème : notre fonction initialize prend le dessus sur la fonction de la classe parente. Autrement dit, impossible d’initialiser avec la méthode de la classe team.

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

Error in .subset2(public_bind_env, "initialize")(...) : 
  arguments inutilisés (Mail = "[email protected]", Phone = "01.85.09.14.03")

Impossible dites-vous ? Pas avec l’utilisation de super$.

Destiné à la gestion de l’héritage, ce symbole accède aux fonctions ou données de la classe parente. Par exemple, nous pouvons « pimper » la classe member pour qu’elle utilise la méthode de sa classe mère. Profitons-en aussi pour ajouter des champs supplémentaires.

member <- R6Class("Member", 
                  inherit = team,
                  public = list(
                    contact = function(){
                      print(data.frame(Mail = private$privMail, 
                                       Phone = private$privPhone, 
                                       TwitterTeam = private$privTwitterTeam, 
                                       TwitterPerso = private$privTwitterPerso))
                      message(private$privCopyright)
                    }, 
                    initialize = function(Mail, Phone, Twitter){
                      print("Hello !")
                      super$initialize(Mail, Phone)
                      private$privTwitterPerso <- Twitter
                    }
                  ), 
                  private = list(
                    privTwitterTeam = "@thinkr_fr",
                    privCopyright = "Classe créée par ThinkR", 
                    privTwitterPerso = NULL
                  ), 
                  active = list(
                    TwitterTeam = function(){
                      private$privTwitterTeam
                    }, 
                    TwitterPerso = function(value){
                      if (missing(value)) return(private$privTwitterPerso)
                      else if (stringi::stri_detect_regex(str = value, pattern = "^@")) {
                        private$privTwitterPerso <- value
                      } else {
                        message("Erreur : le compte Twitter doit commencer par un @")
                      }
                    }
                  )
)

Testons donc un peu tout ça

colin <- member$new(Mail = "[email protected]", Phone = "01.85.09.14.03", Twitter = "@_colinfay")
[1] "Hello !"

colin$contact()
             Mail          Phone TwitterTeam TwitterPerso
1 [email protected] 01.85.09.14.03  @thinkr_fr   @_colinfay
Classe créée par ThinkR

colin$TwitterPerso <- "_colinfay"
Le compte Twitter doit commencer par un @

Bien, tout ça a l’air dans l’ordre !

Héritage multiple

Bon, puisqu’on y est, autant pousser le bouchon encore plus loin. Comment créer une chaine d’héritage ? C’est-à-dire, comme créer un grand-parent, un parent et un enfant, et que le plus jeune du lot puisse avec accès au plus âgé ?

Admettons que nous souhaitions une nouvelle classe qui contienne le compte Twitter de la team (donc qui héritera de la classe member), mais sans avoir à spécifier à chaque fois le compte Twitter perso (obligatoire dans la classe member). Une classe qui utilise pour cela la fonction initialize de la classe team, tout en conservant les données de member. En clair, nous avons besoin d’hériter de tous les éléments de la classe member, sauf de la fonction initialize, qui est celle de la classe team. Alors, on fait super$super$, non ?

light_member <- R6Class("LightMember", 
                         inherit = member, 
                         public = list(
                           initialize = function(Mail, Phone){
                             super$super$initialize(Mail, Phone)
                           }
                         ))

colin_light <- light_member$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
Error in .subset2(public_bind_env, "initialize")(...) : 
  tentative d'appliquer un objet qui n'est pas une fonction

Eh non ! Par défaut, les classes {R6} on seulement accès aux éléments de leurs parents directs. Cependant, chaque classe peut exposer son parent, en passant par un active binding.

Interlude : enrichir une classe

Nous devons enrichir notre classe team d’une nouvelle méthode d'active binding. Comment ça marche ? Prenons un exemple plus simple : ajouter un élément à la liste publique. Pour cela, nous devons faire appel à la méthode $set(), qui permet d’ajouter, ou de remplacer (avec overwrite=TRUE), un élément d’une classe déjà définie.

team$set("public", "web", function() print("https://thinkr.fr"))

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
thinkr$web()
[1] "https://thinkr.fr"

colin <- member$new(Mail = "[email protected]", Phone = "01.85.09.14.03", Twitter = "@_colinfay")
[1] "Hello !"
colin$web()
[1] "https://thinkr.fr"

Interlude : off

Ainsi donc, nous pouvons ajouter un élément à la liste active de notre classe mère (member), qui viendra exposer son parent (team). Par convention, on utilise super_, qui est une fonction qui renvoie tout simplement super.

member$set("active", "super_", function() super)

light_member <- R6Class("LightMember", 
                         inherit = member, 
                         public = list(
                           initialize = function(Mail, Phone){
                             super$super_$initialize(Mail, Phone)
                           }
                         ))
colin_light <- light_member$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
colin_light$Mail
[1] "[email protected]"
colin_light$Phone
[1] "01.85.09.14.03"
colin_light$TwitterTeam
[1] "@thinkr_fr"
colin_light$TwitterPerso
NULL

# En cas de besoin 
colin_light$TwitterPerso <- "_colinfay"
`Erreur : le compte Twitter doit commencer par un @`
#Oops 
colin_light$TwitterPerso <- "@_colinfay"
colin_light$TwitterPerso
[1] "@_colinfay"

Classe portables et non portables

Par défaut, {R6} crée des classes portables entre packages. Autrement dit, une classe créée dans un package sera accessible dans un autre, grâce au concept de portabilité. Derrière ce nom un brin barbare, on retrouve tout simplement l’idée que, lorsqu’une classe hérite d’une superclasse, elle hérite également de son environnement : la méthode est donc exécutée dans l’environnement parent, pas dans l’environnement enfant.

Pour empêcher cette fonctionnalité, il suffit d’indiquer portable=FALSE dans la classe. Pourquoi l’empêcher ? Une classe non portable permet de ne pas avoir à utiliser self pour faire référence aux objets internes. Les classes non portables affichent également des performances légèrement meilleures en termes d’exécution (cela dit, nous parlons ici de quelques microsecondes).

classe_non_portable <- R6Class("NonPort", 
                               portable = FALSE, 
                               public = list(
                                 message = "Pas besoin de self", 
                                 initialize = function(){
                                   print(message)
                                 }
                               )
)

classe_non_portable$new()
[1] "Pas besoin de self" 

classe_portable <- R6Class("Port", 
                               portable = TRUE, 
                               public = list(
                                 message = "Besoin de self", 
                                 initialize = function(){
                                   print(self$message)
                                 }
                               )
)
classe_portable$new()
[1] "Besoin de self"

Et en termes de performance ?

library(microbenchmark)
mb <- microbenchmark(classe_non_portable$new(), classe_portable$new(), times = 10000)
mb
Unit: microseconds
                      expr    min      lq     mean  median       uq      max neval cld
 classe_non_portable$new() 64.568 69.6985 102.6591 76.4940 112.9245 2659.403 10000  a 
     classe_portable$new() 66.352 71.5030 105.8632 78.4305 110.4635 3568.485 10000   b
autoplot(mb)

Oui, nous parlons vraiment ici de microsecondes…

Partager des données entre classes

Bien bien bien. Et sinon, comment partager des données entre toutes les classes ? Car oui, nous avons vu que les classes enfants héritaient de leurs parents, et pouvaient spécifier des éléments supplémentaires ou en modifier. Mais comment cela se passe si nous voulons changer un élément dans toutes les instances de classes, sans avoir à remonter l’arbre pour changer à chaque fois ? Pour cela, il suffit de créer un environnement dans une méthode shared, et de partager cet environnement avec toutes les instances de la classe.

Ici, par exemple, nous allons rendre l’élément web accessible à tous, mais aussi modifiable.

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", 
                  shared = {
                    env <- new.env()
                    env$web <- "https://thinkr.fr"
                    env
                  }
                ), 
                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")
                    }
                  }, 
                  web = function(value){
                    if (missing(value)) return(private$shared$web)
                    else  {
                      private$shared$web <- value
                  }
                  }
                )
)

thinkr <- team$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
colin <- member$new(Mail = "[email protected]", Phone = "01.85.09.14.03", Twitter = "@_colinfay")
colin_light <- light_member$new(Mail = "[email protected]", Phone = "01.85.09.14.03")

thinkr$web
[1] "https://thinkr.fr"
colin$web
[1] "https://thinkr.fr"
colin_light$web
[1] "https://thinkr.fr"

colin_light$web <- "https://thinkr.fr/le-blog/"
thinkr$web
[1] "https://thinkr.fr/le-blog/"
colin$web
[1] "https://thinkr.fr/le-blog/"
colin_light$web
[1] "https://thinkr.fr/le-blog/"

Easy peasy, n’est-ce pas ?

Impression customisée

Enfin (et ça sera tout pour aujourd’hui), il est possible de customiser le comportement de votre objet lorsque vous tapez son nom dans la console : ce qui est, rappelons-le, l’équivalent de taper print(objet). Pour ce faire, vous pouvez spécifier une méthode print au sein de votre objet.

# Comportement par défault
colin_light
<LightMember>
  Inherits from: <Member>
  Public:
    clone: function (deep = FALSE) 
    contact: function () 
    finalize: function () 
    initialize: function (Mail, Phone) 
    Mail: active binding
    Phone: active binding
    super_: active binding
    TwitterPerso: active binding
    TwitterTeam: active binding
    web: active binding
  Private:
    privCopyright: Classe créée par ThinkR
    privMail: [email protected]
    privPhone: 01.85.09.14.03
    privTwitterPerso: NULL
    privTwitterTeam: @thinkr_fr
    shared: environment

# Comportement customisé 
light_member <- R6Class("LightMember", 
                         inherit = member, 
                         public = list(
                           initialize = function(Mail, Phone){
                             super$super_$initialize(Mail, Phone)
                           }, 
                           print = function(){
                             print(self$Mail)
                             print(self$Phone)
                           }
                         ))

colin_light <- light_member$new(Mail = "[email protected]", Phone = "01.85.09.14.03")
colin_light
[1] "[email protected]"
[1] "01.85.09.14.03"

Bien, on a pas mal bossé aujourd’hui, et nous sommes entrés dans des modalités (très) avancées de la programmation avec {R6}. Il nous restera quelques concepts à voir, mais on en reparlera une autre fois. En attendant, n’hésitez pas à mettre les mains dans la mécanique, et si vous rencontrez des soucis, envoyez nous un tweet !


À propos de l'auteur

Colin Fay

Colin Fay

Data scientist & R Hacker


Commentaires


À lire également