13  S3

13.1 Introducción

S3 es el primer y más simple sistema OO de R. S3 es informal y ad hoc, pero hay cierta elegancia en su minimalismo: no se le puede quitar ninguna parte y seguir teniendo un sistema OO útil. Por estas razones, debe usarlo, a menos que tenga una razón convincente para hacerlo de otra manera. S3 es el único sistema OO utilizado en los paquetes base y stats, y es el sistema más utilizado en los paquetes CRAN.

S3 es muy flexible, lo que significa que te permite hacer cosas que son bastante desaconsejables. Si viene de un entorno estricto como Java, esto parecerá bastante aterrador, pero le da a los programadores de R una gran libertad. Puede ser muy difícil evitar que las personas hagan algo que usted no quiere que hagan, pero sus usuarios nunca se detendrán porque hay algo que aún no ha implementado. Dado que S3 tiene pocas restricciones integradas, la clave para su uso exitoso es aplicar las restricciones usted mismo. Por lo tanto, este capítulo le enseñará las convenciones que debe seguir (casi) siempre.

El objetivo de este capítulo es mostrarle cómo funciona el sistema S3, no cómo usarlo de manera efectiva para crear nuevas clases y genéricos. Recomiendo combinar el conocimiento teórico de este capítulo con el conocimiento práctico codificado en el paquete vctrs.

Estructura

  • La Sección 13.2 brinda una descripción general rápida de todos los componentes principales de S3: clases, genéricos y métodos. También aprenderá sobre sloop::s3_dispatch(), que usaremos a lo largo del capítulo para explorar cómo funciona S3.

  • La Sección 13.3 entra en los detalles de la creación de una nueva clase S3, incluidas las tres funciones que deberían acompañar a la mayoría de las clases: un constructor, un ayudante y un validador.

  • La Sección 13.4 describe cómo funcionan los métodos y genéricos de S3, incluidos los aspectos básicos del envío de métodos.

  • La Sección 13.5 analiza los cuatro estilos principales de los objetos de S3: vector, registro, marco de datos y escalar.

  • La Sección 13.6 demuestra cómo funciona la herencia en S3 y le muestra lo que necesita para hacer que una clase sea “subclasificable”.

  • La Sección 13.7 concluye el capítulo con una discusión de los detalles más finos del envío de métodos, incluidos los tipos base, los genéricos internos, los genéricos de grupo y el envío doble.

Requisitos previos

Las clases de S3 se implementan mediante atributos, así que asegúrese de estar familiarizado con los detalles descritos en la Sección 3.3. Usaremos vectores S3 base existentes para ejemplos y exploración, así que asegúrese de estar familiarizado con las clases factor, Date, difftime, POSIXct y POSIXlt descritas en la Sección 3.4.

Usaremos el paquete sloop para sus ayudantes interactivos.

library(sloop)

13.2 Lo esencial

Un objeto S3 es un tipo base con al menos un atributo de “clase” (se pueden usar otros atributos para almacenar otros datos). Por ejemplo, tome el factor. Su tipo base es el vector entero, tiene un atributo clase de “factor”, y un atributo niveles que almacena los niveles posibles:

f <- factor(c("a", "b", "c"))

typeof(f)
#> [1] "integer"
attributes(f)
#> $levels
#> [1] "a" "b" "c"
#> 
#> $class
#> [1] "factor"

Puede obtener el tipo base subyacente al unclass(), lo que elimina el atributo de clase, lo que hace que pierda su comportamiento especial:

unclass(f)
#> [1] 1 2 3
#> attr(,"levels")
#> [1] "a" "b" "c"

Un objeto de S3 se comporta de manera diferente a su tipo base subyacente cada vez que se pasa a un genérico (abreviatura de función genérica). La forma más fácil de saber si una función es genérica es usar sloop::ftype() y buscar “genérica” en la salida:

ftype(print)
#> [1] "S3"      "generic"
ftype(str)
#> [1] "S3"      "generic"
ftype(unclass)
#> [1] "primitive"

Una función genérica define una interfaz, que utiliza una implementación diferente según la clase de un argumento (casi siempre el primer argumento). Muchas funciones básicas de R son genéricas, incluida la importante print():

print(f)
#> [1] a b c
#> Levels: a b c

# la eliminación de clase vuelve al comportamiento de entero
print(unclass(f))
#> [1] 1 2 3
#> attr(,"levels")
#> [1] "a" "b" "c"

Tenga en cuenta que str() es genérico, y algunas clases de S3 usan ese genérico para ocultar los detalles internos. Por ejemplo, la clase POSIXlt que se usa para representar datos de fecha y hora en realidad está construida encima de una lista, un hecho que está oculto por su método str():

time <- strptime(c("2017-01-01", "2020-05-04 03:21"), "%Y-%m-%d")
str(time)
#>  POSIXlt[1:2], format: "2017-01-01" "2020-05-04"

str(unclass(time))
#> List of 11
#>  $ sec   : num [1:2] 0 0
#>  $ min   : int [1:2] 0 0
#>  $ hour  : int [1:2] 0 0
#>  $ mday  : int [1:2] 1 4
#>  $ mon   : int [1:2] 0 4
#>  $ year  : int [1:2] 117 120
#>  $ wday  : int [1:2] 0 1
#>  $ yday  : int [1:2] 0 124
#>  $ isdst : int [1:2] 0 0
#>  $ zone  : chr [1:2] "UTC" "UTC"
#>  $ gmtoff: int [1:2] 0 0
#>  - attr(*, "tzone")= chr "UTC"
#>  - attr(*, "balanced")= logi TRUE

