14  R6

14.1 Introducción

Este capítulo describe el sistema R6 OOP. R6 tiene dos propiedades especiales:

  • Utiliza el paradigma OOP encapsulado, lo que significa que los métodos pertenecen a los objetos, no a los genéricos, y los llamas como object$method().

  • Los objetos R6 son mutables, lo que significa que se modifican en su lugar y, por lo tanto, tienen semántica de referencia.

Si aprendió programación orientada a objetos en otro lenguaje de programación, es probable que R6 se sienta muy natural y se incline a preferirlo a S3. Resista la tentación de seguir el camino de menor resistencia: en la mayoría de los casos, R6 lo llevará a un código R no idiomático. Volveremos a este tema en la Sección 16.3.

R6 es muy similar a un sistema OOP base llamado clases de referencia, o RC para abreviar. Describo por qué enseño R6 y no RC en la Sección 14.5.

Estructura

  • La Sección 14.2 introduce R6::R6Class(), la única función que necesita saber para crear clases R6. Aprenderá sobre el método constructor, $new(), que le permite crear objetos R6, así como otros métodos importantes como $initialize() y $print().

  • La Sección 14.3 analiza los mecanismos de acceso de R6: campos privados y activos. Juntos, estos le permiten ocultar datos del usuario o exponer datos privados para leer pero no escribir.

  • La Sección 14.4 explora las consecuencias de la semántica de referencia de R6. Aprenderá sobre el uso de finalizadores para limpiar automáticamente cualquier operación realizada en el inicializador y un problema común si usa un objeto R6 como un campo en otro objeto R6.

  • La Sección 14.5 describe por qué cubro R6, en lugar del sistema RC base.

Requisitos previos

Debido a que R6 no está integrado en la base R, deberá instalar y cargar el paquete R6 para usarlo:

# install.packages("R6")
library(R6)

Los objetos R6 tienen semántica de referencia, lo que significa que se modifican en el lugar, no se copian al modificar. Si no está familiarizado con estos términos, repase su vocabulario leyendo la Sección 2.5.

14.2 Clases y métodos

R6 solo necesita una única llamada de función para crear tanto la clase como sus métodos: R6::R6Class(). ¡Esta es la única función del paquete que usará!1

El siguiente ejemplo muestra los dos argumentos más importantes para R6Class():

  • El primer argumento es el classname. No es estrictamente necesario, pero mejora los mensajes de error y permite usar objetos R6 con genéricos S3. Por convención, las clases R6 tienen nombres UpperCamelCase.

  • El segundo argumento, public, proporciona una lista de métodos (funciones) y campos (cualquier otra cosa) que conforman la interfaz pública del objeto. Por convención, los métodos y campos usan snake_case. Los métodos pueden acceder a los métodos y campos del objeto actual a través de self$.2

Accumulator <- R6Class("Accumulator", list(
  sum = 0,
  add = function(x = 1) {
    self$sum <- self$sum + x 
    invisible(self)
  })
)

Siempre debe asignar el resultado de R6Class() a una variable con el mismo nombre que la clase, porque R6Class() devuelve un objeto R6 que define la clase:

Accumulator
#> <Accumulator> object generator
#>   Public:
#>     sum: 0
#>     add: function (x = 1) 
#>     clone: function (deep = FALSE) 
#>   Parent env: <environment: R_GlobalEnv>
#>   Locked objects: TRUE
#>   Locked class: FALSE
#>   Portable: TRUE

Construyes un nuevo objeto a partir de la clase llamando al método new(). En R6, los métodos pertenecen a los objetos, por lo que usa $ para acceder a new():

x <- Accumulator$new() 

A continuación, puede llamar a los métodos y acceder a los campos con $:

x$add(4) 
x$sum
#> [1] 4

En esta clase, los campos y métodos son públicos, lo que significa que puede obtener o establecer el valor de cualquier campo. Más adelante, veremos cómo usar campos y métodos privados para evitar el acceso casual a las partes internas de su clase.

Para que quede claro cuando hablamos de campos y métodos en lugar de variables y funciones, pondré el prefijo $ en sus nombres. Por ejemplo, la clase Accumulate tiene el campo $sum y el método $add().

