19  Internos de ggplot2

You are reading the work-in-progress third edition of the ggplot2 book. This chapter should be readable but is currently undergoing final polishing.

A lo largo de este libro hemos descrito ggplot2 desde la perspectiva de un usuario más que de un desarrollador. Desde el punto de vista del usuario, lo importante es entender cómo funciona la interfaz de ggplot2. Para realizar una visualización de datos, el usuario necesita saber cómo se pueden usar funciones como ggplot() y geom_point() para especificar un gráfico, pero muy pocos usuarios necesitan comprender cómo ggplot2 traduce esta especificación del gráfico en una imagen. . Sin embargo, para un desarrollador de ggplot2 que espera diseñar extensiones, esta comprensión es primordial.

Al dar el salto de usuario a desarrollador, es común encontrar frustraciones porque la naturaleza de la interfaz ggplot2 es muy diferente a la estructura de la maquinaria subyacente que la hace funcionar. A medida que extender ggplot2 se vuelve más común, también lo hace la frustración relacionada con comprender cómo encaja todo. Este capítulo está dedicado a proporcionar una descripción de cómo funciona ggplot2 “detrás de las cortinas”. Nos centramos en el diseño del sistema más que en los detalles técnicos de su implementación, y el objetivo es proporcionar una comprensión conceptual de cómo encajan las piezas. Comenzamos con una descripción general del proceso que se desarrolla cuando se traza un objeto ggplot y luego profundizamos en los detalles, describiendo cómo los datos fluyen a través de todo este proceso y terminan como elementos visuales en su trama.

19.1 El método plot()

Para comprender la maquinaria que sustenta ggplot2, es importante reconocer que casi todo lo relacionado con el dibujo de la trama ocurre cuando imprimes el objeto ggplot, no cuando lo construyes. Por ejemplo, en el código siguiente, el objeto p es una especificación abstracta de los datos de la trama, las capas, etc. No construye la imagen en sí:

p <- ggplot(mpg, aes(displ, hwy, color = drv)) + 
  geom_point(position = "jitter") +
  geom_smooth(method = "lm", formula = y ~ x) + 
  facet_wrap(vars(year)) + 
  ggtitle("A plot for expository purposes")

ggplot2 está diseñado de esta manera para permitir al usuario agregar nuevos elementos a un gráfico sin necesidad de volver a calcular nada. Una implicación de esto es que si quieres entender la mecánica de ggplot2, tienes que seguir tu trama a medida que avanza por la madriguera del conejo plot()1. Puede inspeccionar el método de impresión para objetos ggplot escribiendo ggplot2:::plot.ggplot en la consola, pero para este capítulo trabajaremos con una versión simplificada. Reducido a lo esencial, el método de trazado ggplot2 tiene la misma estructura que la siguiente función ggprint():

ggprint <- function(x) {
  data <- ggplot_build(x)
  gtable <- ggplot_gtable(data)
  grid::grid.newpage()
  grid::grid.draw(gtable)
  return(invisible(x))
}

Esta función no maneja todos los casos de uso posibles, pero es suficiente para dibujar el gráfico especificado anteriormente:

ggprint(p) 

El código de nuestro método de impresión simplificado revela cuatro pasos distintos:

  • Primero, llama a ggplot_build() donde los datos de cada capa se preparan y organizan en un formato estandarizado adecuado para trazar.

  • En segundo lugar, los datos preparados se pasan a ggplot_gtable() y los convierte en elementos gráficos almacenados en una gtable (volveremos a eso más adelante).

  • En tercer lugar, el objeto gtable se convierte en una imagen con la ayuda del paquete grid.

  • Cuarto, el objeto ggplot original se devuelve de forma invisible al usuario.

Una cosa que este proceso revela es que ggplot2 no realiza ningún dibujo de bajo nivel: su responsabilidad termina cuando se ha creado el objeto gtable. El paquete gtable (que implementa la clase gtable) tampoco realiza ningún dibujo. Todo el dibujo lo realiza el paquete grid junto con el dispositivo gráfico activo. Este es un punto importante, ya que significa que ggplot2, o cualquier extensión de ggplot2, no se preocupa por el meollo de la creación de la salida visual. Más bien, su trabajo es convertir los datos del usuario a una o más primitivas gráficas como polígonos, líneas, puntos, etc. y luego entregar la responsabilidad al paquete grid.

Aunque no es estrictamente correcto hacerlo, nos referiremos a esta conversión a primitivas gráficas como proceso de renderizado. Las siguientes dos secciones siguen los datos a través de la madriguera del conejo de renderizado a través del paso de compilación (Sección 19.2) y el paso de gtable (Sección 19.3) después de lo cual, algo así como Alicia en la novela de Lewis Carroll, finalmente llega a la cuadrícula. El país de las maravillas como una colección de primitivos gráficos.