El genérico es un intermediario: su trabajo es definir la interfaz (es decir, los argumentos) y luego encontrar la implementación correcta para el trabajo. La implementación para una clase específica se denomina método, y el genérico encuentra ese método realizando despacho de métodos.

Puede usar sloop::s3_dispatch() para ver el proceso de envío del método:

s3_dispatch(print(f))
#> => print.factor
#>  * print.default

Volveremos a los detalles del envío en la Sección 13.4.1, por ahora tenga en cuenta que los métodos S3 son funciones con un esquema de nombres especial, generic.class(). Por ejemplo, el método factor para el genérico print() se llama print.factor(). Nunca debe llamar al método directamente, sino confiar en el genérico para encontrarlo por usted.

En general, puede identificar un método por la presencia de . en el nombre de la función, pero hay una serie de funciones importantes en base R que se escribieron antes de S3 y, por lo tanto, usan . para unir palabras. Si no está seguro, verifique con sloop::ftype():

ftype(t.test)
#> [1] "S3"      "generic"
ftype(t.data.frame)
#> [1] "S3"     "method"

A diferencia de la mayoría de las funciones, no puede ver el código fuente de la mayoría de los métodos S3 1 simplemente escribiendo sus nombres. Esto se debe a que los métodos de S3 generalmente no se exportan: viven solo dentro del paquete y no están disponibles en el entorno global. En su lugar, puede usar sloop::s3_get_method(), que funcionará independientemente de dónde resida el método:

weighted.mean.Date
#> Error in eval(expr, envir, enclos): object 'weighted.mean.Date' not found

s3_get_method(weighted.mean.Date)
#> function (x, w, ...) 
#> .Date(weighted.mean(unclass(x), w, ...))
#> <bytecode: 0x556daf77d0d8>
#> <environment: namespace:stats>

13.2.1 Ejercicios

  1. Describe la diferencia entre t.test() y t.data.frame(). ¿Cuándo se llama cada función?

  2. Haga una lista de las funciones básicas de R que se usan comúnmente y que contienen . en su nombre, pero que no son métodos de S3.

  3. ¿Qué hace el método as.data.frame.data.frame()? ¿Por qué es confuso? ¿Cómo podría evitar esta confusión en su propio código?

  4. Describa la diferencia de comportamiento en estas dos llamadas.

    set.seed(1014)
    some_days <- as.Date("2017-01-31") + sample(10, 5)
    
    mean(some_days)
    #> [1] "2017-02-06"
    mean(unclass(some_days))
    #> [1] 17203
  5. ¿Qué clase de objeto devuelve el siguiente código? ¿Sobre qué tipo de base está construido? ¿Qué atributos utiliza?

    x <- ecdf(rpois(100, 10))
    x
    #> Empirical CDF 
    #> Call: ecdf(rpois(100, 10))
    #>  x[1:18] =  2,  3,  4,  ..., 2e+01, 2e+01
  6. ¿Qué clase de objeto devuelve el siguiente código? ¿Sobre qué tipo de base está construido? ¿Qué atributos utiliza?

    x <- table(rpois(100, 5))
    x
    #> 
    #>  1  2  3  4  5  6  7  8  9 10 
    #>  7  5 18 14 15 15 14  4  5  3

13.3 Clases

Si ha realizado programación orientada a objetos en otros lenguajes, se sorprenderá al saber que S3 no tiene una definición formal de una clase: para convertir un objeto en una instancia de una clase, simplemente establezca el atributo de clase. Puedes hacerlo durante la creación con structure(), o después del hecho con class<-():

# Crear y asignar clases en un solo paso
x <- structure(list(), class = "my_class")

# Crear, luego establecer la clase
x <- list()
class(x) <- "my_class"

Puede determinar la clase de un objeto S3 con class(x) y ver si un objeto es una instancia de una clase usando inherits(x, "classname").

class(x)
#> [1] "my_class"
inherits(x, "my_class")
#> [1] TRUE
inherits(x, "your_class")
#> [1] FALSE

El nombre de la clase puede ser cualquier cadena, pero recomiendo usar solo letras y _. Evite . porque (como se mencionó anteriormente) puede confundirse con el separador . entre un nombre genérico y un nombre de clase. Al usar una clase en un paquete, recomiendo incluir el nombre del paquete en el nombre de la clase. Eso asegura que no chocará accidentalmente con una clase definida por otro paquete.

S3 no tiene comprobaciones de corrección, lo que significa que puede cambiar la clase de los objetos existentes:

# Crear un modelo lineal
mod <- lm(log(mpg) ~ log(disp), data = mtcars)
class(mod)
#> [1] "lm"
print(mod)
#> 
#> Call:
#> lm(formula = log(mpg) ~ log(disp), data = mtcars)
#> 
#> Coefficients:
#> (Intercept)    log(disp)  
#>       5.381       -0.459

# Conviértelo en una fecha (?!)
class(mod) <- "Date"

# Como era de esperar, esto no funciona muy bien
print(mod)
#> Error in as.POSIXlt(.Internal(Date2POSIXlt(x, tz)), tz = tz): 'list' object cannot be coerced to type 'double'

Si ha usado otros lenguajes orientados a objetos, esto podría hacerle sentir mareado, pero en la práctica esta flexibilidad causa pocos problemas. R no evita que te dispares en el pie, pero mientras no apuntes el arma a los dedos de los pies y aprietes el gatillo, no tendrás ningún problema.

