forked from cienciadedatos/r4ds
-
Notifications
You must be signed in to change notification settings - Fork 0
/
18-pipes.qmd
227 lines (155 loc) · 11.4 KB
/
18-pipes.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# Pipes
## Introducción
Los pipes son una herramienta poderosa para expresar claramente una secuencia de múltiples operaciones. Hasta aquí, has venido usándolos sin saber cómo funcionan o qué alternativas existen. En este capítulo ya es tiempo de explorarlos en más detalle. En él aprenderás qué alternativas existen, cuándo no deberías utilizarlos y algunas herramientas útiles relacionadas.
### Prerequisitos
El pipe, `%>%`, viene del paquete __magrittr__ de Stefan Milton Bache. Los paquetes del Tidyverse cargan `%>%` automáticamente, por lo que usualmente no tendrás que cargar __magrittr__ de forma explícita. Sin embargo, como acá nos enfocaremos en el uso de pipes y no usaremos otros paquetes del tidyverse, lo cargaremos explícitamente.
```{r setup, message = FALSE}
library(magrittr)
library(datos)
```
## Alternativas a los pipes
El objetivo de un pipe es ayudarte a escribir código de una manera que sea más fácil de leer y entender. Para ver por qué un pipe es tan útil, vamos a explorar diferentes formas de escribir el mismo código. Usemos código para contar una historia acerca de un pequeño conejito llamado Foo Foo:
> El pequeño conejito Foo Foo
> Fue saltando por el bosque
> Recogiendo ratones del campo
> Y golpeándolos en la cabeza
Este es un poema popular para niños que se acompaña mediante gestos con las manos.
Empezaremos por definir un objeto que represente al pequeño conejito Foo Foo:
```{r, eval = FALSE}
foo_foo <- pequeño_conejito()
```
Y usaremos una función para cada verbo clave: `saltar()`, `recoger()`, y `golpear()`. Usando este objeto y estos verbos, existen (al menos) cuatro maneras en las que podemos volver a contar la historia en código:
1. Guardar cada paso intermedio como un nuevo objeto.
2. Sobreescribir el objeto original muchas veces.
3. Componer funciones.
4. Usar un pipe.
Desarrollaremos cada uno de estos enfoques, mostrándote el código y hablando de las ventajas y desventajas.
### Pasos intermedios
El enfoque más sencillo es guardar cada paso como un nuevo objeto:
```{r, eval = FALSE}
foo_foo_1 <- saltar(foo_foo, a_traves = bosque)
foo_foo_2 <- recoger(foo_foo_1, que = ratones_del_campo)
foo_foo_3 <- golpear(foo_foo_2, en = cabeza)
```
La principal desventaja de esta manera es que te obliga a nombrar cada paso intermedio. Si hay nombres naturales, es una buena idea y deberías hacerlo. Pero muchas veces, como en este ejemplo, no hay nombres naturales, por lo que agregas sufijos numéricos para hacer los nombres únicos. Esto conduce a dos problemas:
1. El código está abarrotado con nombres poco importantes.
2. Hay que incrementar cuidadosamente el sufijo en cada línea.
Cada vez que escribimos código como este, nos ocurre que inevitablemente usamos el número incorrecto en una línea y luego perdemos 10 minutos rascándonos la cabeza tratándo de darnos cuenta por qué no funciona.
Probablemente te preocupa también que esta forma crea muchas copias de tus datos y ocupa demasiada memoria. Sorprendentemente, este no es el caso. Primero, ten en cuenta que preocuparse proactivamente de la memoria no es una manera útil de invertir tu tiempo: preocúpate acerca de ello cuando se convierta en un problema (es decir, cuando te quedes sin memoria), no antes. En segundo lugar, R no es estúpido y compartirá las columnas a lo largo de los dataframes cuando sea posible. Echemos un vistazo a un pipe de manipulación de datos en el que agregamos una nueva columna a `datos::diamantes`:
```{r}
diamantes2 <- diamantes %>%
dplyr::mutate(precio_por_quilate = precio / quilate)
pryr::object_size(diamantes)
pryr::object_size(diamantes2)
pryr::object_size(diamantes, diamantes2)
```
`pryr::object_size()` (_tamaño de objeto_) devuelve la cantidad de memoria ocupada por todos los argumentos de un objeto. Los resultados parecen contraintuitivos al principio:
* `diamantes` ocupa 3.46 MB,
* `diamantes2` ocupa 3.89 MB,
* ¡`diamantes` y `diamantes2` juntos ocupan 3.89 MB!
¿Cómo es que funciona esto? Bien, `diamantes2` tiene 10 columnas en común con `diamantes`: no hay necesidad de duplicar los datos, así que ambos data frames tienen variables en común. Estas variables solo serán copiadas si modificas una de ellas. En el siguiente ejemplo, modificamos un solo valor en `diamantes$quilate`. Esto significa que la variable `quilate` no podrá ser compartida entre los dos data frames, por lo que se debe realizar una copia. El tamaño de cada data frame no cambia, pero el tamaño colectivo se incrementa:
```{r}
diamantes$quilate[1] <- NA
pryr::object_size(diamantes)
pryr::object_size(diamantes2)
pryr::object_size(diamantes, diamantes2)
```
(Fíjate que aquí usamos `pryr::object_size()`, no la versión de `object.size()` ya precargada. `object.size()` toma un solo objeto, por lo que no puede computar cómo los datos son compartidos a través de múltiples objetos.)
### Sobrescribir el original
En vez de crear objetos en cada paso intermedio, podemos sobrescribir el objeto original:
```{r, eval = FALSE}
foo_foo <- saltar(foo_foo, a_traves = bosque)
foo_foo <- recoger(foo_foo, que = ratones_del_campo)
foo_foo <- golpear(foo_foo, en = cabeza)
```
Esto es menos tipeo (y menos que pensar), así que es menos probable que cometas errores. Sin embargo, hay dos problemas:
1. Depurar es doloroso: si cometes un error vas a necesitar correr de nuevo todo el código desde el principio.
2. La repetición del objeto a medida que es transformado (¡hemos escrito foo_foo 6 veces!) hace poco transparente lo que está siendo cambiado en cada línea.
### Composición de funciones
Otro enfoque es abandonar la asignación y encadenar todas las llamadas a las funciones:
```{r, eval = FALSE}
golpear(
recoger(
saltar(foo_foo, a_traves = bosque),
que = raton_de_campo
),
en = la_cabeza
)
```
Aquí la desventaja es que se debe leer de adentro hacia afuera, de derecha a izquierda y que los argumentos terminan separados (problema que se conoce como [Dagwood sandwich](https://es.wikipedia.org/wiki/Dagwood_sandwich)). En resumen, este código es difícil de leer para un ser humano.
### Uso de pipe
Finalmente, podemos usar el pipe:
```{r, eval = FALSE}
foo_foo %>%
saltar(a_traves = bosque) %>%
recoger(que = ratones_campo) %>%
golpear(en = cabeza)
```
Esta es nuestra forma preferida, ya que se enfoca en los verbos, no en los sustantivos. Puedes leer esta secuencia de composición de funciones como si fuera un conjunto de acciones imperativas. Foo salta, luego recoge, luego golpea. La desventaja, por supuesto, es que necesitas estar familiarizado con el uso de los pipes. Si nunca has visto `%>%` antes, no tendrás idea acerca de lo que realiza el código. Afortunadamente, la mayoría de la gente entiende la idea fácilmente, así que cuando compartes tu código con otros que no están familiarizados con los pipes, puedes enseñárselos fácilmente.
El pipe trabaja realizando una "transformación léxica": detrás de escena, magrittr reensambla el código en el pipe a una forma que funciona sobrescribiendo un objeto intermedio. Cuando se ejecuta un pipe como el de arriba, magrittr hace algo como esto:
```{r, eval = FALSE}
mi_pipe <- function(.) {
. <- saltar(., a_traves = bosque)
. <- recoger(., que = ratones_campo)
golpear(., en = la_cabeza)
}
mi_pipe(foo_foo)
```
Esto significa que un pipe no funcionará con dos clases de funciones:
1. Funciones que usan el entorno actual. Por ejemplo, `assign()` (_asignar_) creará una nueva variable con el nombre dado en el entorno actual:
```{r}
assign("x", 10)
x
"x" %>% assign(100)
x
```
El uso de la asignación con el pipe no funcionará porque lo asigna a un entorno temporal usado por `%>%`. Si quieres usar la asignación con un pipe, debes explicitar el entorno:
```{r}
env <- environment()
"x" %>% assign(100, envir = env)
x
```
Otras funciones con este problema incluyen `get()` y `load()`.
1. Funciones que usan _lazy evaluation_ (evaluación diferida o "perezosa"). En R, los argumentos de las funciones son solamente computados cuando la función los usa, no antes de llamar a la función. El pipe computa cada elemento por turno, por lo que no puedes confiar en este comportamiento.
Un caso en el cual esto es un problema es el de `tryCatch()`, que te permite capturar y manejar errores:
```{r, error = TRUE}
tryCatch(stop("!"), error = function(e) "Un error")
stop("!") %>%
tryCatch(error = function(e) "Un error")
```
Hay una cantidad relativamente grande de funciones con este comportamiento, que incluye a `try()`, `suppressMessages()` y `suppressWarnings()` en R base.
## Cuándo no usar el pipe
El pipe es una herramienta poderosa, pero no es la única herramienta a tu disposición ¡y no soluciona todos los problemas! Los pipes son mayoritariamente usados para reescribir una secuencia lineal bastante corta de operaciones. Creemos que deberías buscar otra herramienta cuando:
* Tus pipes son más largos que (digamos) 10 pasos. En ese caso, crea objetos intermedios con nombres significativos. Esto hará la depuración más fácil, porque puedes chequear con mayor facilidad los resultados intermedios. Además, hace más simple entender tu código, ya que los nombres de las variables pueden ayudar a comunicar la intención.
* Tienes múltiples _inputs_ y _outputs_. Si no hay un objeto principal para ser transformado, sino dos o más objetos siendo combinados juntos, no uses el pipe.
* Si estás empezando a pensar acerca de un grafo dirigido con una estructura de dependencia compleja. Los pipes son fundamentalmente lineales y usarlos para expresar relaciones complejas, típicamente llevarán a un código confuso.
## Otras herramientas de magrittr
Todos los paquetes del tidyverse automáticamente harán que `%>%` esté disponible, por lo que normalmente no será necesario cargar __magrittr__ explícitamente. De todas formas, hay otras herramientas útiles dentro de magrittr que podrías querer utilizar:
* Cuando se trabaja en un pipe más complejo, a veces es útil llamar a una función por sus efectos secundarios. Tal vez quieras imprimir el objeto actual, o graficarlo, o guardarlo en el disco. Muchas veces, estas funciones no devuelven nada, efectivamente terminando el pipe.
Para solucionar este problema, puedes usar el pipe "T". `%T>%`trabaja igual que `%>%`, excepto que devuelve el lado izquierdo en vez del lado derecho. Se lo llama "T" porque literalmente tiene la forma de una T.
```{r}
rnorm(100) %>%
matrix(ncol = 2) %>%
plot() %>%
str()
rnorm(100) %>%
matrix(ncol = 2) %T>%
plot() %>%
str()
```
* Si estás trabajando con funciones que no tienen una API basada en data frames (esto es, pasas vectores individuales, no un data frame o expresiones que serán evaluadas en el contexto de un data frame), puedes encontrar `%$%` útil. Este operador "explota" las variables en un dataframe para que te puedas referir a ellas de manera explícita. Esto es útil cuando se trabaja con muchas funciones en R base:
```{r}
mtautos %$%
cor(cilindrada, millas)
```
* Para asignaciones, magrittr provee el operador `%<>%` que te permite reemplazar el código de la siguiente forma:
```{r, eval = FALSE}
mtautos <- mtautos %>%
transform(cilindros = cilindros * 2)
```
con
```{r, eval = FALSE}
mtautos %<>% transform(cilindros = cilindros * 2)
```
No somos partidarios de este operador porque creemos que una asignación es una operación especial que siempre debe ser clara cuando sucede.
En nuestra opinión, un poco de duplicación (esto es, repetir el nombre de un objeto dos veces) está bien para lograr hacer la asignación más explícita.