1 简介
管道能够清晰的表达多步操作过程。那么管道到底是如何工作的呢?有哪些方法可以替代管道呢?在什么情况下不能使用管道呢?接下来我们将一步步讨论。
1.1 加载包
管道符:%>%
是来自Stefan Milton Bache的magrittr包。tidyverse 中的包%>%
会自动加载,因此我们不需要加载 magrittr。为了突出管道来源的包,我们先加载它。
library(magrittr)
2 管道替代方案
管道的目的是帮助您以更易于阅读和理解编写的代码。为了了解管道为何如此有用,通过编写多种相同代码来比较。下面来看一个关于一只名叫 Foo Foo 的小兔子的故事:
Little bunny Foo Foo
Went hopping through the forest
Scooping up the field mice
And bopping them on the head
这是一首英语流行儿童诗。
我们将首先定义一个对象来代表小兔子 Foo Foo:
foo_foo <- little_bunny()
我们将为每个关键动作使用一个函数:hop()、scoop()和bop()。使用这个对象和这些动做,我们可以(至少)有四种方式在代码中复述这个故事:
- 将每个中间步骤保存为一个新对象。
- 多次覆盖原始对象。
- 编写函数。
- 使用管道。
我们将研究每种方法,展示代码优缺点。
2.1 中间步骤
最简单的方法是将每个步骤保存为一个新对象:
foo_foo_1 <- hop(foo_foo, through = forest)
foo_foo_2 <- scoop(foo_foo_1, up = field_mice)
foo_foo_3 <- bop(foo_foo_2, on = head)
这种方法的的主要缺点是你必须为每个中间元素命名。
实际的数据管道操作,我们在其中添加了一个新列ggplot2::diamonds
:
diamonds <- ggplot2::diamonds
diamonds2 <- diamonds %>%
dplyr::mutate(price_per_carat = price / carat)
pryr::object_size(diamonds)
#> Registered S3 method overwritten by 'pryr':
#> method from
#> print.bytes Rcpp
#> 3.46 MB
pryr::object_size(diamonds2)
#> 3.89 MB
pryr::object_size(diamonds, diamonds2)
#> 3.89 MB
pryr::object_size()
给出其所有参数占用的内存。然而一看结果觉得不可思议:
-
diamonds
占用 3.46 MB, -
diamonds2
占用 3.89 MB, -
diamonds
和diamonds2
一起占用 3.89 MB!
这是怎么回事呢?diamonds2
与diamonds
有 10 列: 没有必要复制所有数据,所以两个数据框拥有共同的变量。如果修改其中一个变量,这个变量会被复制。在下面例子中,修改了diamonds$carat
. 这意味着carat
变量不能再在两个数据帧之间共享,必须进行复制。每个数据帧的大小不变,但集体大小增加:
diamonds$carat[1] <- NA
pryr::object_size(diamonds)
#> 3.46 MB
pryr::object_size(diamonds2)
#> 3.89 MB
pryr::object_size(diamonds, diamonds2)
#> 4.32 MB
2.2 覆盖对象
我们可以直接覆盖原始对象,而需要在每一步都创建中间对象:
foo_foo <- hop(foo_foo, through = forest)
foo_foo <- scoop(foo_foo, up = field_mice)
foo_foo <- bop(foo_foo, on = head)
这是更少的打字(和更少的思考),所以你不太可能犯错误。但是,有两个问题:
不方便调试:如果你某一步出现了错误,你需要从头开始重新运行 。
对象名重复使用(我们已经写了
foo_foo
六次!)掩盖了每一行的变化。
2.3 构造函数
直接合并函数调用:
bop(
scoop(
hop(foo_foo, through = forest),
up = field_mice
),
on = head
)
这里的缺点是你必须从里到外,从右到左阅读,代码阅读起来困难。
2.4 使用管道
最后,我们可以使用管道:
foo_foo %>%
hop(through = forest) %>%
scoop(up = field_mice) %>%
bop(on = head)
这是我最喜欢的形式,因为它侧重于每一步操作,可以直接阅读这一系列的函数组合,就像它是一组命令式操作。Foo Foo ,hop,scoop,最后是bops。
管道通过执行“词法转换”来工作:在实际运行时,magrittr将管道中的代码重组为一种形式,这种形式通过覆盖一个中间对象来工作。当你运行一个像上面这样的管道时,magrittr会这样做:
my_pipe <- function(.) {
. <- hop(., through = forest)
. <- scoop(., up = field_mice)
bop(., on = head)
}
my_pipe(foo_foo)
这意味着管道不适用于两类函数:
-
使用当前环境的函数。例如,
assign()
在当前环境中创建一个具有给定名称的新变量:assign("x", 10) x #> [1] 10 "x" %>% assign(100) x #> [1] 10
对管道使用 assign 不起作用,因为它将管道分配给由
%>%
使用的临时环境。 如果您确实想对管道使用assign,则必须明确说明环境:env <- environment() "x" %>% assign(100, envir = env) x #> [1] 100
get()
和load()
函数也存在这样的问题。
-
使用惰性求值的函数。在 R 中,函数参数只在函数使用时计算,而不是在调用函数之前。管道依次计算每个元素,因此您不能依赖此行为。
在使用
tryCatch()
是也会出现问题,它可以抓取捕获和处理错误:tryCatch(stop("!"), error = function(e) "An error") #> [1] "An error" stop("!") %>% tryCatch(error = function(e) "An error") #> Error in eval(lhs, parent, parent): !
具有这种行为的函数类相对广泛,包括基R中的try()
、suppressMessages()
和suppressWarnings()
。
3 何时不使用管道
管道是一个强大的工具,但它不是你可以使用的唯一工具,也不能解决所有问题!管道对于重写相当短的线性操作序列最有用。在以下情况下建议使用另一种工具:
你的管道比(比如说)十步长。在这种情况下,创建具有有意义名称的中间对象。这将使调试更容易,因为您可以更轻松地检查中间结果,并且更容易理解代码,因为变量名称可以帮助传达意图。
有多个输入或输出。如果没有变换一个主要对象,而是将两个或多个对象组合在一起,则不要使用管道。
考虑具有复杂依赖结构的有向图。管道基本上是线性的,表达与它们的复杂关系通常会产生令人困惑的代码。
18.4 magrittr 的其他工具
magrittr 包中的其他一些有用工具有哪些呢?
- 当使用更复杂的管道时,有时调用一个函数来处理它的副作用是很有用的。也许您想打印出当前对象,或绘制它,或将其保存到磁盘。很多时候,这样的函数不返回任何东西,有效地终止了管道。
为了解决这个问题,可以使用“tee”管道。%T>%
与%>%
类似,不同的是%T>%
返回左边而不是右边。它被称为“tee”,因为它就像一个字面上的t形管。
rnorm(100) %>%
matrix(ncol = 2) %>%
plot() %>%
str()
#> NULL
rnorm(100) %>%
matrix(ncol = 2) %T>%
plot() %>%
str()
#> num [1:50, 1:2] -0.387 -0.785 -1.057 -0.796 -1.756 ...
-
如果你使用的函数没有基于数据帧的API(例如,你传递给它们单独的向量,而不是一个数据帧和要在该数据帧上下文中计算的表达式),你可能会发现
%$%
很有用。它“展开”数据帧中的变量,可以直接引用它们。这在处理以Base R的许多函数时非常有用:mtcars %$% cor(disp, mpg) #> [1] -0.8475514
-
对于赋值,magrittr提供了
%<>%
操作符,它允许你替换如下代码:mtcars <- mtcars %>% transform(cyl = cyl * 2)
和
mtcars %<>% transform(cyl = cyl * 2)
我不喜欢这个运算符,因为我认为赋值是一种特殊的操作,它在发生时应该总是很清楚。在我看来,一点点重复(即重复两次对象的名称)是可以的,因为可以使分配更加明确。