14.2.1 Encadenamiento de métodos

$add() se llama principalmente por su efecto secundario de actualizar $sum.

Accumulator <- R6Class("Accumulator", list(
  sum = 0,
  add = function(x = 1) {
    self$sum <- self$sum + x 
    invisible(self)
  })
)

Los métodos R6 de efectos secundarios siempre deben devolver self de forma invisible. Esto devuelve el objeto “actual” y hace posible encadenar varias llamadas a métodos:

x$add(10)$add(10)$sum
#> [1] 24

Para facilitar la lectura, puede poner una llamada de método en cada línea:

x$
  add(10)$
  add(10)$
  sum
#> [1] 44

Esta técnica se llama encadenamiento de métodos y se usa comúnmente en lenguajes como Python y JavaScript. El encadenamiento de métodos está profundamente relacionado con la tubería, y discutiremos los pros y los contras de cada enfoque en la Sección 16.3.3.

14.2.2 Métodos importantes

Hay dos métodos importantes que deben definirse para la mayoría de las clases: $initialize() y $print(). No son obligatorios, pero proporcionarlos hará que su clase sea más fácil de usar.

$initialize() anula el comportamiento predeterminado de $new(). Por ejemplo, el siguiente código define una clase de Persona con los campos $name y $age. Para asegurar que $name sea siempre una sola cadena, y $age sea siempre un solo número, puse controles en $initialize().

Person <- R6Class("Person", list(
  name = NULL,
  age = NA,
  initialize = function(name, age = NA) {
    stopifnot(is.character(name), length(name) == 1)
    stopifnot(is.numeric(age), length(age) == 1)
    
    self$name <- name
    self$age <- age
  }
))

hadley <- Person$new("Hadley", age = "thirty-eight")
#> Error in initialize(...): is.numeric(age) is not TRUE

hadley <- Person$new("Hadley", age = 38)

Si tiene requisitos de validación más costosos, impleméntelos en un $validate() separado y solo llame cuando sea necesario.

Definir $print() le permite anular el comportamiento de impresión predeterminado. Como con cualquier método R6 llamado por sus efectos secundarios, $print() debería devolver invisible(self).

Person <- R6Class("Person", list(
  name = NULL,
  age = NA,
  initialize = function(name, age = NA) {
    self$name <- name
    self$age <- age
  },
  print = function(...) {
    cat("Person: \n")
    cat("  Name: ", self$name, "\n", sep = "")
    cat("  Age:  ", self$age, "\n", sep = "")
    invisible(self)
  }
))

hadley2 <- Person$new("Hadley")
hadley2
#> Person: 
#>   Name: Hadley
#>   Age:  NA

Este código ilustra un aspecto importante de R6. Debido a que los métodos están vinculados a objetos individuales, el objeto hadley creado previamente no obtiene este nuevo método:

hadley
#> <Person>
#>   Public:
#>     age: 38
#>     clone: function (deep = FALSE) 
#>     initialize: function (name, age = NA) 
#>     name: Hadley

hadley$print
#> NULL

Desde la perspectiva de R6, no hay relación entre hadley y hadley2; coincidentemente comparten el mismo nombre de clase. Esto no causa problemas cuando se usan objetos R6 ya desarrollados, pero puede hacer que la experimentación interactiva sea confusa. Si está cambiando el código y no puede averiguar por qué los resultados de las llamadas a métodos no son diferentes, asegúrese de haber reconstruido los objetos R6 con la nueva clase.

14.2.3 Agregar métodos después de la creación

En lugar de crear continuamente nuevas clases, también es posible modificar los campos y métodos de una clase existente. Esto es útil al explorar de forma interactiva o cuando tiene una clase con muchas funciones que le gustaría dividir en partes. Agrega nuevos elementos a una clase existente con $set(), proporcionando la visibilidad (más información en la Sección 14.3), el nombre y el componente.

Accumulator <- R6Class("Accumulator")
Accumulator$set("public", "sum", 0)
Accumulator$set("public", "add", function(x = 1) {
  self$sum <- self$sum + x 
  invisible(self)
})