19.2 El paso de construcción

ggplot_build(), como se analizó anteriormente, toma la representación declarativa construida con la API pública y la aumenta preparando los datos para su conversión a primitivas gráficas.

19.2.1 Preparación de datos

La primera parte del procesamiento es obtener los datos asociados con cada capa y ponerlos en un formato predecible. Una capa puede proporcionar datos de una de tres maneras: puede proporcionar los suyos propios (por ejemplo, si el argumento data de una geom es un marco de datos), puede heredar los datos globales proporcionados a ggplot(), o de lo contrario, podría proporcionar una función que devuelva un marco de datos cuando se aplica a los datos globales. En los tres casos, el resultado es un marco de datos que se pasa al diseño del trazado, que organiza sistemas de coordenadas y facetas. Cuando esto sucede, los datos se pasan primero al sistema de coordenadas de la trama, que puede cambiarlos (pero generalmente no lo hace), y luego a la faceta que inspecciona los datos para determinar cuántos paneles debe tener la trama y cómo deben organizarse. . Durante este proceso, los datos asociados con cada capa se aumentarán con una columna PANEL. Esta columna (debe) mantenerse durante todo el proceso de renderizado y se utiliza para vincular cada fila de datos a un panel de facetas específico en el gráfico final.

La última parte de la preparación de datos es convertir los datos de la capa en valores estéticos calculados. Esto implica evaluar todas las expresiones estéticas de aes() en los datos de la capa. Además, si no se da explícitamente, la estética group se calcula a partir de la interacción de todas las estéticas no continuas. La estética del group es, como PANEL, una columna especial que debe mantenerse durante todo el procesamiento. Como ejemplo, el gráfico p creado anteriormente contiene solo la capa especificada por geom_point() y al final del proceso de preparación de datos, las primeras 10 filas de los datos asociados con esta capa se ven así:

#>      x  y colour PANEL group
#> 1  1.8 29      f     1     2
#> 2  1.8 29      f     1     2
#> 3  2.0 31      f     2     2
#> 4  2.0 30      f     2     2
#> 5  2.8 26      f     1     2
#> 6  2.8 26      f     1     2
#> 7  3.1 27      f     2     2
#> 8  1.8 26      4     1     1
#> 9  1.8 25      4     1     1
#> 10 2.0 28      4     2     1

19.2.2 Transformación de datos

Una vez que los datos de la capa se han extraído y convertido a un formato predecible, se someten a una serie de transformaciones hasta que tienen el formato esperado por la geometría de la capa.

El primer paso es aplicar cualquier transformación de escala a las columnas de los datos. Es en esta etapa del proceso que cualquier argumento a favor de trans en una escala tiene efecto, y toda la representación posterior tendrá lugar en este espacio transformado. Esta es la razón por la que establecer una transformación de posición en la escala tiene un efecto diferente que establecerla en el sistema de coordenadas. Si la transformación se especifica en la escala, se aplica antes de cualquier otro cálculo, pero si se especifica en el sistema de coordenadas, la transformación se aplica después de esos cálculos. Por ejemplo, nuestro gráfico original p no implica transformaciones de escala, por lo que los datos de la capa permanecen intactos en esta etapa. Las primeras tres filas se muestran a continuación:

#>     x  y colour PANEL group
#> 1 1.8 29      f     1     2
#> 2 1.8 29      f     1     2
#> 3 2.0 31      f     2     2

Por el contrario, si nuestro objeto de trazado es p + scale_x_log10() e inspeccionamos los datos de la capa en este punto del procesamiento, vemos que la variable x se ha transformado apropiadamente:

#>       x  y colour PANEL group
#> 1 0.255 29      f     1     2
#> 2 0.255 29      f     1     2
#> 3 0.301 31      f     2     2

El segundo paso del proceso es mapear la estética de la posición utilizando las escalas de posición, que se despliegan de manera diferente según el tipo de escala involucrada. Para escalas de posición continuas, como las utilizadas en nuestro ejemplo, la función fuera de límites especificada en el argumento oob (Sección 14.4) se aplica en este punto y los valores NA en los datos de la capa se eliminan. . Esto hace poca diferencia para p, pero si estuviéramos graficando p + xlim(2, 8) en lugar de ello, la función oobscales::censor() en este caso – reemplazaría a x valores por debajo de 2 con NA como se ilustra a continuación:

#> Warning: Removed 22 rows containing non-finite outside the scale range
#> (`stat_smooth()`).
#>    x  y colour PANEL group
#> 1 NA 29      f     1     2
#> 2 NA 29      f     1     2
#> 3  2 31      f     2     2

