Capítulo 6. Sistemas de objetos, evaluación ordenada y patrones

▶ Ejecutar este capítulo en Binder

La primera vez que se abre, Binder construye el entorno en la nube (unos 10-20 min); verás una pantalla de progreso. Después queda en caché y abre en segundos. Si parece que no responde, espera a que termine de construirse o vuelve a intentarlo.

Hay una pregunta que desconcierta a quien llega a R desde otro lenguaje: ¿cómo se define una clase? La respuesta incómoda es que hay cuatro maneras, y que la más usada no se parece a ninguna clase que hayas visto. R no tiene un sistema de objetos, sino una historia de sistemas de objetos superpuestos, y esa aparente anarquía es en realidad una lección de diseño: cada sistema nació para resolver un problema concreto, y saber cuál elegir —o, más a menudo, reconocer cuál estás usando ya sin saberlo— es lo que separa el código que fluye con el lenguaje del que pelea contra él.

Este capítulo ordena ese paisaje. Primero, el reparto: los cuatro sistemas —S3, el ubicuo e informal; S4, el formal y verboso; R6, el mutable de referencia; y S7, el sucesor moderno que quiere unificarlos— con el criterio para elegir entre ellos. Después, el rasgo de R que ningún lenguaje orientado a objetos convencional tiene y sin el cual no se entiende su ecosistema de datos: la evaluación ordenada, la cuasicitación y la interfaz de fórmula, la maquinaria que hace posible que filter(pistas, energia > 0.6) funcione sin comillas alrededor de energia. Y por último, los patrones de diseño que de verdad se repiten en el código de datos —transformador, estrategia, pipeline, fábrica— con la advertencia que los enmarca a todos: en un lenguaje funcional, la clase es una herramienta entre varias, y muchas veces la peor. Como siempre, cada salida se ha ejecutado antes de imprimirla.

Cuatro sistemas de objetos, un mapa

Conviene empezar por el final: el mapa de la tabla 6.1, para tener la brújula antes de entrar en el bosque. Los cuatro sistemas se reparten en dos ejes. El primero es la semántica: S3, S4 y S7 son de valor —copiar el objeto copia sus datos, la regla del capítulo 2—, mientras que R6 es de referencia —copiar la etiqueta no copia el objeto—. El segundo es el formalismo: S3 es un acuerdo entre caballeros que nadie obliga a cumplir; S4, S7 y R6 imponen estructura, tipos y validación. De esos dos ejes salen las cuatro respuestas, y casi todo el arte está en no llevar a un sistema el problema del otro.

Los cuatro sistemas de objetos de R. El reparto por semántica y formalismo, y el nicho de cada uno. La regla por defecto de este libro: S3 para extender, S7 para clases nuevas con garantías, R6 solo cuando el estado mutable es la esencia.
Sistema Semántica Formal Nicho
S3 valor no el 90 % de R; métodos sobre clases informales
S4 valor Bioconductor, despacho múltiple, rigor
R6 referencia estado mutable: conexiones, contadores, colas
S7 valor clases nuevas con validador; el futuro

Antes de verlos uno a uno, un aviso que ahorra confusión: no hay que elegir sistema en abstracto, como quien escoge religión. Se elige por tarea. Extender el comportamiento de un objeto que ya existe —que se imprima distinto, que summary diga algo útil— es casi siempre S3, porque así está construido todo R. Crear un tipo nuevo con campos garantizados y validación —una configuración, un modelo, un contrato de datos— es hoy trabajo de S7. Y solo cuando el objeto es un estado que cambia en el tiempo —un contador, una conexión, una barra de progreso— entra R6. S4 se estudia sobre todo para leerlo, porque medio Bioconductor está escrito en él.

S3: el sistema que ya usas sin saberlo

Empecemos por una revelación. Cuando en el capítulo 3 ajustamos un modelo y lo imprimimos, ya estábamos usando programación orientada a objetos:

m <- lm(mpg ~ wt, data = mtcars)
class(m)             # una clase
#> [1] "lm"
is.list(m)           # ...que por debajo es una simple lista
#> [1] TRUE

Un modelo lm es una lista corriente (cap. 2) con una etiqueta pegada: el atributo class. Y cuando escribes print(m) o summary(m), R mira esa etiqueta y elige la función adecuada. Eso es todo S3: un sistema construido sobre dos ideas mínimas —un objeto es un dato con un atributo class, y un método es una función cuyo nombre lleva la clase detrás de un punto—. No hay declaración de clase, ni comprobación, ni ceremonia. Se fabrica un objeto pegándole la etiqueta:

pista <- function(titulo, genero, energia) {
  structure(list(titulo = titulo, genero = genero, energia = energia),
            class = "pista")           # la etiqueta que lo hace una "pista"
}
p <- pista("Nube", "pop", 0.74)
class(p)
#> [1] "pista"

Y se le da comportamiento definiendo funciones con el sufijo de la clase. El mecanismo que las conecta es el genérico: una función que no hace el trabajo, sino que mira la clase de su argumento y delega en el método correspondiente. print es un genérico; para que nuestra pista se imprima a su manera, basta escribir print.pista:

print.pista <- function(x, ...) {
  cat("<pista>", x$titulo, "(", x$genero, ")\n")
}
p                                       # la autoimpresion ya despacha
#> <pista> Nube ( pop )

Para escribir un genérico propio se usa UseMethod, que es literalmente «mira la clase del primer argumento y salta al método»:

duracion <- function(x, ...) UseMethod("duracion")
duracion.pista <- function(x, ...) "3:52"
duracion.default <- function(x, ...) stop("no sé cómo durar esto")
duracion(p)
#> [1] "3:52"
duracion(42)                            # cae en el metodo .default
#> Error: no sé cómo durar esto

Figura 6.1. El despacho de S3. El genérico no hace el trabajo: UseMethod lee la clase del primer argumento y salta al método cuyo nombre lleva esa clase tras el punto. Si el vector de clases no casa con ningún método, cae en .default. Todo el sistema es esta búsqueda de un nombre.

El método .default es la red de seguridad: el método que se usa cuando ninguna clase encaja. Este es todo el aparato de S3, y su ubicuidad no es una exageración retórica: en una sesión limpia de R, el genérico print tiene ya 194 métodos registrados, y format, 73. Cada objeto que R imprime bonito —un factor, una fecha, una tabla, un modelo— lo hace por este mismo mecanismo.

La informalidad tiene precio: nadie valida

La ligereza de S3 es su virtud y su talón de Aquiles. Como una clase es solo una etiqueta de texto, cualquiera puede pegarla sobre cualquier cosa, y S3 se lo cree:

falsa <- list(titulo = "X")      # sin genero, sin energia
class(falsa) <- "pista"          # y sin embargo, "es" una pista
print.pista(falsa)               # el metodo la trata como tal...
#> <pista> X (  )                 <- ...y falla en silencio, sin genero

S3 no comprueba que un objeto de clase "pista" tenga los campos que un método espera. Esa confianza es cómoda para prototipar y peligrosa para producir. La disciplina que la comunidad de R ha decantado (Wickham 2019) para domesticarla son tres funciones separadas: un constructor de bajo nivel, rápido y sin comprobaciones (new_pista); un validador que verifica las invariantes (validate_pista); y un ayudante amable para el usuario que combina ambos. El patrón, sobre un tipo «temperatura» que no puede bajar del cero absoluto:

new_temp <- function(x = double()) {
  stopifnot(is.double(x))               # contrato minimo, barato
  structure(x, class = "temperatura")
}
validate_temp <- function(x) {
  if (any(unclass(x) < -273.15, na.rm = TRUE))
    stop("por debajo del cero absoluto")
  x
}
temperatura <- function(x) validate_temp(new_temp(as.double(x)))  # el ayudante

temperatura(-300)
#> Error: por debajo del cero absoluto

Nótese unclass(x) dentro del validador: para comparar los números sin disparar de nuevo el despacho de métodos, se le quita temporalmente la clase. Es un idioma que reaparece cada vez que un método necesita tratar su objeto como el dato desnudo que hay debajo.

Herencia S3: un vector de clases

La herencia en S3 es tan sencilla como el resto: el atributo class puede ser un vector, y R lo recorre de izquierda a derecha buscando un método. El primero que encuentra, gana.

album <- function(titulo, pistas) {
  structure(list(titulo = titulo, pistas = pistas),
            class = c("recopilatorio", "album"))   # recopilatorio Y album
}
print.album <- function(x, ...) {
  cat("<album>", x$titulo, "-", length(x$pistas), "pistas\n")
}
a <- album("Grandes éxitos", list(p, p))
a                          # no hay print.recopilatorio -> hereda print.album
#> <album> Grandes éxitos - 2 pistas

Como no existe print.recopilatorio, R sigue por el vector de clases y encuentra print.album. La subclase hereda el comportamiento de la superclase con solo nombrarla primero. Y cuando la subclase quiere extender un método sin reescribirlo entero, existe NextMethod, que llama al método de la siguiente clase del vector:

summary.album <- function(object, ...) cat("resumen de", object$titulo, "\n")
summary.recopilatorio <- function(object, ...) {
  cat("[recopilatorio] ")
  NextMethod()             # y ahora que siga summary.album
}
summary(a)
#> [recopilatorio] resumen de Grandes éxitos

Este encadenamiento —hacer algo propio y después delegar en la superclase— es el mismo ‘super’ que veremos en R6 y S7, aquí en versión mínima. Con esto S3 está completo: constructor, genérico, método, .default, herencia por vector y NextMethod. Cinco piezas para el sistema sobre el que descansa el 90 % de R.

Genéricos de grupo: dar sentido a + y [

Hay una clase de método que multiplica lo que S3 puede hacer con poco esfuerzo: los genéricos de grupo. En lugar de escribir un método por cada operador, R permite capturar familias enteras de una vez. El grupo Ops cubre la aritmética y las comparaciones (+, -, *, ==, <…); el grupo Math, las funciones matemáticas (abs, sqrt, log…). Un tipo «dinero» que guarda céntimos como entero para no arrastrar errores de coma flotante (cap. 2) los aprovecha:

dinero <- function(centimos) structure(as.integer(centimos), class = "dinero")
print.dinero <- function(x, ...) cat(sprintf("%.2f EUR\n", unclass(x) / 100))

Ops.dinero <- function(e1, e2) {
  v <- get(.Generic)(unclass(e1), unclass(e2))   # .Generic: que operador llego
  if (.Generic %in% c("+", "-", "*")) dinero(v) else v   # aritmetica -> dinero
}
dinero(1250) + dinero(350)
#> 16.00 EUR
dinero(1250) > dinero(350)
#> [1] TRUE

La variable especial .Generic contiene, dentro del método, el nombre del operador concreto que se invocó, de modo que un solo Ops.dinero atiende la aritmética y las comparaciones y decide en cada caso qué devolver: la suma de dos importes es dinero, pero su comparación es un lógico. Con la misma economía, un método para el corchete convierte a la clase en una colección con la que se puede rebanar, y un método length la hace medible:

playlist <- function(...) structure(list(...), class = "playlist")
`[.playlist` <- function(x, i) structure(unclass(x)[i], class = "playlist")
length.playlist <- function(x) length(unclass(x))
length(playlist("a", "b", "c", "d")[2:3])
#> [1] 2                        <- rebanar una playlist da otra playlist

Esta es la razón profunda de que en R tantos objetos —fechas, factores, unidades, importes— «se comporten como números» sin serlo: definen los métodos de grupo adecuados y el resto del lenguaje los trata con naturalidad. Es sobrecarga de operadores, sí, pero conseguida con el mismo mecanismo humilde del resto de S3: una función con un nombre convenido.

Un objeto que sigue el protocolo de R

Reunamos las piezas de S3 en algo con la forma de los objetos que R trae de serie. Cuando ajustas un lm, obtienes un objeto que se imprime con print, se resume con summary, revela sus parámetros con coef y predice con predict. Ese repertorio de genéricos es un protocolo tácito: cualquiera que lo respete produce objetos que encajan en el resto de R. Construyamos un modelo mínimo —la media de una variable por grupo— que lo cumpla entero:

modelo_medias <- function(formula, datos) {          # el "fit"
  y <- all.vars(formula)[1]; g <- all.vars(formula)[2]
  structure(list(medias = tapply(datos[[y]], datos[[g]], mean),
                 y = y, g = g, n = nrow(datos)),
            class = "modelo_medias")
}
print.modelo_medias <- function(x, ...) {
  cat("Modelo de medias:", x$y, "~", x$g, "(n =", x$n, ")\n"); invisible(x)
}
coef.modelo_medias <- function(object, ...) object$medias
predict.modelo_medias <- function(object, newdata, ...)
  unname(object$medias[as.character(newdata[[object$g]])])

Cuatro métodos sobre cuatro genéricos que R ya tiene, y el objeto se comporta como uno de la casa:

catalogo <- data.frame(
  genero      = c("pop", "rock", "pop", "jazz", "rock", "pop"),
  popularidad = c(80, 55, 91, 40, 62, 73),
  energia     = c(0.73, 0.62, 0.81, 0.40, 0.58, 0.69))
m <- modelo_medias(energia ~ genero, catalogo)
m                                          # print.modelo_medias
#> Modelo de medias: energia ~ genero (n = 6)
predict(m, data.frame(genero = c("pop", "jazz")))   # predict.modelo_medias
#> [1] 0.743 0.400

Ni predict ni coef son funciones nuestras: son genéricos de R base a los que nos hemos enganchado definiendo el método con el sufijo de nuestra clase. Ese es el rendimiento del despacho S3: escribir un objeto que habla el idioma común del ecosistema —que cualquier usuario de R sabe usar sin leer documentación, porque predict es predict en todas partes—. La fórmula energia ~ genero que recibe el constructor es, de nuevo, evaluación ordenada: la desmenuzamos con all.vars sin evaluarla, como hará la sección §6.6.4. Extender el ecosistema, no reinventarlo, es la esencia de S3 y la razón de su ubicuidad.

NotaAvanzado

¿Cómo saber, ante un genérico, qué método se ejecutará realmente? Para S3, el paquete sloop ofrece s3_dispatch(print(m)), que traza la cadena de clases que R recorre y marca con una flecha el método elegido —oro puro para depurar un despacho que no hace lo esperado—. Sin dependencias, methods(class = "modelo_medias") lista los métodos definidos para una clase, y getS3method("print", "modelo_medias") recupera el código de uno concreto. Cuando un objeto ajeno «no se imprime como debería», estas tres herramientas localizan en segundos qué método —o su ausencia— es el culpable.

S4: rigor formal y despacho múltiple

Cuando la informalidad de S3 no basta —cuando un tipo debe garantizar sus campos, o cuando el método depende de más de un argumento— aparece S4, el sistema formal que R tomó prestado del mundo de los lenguajes orientados a objetos clásicos. Todo en S4 es explícito y declarado. Una clase se define con setClass, sus campos se llaman slots y llevan tipo, y su integridad se protege con una función de validity que corre en cada construcción:

setClass("Pista4",
  representation(titulo = "character", energia = "numeric"),
  validity = function(object) {
    if (object@energia < 0 || object@energia > 1) "energía fuera de [0, 1]"
    else TRUE
  })
p4 <- new("Pista4", titulo = "Nube", energia = 0.74)
p4@titulo                    # los slots se acceden con @
#> [1] "Nube"
new("Pista4", titulo = "X", energia = 9)
#> Error: invalid class "Pista4" object: energía fuera de [0, 1]

El operador @ es a los slots de S4 lo que $ a los elementos de una lista, con una diferencia crucial: un slot inexistente o de tipo equivocado es un error, no un NULL silencioso. Esa rigidez es justamente lo que se busca. Pero la característica que de verdad distingue a S4, y que ningún otro sistema de R iguala, es el despacho múltiple: el método puede elegirse en función de las clases de varios argumentos a la vez.

setGeneric("mezclar", function(a, b) standardGeneric("mezclar"))
setClass("Voz",  representation(nombre = "character"))
setClass("Beat", representation(bpm = "numeric"))
setMethod("mezclar", signature("Voz", "Beat"), function(a, b)
  cat("voz", a@nombre, "sobre", b@bpm, "bpm\n"))
mezclar(new("Voz", nombre = "Ana"), new("Beat", bpm = 120))
#> voz Ana sobre 120 bpm

En S3, print despacha solo sobre su primer argumento; en S4, mezclar elige el método según la combinación de tipos de a y b, y podría haber un método distinto para signature("Beat", "Voz"). Es el patrón que motiva a S4 en dominios como el álgebra —donde matriz * escalar y escalar * matriz son operaciones distintas— o la bioinformática. La herencia usa contains y el equivalente de NextMethod es callNextMethod:

setClass("PistaViva", contains = "Pista4",   # hereda los slots de Pista4
         representation(sala = "character"))
setGeneric("describir", function(x) standardGeneric("describir"))
setMethod("describir", "Pista4",    function(x) cat("pista:", x@titulo, "\n"))
setMethod("describir", "PistaViva", function(x) {
  callNextMethod()                            # primero lo del padre
  cat("  en directo desde", x@sala, "\n")
})
describir(new("PistaViva", titulo = "Nube", energia = 0.6, sala = "Apolo"))
#> pista: Nube
#>   en directo desde Apolo

El precio de S4 es la verbosidad —cada clase, cada genérico y cada método es una llamada explícita— y una sintaxis que muchos encuentran pesada. Por eso, en 2026, para código nuevo la recomendación se ha desplazado: lo que antes pedía S4 hoy lo hace S7 con menos ceremonia y mejor integración. S4 se aprende, sobre todo, para leer las miles de clases que Bioconductor y otros paquetes maduros ya han escrito con él. El detector rápido de qué mundo habita un objeto es isS4:

isS4(p4)      # TRUE
isS4(m)       # FALSE: el modelo lm era S3

Tres piezas más completan el retrato de S4, por si te toca leerlo. La validez puede añadirse después de definir la clase con setValidity, útil cuando la restricción llega más tarde. La impresión se personaliza con un método para el genérico show —el print del mundo S4—. Y las uniones de clases, con setClassUnion, crean un tipo virtual que abarca varios, de modo que un slot pueda aceptar, por ejemplo, un número o un texto:

setClassUnion("NumeroOTexto", c("numeric", "character"))
setClass("Etiqueta", representation(v = "NumeroOTexto"))
new("Etiqueta", v = 5)@v         # 5
new("Etiqueta", v = "alto")@v    # "alto": ambos validos

Estas construcciones —validez diferida, show, uniones— son omnipresentes en el código S4 de los paquetes maduros, y reconocerlas basta para leerlo con soltura aunque nunca escribas una clase S4 propia.

NotaAvanzado

El despacho múltiple abre una puerta que el despacho simple no tiene: la ambigüedad. Si existen métodos para signature("A", "ANY") y para signature("ANY", "B"), y se llama con un objeto (A, B), ¿cuál gana? Ninguno domina claramente, y S4 emite un aviso de ambigüedad y elige uno por reglas de desempate que casi nadie recuerda. Es una de las razones por las que el despacho múltiple, pese a su elegancia, se reserva para dominios donde de verdad hace falta —álgebra, geometría computacional— y se evita en el código de datos corriente, donde un genérico que despacha sobre su primer argumento es más predecible. S7 admite despacho múltiple con las mismas cautelas.

R6: cuando el objeto es un estado que cambia

Los tres sistemas anteriores comparten la semántica de valor de R: un objeto es un dato, y modificarlo produce una copia (cap. 2). Eso es lo correcto casi siempre —«una función no puede estropear tus datos»—, pero hay una familia de objetos para los que es un estorbo: aquellos cuya esencia es cambiar. Un contador que se incrementa, una conexión que se abre y se cierra, una barra de progreso, una caché que crece: modelarlos como valores inmutables obliga a ir devolviendo copias nuevas en cada paso, y el código se vuelve un pasamanos incómodo. Para ellos existe R6 (Chang 2025), el sistema de semántica de referencia.

library(R6)
Contador <- R6Class("Contador",
  public = list(
    total = 0,
    initialize = function(inicio = 0) self$total <- inicio,
    suma = function(x = 1) { self$total <- self$total + x; invisible(self) },
    valor = function() self$total
  ))
c1 <- Contador$new()
c1$suma(3)$suma(4)           # encadenable: suma() da invisible(self)
c1$valor()
#> [1] 7

Dos rasgos saltan a la vista y ambos son deliberados. El primero es self: dentro de los métodos, el objeto se refiere a sí mismo por ese nombre, y self$total <- ... modifica el objeto en su sitio, sin copiarlo. El segundo es el encadenamiento con $, más cercano a los lenguajes clásicos de objetos que al resto de R. Pero la consecuencia que hay que interiorizar —porque es fuente de errores para quien viene del R de valor— es que R6 no copia al asignar:

c2 <- c1                     # NO es una copia: es otra etiqueta al MISMO objeto
c2$suma(100)
c1$valor()                   # tocar c2 cambio c1
#> [1] 107
c3 <- c1$clone()             # la copia hay que pedirla explicitamente
c3$suma(1000)
c1$valor()                   # c1 intacto: c3 era de verdad otro objeto
#> [1] 107

Donde un vector o una lista se copian al modificarse, un objeto R6 se comparte. Es la misma semántica de los environments del capítulo 2 —de hecho, R6 está construido sobre ellos— y exige el mismo cuidado: si dos partes del programa tienen el mismo objeto R6, se ven los cambios mutuamente, y el clone() es la única forma de romper el vínculo.

A cambio de ese cuidado, R6 ofrece la encapsulación más genuina de todo R. Puede declarar miembros privados, invisibles desde fuera, y campos activos que parecen datos pero ejecutan código al leerse o escribirse:

Cuenta <- R6Class("Cuenta",
  public = list(
    initialize = function(saldo = 0) private$.saldo <- saldo,
    ingresar = function(x) {
      private$.saldo <- private$.saldo + x; invisible(self) }
  ),
  private = list(.saldo = 0),                 # invisible desde fuera
  active = list(
    saldo = function(value) {                 # parece un campo, es una funcion
      if (missing(value)) private$.saldo      # al leer: devuelve
      else stop("el saldo no se asigna; usa ingresar()")   # al escribir: veta
    }
  ))
cta <- Cuenta$new(100); cta$ingresar(50)
cta$saldo                    # se lee como un campo
#> [1] 150
cta$saldo <- 999             # pero la escritura esta controlada
#> Error: el saldo no se asigna; usa ingresar()

El campo privado .saldo no existe para el mundo exterior —cta$.saldo es NULL—, y el saldo solo cambia por la puerta prevista, ingresar. Esto es encapsulación real, no una convención de nombres: el invariante «el saldo solo se modifica de formas válidas» queda protegido por el lenguaje. La herencia usa inherit y da acceso al padre por super$:

CuentaRemunerada <- R6Class("CuentaRemunerada", inherit = Cuenta,
  public = list(
    tipo = 0.02,
    intereses = function() {
      super$ingresar(private$.saldo * self$tipo)   # reutiliza el padre
      invisible(self)
    }
  ))
CuentaRemunerada$new(1000)$intereses()$saldo
#> [1] 1020

Dos comodidades más del generador R6 conviene conocer. Un método llamado print en la lista pública personaliza cómo se muestra el objeto —el equivalente R6 de un método S3 de impresión—. Y el clone tiene una sutileza cuando el objeto contiene otros objetos R6: por defecto es superficial (copia el objeto pero comparte los objetos anidados), y para copiar en profundidad hay que pedir clone(deep = TRUE):

Nodo <- R6Class("Nodo", public = list(
  valor = NULL, hijo = NULL,
  initialize = function(v) self$valor <- v,
  print = function(...) { cat("<Nodo>", self$valor, "\n"); invisible(self) }))

n <- Nodo$new(1); n$hijo <- Nodo$new(2)
superficial <- n$clone()             # comparte el hijo con n
superficial$hijo$valor <- 99
n$hijo$valor                         # ...y el cambio se ve en n
#> [1] 99
profundo <- n$clone(deep = TRUE)     # copia tambien el hijo
profundo$hijo$valor <- 7
n$hijo$valor                         # n intacto: el hijo era independiente
#> [1] 99

Es la misma distinción superficial/profundo de cualquier lenguaje con referencias, y la fuente de una clase entera de errores sutiles: creer que se ha copiado un objeto R6 cuando solo se ha copiado su cáscara. Con objetos R6 que anidan objetos R6, deep = TRUE suele ser lo que de verdad se quiere.

La regla de oro con R6: úsalo cuando la identidad del objeto importa más que su valor —cuando «este contador» y «una copia con el mismo total» son cosas distintas—. Si lo que tienes es un dato que se transforma, y no un estado que persiste, la semántica de valor de S3/S7 es más segura y más idiomática. R6 resuelve un problema real, pero es el sistema que más fácil se usa de más.

El caso que justifica R6: un acumulador en línea

Para ver cuándo R6 gana de verdad, un ejemplo donde la mutación no es un lujo sino la esencia: calcular media y varianza de un flujo de datos que no cabe en memoria (cap. 5), viendo cada valor una sola vez. El algoritmo de Welford mantiene tres números que se actualizan con cada llegada, y un objeto R6 es su hogar natural —el estado es el objeto—:

EstadoRodante <- R6Class("EstadoRodante",
  public = list(
    n = 0, media = 0, m2 = 0,
    add = function(x) {                       # incorpora un valor y actualiza
      self$n <- self$n + 1
      d <- x - self$media
      self$media <- self$media + d / self$n
      self$m2 <- self$m2 + d * (x - self$media)
      invisible(self)
    },
    var = function() if (self$n < 2) NA else self$m2 / (self$n - 1)
  ))

set.seed(2026)
flujo <- rnorm(10000, 50, 10)                  # 10 000 valores sinteticos
acc <- EstadoRodante$new()
for (x in flujo) acc$add(x)                    # un pase, memoria constante
c(acc$media, acc$var())
#> [1]  50.037 100.29        <- identicos a mean() y var() del flujo entero

Modelar esto con semántica de valor sería antinatural: cada add tendría que devolver un objeto nuevo y el bucle iría reasignándolo, un pasamanos sin sentido cuando lo que existe conceptualmente es un acumulador que evoluciona. Aquí la identidad importa —«este acumulador», con su historia— y esa es la señal inequívoca de que R6 es la herramienta correcta. El contraste con la tipificación del transformador (§6.7.1), donde ajustar devuelve un objeto nuevo, no es contradicción: allí el ajuste es un valor inmutable que se guarda; aquí el acumulador es un proceso que transcurre.

S7: el futuro que unifica

Los tres sistemas anteriores son historia acumulada: S3 desde los orígenes, S4 de los noventa, R6 de la era de los paquetes. Cada uno resuelve su problema, pero la convivencia es incómoda —sintaxis distintas, reglas de herencia distintas, integración desigual—. S7 (Vaughan et al. 2025) es la respuesta madura de la comunidad de R a ese desorden: un sistema nuevo, en CRAN desde 2023 y con la vocación declarada de entrar algún día en R base, que toma lo mejor de S3 y S4 —semántica de valor, despacho de métodos, validación formal— con una sintaxis limpia y, sobre todo, integrándose con lo que ya existe en lugar de competir. Una clase S7 se define con new_class y propiedades tipadas:

library(S7)
Pista <- new_class("Pista",
  properties = list(
    titulo = class_character,
    energia = new_property(class_numeric, default = 0.5),
    popularidad = class_integer
  ),
  validator = function(self) {
    if (self@energia < 0 || self@energia > 1) "energía debe estar en [0, 1]"
    else if (length(self@popularidad) &&
             (self@popularidad < 0 || self@popularidad > 100))
      "popularidad debe estar en [0, 100]"
  })
p <- Pista(titulo = "Nube", energia = 0.74, popularidad = 74L)
p@energia
#> [1] 0.74

De un vistazo se reconoce lo bueno de cada predecesor: las propiedades llevan tipo —como los slots de S4— y se acceden con @, hay un validator —como la validity de S4—, y admiten valores por defecto. Pero S7 añade dos garantías que ninguno tenía tan pulidas. La primera: los tipos se comprueban en la frontera, al construir, con mensajes claros:

Pista(titulo = "X", energia = "alta")
#> Error: <Pista> object properties are invalid:
#> - @energia must be <integer> or <double>, not <character>
Pista(titulo = 42, energia = 0.5)
#> Error: <Pista> object properties are invalid:
#> - @titulo must be <character>, not <double>

La segunda, y es la que lo hace idóneo para datos: el validador corre también al modificar, no solo al crear. Un objeto S7 no puede llegar a un estado inválido en ningún momento de su vida:

p@energia <- 2               # intento de dejarlo invalido
#> Error: <Pista> object is invalid:
#> - energía debe estar en [0, 1]
p@energia <- 0.3             # valido: pasa sin ruido

Esta propiedad —invariantes que se mantienen durante toda la vida del objeto, no solo en el nacimiento— es exactamente lo que un contrato de datos necesita, y es la razón de que S7 sea el andamio natural de los contratos de datos con validación continua. En el capítulo 10, cuando formalicemos la calidad de datos, este validador será el andamio.

Genéricos, herencia y convivencia

Los métodos de S7 se declaran con new_generic y method, con una sintaxis que a estas alturas resulta familiar:

describir <- new_generic("describir", "x")
method(describir, Pista) <- function(x) {
  cat("Pista:", x@titulo, "| energía", x@energia, "\n")
}
describir(p)
#> Pista: Nube | energía 0.3

La herencia usa parent, y la llamada al método del padre es super(objeto, to = Clase) —el eco directo de NextMethod y callNextMethod—:

PistaViva <- new_class("PistaViva", parent = Pista,
  properties = list(sala = class_character))
method(describir, PistaViva) <- function(x) {
  describir(super(x, to = Pista))         # primero el metodo del padre
  cat("  en directo desde", x@sala, "\n")
}
describir(PistaViva(titulo = "Nube", energia = 0.6, sala = "Apolo"))
#> Pista: Nube | energía 0.6
#>   en directo desde Apolo

Y aquí está la jugada maestra, la que justifica que exista un cuarto sistema en lugar de un tercero: S7 convive con S3 y S4. Se puede registrar un método S7 sobre un genérico S3 clásico como print, y el despacho de siempre lo encuentra:

method(print, Pista) <- function(x, ...) cat("<Pista>", x@titulo, "\n")
print(p)                     # el print de S3 despacha al metodo S7
#> <Pista> Nube

Esta interoperabilidad es la diferencia entre S7 y un enésimo sistema aislado: no pide reescribir el ecosistema, se enchufa a él. Una clase S7 es a la vez un ciudadano de pleno derecho del mundo S3 —su print, su format, su summary funcionan— y un objeto con tipos y validación modernos. Por eso la recomendación de este libro para clases nuevas es, sin ambigüedad, S7; S3 queda para extender lo existente, y R6 para el estado mutable.

Una comodidad más que merece mención: las propiedades calculadas. Una propiedad con getter se comporta como un campo pero se calcula al vuelo, y es de solo lectura:

Circulo <- new_class("Circulo", properties = list(
  radio = class_numeric,
  area  = new_property(class_numeric,
            getter = function(self) pi * self@radio^2)))
Circulo(radio = 2)@area
#> [1] 12.566

El area no se almacena —no puede quedar desincronizado con el radio—, se deriva. Es el equivalente S7 del @property de otros lenguajes, y una defensa natural contra el estado redundante.

Coerción y operadores: convert y grupos

Dos comodidades más redondean S7 y muestran su ambición de sistema completo. La primera es la coerción explícita entre clases con el genérico convert, que despacha —a la manera de S4— sobre el par (origen, destino):

Celsius    <- new_class("Celsius",    properties = list(t = class_numeric))
Fahrenheit <- new_class("Fahrenheit", properties = list(t = class_numeric))
method(convert, list(from = Celsius, to = Fahrenheit)) <-
  function(from, to, ...)
  Fahrenheit(t = from@t * 9/5 + 32)
convert(Celsius(t = 100), to = Fahrenheit)@t
#> [1] 212

La conversión queda declarada como un método, no escondida en una función suelta: cualquiera que tenga un Celsius sabe que puede pedir un Fahrenheit, y la regla vive en un único sitio. La segunda comodidad es la sobrecarga de operadores, que en S7 es registrar un método sobre el propio + —el mismo genérico de siempre— con despacho por las clases de ambos operandos:

Vec2 <- new_class("Vec2",
  properties = list(x = class_numeric, y = class_numeric))
method(`+`, list(Vec2, Vec2)) <- function(e1, e2)
  Vec2(x = e1@x + e2@x, y = e1@y + e2@y)
v <- Vec2(x = 1, y = 2) + Vec2(x = 3, y = 4)
c(v@x, v@y)
#> [1] 4 6

Donde S3 usaba el grupo Ops y .Generic (§6.2.3), S7 registra directamente sobre el operador con despacho múltiple limpio: la misma capacidad, expresada con la coherencia del sistema nuevo.

Dos refinamientos de las propiedades cierran el catálogo de S7. Cada propiedad puede llevar su propio validador y su propio setter, de modo que la restricción y la normalización vivan junto al campo que gobiernan:

TemperaturaC <- new_class("TemperaturaC", properties = list(
  celsius = new_property(class_numeric,
    validator = function(value) if (value < -273.15) "bajo el cero absoluto",
    setter = function(self, value) { self@celsius <- round(value, 2); self })))
TemperaturaC(celsius = 20.456)@celsius   # el setter redondea al asignar
#> [1] 20.46
TemperaturaC(celsius = -300)             # el validador de la propiedad veta
#> Error: <TemperaturaC>@celsius bajo el cero absoluto

Y una propiedad puede aceptar una unión de tipos con new_union, el equivalente S7 del setClassUnion de S4 —por ejemplo, un identificador que sea texto o entero, pero nunca un decimal—:

Registro <- new_class("Registro", properties = list(
  id = new_union(class_character, class_integer)))
Registro(id = "t01")@id      # "t01"
Registro(id = 5L)@id         # 5L: ambos validos
Registro(id = 3.5)           # Error: 3.5 no es character ni integer

Figura 6.2. La genealogía de los sistemas de objetos de R. Cuatro capas históricas, no cuatro alternativas equivalentes. S3 es el sustrato ubicuo; S4 añadió rigor; R6 trajo la semántica de referencia para el estado mutable; S7 recoge la semántica de valor y la validación de sus antecesores con una sintaxis limpia e —clave— compatible con el despacho de S3. Para código nuevo de datos, S7; para extender lo que ya existe, S3.

Los cuatro, lado a lado

Nada fija mejor las diferencias que ver la misma clase escrita en los cuatro sistemas. Tomemos «una temperatura en grados Celsius que no puede bajar del cero absoluto» —una invariante clarísima— y comparemos.

# S3: constructor con validacion; ligero, pero la validacion es voluntaria
temp_s3 <- function(c) {
  if (c < -273.15) stop("bajo el cero absoluto")
  structure(list(c = c), class = "temp_s3")
}

# S4: formal y declarado; validez al construir
setClass("TempS4", representation(c = "numeric"),
  validity = function(object)
    if (object@c < -273.15) "bajo el cero absoluto" else TRUE)

# R6: semantica de referencia; el objeto es mutable
TempR6 <- R6Class("TempR6", public = list(c = NULL,
  initialize = function(c) {
    if (c < -273.15) stop("bajo el cero absoluto"); self$c <- c }))

# S7: propiedades tipadas y validador que vigila toda la vida del objeto
TempS7 <- new_class("TempS7", properties = list(c = class_numeric),
  validator = function(self) if (self@c < -273.15) "bajo el cero absoluto")

Los cuatro rechazan -300 al construir, pero no dan las mismas garantías. Hay una diferencia que decide entre S4 y S7 para un contrato de datos, y conviene medirla en vez de creerla:

t4 <- new("TempS4", c = 20);  t4@c <- -300   # S4: la asignacion NO revalida
t4@c
#> [1] -300                   <- el objeto quedo INVALIDO, sin protesta
t7 <- TempS7(c = 20);         t7@c <- -300   # S7: el validador se dispara
#> Error: <TempS7> object is invalid: bajo el cero absoluto

Ahí está la razón de fondo por la que S7 es el elegido para modelar datos con garantías: en S4, la validez se comprueba al construir pero no al modificar un slot —hay que invocar validObject a mano—, de modo que un objeto válido puede corromperse con una asignación y nadie se entera. En S7, el validador vigila también las modificaciones, y un objeto no puede pasar por un estado inválido en ningún momento. Para un contrato —«esta popularidad está entre 0 y 100, siempre»— esa vigilancia continua no es un detalle: es la propiedad entera. La tabla 6.2 destila el veredicto.

La misma clase, cuatro sistemas. Qué garantiza cada uno para la temperatura con cero absoluto. La vigilancia del validador al modificar es lo que distingue a S7 para un contrato de datos.
S3 S4 R6 S7
Tipos declarados no no
Valida al construir opcional opcional
Valida al modificar no no no
Semántica valor valor referencia valor
Acceso a campos $ @ $ @
Verbosidad mínima alta media media
NotaAvanzado

¿Cuánto cuesta el despacho? Con el rigor del capítulo 4, midámoslo: una llamada a un método trivial en cada sistema, comparada con una función pura. Los cocientes, medidos con bench::mark y estables entre repeticiones, son reveladores: S3, S4 y R6 cuestan del orden de 4 a 5 veces una función pura; S7, por ser joven y aún poco optimizado, ronda las 25 veces. Pero antes de sacar conclusiones apresuradas, la cifra absoluta pone las cosas en su sitio: incluso el más caro despacha en microsegundos. Es decir, el sobrecoste del despacho solo importa si se llama al método millones de veces en un bucle interno —y en ese caso, como enseñó el capítulo 4, la solución no es cambiar de sistema de objetos sino vectorizar para no llamar al método una vez por elemento—. Para todo lo demás, elige el sistema por sus garantías, no por su velocidad de despacho: la diferencia es real en el microscopio e invisible en la práctica.

Patrones de diseño para datos

Los «patrones de diseño» nacieron para lenguajes de objetos rígidos, y buena parte del catálogo clásico (Gamma et al. 1994) se disuelve en R porque el lenguaje ya trae la solución de fábrica: donde otros necesitan el patrón Estrategia con sus clases, R pasa una función como argumento y asunto resuelto. Pero unos pocos patrones sí capturan algo esencial del trabajo con datos, y merece la pena nombrarlos porque estructuran el código de preprocesado y modelado que domina de aquí al final del libro.

El Transformador: aprender y aplicar

Es el patrón central de la ciencia de datos, y el que más errores previene. Un transformador vive en dos tiempos que no deben mezclarse: primero observa un conjunto de datos y de él extrae los números que le hacen falta (fit); después obra sobre datos —los mismos u otros— usando aquellos números (transform). Tipificar —restar la media y dividir por la desviación— lo ilustra, y su separación en dos fases no es cosmética: es la barrera contra la fuga de información entre entrenamiento y prueba que el capítulo 13 convertirá en metodología. En R funcional puro, un transformador es una función que devuelve funciones con los parámetros capturados en su clausura (cap. 3):

tipificador <- function() {
  media <- NULL; desv <- NULL
  list(
    fit = function(x) { media <<- mean(x); desv <<- sd(x); invisible(NULL) },
    transform = function(x) (x - media) / desv    # usa los params aprendidos
  )
}
tf <- tipificador()
tf$fit(entrenamiento)                 # aprende SOLO de entrenamiento
z_test <- tf$transform(prueba)        # aplica esos params a prueba

La clave metodológica está en la última línea: los datos de prueba se tipifican con la media y la desviación del entrenamiento, no con las suyas. Un transformador que se «reajustara» sobre la prueba filtraría información del conjunto de evaluación y daría métricas optimistas y falsas. La misma idea, con el contrato explícito de S7 —donde fit devuelve un objeto nuevo ya ajustado, inmutable—:

Tipificar <- new_class("Tipificar", properties = list(
  media = class_numeric, desv = class_numeric))
ajustar <- new_generic("ajustar", "obj")
aplicar <- new_generic("aplicar", "obj")
method(ajustar, Tipificar) <- function(obj, x)
  Tipificar(media = mean(x), desv = sd(x))
method(aplicar, Tipificar) <- function(obj, x) (x - obj@media) / obj@desv

Figura 6.4. El patrón transformador y la barrera contra la fuga. fit aprende los parámetros solo del entrenamiento. Esos mismos parámetros se aplican con transform tanto al entrenamiento como a la prueba. La prueba nunca influye en los parámetros: si lo hiciera —si el transformador se reajustara sobre ella— filtraría información del conjunto de evaluación y las métricas mentirían. Esta separación es la metodología del capítulo 13, aquí en su forma más simple.

Estrategia, Pipeline y Fábrica

La Estrategia —algoritmos intercambiables tras una interfaz común— es en R una simple lista de funciones, sin clase alguna:

escaladores <- list(
  min_max = function(x) (x - min(x)) / (max(x) - min(x)),
  z_score = function(x) (x - mean(x)) / sd(x),
  robusto = function(x) (x - median(x)) / IQR(x)
)
escalar <- function(x, metodo = "z_score") escaladores[[metodo]](x)

Añadir una estrategia es añadir una entrada a la lista; elegirla es indexar por nombre. Lo que en un lenguaje de clases sería una jerarquía de subclases, aquí son tres líneas —porque las funciones son datos de primera clase (cap. 3)—.

El Pipeline encadena pasos en un objeto reproducible: aplicar cada transformador en orden es un reduce (cap. 3) sobre la lista de pasos.

Pipeline <- new_class("Pipeline", properties = list(pasos = class_list))
correr <- new_generic("correr", "obj")
method(correr, Pipeline) <- function(obj, x) {
  reduce(obj@pasos, function(acc, paso) paso(acc), .init = x)
}
tuberia <- Pipeline(pasos = list(\(x) log1p(x), \(x) (x - mean(x)) / sd(x)))
r <- correr(tuberia, abs(datos))      # log y luego tipificado, en cadena

Y la Fábrica resuelve un acoplamiento: quien pide un escalador no debería tener que conocer la clase exacta que se le entrega, solo pedirlo por un nombre. En R eso es un switch (cap. 3) que mapea la clave al constructor:

crear_escalador <- function(tipo) {
  switch(tipo,
    tipico  = Tipificar(),
    robusto = EscaladorRobusto(),
    stop("escalador desconocido: ", tipo))
}

Un quinto patrón cierra el catálogo útil: el Decorador, que envuelve una función para añadirle comportamiento —registro, medición, caché— sin tocar su código. En R no necesita clases: es una función que recibe una función y devuelve otra, la técnica de los adverbios del capítulo 3. Un decorador que registra cada aplicación:

tipificar <- function(x) (x - mean(x)) / sd(x)
con_registro <- function(fn, nombre) {
  function(...) { cat("[log] aplicando", nombre, "\n"); fn(...) }
}
tipificar_log <- con_registro(tipificar, "tipificar")
tipificar_log(c(1, 2, 3, 4, 5))
#> [log] aplicando tipificar
#> [1] -1.26 -0.63  0.00  0.63  1.26

El decorador conserva la interfaz de la función original —recibe y devuelve lo mismo— y le añade una capa transparente. Es el mismo mecanismo con el que safely y memoise (cap. 3) añadían captura de errores y caché, y con el que un pipeline de producción añade trazas o cronometraje a cada paso sin ensuciar la lógica de cada transformador. Que en R sea una simple clausura, y no una jerarquía de clases envolventes, es —una vez más— el lenguaje funcional resolviendo con funciones lo que otros resuelven con objetos.

Estos cinco —transformador, estrategia, pipeline, fábrica, decorador— son los que de verdad reaparecen en el código de datos. Nótese que ninguno exigió una jerarquía de herencia profunda: en R, los patrones tienden a colapsar en funciones y listas, y esa es precisamente la señal de que el lenguaje está haciendo su trabajo.

Cuándo NO usar una clase

Después de cuatro sistemas de objetos y un puñado de patrones, la lección más importante del capítulo es contraria a todo lo anterior: en R, la mayoría de las veces, no necesitas una clase. R es, en su corazón, un lenguaje funcional (cap. 3), y su idioma natural no es el objeto con estado sino el data frame que atraviesa una tubería de funciones puras. El olor a sobrediseño más común entre quien llega de un lenguaje orientado a objetos es envolver en una clase lo que era una función:

# Sobrediseño: una clase con un metodo y sin estado...
Normalizador <- R6Class("Normalizador", public = list(
  normalizar = function(x) (x - mean(x)) / sd(x)))
Normalizador$new()$normalizar(x)

# ...es, exactamente, esta funcion:
normalizar <- function(x) (x - mean(x)) / sd(x)
normalizar(x)

La clase no aporta nada: no hay estado que encapsular, no hay invariante que proteger, no hay comportamiento que despachar por tipo. Es ceremonia vacía, y la ceremonia vacía es coste sin beneficio: más código que leer, más indirección que seguir, más superficie para el error. La pregunta que decide es simple: ¿hay estado que gestionar, o invariantes que garantizar, o despacho por tipo que resolver? Si la respuesta a las tres es no, una función pura es más clara, más comprobable y más idiomática.

¿Clase o función? La decisión, y qué herramienta usar cuando la clase sí aporta. Ante la duda, función: es lo que R prefiere.
Situación Herramienta Dónde
transformar un dato sin recordar nada función pura cap. 3
extender cómo se imprime/resume un objeto método S3 §6.2
tipo nuevo con campos e invariantes clase S7 §6.5
estado que cambia (contador, conexión) clase R6 §6.4
despacho según varios argumentos S4 §6.3
algoritmos intercambiables lista de funciones §6.7.2
encadenar pasos de preprocesado reduce sobre lista §6.7.2

Reconocer y navegar un objeto ajeno

Con cuatro sistemas conviviendo, una destreza cotidiana es diagnosticar, ante un objeto que devuelve una función desconocida, de qué sistema es y cómo está hecho por dentro. Los detectores son pocos y bastan: isS4 distingue S4; inherits(x, "R6") y inherits(x, "S7_object") identifican R6 y S7; lo demás es S3 o un tipo base. Una función de diagnóstico los reúne:

identificar <- function(x) {
  if (isS4(x)) "S4"
  else if (inherits(x, "R6")) "R6"
  else if (inherits(x, "S7_object")) "S7"
  else "S3 (o tipo base)"
}
identificar(lm(mpg ~ wt, mtcars))          # "S3 (o tipo base)"
identificar(Matrix::Matrix(0, 2, 2))       # "S4"
identificar(Pista(titulo = "x", energia = 0.5))   # "S7"

Sabido el sistema, cada uno tiene su llave para mirar dentro. En S3, el objeto es una estructura base con atributos: typeof revela lo que hay debajo —un lm es una list— y names enumera sus componentes. En S4, slotNames lista los slots. En S7, props devuelve las propiedades. Y para todos, str —del capítulo 2— despliega la estructura completa de un vistazo:

typeof(lm(mpg ~ wt, mtcars))               # "list": un modelo es una lista S3
head(names(lm(mpg ~ wt, mtcars)), 3)       # "coefficients" "residuals" ...
slotNames(Matrix::Matrix(0, 2, 2))     # slots S4: "diag" "Dim" "Dimnames" "x"

Este reflejo —identificar el sistema, luego abrir el objeto con la llave correcta— es lo que permite leer y depurar código ajeno sin naufragar en la diversidad de sistemas. Un modelo que «no se comporta» casi siempre se entiende mirando de qué está hecho: qué clase tiene, qué método despacha (§6.2.4), qué campos guarda. En R, casi todo objeto acaba siendo, bajo la superficie, una lista o un vector con una etiqueta (cap. 4); reconocer la etiqueta es la mitad del trabajo.

Figura 6.5. ¿Qué sistema de objetos usar? El árbol de decisión del capítulo. La primera pregunta —¿el objeto es un estado que muta?— aparta R6 de los demás. Entre los de valor, una clase nueva con validación es S7 (y S4 solo si de verdad hace falta despacho múltiple); extender cómo se comporta un objeto existente es S3; y si no hay estado, ni invariante, ni despacho que resolver, la respuesta correcta no es ninguna clase, sino una función pura.

Un tipo de dominio, de principio a fin

Para ver cómo se combinan las piezas cuando una clase está justificada, construyamos un tipo de dominio completo con S7: una Duracion musical. Es un caso de libro —una cantidad con unidades, un formato propio, aritmética y una invariante—, exactamente el tipo de valor que merece una clase porque el número desnudo (segundos) pierde su significado. Empezamos por la clase, su validador y un constructor amable que acepta el formato "m:ss":

Duracion <- new_class("Duracion",
  properties = list(seg = class_numeric),
  validator = function(self)
    if (self@seg < 0) "la duración no puede ser negativa")

duracion <- function(texto) {              # ayudante desde "3:52"
  partes <- as.integer(strsplit(texto, ":")[[1]])
  Duracion(seg = partes[1] * 60 + partes[2])
}

Le damos su formato propio registrando métodos sobre los genéricos S3 format y print (§6.2), para que se muestre como un tiempo y no como un número de segundos:

method(format, Duracion) <- function(x, ...)
  sprintf("%d:%02d", x@seg %/% 60, x@seg %% 60)
method(print,  Duracion) <- function(x, ...) cat(format(x), "\n")
duracion("3:52")
#> 3:52

Le damos aritmética y comparación registrando métodos sobre los operadores (§6.5.2), de modo que dos duraciones se sumen y se ordenen con la naturalidad de los números que representan:

method(`+`, list(Duracion, Duracion)) <-
  function(e1, e2) Duracion(seg = e1@seg + e2@seg)
method(`<`, list(Duracion, Duracion)) <- function(e1, e2) e1@seg < e2@seg
duracion("3:52") + duracion("4:10")
#> 8:02
duracion("3:52") < duracion("4:10")
#> [1] TRUE

Y como los operadores están definidos, el tipo compone con las herramientas funcionales del capítulo 3 sin más trabajo: la duración total de un álbum es un Reduce con +, exactamente igual que si fueran números —porque, para el resto de R, ya se comportan como tales—:

album <- list(duracion("3:52"), duracion("4:10"), duracion("2:45"))
Reduce(`+`, album)
#> 10:47
duracion("-1:00")                          # y la invariante monta guardia
#> Error: <Duracion> object is invalid: la duración no puede ser negativa

En una treintena de líneas, Duracion es un tipo de pleno derecho: se construye desde texto, se imprime con su formato, se suma, se compara, se reduce, y no puede existir en un estado inválido. Cada método ganó su sitio —formato, aritmética, validación—, y ninguno sobra. Esta es la cara amable de los objetos en R: cuando el dato tiene identidad, unidades y reglas propias, una clase bien hecha lo convierte en un ciudadano del lenguaje que el resto del código usa sin enterarse de que no es un número. La pregunta, siempre, es si esa identidad existe; cuando existe, así se le da forma.

NotaAvanzado

Un detalle que enlaza con la lección del capítulo 4: nada obliga a que una Duracion contenga un valor. Como su propiedad seg es un class_numeric, puede guardar un vector entero, y si los métodos están escritos con operaciones vectorizadas —sprintf, %/%, +— el tipo funciona sobre miles de duraciones a la vez sin cambiar una línea:

Duracion(seg = c(232, 250, 165))
#> 3:52 4:10 2:45          <- un objeto, tres duraciones, formateadas de golpe

Diseñar los tipos de dominio para que envuelvan vectores en lugar de escalares es lo que los mantiene en las rutas rápidas de R (cap. 4): una columna de un data frame podría ser, así, una columna de duraciones, tan eficiente como la de dobles que lleva debajo. Es la misma filosofía con la que R construye sus propios tipos —una fecha Date no es un objeto por día, sino un vector de días—, y la razón de que «pensar en vectores» siga siendo la brújula incluso cuando se diseñan clases.

El estilo de datos frente al estilo de objetos

Conviene rematar la discusión de «cuándo una clase» con la filosofía que la sostiene, porque marca la diferencia entre escribir R con el lenguaje o contra él. Quien llega de Java o C++ trae un reflejo: modelar el dominio como una jerarquía de objetos con estado, métodos que mutan campos, herencia para compartir comportamiento. En R ese reflejo produce código que funciona pero resulta ajeno, y casi siempre hay una alternativa más simple y más idiomática: el estilo de datos, donde el dominio se representa con estructuras inmutables —vectores, listas, data frames (cap. 2)— y el comportamiento vive en funciones puras que las transforman (cap. 3).

El contraste se ve en un caso concreto. Modelar «un catálogo de pistas sobre el que calcular estadísticas» al estilo de objetos pediría una clase Catalogo con métodos media_energia, filtrar_genero, anadir_pista que mutan un estado interno. Al estilo de datos, el catálogo es un data frame, y esos métodos son verbos de dplyr que ya existen:

# estilo de objetos: un catalogo que se muta
cat <- Catalogo$new(pistas); cat$filtrar_genero("pop"); cat$media_energia()

# estilo de datos: el data frame ES el catalogo, los verbos son las operaciones
pistas |> filter(genero == "pop") |> summarise(m = mean(energia))

La segunda versión no inventa ninguna abstracción: reutiliza el vocabulario que todo usuario de R ya conoce, no arrastra estado mutable que pueda corromperse, y cada paso es una función pura que devuelve un dato nuevo sin tocar el original —«una función no puede estropear tus datos» (cap. 2)—. Esa es la apuesta del tidyverse (Wickham et al. 2019), y la razón de que el código de datos en R tenga tan pocas clases: el data frame inmutable que fluye por una tubería de verbos es una abstracción tan potente que hace innecesarias casi todas las demás.

¿Significa esto que las clases sobran? No: significa que ganan su sitio donde aportan una garantía que el data frame no da. Un contrato con validación continua (S7, §6.5); un estado que evoluciona y cuya identidad importa (R6, §6.4); una extensión del protocolo de impresión o resumen de un objeto (S3, §6.2). Fuera de esos casos, el estilo de datos gana en claridad, en comprobabilidad y en familiaridad. La regla que resume el capítulo entero: usa objetos para las garantías que solo los objetos dan, y datos con funciones para todo lo demás —que es casi todo—.

Un ejemplo integrador: un mini-framework de preprocesado

Reunamos las piezas en algo que se parezca al código real: un pequeño marco de preprocesado, construido con S7, que aprende de unos datos y se aplica a otros con las mismas garantías. Es el esqueleto de lo que recipes (cap. 13) hace industrialmente, reducido a lo esencial para que se vea entero. Sobre el catálogo musical, el objetivo es tipificar los rasgos de audio y acotar la popularidad, sin fuga de información entre train y test.

Primero, el contrato: una clase abstracta que fija la interfaz común de todo transformador —dos genéricos, ajustar y aplicar—.

library(S7)
Transformador <- new_class("Transformador", abstract = TRUE,
  properties = list(ajustado = new_property(class_logical, default = FALSE)))
ajustar <- new_generic("ajustar", "obj")   # aprende parametros de los datos
aplicar <- new_generic("aplicar", "obj")   # los aplica a un data frame

Ser abstract significa que Transformador no se instancia: solo existe para que hereden de él los transformadores concretos, garantizando que todos hablan el mismo idioma. El primero, tipificar una columna: aprende su media y su desviación, y devuelve un objeto nuevo marcado como ajustado.

Tipificar <- new_class("Tipificar", parent = Transformador,
  properties = list(col = class_character, media = class_numeric,
                    desv = class_numeric))
method(ajustar, Tipificar) <- function(obj, datos) {
  x <- datos[[obj@col]]
  Tipificar(col = obj@col, media = mean(x), desv = sd(x), ajustado = TRUE)
}
method(aplicar, Tipificar) <- function(obj, datos) {
  stopifnot(obj@ajustado)               # no se aplica lo que no se ha ajustado
  datos[[obj@col]] <- (datos[[obj@col]] - obj@media) / obj@desv
  datos
}

El segundo, acotar una columna a un rango: no necesita aprender nada, así que su ajustar solo se marca como listo. Que herede el mismo contrato es lo que permite tratarlos por igual dentro del pipeline.

Acotar <- new_class("Acotar", parent = Transformador,
  properties = list(col = class_character,
                    lo = class_numeric, hi = class_numeric))
method(ajustar, Acotar) <- function(obj, datos) { obj@ajustado <- TRUE; obj }
method(aplicar, Acotar) <- function(obj, datos) {
  datos[[obj@col]] <- pmin(pmax(datos[[obj@col]], obj@lo), obj@hi)
  datos
}

El pipeline reúne los pasos y encadena las dos fases. Su ajustar_todo tiene una sutileza importante: cada paso aprende sobre los datos ya transformados por los anteriores, así que ajusta y aplica en el mismo recorrido, y guarda los pasos ya ajustados. Y su validador rechaza cualquier pipeline cuyos pasos no sean transformadores de verdad:

Pipeline <- new_class("Pipeline",
  properties = list(pasos = class_list),
  validator = function(self) {
    if (!all(map_lgl(self@pasos, \(p) S7_inherits(p, Transformador))))
      "todos los pasos deben ser Transformador"
  })
ajustar_todo <- new_generic("ajustar_todo", "obj")
method(ajustar_todo, Pipeline) <- function(obj, datos) {
  ajustados <- list(); d <- datos
  for (paso in obj@pasos) {
    a <- ajustar(paso, d); d <- aplicar(a, d)   # aprende y avanza
    ajustados <- c(ajustados, list(a))
  }
  Pipeline(pasos = ajustados)
}
aplicar_todo <- new_generic("aplicar_todo", "obj")
method(aplicar_todo, Pipeline) <- function(obj, datos)
  reduce(obj@pasos, function(acc, paso) aplicar(paso, acc), .init = datos)

Con las piezas montadas, el uso es el que uno esperaría de una herramienta seria: definir el pipeline, ajustarlo sobre el entrenamiento, aplicarlo a lo que haga falta.

mus <- read_parquet("data/processed/musica.parquet",
                    col_select = c("track_genre", "energy",
                                   "danceability", "popularity"))
train <- slice_sample(mus, n = 5000)         # (semilla 2026)

pipe <- Pipeline(pasos = list(
  Acotar(col = "popularity", lo = 0, hi = 100),
  Tipificar(col = "energy"),
  Tipificar(col = "danceability")))
ajustado <- ajustar_todo(pipe, train)        # aprende de TRAIN
salida   <- aplicar_todo(ajustado, train)
c(media = mean(salida$energy), sd = sd(salida$energy))
#>  media     sd
#>      0      1                <- energy tipificada: media 0, desv 1
ajustado@pasos[[2]]@media                    # el parametro aprendido
#> [1] 0.639

Y la prueba de fuego, la que da sentido a todo el andamiaje: aplicar el pipeline ajustado a datos nuevos los lleva a la misma escala, usando los parámetros del entrenamiento —no los suyos—:

nuevos <- slice_sample(mus, n = 100)
mean(aplicar_todo(ajustado, nuevos)$energy)
#> [1] 0.053            <- no es 0 exacto: es correcto, son datos de prueba

Que la media de la energía en los datos nuevos no sea exactamente cero es la señal de que todo funciona: si lo fuera, el transformador se habría reajustado sobre la prueba, cometiendo la fuga que tanto cuidamos de evitar. El validador, por su parte, monta guardia:

Pipeline(pasos = list(Tipificar(col = "energy"), "no soy un paso"))
#> Error: <Pipeline> object is invalid:
#> - todos los pasos deben ser Transformador

Dos toques finales lo convierten en una herramienta que se puede usar y guardar. Un método print para el genérico S3 —la lección de §6.2 aplicada— hace que el pipeline se muestre legible en lugar de volcar su interior:

method(print, Pipeline) <- function(x, ...) {
  cat("<Pipeline>", length(x@pasos), "pasos:\n")
  for (p in x@pasos)
    cat("  -", class(p)[1], if (p@ajustado) "(ajustado)" else "", "\n")
  invisible(x)
}
ajustado
#> <Pipeline> 3 pasos:
#>   - Acotar (ajustado)
#>   - Tipificar (ajustado)
#>   - Tipificar (ajustado)

Y como un pipeline ajustado es un objeto de valor con sus parámetros dentro, saveRDS (cap. 5) lo persiste entero —clases, propiedades y todo— para reutilizarlo en otra sesión sin reajustar:

saveRDS(ajustado, "pipeline.rds")
p2 <- readRDS("pipeline.rds")
S7_inherits(p2, Pipeline)              # la clase S7 sobrevive al viaje
#> [1] TRUE
class(p2@pasos[[1]])[1]                # y la de cada paso tambien
#> [1] "Acotar"

Que el objeto S7 sobreviva intacto a saveRDS/readRDS es el cierre del círculo con el capítulo anterior: un modelo o un preprocesador ajustado es, al fin y al cabo, un objeto con estado que se guarda como cualquier otro (cap. 5), y en el capítulo 16 será exactamente así como se despliegue a producción.

Este mini-framework tiene, en pequeño, todo lo que el capítulo defendió: S7 para las clases con contrato y validación, un genérico para el despacho por tipo, la semántica de valor que hace que ajustar devuelva un objeto nuevo en lugar de mutar el viejo, y funciones puras (reduce) para la composición. No hay una sola clase de más, y cada una gana su sitio protegiendo un invariante o despachando un método. Ese es el criterio que este capítulo pide llevarse: no «programa con objetos», sino «usa el sistema justo para la garantía que necesitas, y una función cuando no necesitas ninguna».

Síntesis: objetos con criterio en un lenguaje funcional

Merece la pena recoger el hilo, porque el capítulo ha recorrido un terreno amplio y su moraleja es fácil de perder entre la sintaxis de cuatro sistemas. La tensión de fondo es esta: R es un lenguaje funcional cuyo objeto natural es el dato inmutable que fluye por funciones puras, pero al mismo tiempo ofrece cuatro maquinarias de programación orientada a objetos. La pregunta que el capítulo ha intentado responder no es «cómo se usa cada una» —eso es documentación— sino «cuándo cada una gana su sitio», que es criterio.

El criterio se apoya en tres observaciones que conviene fijar. La primera es que la mayoría del código de datos no necesita clases en absoluto: un data frame atravesando verbos de dplyr expresa la inmensa mayoría de los análisis con más claridad que cualquier jerarquía de objetos, y la disciplina de resistir el reflejo de «modelar el dominio con clases» —tan arraigado en quien viene de otros lenguajes— es, ella misma, aprender a programar en R. La segunda es que, cuando una clase aporta, casi siempre aporta una de tres cosas concretas: un contrato con validación continua, un estado que evoluciona con identidad propia, o una extensión del comportamiento de un objeto ya existente. A cada una le corresponde una herramienta —S7, R6, S3— y elegir bien es emparejar la necesidad con la garantía, no seguir una moda ni una costumbre heredada de otro lenguaje. La tercera es que la evaluación ordenada, por exótica que parezca al principio, no es una rareza académica sino el pegamento que hace coherente todo el ecosistema de datos de R: entender que filter, mutate, ggplot y lm comparten la misma idea —capturar una expresión y decidir dónde evaluarla— es entender por qué el código de datos en R se lee como se lee, y es la llave para escribir funciones propias que encajen con naturalidad en ese ecosistema en lugar de pelear contra él.

Hay una simetría elegante entre las dos mitades del capítulo. Los sistemas de objetos son maneras de dar comportamiento a los datos —métodos que despachan según el tipo—; la evaluación ordenada es una manera de dar flexibilidad al código —expresiones que se capturan y se reencaminan—. Y ambas comparten una raíz que atraviesa todo el libro: en R, el código es un dato más (cap. 3), las clases son atributos sobre estructuras corrientes (cap. 4), y las expresiones se pueden guardar, inspeccionar y construir como cualquier otro objeto. Esa uniformidad —que todo, hasta el lenguaje mismo, sea manipulable con las herramientas del lenguaje— es lo que hace de R un instrumento tan maleable para los datos, y lo que este capítulo ha querido dejar a la vista. Quien lo interioriza deja de preguntarse «¿qué clase declaro?» y empieza a preguntarse «¿qué garantía necesito, y cuál es la manera más simple de conseguirla?». Casi siempre, la respuesta más simple es también la más idiomática; y cuando no lo es, ya sabes cuál de los cuatro sistemas llamar.

Errores frecuentes con objetos y evaluación

  • Confiar en que una clase S3 tiene los campos que espera un método. La etiqueta se pega sin validar (§6.2.1). Solución: el patrón constructor/validador/ayudante, o directamente S7 con su validador.

  • Elegir S4 para una clase nueva por costumbre. En 2026, S7 hace lo mismo con menos ceremonia y mejor integración (§6.5). Solución: S4 para leer Bioconductor; S7 para escribir.

  • Olvidar que R6 no copia al asignar. b <- a comparte el objeto; tocar b cambia a (§6.4). Solución: a$clone() cuando quieras una copia de verdad.

  • Usar R6 para un dato que se transforma. La semántica de referencia solo aporta cuando la identidad del objeto importa. Solución: S3/S7 de valor para datos; R6 para estado.

  • datos$col dentro de una función que envuelve dplyr. Busca una columna literal col (§6.6.2). Solución: el abrazo { col }.

  • Construir código con paste y eval(parse()) para un nombre de columna en texto. Es la puerta de atrás: ejecuta cualquier cosa. Solución: el pronombre .data[[txt]] (§6.6.2).

  • Envolver en una clase lo que es una función pura. Ceremonia sin estado, invariante ni despacho (§6.8). Solución: la función, a secas.

  • Reajustar un transformador sobre los datos de prueba. Fuga de información: métricas falsamente buenas (§6.7.1). Solución: fit solo en entrenamiento; transform con esos parámetros.

  • Acceder a un slot S4 o propiedad S7 inexistente esperando NULL. A diferencia de $ en listas, @ sobre un campo inexistente es un error. Solución: es una virtud, no un fallo; declara las propiedades que necesitas.

  • Mezclar el @ de S4/S7 con el $ de S3/R6. Cada sistema tiene su operador de acceso; confundirlos falla. Solución: @ para slots (S4) y propiedades (S7), $ para listas (S3) y miembros (R6).

Y el destilado en diez reglas, tabla 6.5.

Objetos y evaluación: diez reglas. El resumen operativo del capítulo.
Regla Dónde
Ante la duda, una función pura; la clase se justifica, no se asume. §6.8
Extender un objeto existente (imprimir, resumir): método S3. §6.2
Clase nueva con campos e invariantes: S7 con validador. §6.5
Estado que cambia en el tiempo: R6, y recuerda su semántica de referencia. §6.4
S4 para leer Bioconductor y para el despacho múltiple. §6.3
Domestica S3 con constructor + validador + ayudante. §6.2.1
Al envolver dplyr, abraza el argumento: { }. §6.6.2
Nombre de columna en texto: .data[[txt]], nunca parse. §6.6.2
La fórmula ~ es evaluación ordenada con treinta años. §6.6.4
Transformador: aprende en train, aplica con esos parámetros. §6.7.1
Vocabulario del capítulo. Los términos que el resto del libro usará sin volver a definirlos.
Término Significado
genérico función que despacha al método según la clase de su argumento
método implementación de un genérico para una clase concreta
despacho elegir el método según la(s) clase(s) de los argumentos
slot / propiedad campo tipado de un objeto S4 / S7, se accede con @
validador función que verifica las invariantes de una clase
semántica de referencia asignar comparte el objeto; modificar afecta a todos (R6)
evaluación ordenada capturar una expresión y elegir dónde evaluarla
cuasicitación congelar código como dato (expr) para evaluarlo luego
abrazo { } reenviar un argumento-columna sin evaluar a un verbo dplyr
fórmula expresión con dos lados que recuerda su entorno (~)
transformador objeto que aprende parámetros (fit) y los aplica (transform)

Lecturas recomendadas

El tratamiento de referencia de los sistemas de objetos de R es la segunda parte de Advanced R (Wickham 2019), que dedica capítulos independientes a S3, S4 y R5/R6 con el rigor y los ejemplos que aquí solo se han esbozado; su discusión del patrón constructor/validador/ayudante es la fuente directa de §6.2.1. La documentación de S7 (Vaughan et al. 2025) explica el diseño del sistema y su hoja de ruta hacia R base, y la de R6 (Chang 2025), la semántica de referencia y sus campos activos y privados. Sobre evaluación ordenada, la viñeta de programming with dplyr y la documentación de rlang (Henry y Wickham 2025) son la vía de entrada, y el capítulo de metaprogramación de (Wickham 2019) la profundización. Para los patrones de diseño en su formulación clásica, el catálogo original (Gamma et al. 1994) sigue siendo la referencia, con la salvedad —discutida en §6.7— de que un lenguaje funcional disuelve buena parte de ellos. Y para la filosofía de fondo —cuándo el objeto ayuda y cuándo estorba— el tidyverse y su guía de estilo (Wickham et al. 2019) encarnan la apuesta por el dato inmutable y la función pura que este capítulo ha defendido.

Referencias

Chang, Winston. 2025. «R6: Encapsulated Classes with Reference Semantics». https://r6.r-lib.org/.
Gamma, Erich, Richard Helm, Ralph Johnson, y John Vlissides. 1994. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional Computing Series. Addison-Wesley.
Henry, Lionel, y Hadley Wickham. 2025. «rlang: Language Features and Condition System for R». https://rlang.r-lib.org/.
Vaughan, Davis, Jim Hester, Tomasz Kalinowski, William Michael Landau, Hadley Wickham, y Lionel Henry. 2025. «S7: An Object Oriented System Meant to Become a Successor to S3 and S4». https://rconsortium.github.io/S7/.
Wickham, Hadley. 2019. Advanced R. 2.ª ed. Chapman; Hall/CRC. https://adv-r.hadley.nz/.
Wickham, Hadley, Mara Averick, Jennifer Bryan, et al. 2019. «Welcome to the tidyverse». Journal of Open Source Software 4 (43): 1686. https://doi.org/10.21105/joss.01686.