Para evitar las intersecciones de pie y bala al crear su propia clase, le recomiendo que proporcione generalmente tres funciones:

  • Un constructor de bajo nivel, new_myclass(), que crea eficientemente nuevos objetos con la estructura correcta.

  • Un validador, validate_myclass(), que realiza verificaciones más costosas desde el punto de vista computacional para garantizar que el objeto tenga los valores correctos.

  • Un ayudante fácil de usar, myclass(), que proporciona una manera conveniente para que otros creen objetos de su clase.

No necesita un validador para clases muy simples, y puede omitir el asistente si la clase es solo para uso interno, pero siempre debe proporcionar un constructor.

13.3.1 Constructores

S3 no proporciona una definición formal de una clase, por lo que no tiene una forma integrada de garantizar que todos los objetos de una clase determinada tengan la misma estructura (es decir, el mismo tipo base y los mismos atributos con los mismos tipos). En su lugar, debe aplicar una estructura coherente mediante el uso de un constructor.

El constructor debe seguir tres principios:

  • Se llamará new_myclass().

  • Tener un argumento para el objeto base y uno para cada atributo.

  • Comprobar el tipo del objeto base y los tipos de cada atributo.

Ilustraré estas ideas creando constructores para las clases base2 con las que ya está familiarizado. Para comenzar, hagamos un constructor para la clase S3 más simple: Date. Una fecha es simplemente un doble con un único atributo: su clase es Date. Esto lo convierte en un constructor muy simple:

new_Date <- function(x = double()) {
  stopifnot(is.double(x))
  structure(x, class = "Date")
}

new_Date(c(-1, 0, 1))
#> [1] "1969-12-31" "1970-01-01" "1970-01-02"

El propósito de los constructores es ayudarte a ti, el desarrollador. Eso significa que puede mantenerlos simples y no necesita optimizar los mensajes de error para el consumo público. Si espera que los usuarios también creen objetos, debe crear una función de ayuda amigable, llamada class_name(), que describiré en breve.

Un constructor un poco más complicado es el de difftime, que se usa para representar diferencias de tiempo. Se basa de nuevo en un doble, pero tiene un atributo de unidades que debe tomar uno de un pequeño conjunto de valores:

new_difftime <- function(x = double(), units = "secs") {
  stopifnot(is.double(x))
  units <- match.arg(units, c("secs", "mins", "hours", "days", "weeks"))

  structure(x,
    class = "difftime",
    units = units
  )
}

new_difftime(c(1, 10, 3600), "secs")
#> Time differences in secs
#> [1]    1   10 3600
new_difftime(52, "weeks")
#> Time difference of 52 weeks

El constructor es una función de desarrollador: será llamado en muchos lugares por un usuario experimentado. Eso significa que está bien intercambiar un poco de seguridad a cambio de rendimiento, y debe evitar verificaciones potencialmente lentas en el constructor.

13.3.2 Validadores

Las clases más complicadas requieren controles de validez más complicados. Tome factores, por ejemplo. Un constructor solo verifica que los tipos sean correctos, lo que permite crear factores con formato incorrecto:

new_factor <- function(x = integer(), levels = character()) {
  stopifnot(is.integer(x))
  stopifnot(is.character(levels))

  structure(
    x,
    levels = levels,
    class = "factor"
  )
}

new_factor(1:5, "a")
#> Error in as.character.factor(x): malformed factor
new_factor(0:1, "a")
#> Error in as.character.factor(x): malformed factor

En lugar de sobrecargar al constructor con controles complicados, es mejor ponerlos en una función separada. Si lo hace, le permite crear nuevos objetos de forma económica cuando sabe que los valores son correctos y reutilizar fácilmente las comprobaciones en otros lugares.

validate_factor <- function(x) {
  values <- unclass(x)
  levels <- attr(x, "levels")

  if (!all(!is.na(values) & values > 0)) {
    stop(
      "Todos los valores `x` deben ser no faltantes y mayores que cero",
      call. = FALSE
    )
  }

  if (length(levels) < max(values)) {
    stop(
      "Debe haber al menos tantos `levels` como valores posibles en `x`",
      call. = FALSE
    )
  }

  x
}

validate_factor(new_factor(1:5, "a"))
#> Error: Debe haber al menos tantos `levels` como valores posibles en `x`
validate_factor(new_factor(0:1, "a"))
#> Error: Todos los valores `x` deben ser no faltantes y mayores que cero

Esta función de validación se llama principalmente por sus efectos secundarios (arrojar un error si el objeto no es válido), por lo que esperaría que devuelva su entrada principal de forma invisible (como se describe en la Sección 6.7.2). Sin embargo, es útil que los métodos de validación regresen visiblemente, como veremos a continuación.

13.3.3 Ayudantes

Si desea que los usuarios construyan objetos de su clase, también debe proporcionar un método auxiliar que les haga la vida lo más fácil posible. Un ayudante siempre debe:

  • Tener el mismo nombre que la clase, p. myclass().

  • Termine llamando al constructor y al validador, si existe.

  • Cree mensajes de error cuidadosamente elaborados y adaptados a un usuario final.

  • Tenga una interfaz de usuario cuidadosamente diseñada con valores predeterminados cuidadosamente seleccionados y conversiones útiles.