Para posiciones discretas, el cambio es más radical, porque los valores coinciden con los valores de llimits o la especificación de interrupciones con breaks proporcionada por el usuario y luego se convierten a posiciones con valores enteros. Finalmente, para escalas de posición agrupadas, los datos continuos primero se cortan en bins usando el argumento breaks, y la posición de cada bin se establece en el punto medio de su rango. La razón para realizar el mapeo en esta etapa del proceso es la coherencia: no importa qué tipo de escala de posición se utilice, parecerá continua para los cálculos de estadísticas y geom. Esto es importante porque, de lo contrario, los cálculos como la esquiva y la fluctuación fallarían en escalas discretas.

En la tercera etapa de esta transformación, los datos se entregan a la capa de estadísticas donde se lleva a cabo cualquier transformación estadística. El procedimiento es el siguiente: primero, la estadística puede inspeccionar los datos y modificar sus parámetros, luego realizar una preparación única de los datos. A continuación, los datos de la capa se dividen en PANEL y group, y las estadísticas se calculan antes de volver a ensamblar los datos.2 Una vez que los datos se han vuelto a ensamblar en su nueva forma, pasan por otro proceso de mapeo estético. Aquí es donde se agrega a los datos cualquier estética cuyo cálculo se haya retrasado usando stat() (o la antigua notación ..var..). Observe que esta es la razón por la cual las expresiones stat(), incluida la fórmula utilizada para especificar el modelo de regresión en la capa geom_smooth() de nuestro gráfico de ejemplo p, no pueden hacer referencia a los datos originales. Simplemente no existe en este momento.

Como ejemplo, considere la segunda capa de nuestro gráfico, que produce las regresiones lineales. Antes de realizar los cálculos estadísticos, los datos de esta capa simplemente contienen las coordenadas y las columnas PANEL y group requeridas.

#>     x  y colour PANEL group
#> 1 1.8 29      f     1     2
#> 2 1.8 29      f     1     2
#> 3 2.0 31      f     2     2

Una vez realizados los cálculos estadísticos, los datos de la capa cambian considerablemente:

#>      x    y ymin ymax    se flipped_aes colour PANEL group
#> 1 1.80 24.3 23.1 25.6 0.625       FALSE      4     1     1
#> 2 1.86 24.2 22.9 25.4 0.612       FALSE      4     1     1
#> 3 1.92 24.0 22.8 25.2 0.598       FALSE      4     1     1

En este punto, la geom reemplaza a la estadística (casi). La primera acción que toma es inspeccionar los datos, actualizar sus parámetros y posiblemente realizar una modificación de primer paso de los datos (la misma configuración que para las estadísticas). Posiblemente aquí es donde algunas de las columnas se reparametrizan, p. x+width se cambia a xmin+xmax. Después de esto se aplica el ajuste de posición, de modo que p.e. las barras superpuestas se apilan, etc. Para nuestro gráfico de ejemplo p, es en este paso que se aplica la fluctuación en la primera capa del gráfico y las coordenadas x e y se alteran:

#>      x    y colour PANEL group
#> 1 1.84 28.7      f     1     2
#> 2 1.77 29.1      f     1     2
#> 3 2.03 31.3      f     2     2

A continuación, y quizás sorprendentemente, todas las escalas de posición se restablecen, se vuelven a entrenar y se aplican a los datos de la capa. Pensándolo bien, esto es absolutamente necesario porque, por ejemplo, el apilamiento puede cambiar drásticamente el rango de uno de los ejes. En algunos casos (por ejemplo, en el ejemplo de histograma anterior), es posible que una de las estéticas de posición ni siquiera esté disponible hasta después de los cálculos de estadísticas y, si las escalas no se volvieran a entrenar, nunca se entrenarían.

La última parte de la transformación de datos es entrenar y mapear todas las estéticas no posicionales, es decir, convertir cualquier entrada discreta o continua que esté asignada a parámetros gráficos como colores, tipos de línea, tamaños, etc. Además, se agrega cualquier estética predeterminada de geom. de modo que los datos ahora estén en un estado predecible para la geom. En el último paso, tanto la estadística como la faceta tienen una última oportunidad de modificar los datos en su forma asignada final con sus métodos finish_data() antes de finalizar el paso de compilación. Para el objeto de trazado p, las primeras filas del estado final de los datos de la capa se ven así:

#>    colour    x    y PANEL group shape size fill alpha stroke
#> 1 #00BA38 1.83 29.1     1     2    19  1.5   NA    NA    0.5
#> 2 #00BA38 1.79 29.2     1     2    19  1.5   NA    NA    0.5
#> 3 #00BA38 1.99 30.7     2     2    19  1.5   NA    NA    0.5