Como se indicó anteriormente, los nuevos métodos y campos solo están disponibles para nuevos objetos; no se agregan retrospectivamente a los objetos existentes.

14.2.4 Herencia

Para heredar el comportamiento de una clase existente, proporcione el objeto de la clase al argumento inherit:

AccumulatorChatty <- R6Class("AccumulatorChatty", 
  inherit = Accumulator,
  public = list(
    add = function(x = 1) {
      cat("Adding ", x, "\n", sep = "")
      super$add(x = x)
    }
  )
)

x2 <- AccumulatorChatty$new()
x2$add(10)$add(1)$sum
#> Adding 10
#> Adding 1
#> [1] 11

$add() anula la implementación de la superclase, pero aún podemos delegar a la implementación de la superclase usando super$. (Esto es análogo a NextMethod() en S3, como se explica en la Sección 13.6.) Cualquier método que no se invalide utilizará la implementación en la clase principal.

14.2.5 Introspección

Cada objeto R6 tiene una clase S3 que refleja su jerarquía de clases R6. Esto significa que la forma más fácil de determinar la clase (y todas las clases de las que hereda) es usar class():

class(hadley2)
#> [1] "Person" "R6"

La jerarquía S3 incluye la clase base “R6”. Esto proporciona un comportamiento común, incluido un método print.R6() que llama a $print(), como se describe arriba.

Puede enumerar todos los métodos y campos con names():

names(hadley2)
#> [1] ".__enclos_env__" "age"             "name"            "clone"          
#> [5] "print"           "initialize"

Definimos $name, $age, $print e $initialize. Como sugiere el nombre, .__enclos_env__ es un detalle de implementación interna que no debe tocar; volveremos a $clone() en la Sección 14.4.

14.2.6 Ejercicios

  1. Cree una cuenta bancaria clase R6 que almacene un saldo y le permita depositar y retirar dinero. Cree una subclase que arroje un error si intenta entrar en sobregiro. Cree otra subclase que le permita entrar en sobregiro, pero le cobre una tarifa.

  2. Cree una clase R6 que represente un mazo de cartas barajado. Deberías poder sacar cartas del mazo con $draw(n), devolver todas las cartas al mazo y volver a barajar con $reshuffle(). Use el siguiente código para hacer un vector de tarjetas.

    suit <- c("♠", "♥", "♦", "♣")
    value <- c("A", 2:10, "J", "Q", "K")
    cards <- paste0(rep(value, 4), suit)
  3. ¿Por qué no puedes modelar una cuenta bancaria o una baraja de cartas con una clase S3?

  4. Cree una clase R6 que le permita obtener y establecer la zona horaria actual. Puede acceder a la zona horaria actual con Sys.timezone() y configurarla con Sys.setenv(TZ = "newtimezone"). Al configurar la zona horaria, asegúrese de que la nueva zona horaria esté en la lista proporcionada por OlsonNames().

  5. Cree una clase R6 que administre el directorio de trabajo actual. Debe tener los métodos $get() y $set().

  6. ¿Por qué no puede modelar la zona horaria o el directorio de trabajo actual con una clase S3?

  7. ¿Sobre qué tipo base se construyen los objetos R6? ¿Qué atributos tienen?

14.3 Control de acceso

R6Class() tiene otros dos argumentos que funcionan de manera similar a public:

  • private te permite crear campos y métodos que solo están disponibles dentro de la clase, no fuera de ella.

  • activo le permite usar funciones de acceso para definir campos dinámicos o activos.

Estos se describen en las siguientes secciones.

14.3.1 Privacidad

Con R6 puedes definir campos y métodos privados, elementos a los que solo se puede acceder desde dentro de la clase, no desde fuera3. Hay dos cosas que debe saber para aprovechar los elementos privados:

  • El argumento ‘privado’ de ‘R6Class’ funciona de la misma manera que el argumento ‘publico’: le da una lista con nombre de métodos (funciones) y campos (todo lo demás).

  • Los campos y métodos definidos en private están disponibles dentro de los métodos que usan private$ en lugar de self$. No puede acceder a campos o métodos privados fuera de la clase.