La última viñeta es la más complicada y es difícil dar consejos generales. Sin embargo, hay tres patrones comunes:

  • A veces, todo lo que necesita hacer el ayudante es forzar sus entradas al tipo deseado. Por ejemplo, new_difftime() es muy estricto y viola la convención habitual de que puede usar un vector entero siempre que pueda usar un vector doble:

    new_difftime(1:10)
    #> Error in new_difftime(1:10): is.double(x) is not TRUE

    No es el trabajo del constructor ser flexible, así que aquí creamos un ayudante que solo fuerza la entrada a un doble.

    difftime <- function(x = double(), units = "secs") {
      x <- as.double(x)
      new_difftime(x, units = units)
    }
    
    difftime(1:10)
    #> Time differences in secs
    #>  [1]  1  2  3  4  5  6  7  8  9 10

  • A menudo, la representación más natural de un objeto complejo es una cadena. Por ejemplo, es muy conveniente especificar factores con un vector de caracteres. El siguiente código muestra una versión simple de factor(): toma un vector de caracteres y supone que los niveles deberían ser valores únicos. Esto no siempre es correcto (ya que es posible que algunos niveles no se vean en los datos), pero es un valor predeterminado útil.

    factor <- function(x = character(), levels = unique(x)) {
      ind <- match(x, levels)
      validate_factor(new_factor(ind, levels))
    }
    
    factor(c("a", "a", "b"))
    #> [1] a a b
    #> Levels: a b

  • Algunos objetos complejos se especifican de manera más natural mediante múltiples componentes simples. Por ejemplo, creo que es natural construir una fecha y hora proporcionando los componentes individuales (año, mes, día, etc.). Eso me lleva a este ayudante POSIXct() que se parece a la función existente ISODatetime()3:

    POSIXct <- function(year = integer(), 
                        month = integer(), 
                        day = integer(), 
                        hour = 0L, 
                        minute = 0L, 
                        sec = 0, 
                        tzone = "") {
      ISOdatetime(year, month, day, hour, minute, sec, tz = tzone)
    }
    
    POSIXct(2020, 1, 1, tzone = "America/New_York")
    #> [1] "2020-01-01 EST"

Para clases más complicadas, debe sentirse libre de ir más allá de estos patrones para hacer la vida lo más fácil posible para sus usuarios.

13.3.4 Ejercicios

  1. Escribe un constructor para los objetos data.frame. ¿Sobre qué tipo base se construye un marco de datos? ¿Qué atributos utiliza? ¿Cuáles son las restricciones impuestas a los elementos individuales? ¿Qué pasa con los nombres?

  2. Mejore mi ayudante factor() para que tenga un mejor comportamiento cuando uno o más valores no se encuentran en los niveles. ¿Qué hace base::factor() en esta situación?

  3. Lee atentamente el código fuente de factor(). ¿Qué hace que mi constructor no hace?

  4. Los factores tienen un atributo opcional de “contrastes”. Lea la ayuda de C() y describa brevemente el propósito del atributo. ¿Qué tipo debe tener? Reescriba el constructor new_factor() para incluir este atributo.

  5. Lea la documentación de utils::as.roman(). ¿Cómo escribirías un constructor para esta clase? ¿Necesita un validador? ¿Qué podría hacer un ayudante?

13.4 Genéricos y métodos

El trabajo de un genérico S3 es realizar el envío de métodos, es decir, encontrar la implementación específica para una clase. El envío de métodos se realiza mediante UseMethod(), al que todos los genéricos llaman4. UseMethod() toma dos argumentos: el nombre de la función genérica (obligatorio) y el argumento a usar para el envío del método (opcional). Si omite el segundo argumento, se enviará en función del primer argumento, que casi siempre es lo que se desea.

La mayoría de los genéricos son muy simples y consisten solo en una llamada a UseMethod(). Tome mean() por ejemplo:

mean
#> function (x, ...) 
#> UseMethod("mean")
#> <bytecode: 0x556da9ce4eb0>
#> <environment: namespace:base>

Crear su propio genérico es igualmente simple:

my_new_generic <- function(x) {
  UseMethod("my_new_generic")
}

(Si se pregunta por qué tenemos que repetir my_new_generic dos veces, piense en la Sección 6.2.3.)

No pasa ninguno de los argumentos del genérico a UseMethod(); utiliza magia profunda para pasar al método automáticamente. El proceso preciso es complicado y con frecuencia sorprendente, por lo que debe evitar realizar cualquier cálculo de forma genérica. Para conocer todos los detalles, lea detenidamente la sección Detalles técnicos en ?UseMethod.

13.4.1 Método de envío

¿Cómo funciona UseMethod()? Básicamente, crea un vector de nombres de métodos, paste0("generic", ".", c(class(x), "default")), y luego busca cada método potencial a su vez. Podemos ver esto en acción con sloop::s3_dispatch(). Le das una llamada a un genérico S3 y enumera todos los métodos posibles. Por ejemplo, ¿qué método se llama cuando imprime un objeto Date?

x <- Sys.Date()
s3_dispatch(print(x))
#> => print.Date
#>  * print.default

La salida aquí es simple:

  • => indica el método que se llama, aquí print.Date()
  • * indica un método que está definido, pero no llamado, aquí print.default().

La clase “predeterminada” es una pseudoclase especial. Esta no es una clase real, pero se incluye para que sea posible definir un respaldo estándar que se encuentra siempre que un método específico de clase no está disponible.

La esencia del envío de métodos es bastante simple, pero a medida que avanza el capítulo, verá que se vuelve progresivamente más complicado para abarcar la herencia, los tipos base, los genéricos internos y los genéricos de grupo. El siguiente código muestra un par de casos más complicados a los que volveremos en las secciones, Sección 13.6 y Sección 13.7.

x <- matrix(1:10, nrow = 2)
s3_dispatch(mean(x))
#>    mean.matrix
#>    mean.integer
#>    mean.numeric
#> => mean.default