19.2.3 Salida

El valor de retorno de ggplot_build() es una estructura de lista con la clase ggplot_built. Contiene los datos calculados, así como un objeto Layout que contiene información sobre el sistema de coordenadas entrenado y las facetas. Además, contiene una copia del objeto de la trama original, pero ahora con escalas entrenadas.

19.3 El paso gtable

El propósito de ggplot_gtable() es tomar el resultado del paso de compilación y, con la ayuda del paquete gtable, convertirlo en un objeto que se pueda trazar usando grid (hablaremos más sobre gtable en Sección 21.5.6). En este punto, los principales elementos responsables de cálculos adicionales son las geomas, el sistema de coordenadas, la faceta y el tema. Las estadísticas y los ajustes de posición ya han contribuido.

19.3.1 Renderizando los paneles

Lo primero que sucede es que los datos se convierten en su representación gráfica. Esto sucede en dos pasos. Primero, cada capa se convierte en una lista de objetos gráficos (grobs). Al igual que con las estadísticas, la conversión se realiza dividiendo los datos, primero por PANEL y luego por group, con la posibilidad de que geom intercepte esta división por razones de rendimiento. Si bien ya se ha realizado gran parte de la preparación de datos, no es raro que geom realice alguna transformación adicional de los datos durante este paso. Una parte crucial es transformar y normalizar los datos de posición. Esto lo hace el sistema de coordenadas y, si bien a menudo significa simplemente que los datos se normalizan en función de los límites del sistema de coordenadas, también puede incluir transformaciones radicales, como convertir las posiciones en coordenadas polares. El resultado de esto es para cada capa una lista de objetos gList correspondientes a cada panel en el diseño de facetas. Después de esto, la faceta se hace cargo y ensambla los paneles. Para ello, primero recopila los grobs para cada panel de las capas, junto con franjas de renderizado, fondos, líneas de cuadrícula y ejes según el tema, y combina todo esto en una única gList para cada panel. Luego procede a organizar todos estos paneles en una tabla basada en el diseño del panel calculado. Para la mayoría de los gráficos, esto es simple ya que solo hay un panel, pero, por ejemplo, trazar usando facet_wrap() puede ser bastante complicado. La salida es la base del objeto gtable final. En esta etapa del proceso, nuestro gráfico de ejemplo p se ve así:

19.3.2 Agregar guías

Hay dos tipos de guías en ggplot2: ejes y leyendas. Como ilustra nuestro gráfico p, en este punto los ejes ya se han renderizado y ensamblado junto con los paneles, pero aún faltan las leyendas. Representar las leyendas es un proceso complicado en el que primero se entrena una guía para cada escala. Luego, potencialmente se fusionan varias guías si su mapeo lo permite, antes de que a las capas que contribuyen a la leyenda se les soliciten claves para cada clave de la leyenda. Estos elementos clave luego se ensamblan en capas y se combinan hasta formar la leyenda final en un proceso que recuerda bastante a cómo se combinan las capas en la tabla de paneles. Al final, el resultado es una tabla g que contiene cada cuadro de leyenda organizado y diseñado de acuerdo con el tema y las especificaciones de la guía. Una vez creada, la gtable guía se agrega a la gtable principal de acuerdo con la configuración del tema legend.position. En esta etapa, nuestro argumento de ejemplo está completo en la mayoría de los aspectos: lo único que falta es el título.

19.3.3 Añadiendo adorno

Lo único que queda es agregar título, subtítulo, leyenda y etiqueta, así como agregar fondo y márgenes, momento en el cual la tabla final estará lista.

19.3.4 Salida

En este punto, ggplot2 está listo para entregarse a grid. Nuestro proceso de renderizado es más o menos equivalente al código siguiente y el resultado final es, como se describe anteriormente, una gtable:

p_built <- ggplot_build(p)
p_gtable <- ggplot_gtable(p_built)

class(p_gtable)
#> [1] "gtable" "gTree"  "grob"   "gDesc"

Lo que es menos obvio es que las dimensiones del objeto son impredecibles y dependerán tanto del facetado como de la ubicación de la leyenda y de los títulos que se dibujen. Por lo tanto, no se recomienda depender de la ubicación de las filas y columnas en su código, en caso de que desee modificar aún más la gtable. Sin embargo, todos los elementos de gtable tienen nombre, por lo que aún es posible recuperarlos de manera confiable, p. el grob sostiene el eje y superior izquierdo con un poco de trabajo. A modo de ilustración, la gtable para nuestro gráfico p se muestra en el siguiente código:

p_gtable
#> TableGrob (17 x 17) "layout": 25 grobs
#>     z         cells             name
#> 1   0 ( 1-17, 1-17)       background
#> 2   1 (10-10, 7- 7)        panel-1-1
#> 3   1 (10-10,11-11)        panel-2-1
#> 4   3 ( 8- 8, 7- 7)       axis-t-1-1
#> 5   3 ( 8- 8,11-11)       axis-t-2-1
#> 6   3 (11-11, 7- 7)       axis-b-1-1
#> 7   3 (11-11,11-11)       axis-b-2-1
#> 8   3 (10-10,10-10)       axis-l-1-2
#> 9   3 (10-10, 6- 6)       axis-l-1-1
#> 10  3 (10-10,12-12)       axis-r-1-2
#> 11  3 (10-10, 8- 8)       axis-r-1-1
#> 12  2 ( 9- 9, 7- 7)      strip-t-1-1
#> 13  2 ( 9- 9,11-11)      strip-t-2-1
#> 14  4 ( 7- 7, 7-11)           xlab-t
#> 15  5 (12-12, 7-11)           xlab-b
#> 16  6 (10-10, 5- 5)           ylab-l
#> 17  7 (10-10,13-13)           ylab-r
#> 18  8 (10-10,15-15)  guide-box-right
#> 19  9 (10-10, 3- 3)   guide-box-left
#> 20 10 (14-14, 7-11) guide-box-bottom
#> 21 11 ( 5- 5, 7-11)    guide-box-top
#> 22 12 (10-10, 7-11) guide-box-inside
#> 23 13 ( 4- 4, 7-11)         subtitle
#> 24 14 ( 3- 3, 7-11)            title
#> 25 15 (15-15, 7-11)          caption
#>                                             grob
#> 1                rect[plot.background..rect.707]
#> 2                       gTree[panel-1.gTree.587]
#> 3                       gTree[panel-2.gTree.602]
#> 4                                 zeroGrob[NULL]
#> 5                                 zeroGrob[NULL]
#> 6            absoluteGrob[GRID.absoluteGrob.606]
#> 7            absoluteGrob[GRID.absoluteGrob.606]
#> 8                                 zeroGrob[NULL]
#> 9            absoluteGrob[GRID.absoluteGrob.614]
#> 10                                zeroGrob[NULL]
#> 11                                zeroGrob[NULL]
#> 12                                 gtable[strip]
#> 13                                 gtable[strip]
#> 14                                zeroGrob[NULL]
#> 15 titleGrob[axis.title.x.bottom..titleGrob.669]
#> 16   titleGrob[axis.title.y.left..titleGrob.672]
#> 17                                zeroGrob[NULL]
#> 18                             gtable[guide-box]
#> 19                                zeroGrob[NULL]
#> 20                                zeroGrob[NULL]
#> 21                                zeroGrob[NULL]
#> 22                                zeroGrob[NULL]
#> 23         zeroGrob[plot.subtitle..zeroGrob.704]
#> 24          titleGrob[plot.title..titleGrob.703]
#> 25          zeroGrob[plot.caption..zeroGrob.705]

La trama final, como era de esperar, parece idéntica a la original:

grid::grid.newpage()
grid::grid.draw(p_gtable)

19.4 Presentando ggproto

Sección 19.1 a Sección 19.3 se centran en la secuencia de eventos involucrados en la construcción de un ggplot, pero son intencionalmente vagos en cuanto a qué tipo de objetos de programación realizan este trabajo.

Todos los objetos ggplot2 se crean utilizando el sistema ggproto para programación orientada a objetos, y es inusual que solo lo use ggplot2. Esto es una especie de accidente histórico: ggplot2 originalmente usaba proto (Grothendieck, Kates, y Petzoldt 2016) para programación orientada a objetos, lo que se convirtió en un problema una vez que surgió la necesidad de un mecanismo de extensión oficial debido a las limitaciones del sistema proto. Los intentos de cambiar ggplot2 a otros sistemas como R6 (Chang 2020) resultaron difíciles, y crear un sistema orientado a objetos específico para las necesidades de ggplot2 resultó ser la solución menos mala.

Comprender el sistema de programación orientado a objetos ggproto es importante si desea escribir extensiones de ggplot2. Encontraremos objetos ggproto tal como los usa ggplot2 en Capítulo 20 y Capítulo 21. Al igual que el sistema R6 más conocido, ggproto utiliza semántica de referencia y permite la herencia y el acceso a métodos de las clases principales. Va acompañado de un conjunto de principios de diseño que, si bien ggproto no aplica, son esenciales para comprender cómo se utiliza el sistema en ggplot2. Para ilustrar estos conceptos, esta sección presenta la mecánica central de ggproto en una forma simplificada.