Para concretar esto, podríamos hacer que los campos $age y $name de la clase Persona sean privados. Con esta definición de Person solo podemos establecer $age y $name durante la creación del objeto, y no podemos acceder a sus valores desde fuera de la clase.

Person <- R6Class("Person", 
  public = list(
    initialize = function(name, age = NA) {
      private$name <- name
      private$age <- age
    },
    print = function(...) {
      cat("Person: \n")
      cat("  Name: ", private$name, "\n", sep = "")
      cat("  Age:  ", private$age, "\n", sep = "")
    }
  ),
  private = list(
    age = NA,
    name = NULL
  )
)

hadley3 <- Person$new("Hadley")
hadley3
#> Person: 
#>   Name: Hadley
#>   Age:  NA
hadley3$name
#> NULL

La distinción entre campos públicos y privados es importante cuando crea redes complejas de clases y desea dejar lo más claro posible qué está bien que otros accedan. Cualquier cosa que sea privada puede refactorizarse más fácilmente porque sabe que otros no confían en ella. Los métodos privados tienden a ser menos importantes en R en comparación con otros lenguajes de programación porque las jerarquías de objetos en R tienden a ser más simples.

14.3.2 Campos activos

Los campos activos le permiten definir componentes que parecen campos desde el exterior, pero se definen con funciones, como métodos. Los campos activos se implementan mediante enlaces activos (Sección 7.2.6). Cada enlace activo es una función que toma un único argumento: value. Si el argumento es missing(), se está recuperando el valor; de lo contrario, se está modificando.

Por ejemplo, podría crear un campo activo random que devuelva un valor diferente cada vez que acceda a él:

Rando <- R6::R6Class("Rando", active = list(
  random = function(value) {
    if (missing(value)) {
      runif(1)  
    } else {
      stop("Can't set `$random`", call. = FALSE)
    }
  }
))
x <- Rando$new()
x$random
#> [1] 0.0808
x$random
#> [1] 0.834
x$random
#> [1] 0.601

Los campos activos son especialmente útiles junto con los campos privados, ya que permiten implementar componentes que parecen campos desde el exterior pero proporcionan comprobaciones adicionales. Por ejemplo, podemos usarlos para crear un campo age de solo lectura y para asegurarnos de que name sea un vector de caracteres de longitud 1.

Person <- R6Class("Person", 
  private = list(
    .age = NA,
    .name = NULL
  ),
  active = list(
    age = function(value) {
      if (missing(value)) {
        private$.age
      } else {
        stop("`$age` is read only", call. = FALSE)
      }
    },
    name = function(value) {
      if (missing(value)) {
        private$.name
      } else {
        stopifnot(is.character(value), length(value) == 1)
        private$.name <- value
        self
      }
    }
  ),
  public = list(
    initialize = function(name, age = NA) {
      private$.name <- name
      private$.age <- age
    }
  )
)

hadley4 <- Person$new("Hadley", age = 38)
hadley4$name
#> [1] "Hadley"
hadley4$name <- 10
#> Error in (function (value) : is.character(value) is not TRUE
hadley4$age <- 20
#> Error: `$age` is read only

14.3.3 Ejercicios

  1. Cree una clase de cuenta bancaria que le impida establecer directamente el saldo de la cuenta, pero aún puede retirar y depositar. Lanza un error si intentas entrar en sobregiro.

  2. Cree una clase con un campo $password de solo escritura. Debería tener el método $check_password(password) que devuelva TRUE o FALSE, pero no debería haber forma de ver la contraseña completa.

  3. Extienda la clase Rando con otro enlace activo que le permita acceder al valor aleatorio anterior. Asegúrese de que el enlace activo sea la única forma de acceder al valor.

  4. ¿Pueden las subclases acceder a campos/métodos privados desde su padre? Haz un experimento para averiguarlo.

14.4 Semántica de referencia

Una de las grandes diferencias entre R6 y la mayoría de los demás objetos es que tienen semántica de referencia. La consecuencia principal de la semántica de referencia es que los objetos no se copian cuando se modifican:

y1 <- Accumulator$new() 
y2 <- y1

y1$add(10)
c(y1 = y1$sum, y2 = y2$sum)
#> y1 y2 
#> 10 10

En cambio, si desea una copia, deberá explícitamente $clone() el objeto:

y1 <- Accumulator$new() 
y2 <- y1$clone()

y1$add(10)
c(y1 = y1$sum, y2 = y2$sum)
#> y1 y2 
#> 10  0

($clone() no clona recursivamente objetos R6 anidados. Si quieres eso, tendrás que usar $clone(deep = TRUE).)

Hay otras tres consecuencias menos obvias:

  • Es más difícil razonar sobre el código que usa objetos R6 porque necesita comprender más contexto.

  • Tiene sentido pensar en cuándo se elimina un objeto R6 y puede escribir $finalize() para complementar el $initialize().

  • Si uno de los campos es un objeto R6, debe crearlo dentro $initialize(), no R6Class().

Estas consecuencias se describen con más detalle a continuación.

14.4.1 Razonamiento

En general, la semántica de referencia hace que sea más difícil razonar sobre el código. Tome este ejemplo muy simple:

x <- list(a = 1)
y <- list(b = 2)

z <- f(x, y)

Para la gran mayoría de las funciones, sabes que la línea final solo modifica z.

Tome un ejemplo similar que usa una clase de referencia List imaginaria:

x <- List$new(a = 1)
y <- List$new(b = 2)

z <- f(x, y)

La línea final es mucho más difícil de razonar: si f() llama a métodos de x o y, podría modificarlos así como z. Este es el mayor inconveniente potencial de R6 y debe tener cuidado de evitarlo escribiendo funciones que devuelvan un valor o modifiquen sus entradas R6, pero no ambos. Dicho esto, hacer ambas cosas puede conducir a un código sustancialmente más simple en algunos casos, y discutiremos esto más adelante en la Sección 16.3.2).

14.4.2 Finalizador

Una propiedad útil de la semántica de referencia es que tiene sentido pensar cuándo se finaliza un objeto R6, es decir, cuándo se elimina. Esto no tiene sentido para la mayoría de los objetos porque la semántica de copiar al modificar significa que puede haber muchas versiones transitorias de un objeto, como se menciona en la Sección 2.6. Por ejemplo, lo siguiente crea dos objetos de factor: el segundo se crea cuando se modifican los niveles, dejando que el primero sea destruido por el recolector de basura.

x <- factor(c("a", "b", "c"))
levels(x) <- c("c", "b", "a")

Dado que los objetos R6 no se copian al modificarse, solo se eliminan una vez, y tiene sentido pensar en $finalize() como un complemento de $initialize(). Los finalizadores generalmente juegan un papel similar a on.exit() (como se describe en la Sección 6.7.4), limpiando cualquier recurso creado por el inicializador. Por ejemplo, la siguiente clase envuelve un archivo temporal y lo elimina automáticamente cuando finaliza la clase.

TemporaryFile <- R6Class("TemporaryFile", list(
  path = NULL,
  initialize = function() {
    self$path <- tempfile()
  },
  finalize = function() {
    message("Cleaning up ", self$path)
    unlink(self$path)
  }
))

El método finalize se ejecutará cuando se elimine el objeto (o más precisamente, por la primera recolección de elementos no utilizados después de que el objeto se haya desvinculado de todos los nombres) o cuando R salga. Esto significa que el finalizador se puede llamar de manera efectiva en cualquier parte de su código R y, por lo tanto, es casi imposible razonar sobre el código del finalizador que toca las estructuras de datos compartidas. Evite estos posibles problemas utilizando únicamente el finalizador para limpiar los recursos privados asignados por el inicializador.

tf <- TemporaryFile$new()
rm(tf)
#> Cleaning up /tmp/Rtmpk73JdI/file155f31d8424bd

14.4.3 Campos de R6

Una consecuencia final de la semántica de referencia puede surgir donde no lo espera. Si usa una clase R6 como el valor predeterminado de un campo, ¡se compartirá entre todas las instancias del objeto! Toma el siguiente código: queremos crear una base de datos temporal cada vez que llamamos a TemporaryDatabase$new(), pero el código actual siempre usa la misma ruta.