s3_dispatch(sum(Sys.time()))
#>    sum.POSIXct
#>    sum.POSIXt
#>    sum.default
#> => Summary.POSIXct
#>    Summary.POSIXt
#>    Summary.default
#> -> sum (internal)

13.4.2 Encontrar métodos

sloop::s3_dispatch() te permite encontrar el método específico usado para una sola llamada. ¿Qué sucede si desea encontrar todos los métodos definidos para un genérico o asociados con una clase? Ese es el trabajo de sloop::s3_methods_generic() y sloop::s3_methods_class():

s3_methods_generic("mean")
#> # A tibble: 7 × 4
#>   generic class      visible source             
#>   <chr>   <chr>      <lgl>   <chr>              
#> 1 mean    Date       TRUE    base               
#> 2 mean    default    TRUE    base               
#> 3 mean    difftime   TRUE    base               
#> 4 mean    POSIXct    TRUE    base               
#> 5 mean    POSIXlt    TRUE    base               
#> 6 mean    quosure    FALSE   registered S3method
#> 7 mean    vctrs_vctr FALSE   registered S3method

s3_methods_class("ordered")
#> # A tibble: 4 × 4
#>   generic       class   visible source             
#>   <chr>         <chr>   <lgl>   <chr>              
#> 1 as.data.frame ordered TRUE    base               
#> 2 Ops           ordered TRUE    base               
#> 3 relevel       ordered FALSE   registered S3method
#> 4 Summary       ordered TRUE    base

13.4.3 Crear métodos

Hay dos arrugas a tener en cuenta cuando crea un nuevo método:

  • Primero, solo debe escribir un método si posee el genérico o la clase. R le permitirá definir un método incluso si no lo hace, pero es de muy mala educación. En su lugar, trabaje con el autor del genérico o de la clase para agregar el método en su código.

  • Un método debe tener los mismos argumentos que su genérico. Esto se aplica en los paquetes mediante R CMD check, pero es una buena práctica incluso si no está creando un paquete.

    Hay una excepción a esta regla: si el genérico tiene ..., el método puede contener un superconjunto de argumentos. Esto permite que los métodos tomen argumentos adicionales arbitrarios. La desventaja de usar ..., sin embargo, es que cualquier argumento mal escrito se tragará silenciosamente 5, como se menciona en la Sección 6.6.