19.4.1 objetos ggproto

La creación de un nuevo objeto ggproto se realiza con la función ggproto(), que toma el nombre de la nueva clase como primer argumento, y otro objeto ggproto del cual heredará el nuevo como segundo argumento. Por ejemplo, podríamos crear un objeto ggproto (aunque no tenga ninguna funcionalidad útil) con el siguiente comando:

NewObject <- ggproto(
  `_class` = NULL, 
  `_inherits` = NULL
)

Por convención, los objetos ggproto se denominan usando “UpperCamelCase”, en el que cada palabra comienza con una letra mayúscula. También es convencional omitir los nombres de los argumentos `_class` y `_inherits`, por lo que la forma convencional de este comando sería la siguiente:

NewObject <- ggproto(NULL, NULL)

Si imprimimos este objeto vemos que efectivamente es un objeto ggproto, pero no aparece ninguna otra información.

NewObject 
#> <ggproto object: Class gg>

19.4.2 Creando nuevas clases

Para crear una nueva clase ggproto, lo único que es estrictamente necesario es proporcionar un nombre de clase como primer argumento de ggproto(). Un comando mínimo que define una nueva clase podría verse así:

NewClass <- ggproto("NewClass", NULL)

La variable NewClass todavía hace referencia a un objeto ggproto, pero podemos verificar que tiene el nombre de clase deseado imprimiéndolo:

NewClass
#> <ggproto object: Class NewClass, gg>

Sin embargo, hasta ahora lo único que hemos hecho es crear un objeto que especifica una clase. El objeto NewClass no hace nada. Para crear una clase ggproto que haga algo útil, debemos proporcionar una lista de campos y métodos cuando definimos la clase. En este contexto, los “campos” se utilizan para almacenar datos relevantes para el objeto y los “métodos” son funciones que pueden utilizar los datos almacenados en el objeto. Los campos y métodos se construyen de la misma manera y no se tratan de manera diferente desde la perspectiva del usuario.

Para ilustrar esto, crearemos una nueva clase llamada Person que se usará para almacenar y manipular información sobre una persona. Podemos hacer esto proporcionando a la función ggproto() pares nombre/valor:

Person <- ggproto("Person", NULL,
  
  # campos                  
  given_name = NA,
  family_name = NA,
  birth_date = NA,
  
  # métodos
  full_name = function(self, family_last = TRUE) {
    if(family_last == TRUE) {
      return(paste(self$given_name, self$family_name))
    }
    return(paste(self$family_name, self$given_name))
  },
  age = function(self) {
    days_old <- Sys.Date() - self$birth_date
    floor(as.integer(days_old) / 365.25)
  },
  description = function(self) {
    paste(self$full_name(), "is", self$age(), "years old")
  }
)

La clase Person ahora está asociada con tres campos, correspondientes a given_name y family_name de una persona, así como su birth_date. También posee tres métodos: el método full_name() es una función que construye el nombre completo de la persona, usando la convención de colocar el nombre de pila primero y el apellido segundo, el método age() calcula la edad de la persona en años, y el método description() imprime una breve descripción de la persona.

Al imprimir el objeto se muestran los campos y métodos a los que está asociado:

Person
#> <ggproto object: Class Person, gg>
#>     age: function
#>     birth_date: NA
#>     description: function
#>     family_name: NA
#>     full_name: function
#>     given_name: NA

El objeto ggproto Person es esencialmente una plantilla para la clase, y podemos usarlo para crear registros específicos de personas individuales (discutido en Sección 19.4.3). Si está familiarizado con otros sistemas de programación orientados a objetos, es posible que esperara algo un poco diferente: a menudo las nuevas clases se definen con una función constructora dedicada. Una peculiaridad de ggproto es que ggproto() no hace esto: más bien, el constructor de la clase es en sí mismo un objeto.

Otra cosa a tener en cuenta al definir métodos es el uso de self como primer argumento. Este es un argumento especial que se utiliza para darle al método acceso a los campos y métodos asociados con el objeto ggproto (consulte Sección 19.4.4 para ver un ejemplo). El estatus especial de este argumento es evidente al imprimir un método ggproto:

Person$full_name
#> <ggproto method>
#>   <Wrapper function>
#>     function(...) !!call2(name, !!!args)
#> 
#>   <Inner function (f)>
#>     function (self, family_last = TRUE) 
#> {
#>     if (family_last == TRUE) {
#>         return(paste(self$given_name, self$family_name))
#>     }
#>     return(paste(self$family_name, self$given_name))
#> }