TemporaryDatabase <- R6Class("TemporaryDatabase", list(
  con = NULL,
  file = TemporaryFile$new(),
  initialize = function() {
    self$con <- DBI::dbConnect(RSQLite::SQLite(), path = file$path)
  },
  finalize = function() {
    DBI::dbDisconnect(self$con)
  }
))

db_a <- TemporaryDatabase$new()
db_b <- TemporaryDatabase$new()

db_a$file$path == db_b$file$path
#> [1] TRUE

(Si está familiarizado con Python, esto es muy similar al problema del “argumento predeterminado mutable”).

El problema surge porque TemporaryFile$new() se llama solo una vez cuando se define la clase TemporaryDatabase. Para solucionar el problema, debemos asegurarnos de que se llame cada vez que se llame a TemporaryDatabase$new(), es decir, debemos ponerlo en $initialize():

TemporaryDatabase <- R6Class("TemporaryDatabase", list(
  con = NULL,
  file = NULL,
  initialize = function() {
    self$file <- TemporaryFile$new()
    self$con <- DBI::dbConnect(RSQLite::SQLite(), path = file$path)
  },
  finalize = function() {
    DBI::dbDisconnect(self$con)
  }
))

db_a <- TemporaryDatabase$new()
db_b <- TemporaryDatabase$new()

db_a$file$path == db_b$file$path
#> [1] FALSE

14.4.4 Ejercicios

  1. Cree una clase que le permita escribir una línea en un archivo específico. Debe abrir una conexión al archivo en $initialize(), agregar una línea usando cat() en $append_line() y cerrar la conexión en $finalize().

14.5 ¿Por qué R6?

R6 es muy similar a un sistema OO incorporado llamado clases de referencia, o RC para abreviar. Prefiero R6 a RC porque:

  • R6 es mucho más simple. Tanto R6 como RC están construidos sobre entornos, pero mientras que R6 usa S3, RC usa S4. Esto significa que para comprender completamente RC, debe comprender cómo funciona el S4 más complicado.

  • R6 tiene documentación completa en línea en https://r6.r-lib.org.

  • R6 tiene un mecanismo más simple para la subclasificación de paquetes cruzados, que simplemente funciona sin que tengas que pensar en ello. Para RC, lea los detalles en la sección “Métodos externos; Superclases entre paquetes” de ?setRefClass.

  • RC mezcla variables y campos en la misma pila de entornos para que obtenga (field) y establezca (field <<- value) campos como valores regulares. R6 coloca los campos en un entorno separado para que obtenga (self$field) y establezca (self$field <- value) con un prefijo. El enfoque R6 es más detallado, pero me gusta porque es más explícito.

  • R6 es mucho más rápido que RC. En general, la velocidad de envío del método no es importante fuera de los micropuntos de referencia. Sin embargo, RC es bastante lento y cambiar de RC a R6 condujo a una mejora sustancial del rendimiento en el paquete brillante. Para obtener más detalles, consulte vignette("Rendimiento", "R6").

  • RC está vinculado a R. Eso significa que si se corrigen errores, solo puede aprovechar las correcciones al solicitar una versión más nueva de R. Esto dificulta los paquetes (como los del tidyverse) que necesitan funcionar en muchos R versiones.

  • Finalmente, debido a que las ideas que subyacen en R6 y RC son similares, solo requerirá una pequeña cantidad de esfuerzo adicional para aprender RC si es necesario.


  1. Eso significa que si está creando R6 en un paquete, solo necesita asegurarse de que esté listado en el campo Imports de DESCRIPCIÓN. No hay necesidad de importar el paquete a NAMESPACE.↩︎

  2. A diferencia de Python, R6 proporciona automáticamente la variable self y no forma parte de la firma del método.↩︎

  3. Debido a que R es un lenguaje tan flexible, técnicamente aún es posible acceder a valores privados, pero tendrá que esforzarse mucho más, profundizando en los detalles de la implementación de R6.↩︎