13.4.4 Ejercicios

  1. Lea el código fuente de t() y t.test() y confirme que t.test() es un método genérico de S3 y no un método de S3. ¿Qué pasa si creas un objeto con la clase test y llamas t() con él? ¿Por qué?

    x <- structure(1:10, class = "test")
    t(x)
  2. ¿Para qué genéricos tiene métodos la clase table?

  3. ¿Para qué genéricos tiene métodos la clase ecdf?

  4. ¿Qué base genérica tiene el mayor número de métodos definidos?

  5. Lea detenidamente la documentación de UseMethod() y explique por qué el siguiente código devuelve los resultados que devuelve. ¿Qué dos reglas usuales de evaluación de funciones viola UseMethod()?

    g <- function(x) {
      x <- 10
      y <- 10
      UseMethod("g")
    }
    g.default <- function(x) c(x = x, y = y)
    
    x <- 1
    y <- 1
    g(x)
    #> x y 
    #> 1 1
  6. ¿Cuáles son los argumentos para [? ¿Por qué es una pregunta difícil de responder?

13.5 Estilos de objeto

Hasta ahora me he centrado en clases de estilo vectorial como Date y factor. Estos tienen la propiedad clave de que length(x) representa el número de observaciones en el vector. Hay tres variantes que no tienen esta propiedad:

  • Los objetos de estilo de registro utilizan una lista de vectores de igual longitud para representar componentes individuales del objeto. El mejor ejemplo de esto es POSIXlt, que debajo del capó es una lista de 11 componentes de fecha y hora como año, mes y día. Las clases de estilo de registro anulan longitud() y los métodos de creación de subconjuntos para ocultar este detalle de implementación.

    x <- as.POSIXlt(ISOdatetime(2020, 1, 1, 0, 0, 1:3))
    x
    #> [1] "2020-01-01 00:00:01 UTC" "2020-01-01 00:00:02 UTC"
    #> [3] "2020-01-01 00:00:03 UTC"
    
    length(x)
    #> [1] 3
    length(unclass(x))
    #> [1] 11
    
    x[[1]] # the first date time
    #> [1] "2020-01-01 00:00:01 UTC"
    unclass(x)[[1]] # the first component, the number of seconds
    #> [1] 1 2 3

  • Los marcos de datos son similares a los objetos de estilo de registro en que ambos usan listas de vectores de igual longitud. Sin embargo, los marcos de datos son conceptualmente bidimensionales y los componentes individuales se exponen fácilmente al usuario. El número de observaciones es el número de filas, no la longitud:

    x <- data.frame(x = 1:100, y = 1:100)
    length(x)
    #> [1] 2
    nrow(x)
    #> [1] 100

  • Los objetos escalares normalmente usan una lista para representar una sola cosa. Por ejemplo, un objeto lm es una lista de longitud 12 pero representa un modelo.

    mod <- lm(mpg ~ wt, data = mtcars)
    length(mod)
    #> [1] 12

    Los objetos escalares también se pueden construir sobre funciones, llamadas y entornos6. En general, esto es menos útil, pero puede ver aplicaciones en stats::ecdf(), R6 (Capítulo 14) y rlang::quo() (Capítulo 19) .

Desafortunadamente, describir el uso apropiado de cada uno de estos estilos de objeto está más allá del alcance de este libro. Sin embargo, puede obtener más información en la documentación del paquete vctrs (https://vctrs.r-lib.org); el paquete también proporciona constructores y ayudantes que facilitan la implementación de los diferentes estilos.

13.5.1 Ejercicios

  1. Categorice los objetos devueltos por lm(), factor(), table(), as.Date(), as.POSIXct() ecdf(), ordered(), I() en los estilos descritos anteriormente.

  2. ¿Cómo sería una función constructora para objetos lm, new_lm()? Use ?lm y experimente para descubrir los campos obligatorios y sus tipos.

13.6 Herencia

Las clases de S3 pueden compartir el comportamiento a través de un mecanismo llamado herencia. La herencia está impulsada por tres ideas:

  • La clase puede ser un carácter vector. Por ejemplo, las clases ordered y POSIXct tienen dos componentes en su clase:

    class(ordered("x"))
    #> [1] "ordered" "factor"
    class(Sys.time())
    #> [1] "POSIXct" "POSIXt"

  • Si no se encuentra un método para la clase en el primer elemento del vector, R busca un método para la segunda clase (y así sucesivamente):

    s3_dispatch(print(ordered("x")))
    #>    print.ordered
    #> => print.factor
    #>  * print.default
    s3_dispatch(print(Sys.time()))
    #> => print.POSIXct
    #>    print.POSIXt
    #>  * print.default
  • Un método puede delegar trabajo llamando a NextMethod(). Volveremos a eso muy pronto; por ahora, tenga en cuenta que s3_dispatch() informa delegación con ->.

    s3_dispatch(ordered("x")[1])
    #>    [.ordered
    #> => [.factor
    #>    [.default
    #> -> [ (internal)
    s3_dispatch(Sys.time()[1])
    #> => [.POSIXct
    #>    [.POSIXt
    #>    [.default
    #> -> [ (internal)

Antes de continuar, necesitamos un poco de vocabulario para describir la relación entre las clases que aparecen juntas en un vector de clase. Diremos que ordered es una subclase de factor porque siempre aparece antes que él en el vector de clase y, a la inversa, diremos que factor es una superclase de ordered.

S3 no impone restricciones en la relación entre subclases y superclases, pero su vida será más fácil si impone algunas. Le recomiendo que se adhiera a dos principios simples al crear una subclase:

  • El tipo base de la subclase debe ser el mismo que el de la superclase.

  • Los atributos de la subclase deben ser un superconjunto de los atributos de la superclase.

POSIXt no se adhiere a estos principios porque POSIXct tiene tipo doble y POSIXlt tiene tipo lista. Esto significa que POSIXt no es una superclase, e ilustra que es bastante posible usar el sistema de herencia S3 para implementar otros estilos de código compartido (aquí POSIXt juega un papel más como una interfaz), pero necesitará descubra convenciones seguras usted mismo.

13.6.1 NextMethod()

NextMethod() es la parte más difícil de entender de la herencia, por lo que comenzaremos con un ejemplo concreto para el caso de uso más común: [. Comenzaremos creando una clase de juguete simple: una clase secreta que oculta su salida cuando se imprime:

new_secret <- function(x = double()) {
  stopifnot(is.double(x))
  structure(x, class = "secret")
}

print.secret <- function(x, ...) {
  print(strrep("x", nchar(x)))
  invisible(x)
}

x <- new_secret(c(15, 1, 456))
x
#> [1] "xx"  "x"   "xxx"

Esto funciona, pero el método predeterminado [ no conserva la clase:

s3_dispatch(x[1])
#>    [.secret
#>    [.default
#> => [ (internal)
x[1]
#> [1] 15

Para arreglar esto, necesitamos proporcionar un método [.secret. ¿Cómo podríamos implementar este método? El enfoque ingenuo no funcionará porque nos quedaremos atrapados en un bucle infinito:

`[.secret` <- function(x, i) {
  new_secret(x[i])
}

En su lugar, necesitamos alguna forma de llamar al código [ subyacente, es decir, la implementación que sería llamada si no tuviéramos un método [.secret. Un enfoque sería unclass() el objeto:

`[.secret` <- function(x, i) {
  x <- unclass(x)
  new_secret(x[i])
}
x[1]
#> [1] "xx"

Esto funciona, pero es ineficiente porque crea una copia de x. Un mejor enfoque es usar NextMethod(), que resuelve de manera concisa el problema de delegar al método que se habría llamado si [.secret no existiera:

`[.secret` <- function(x, i) {
  new_secret(NextMethod())
}
x[1]
#> [1] "xx"

Podemos ver lo que está pasando con sloop::s3_dispatch():

s3_dispatch(x[1])
#> => [.secret
#>    [.default
#> -> [ (internal)

El => indica que se llama a [.secret, pero que NextMethod() delega el trabajo al método interno subyacente [, como se muestra en ->.

Al igual que con UseMethod(), la semántica precisa de NextMethod() es compleja. En particular, realiza un seguimiento de la lista de posibles métodos siguientes con una variable especial, lo que significa que la modificación del objeto que se envía no tendrá ningún impacto en el método que se llamará a continuación.

13.6.2 Permitir subclases

Cuando crea una clase, debe decidir si desea permitir subclases, ya que requiere algunos cambios en el constructor y una reflexión cuidadosa en sus métodos.

Para permitir subclases, el constructor principal debe tener argumentos ... y class:

new_secret <- function(x, ..., class = character()) {
  stopifnot(is.double(x))

  structure(
    x,
    ...,
    class = c(class, "secret")
  )
}

Luego, el constructor de la subclase puede simplemente llamar al constructor de la clase principal con argumentos adicionales según sea necesario. Por ejemplo, imagina que queremos crear una clase supersecreta que también oculta la cantidad de caracteres:

new_supersecret <- function(x) {
  new_secret(x, class = "supersecret")
}

print.supersecret <- function(x, ...) {
  print(rep("xxxxx", length(x)))
  invisible(x)
}

x2 <- new_supersecret(c(15, 1, 456))
x2
#> [1] "xxxxx" "xxxxx" "xxxxx"

Para permitir la herencia, también debe pensar detenidamente en sus métodos, ya que ya no puede usar el constructor. Si lo hace, el método siempre devolverá la misma clase, independientemente de la entrada. Esto obliga a quien hace una subclase a hacer mucho trabajo extra.

Concretamente, esto significa que debemos revisar el método [.secret. Actualmente siempre devuelve un secret(), incluso cuando se le da un supersecreto:

`[.secret` <- function(x, ...) {
  new_secret(NextMethod())
}

x2[1:3]
#> [1] "xx"  "x"   "xxx"

Queremos asegurarnos de que [.secret devuelva la misma clase que x incluso si es una subclase. Por lo que puedo decir, no hay forma de resolver este problema usando solo la base R. En su lugar, deberá utilizar el paquete vctrs, que proporciona una solución en forma de vctrs::vec_restore() genérico. Este genérico toma dos entradas: un objeto que ha perdido información de subclase y un objeto de plantilla para usar para la restauración.

Por lo general, los métodos vec_restore() son bastante simples: simplemente llama al constructor con los argumentos apropiados:

vec_restore.secret <- function(x, to, ...) new_secret(x)
vec_restore.supersecret <- function(x, to, ...) new_supersecret(x)

(Si su clase tiene atributos, deberá pasarlos de to al constructor).

Ahora podemos usar vec_restore() en el método [.secret:

`[.secret` <- function(x, ...) {
  vctrs::vec_restore(NextMethod(), x)
}
x2[1:3]
#> [1] "xxxxx" "xxxxx" "xxxxx"

(Solo entendí completamente este problema recientemente, por lo que al momento de escribir no se usa en el tidyverse. Con suerte, para cuando estés leyendo esto, se habrá implementado, lo que hará que sea mucho más fácil (por ejemplo) subclasificar tibbles. )

Si construye su clase usando las herramientas provistas por el paquete vctrs, [ obtendrá este comportamiento automáticamente. Solo necesitará proporcionar su propio método [ si usa atributos que dependen de los datos o desea un comportamiento de subconjunto no estándar. Ver ?vctrs::new_vctr para más detalles.

13.6.3 Ejercicios

  1. ¿Cómo admite subclases [.Date? ¿Cómo no admite subclases?

  2. R tiene dos clases para representar datos de fecha y hora, POSIXct y POSIXlt, que heredan ambas de POSIXt. ¿Qué genéricos tienen comportamientos diferentes para las dos clases? ¿Qué genéricos comparten el mismo comportamiento?

  3. ¿Qué espera que devuelva este código? ¿Qué devuelve realmente? ¿Por qué?

    generic2 <- function(x) UseMethod("generic2")
    generic2.a1 <- function(x) "a1"
    generic2.a2 <- function(x) "a2"
    generic2.b <- function(x) {
      class(x) <- "a1"
      NextMethod()
    }
    
    generic2(structure(list(), class = c("b", "a2")))

13.7 Detalles de envío

Este capítulo concluye con algunos detalles adicionales sobre el envío de métodos. Es seguro omitir estos detalles si es nuevo en S3.

13.7.1 S3 y tipos básicos

¿Qué sucede cuando llama a un genérico S3 con un objeto base, es decir, un objeto sin clase? Podrías pensar que enviaría lo que class() devuelve:

class(matrix(1:5))
#> [1] "matrix" "array"

Pero, lamentablemente, el envío se produce en la clase implícita, que tiene tres componentes:

  • La cadena “array” o “matrix” si el objeto tiene dimensiones
  • El resultado de typeof() con algunos ajustes menores
  • La cadena “numeric” si el objeto es “integer” o “double”

No hay una función base que calcule la clase implícita, pero puede usar sloop::s3_class()

s3_class(matrix(1:5))
#> [1] "matrix"  "integer" "numeric"

Esto es usado por s3_dispatch():

s3_dispatch(print(matrix(1:5)))
#>    print.matrix
#>    print.integer
#>    print.numeric
#> => print.default

Esto significa que la clade, class(), de un objeto no determina de forma única su envío:

x1 <- 1:5
class(x1)
#> [1] "integer"
s3_dispatch(mean(x1))
#>    mean.integer
#>    mean.numeric
#> => mean.default

x2 <- structure(x1, class = "integer")
class(x2)
#> [1] "integer"
s3_dispatch(mean(x2))
#>    mean.integer
#> => mean.default

13.7.2 Genéricos internos

Algunas funciones básicas, como [, sum() y cbind(), se denominan genéricos internos porque no llaman a UseMethod() sino que llaman a las funciones de C DispatchGroup( ) o DispatchOrEval(). s3_dispatch() muestra genéricos internos al incluir el nombre del genérico seguido de (internal):

s3_dispatch(Sys.time()[1])
#> => [.POSIXct
#>    [.POSIXt
#>    [.default
#> -> [ (internal)

Por motivos de rendimiento, los genéricos internos no envían a los métodos a menos que se haya establecido el atributo de clase, lo que significa que los genéricos internos no utilizan la clase implícita. Nuevamente, si alguna vez se siente confundido acerca del envío de métodos, puede confiar en s3_dispatch().

13.7.3 Genéricos del grupo

Los genéricos de grupo son la parte más complicada del envío de métodos de S3 porque involucran tanto NextMethod() como genéricos internos. Al igual que los genéricos internos, solo existen en la base R y no puede definir su propio grupo genérico.

Hay cuatro genéricos de grupo:

  • Matemáticas: abs(), sign(), sqrt(), floor(), cos(), sin(), log(), y más (ver ?Math para la lista completa).

  • Operaciones: +, -, *, /, ^, %%, %/%, &, |, !, ==, !=, <, <=, >=, y >.

  • Resumen: all(), any(), sum(), prod(), min(), max(), y range().

  • Complejo: Arg(), Conj(), Im(), Mod(), Re().

La definición de un solo grupo genérico para su clase anula el comportamiento predeterminado para todos los miembros del grupo. Los métodos para genéricos grupales se buscan solo si los métodos para el genérico específico no existen:

s3_dispatch(sum(Sys.time()))
#>    sum.POSIXct
#>    sum.POSIXt
#>    sum.default
#> => Summary.POSIXct
#>    Summary.POSIXt
#>    Summary.default
#> -> sum (internal)

La mayoría de los genéricos de grupo implican una llamada a NextMethod(). Por ejemplo, tome los objetos difftime(). Si observa el envío del método para abs(), verá que hay un grupo genérico Math definido.

y <- as.difftime(10, units = "mins")
s3_dispatch(abs(y))
#>    abs.difftime
#>    abs.default
#> => Math.difftime
#>    Math.default
#> -> abs (internal)

Math.difftime básicamente se ve así:

Math.difftime <- function(x, ...) {
  new_difftime(NextMethod(), units = attr(x, "units"))
}

Despacha al siguiente método, aquí el valor predeterminado interno, para realizar el cálculo real y luego restaurar la clase y los atributos. (Para admitir mejor las subclases de difftime, sería necesario llamar a vec_restore(), como se describe en la Sección 13.6.2.)

Dentro de una función genérica de grupo, una variable especial .Generic proporciona la función genérica real llamada. Esto puede ser útil cuando se producen mensajes de error y, a veces, puede ser útil si necesita recuperar manualmente el genérico con diferentes argumentos.

13.7.4 Despacho doble

Los genéricos del grupo Ops, que incluye la aritmética de dos argumentos y los operadores booleanos como - y &, implementan un tipo especial de envío de métodos. Despachan en el tipo de ambos argumentos, que se llama despacho doble. Esto es necesario para preservar la propiedad conmutativa de muchos operadores, es decir, a + b debería ser igual a b + a. Tome el siguiente ejemplo simple:

date <- as.Date("2017-01-01")
integer <- 1L

date + integer
#> [1] "2017-01-02"
integer + date
#> [1] "2017-01-02"

Si + se enviara solo en el primer argumento, devolvería valores diferentes para los dos casos. Para superar este problema, los genéricos del grupo Ops utilizan una estrategia ligeramente diferente a la habitual. En lugar de hacer un envío de un solo método, hacen dos, uno para cada entrada. Hay tres posibles resultados de esta búsqueda:

  • Los métodos son los mismos, por lo que no importa qué método se utilice.

  • Los métodos son diferentes y R recurre al método interno con una advertencia.

  • Un método es interno, en cuyo caso R llama al otro método.

Este enfoque es propenso a errores, por lo que si desea implementar un despacho doble robusto para operadores algebraicos, le recomiendo usar el paquete vctrs. Ver ?vctrs::vec_arith para más detalles.

13.7.5 Ejercicios

  1. Explique las diferencias en el envío a continuación:

    length.integer <- function(x) 10
    
    x1 <- 1:5
    class(x1)
    #> [1] "integer"
    s3_dispatch(length(x1))
    #>  * length.integer
    #>    length.numeric
    #>    length.default
    #> => length (internal)
    
    x2 <- structure(x1, class = "integer")
    class(x2)
    #> [1] "integer"
    s3_dispatch(length(x2))
    #> => length.integer
    #>    length.default
    #>  * length (internal)
  2. ¿Qué clases tienen un método para el grupo Math genérico en base R? Lee el código fuente. ¿Cómo funcionan los métodos?

  3. Math.difftime() es más complicado de lo que describí. ¿Por qué?


  1. Las excepciones son los métodos que se encuentran en el paquete base, como t.data.frame, y los métodos que ha creado.↩︎

  2. Las versiones recientes de R tienen constructores .Date(), .difftime(), .POSIXct() y .POSIXlt(), pero son internos, no están bien documentados y no siguen los principios que Recomiendo.↩︎

  3. Este ayudante no es eficiente: en segundo plano ISODatetime() funciona pegando los componentes en una cadena y luego usando strptime(). Un equivalente más eficiente está disponible en lubridate::make_datetime().↩︎

  4. La excepción son los genéricos internos, que se implementan en C y son el tema de la Sección 13.7.2.↩︎

  5. Consulte https://github.com/hadley/ellipsis para ver una forma experimental de advertir cuando los métodos no usan todos los argumentos en ..., lo que proporciona una posible resolución de este problema.↩︎

  6. También puede construir un objeto encima de una lista de pares, pero todavía tengo que encontrar una buena razón para hacerlo.↩︎