Este resultado puede parecer un poco sorprendente: cuando definimos full_name() anteriormente solo proporcionamos el código listado como “función interna”. Lo que sucedió es que ggproto() automáticamente incluyó mi función dentro de una función contenedora que llama a mi código como función interna, al tiempo que garantiza que se use una definición apropiada de self. Cuando se imprime el método, la consola muestra tanto la función contenedora (normalmente de poco interés) como la función interna. La salida en este formato aparece en Capítulo 20 y Capítulo 21.

19.4.3 Creando nuevas instancias

Ahora que hemos definido la clase Person, podemos crear instancias de la clase. Esto se hace pasando un objeto ggproto como segundo argumento a ggproto() y sin especificar un nuevo nombre de clase en el primer argumento. Por ejemplo, podemos crear nuevos objetos Thomas y Danielle que sean instancias de la clase Person de la siguiente manera:

Thomas <- ggproto(NULL, Person,
  given_name = "Thomas Lin",
  family_name = "Pedersen",
  birth_date = as.Date("1985/10/12")
)

Danielle <- ggproto(NULL, Person,
  given_name = "Danielle Jasmine",
  family_name = "Navarro",
  birth_date = as.Date("1977/09/12")
)

Al especificar NULL como primer argumento, se le indica a ggproto() que no defina una nueva clase, sino que cree una nueva instancia de la clase especificada en el segundo argumento. Debido a que Thomas y Danielle son instancias de la clase Person, heredan automáticamente sus métodos age(), full_name() y description():

Thomas$description()
#> [1] "Thomas Lin Pedersen is 39 years old"

Danielle$description()
#> [1] "Danielle Jasmine Navarro is 47 years old"

19.4.4 Creando subclases

En el ejemplo anterior creamos Person como una clase completamente nueva. En la práctica, casi nunca necesitarás hacer esto: en su lugar, probablemente crearás una subclase usando un objeto ggproto existente. Puede hacer esto especificando el nombre de la subclase y el objeto del cual debe heredar en la llamada a ggproto():

# definir la subclase
NewSubClass <- ggproto("NewSubClass", Person)

# verificar que esto funcione
NewSubClass
#> <ggproto object: Class NewSubClass, Person, gg>
#>     age: function
#>     birth_date: NA
#>     description: function
#>     family_name: NA
#>     full_name: function
#>     given_name: NA
#>     super:  <ggproto object: Class Person, gg>

El resultado que se muestra arriba ilustra que NewSubClass ahora proporciona su propia clase y que hereda todos los campos y métodos del objeto Persona que creamos anteriormente. Sin embargo, esta nueva subclase no agrega ninguna funcionalidad nueva.

Al crear una subclase, a menudo queremos agregar nuevos campos o métodos y sobrescribir algunos de los existentes. Por ejemplo, supongamos que queremos definir Royalty como una subclase de Person y agregar campos correspondientes al rank de la realeza en cuestión y el territory sobre el que gobernaban. Debido a que a menudo se hace referencia a la realeza por título y territorio en lugar de en términos de nombre y apellido, también necesitaremos cambiar la forma en que se define el método full_name():

Royalty <- ggproto("Royalty", Person,
  rank = NA,
  territory = NA,
  full_name = function(self) {
    paste(self$rank, self$given_name, "of", self$territory)
  }
)

El objeto Royalty ahora define una subclase de persona que hereda algunos campos (given_name, family_name, birth_date) de la clase Person y proporciona otros campos (rank, territory). Hereda los métodos age() y description() de Person, pero sobrescribe el método full_name().

Ahora podemos crear una nueva instancia de la subclase Royalty:

Victoria <- ggproto(NULL, Royalty,
  given_name = "Victoria",
  family_name = "Hanover",
  rank = "Queen",
  territory = "the United Kingdom",
  birth_date = as.Date("1819/05/24")
)

Entonces, cuando llamamos al método full_name() para Victoria, la salida usa el método especificado en la clase Royalty en lugar del definido en la clase Persona:

Victoria$full_name()
#> [1] "Queen Victoria of the United Kingdom"

Vale la pena señalar lo que sucede cuando llamamos al método description(). Este método se hereda de Person, pero la definición de este método invoca self$full_name(). Aunque description() está definida en Person, en este contexto self todavía se refiere a Victoria, que sigue siendo Royalty. Lo que esto significa es que la salida del método heredado description() utiliza el método full_name() definido para la subclase:

Victoria$description()
#> [1] "Queen Victoria of the United Kingdom is 206 years old"

La creación de subclases a veces requiere que accedamos a la clase principal y sus métodos, lo que podemos hacer con la ayuda de la función ggproto_parent(). Por ejemplo, podemos definir una subclase Police que incluya un campo rank de la misma manera que lo hace la subclase Royalty, pero solo usa este rango como parte del método description():

Police <- ggproto("Police", Person,
  rank = NA, 
  description = function(self) {
    paste(
      self$rank,
      ggproto_parent(Person, self)$description()
    )
  }
)

En este ejemplo, el método description() para la subclase Police se define de una manera que se refiere explícitamente al método description() para la clase principal Person. Al usar ggproto_parent(Person, self) de esta manera, podemos hacer referencia al método dentro de la clase principal, manteniendo la definición local apropiada de self. Como antes, crearemos una instancia específica y verificaremos que funcione como se esperaba:

John <- ggproto(NULL, Police,
  given_name = "John",
  family_name = "McClane",
  rank = "Detective",
  birth_date = as.Date("1955/03/19")
)

John$full_name() 
#> [1] "John McClane"

John$description()
#> [1] "Detective John McClane is 70 years old"

Por razones que discutiremos a continuación, el uso de ggproto_parent() no es tan frecuente en el código fuente de ggplot2.

19.4.5 Guía de estilo para ggproto

Debido a que ggproto es un sistema de clases mínimo diseñado para acomodar ggplot2 y nada más, es importante reconocer que ggproto se usa en ggplot2 de una manera muy específica. Existe para admitir el sistema de extensión ggplot2 y es poco probable que encuentre ggproto en cualquier otro contexto que no sea escribir la extensión ggplot2. Teniendo esto en cuenta, es útil comprender cómo ggplot2 usa ggproto:

  • las clases ggproto se usan selectivamente. El uso de ggproto en ggplot2 no lo abarca todo. Solo la funcionalidad seleccionada se basa en ggproto y no se espera ni se recomienda crear clases de ggproto completamente nuevas en sus extensiones. Como desarrollador de extensiones, nunca creará objetos ggproto completos, sino que creará una subclase de una de las clases principales de ggproto proporcionadas por ggplot2. Capítulo 20 y Capítulo 21 detallarán cómo hacer esto.

  • las clases ggproto no tienen estado. Excepto por unas pocas clases internas que se utilizan para orquestar la representación, se supone que las clases ggproto en ggplot2 son “sin estado”. Lo que esto significa es que ggplot2 espera que una vez construidos, no cambien. Esto rompe una expectativa común para las clases basadas en referencias (donde los métodos a menudo pueden cambiar de forma segura el estado del objeto), pero no es seguro hacerlo con ggplot2. Si su código viola este principio y cambia el estado de una Stat o Geom durante el renderizado, trazar un objeto ggplot guardado afectará todas las instancias de esa Stat o Geom (incluso aquellas utilizadas en otros gráficos) porque todas apuntan al mismo objeto padre ggproto. Teniendo esto en cuenta, sólo hay dos ocasiones en las que debes especificar el estado de un objeto ggproto en ggplot2. Primero, puede especificar el estado al crear el objeto: esto está bien porque este estado debe compartirse entre todas las instancias de todos modos. En segundo lugar, puede especificar el estado mediante un objeto de parámetros administrado en otro lugar. Como verá más adelante (ver Sección 20.2 y Sección 20.3), la mayoría de las clases de ggproto tienen un método setup_params() donde se pueden inspeccionar datos y calcular y almacenar propiedades específicas.

  • las clases ggproto tienen herencia simple. Debido a que las instancias de la clase ggproto no tienen estado, es relativamente seguro llamar a métodos definidos dentro de otras clases, en lugar de heredar explícitamente de la clase. Esta es la razón por la cual la función ggproto_parent() rara vez se llama dentro del código fuente de ggplot2. Como ejemplo, el método setup_params() en GeomErrorbar se define como:

    GeomErrorbar <- ggproto(
      # ...
      setup_params = function(data, params) {
        GeomLinerange$setup_params(data, params)
      }
      # ...
    )

    Este patrón suele ser más fácil de leer que usar ggproto_parent() y como los objetos ggproto no tienen estado, es igual de seguro.

Chang, Winston. 2020. R6: Encapsulated Classes with Reference Semantics. https://CRAN.R-project.org/package=R6.
Grothendieck, Gabor, Louis Kates, y Thomas Petzoldt. 2016. proto: Prototype Object-Based Programming. https://CRAN.R-project.org/package=proto.

  1. Por lo general, no se llama a este método plot() directamente, ya que lo invoca el método print y, por lo tanto, se llama cada vez que se imprime un objeto ggplot.↩︎

  2. Es posible que una estadística evite esta división sobrescribiendo métodos compute_*() específicos y así realizar cierta optimización.↩︎