为什么要使用purrr::map而不是lapply?

240

有没有任何理由让我使用

map(<list-like-object>, function(x) <do stuff>)

代替

lapply(<list-like-object>, function(x) <do stuff>)

输出应该是相同的,我所做的基准测试似乎表明lapply略微更快(这应该是因为map需要评估所有非标准评估输入)。
对于如此简单的情况,是否有任何理由考虑切换到purrr::map?我在这里不问一个人关于语法喜好或不喜欢、purrr提供的其他功能等的看法,而严格比较使用标准评估,即map(<list-like-object>, function(x) <do stuff>)purrr::maplapply。在性能、异常处理等方面,purrr::map有什么优势吗?下面的评论表明它没有,但也许有人可能可以详细说明一下?

15
对于简单的用例,确实最好使用基本的R并避免依赖项。但如果您已经加载了tidyverse,则可以从管道%>%和匿名函数~ .x + 1的语法中受益。 - Aurèle
66
这基本上是一个风格问题。尽管如此,您应该了解基本的 R 函数,因为所有这些 Tidyverse 工具只是在其之上构建的一种外壳。在某个时候,这个外壳可能会破裂。 - Hong Ooi
12
使用~{}简写的lambda表达式(无论是否使用{})对于我来说很方便,因为它可以直接用于purrr::map()purrr::map_…()中的类型强制执行很方便,比vapply()更易懂。虽然purrr::map_df()是一个非常耗费资源的函数,但它也简化了代码。当然,坚持使用基本的R [lsv]apply()也完全没有问题。 - hrbrmstr
7
谢谢您的提问 - 这也是我关注的内容。我使用R已经超过10年了,绝对不会使用purrr包中的东西。我的观点是:tidyverse非常适合分析/交互/报告等操作,而不适合编程。如果你不得不使用lapplymap,那么你就是在编程,有可能最终创建一个程序包。那么依赖越少越好。另外,有时我看到人们在使用map时采用相当晦涩的语法。现在我看到性能测试:如果你习惯于使用apply家族函数,请坚持使用它。 - Eric Lecoutre
7
你写道:“我在这里不是询问关于purrr::map的喜好或不喜好,以及其他功能等等,而是严格比较使用标准评估时purrr::map和lapply之间的区别。” 你接受的答案恰好涉及了你说不想让人涉及的内容。 - Carlos Cinelli
显示剩余12条评论
5个回答

308
如果你只使用purrr中的map()函数,那么它并没有太大的优势。正如Rich Pauloo所指出的那样,map()的主要优势在于其辅助函数,使您能够为常见的特殊情况编写紧凑的代码:
  • ~ . + 1 相当于 function(x) x + 1(在R-4.1及更高版本中为 \(x) x + 1

  • list("x", 1) 相当于 function(x) x[["x"]][[1]]。这些辅助函数比[[更通用 - 有关详细信息,请参见?pluck。对于数据整理.default参数特别有用。

但是,大多数时候,您不仅使用单个*apply()/map()函数,而是使用一堆这些函数,因此purrr的优势在于这些函数之间具有更高的一致性。例如:

  • lapply()的第一个参数是数据,mapply()的第一个参数是函数。所有映射函数的第一个参数始终是数据。

  • 使用vapply()sapply()mapply()时,您可以选择使用USE.NAMES = FALSE来抑制输出中的名称;但是lapply()没有该参数。

  • 没有一种一致的方法将常量参数传递给映射器函数。大多数函数使用...,但mapply()使用MoreArgs(您可能希望称其为MORE.ARGS),而Map()Filter()Reduce()则希望您创建新的匿名函数。在映射函数中,常量参数总是在函数名称之后。

  • 几乎每个purrr函数都是类型稳定的:您可以从函数名称专门预测输出类型。这对于sapply()mapply()不成立。是的,有vapply();但是没有mapply()的等效函数。

你可能认为所有这些细微差别都不重要(就像有些人认为stringr相对于基本的R正则表达式没有优势),但我个人经验是,它们在编程时会造成不必要的阻力(不同的参数顺序曾经让我犯过错误),并且它们使函数式编程技术更难学习,因为除了大的思想外,你还必须学习一堆次要细节。
Purrr还提供了一些方便的map变体,这些变体在基本的R中缺失。
  • modify() preserves the type of the data using [[<- to modify "in place". In conjunction with the _if variant this allows for (IMO beautiful) code like modify_if(df, is.factor, as.character)

  • map2() allows you to map simultaneously over x and y. This makes it easier to express ideas like map2(models, datasets, predict)

  • imap() allows you to map simultaneously over x and its indices (either names or positions). This is makes it easy to (e.g) load all csv files in a directory, adding a filename column to each.

    dir("\\.csv$") %>%
      set_names() %>%
      map(read.csv) %>%
      imap(~ transform(.x, filename = .y))
    
  • walk() returns its input invisibly; and is useful when you're calling a function for its side-effects (i.e. writing files to disk).

更不用说其他助手,如 safely()partial()

个人而言,我发现使用 purrr 时,可以更加轻松地编写函数式代码,减少了思考和实现之间的差距。但是,您的体验可能会有所不同;除非它真正对您有帮助,否则没有必要使用 purrr。

微基准

是的,map()lapply() 稍慢一些。但使用 map()lapply() 的成本取决于您正在映射的内容,而不是执行循环的开销。下面的微基准测试表明,与 lapply() 相比,map() 的成本约为每个元素 40 毫微秒,这似乎不太可能对大多数 R 代码产生实质性影响。

library(purrr)
n <- 1e4
x <- 1:n
f <- function(x) NULL

mb <- microbenchmark::microbenchmark(
  lapply = lapply(x, f),
  map = map(x, f)
)
summary(mb, unit = "ns")$median / n
#> [1] 490.343 546.880

3
在那个例子中,你是不是打算使用 transform() 函数?这个函数是基于 R 语言的 transform() 函数吗?或者我有所遗漏?transform() 函数会将文件名转换成因子,当你(自然地)想要绑定行时,会产生警告。而 mutate() 函数则可以给我想要的字符类型的文件名列。在那里不使用它是否有什么原因呢? - doctorG
3
最好使用mutate(),我只是想要一个没有其他依赖的简单示例。 - hadley
2
这个答案中不应该出现类型特定性吗?map_*是让我在许多脚本中加载purrr的原因。它帮助我处理代码的某些“控制流”方面(stopifnot(is.data.frame(x)))。 - Fr.
10
ggplot和data.table很棒,但我们真的需要为R中的每个单独函数创建一个新的包吗? - adn bps
1
顺便提一下,在基本R中,您可以轻松返回一个列表,同时抑制名称:sapply(1:10, function(x){x}, simplify=FALSE, USE.NAMES=FALSE) - Michael

83

比较purrrlapply,归结为方便性速度


1. purrr::map在语法上比lapply更加方便

提取列表的第二个元素

map(list, 2)  

正如@F. Privé所指出的那样,它与以下内容相同:

map(list, function(x) x[[2]])

使用lapply函数
lapply(list, 2) # doesn't work

我们需要传递一个匿名函数...
lapply(list, function(x) x[[2]])  # now it works

......或者正如@RichScriven所指出的那样,我们将[[作为参数传递给lapply

lapply(list, `[[`, 2)  # a bit more simple syntantically

如果您发现自己使用lapply对许多列表应用函数,并厌倦定义自定义函数或编写匿名函数,那么方便是支持purrr的一个原因。
2. 类型特定的映射函数可以简化许多代码行
- map_chr() - map_lgl() - map_int() - map_dbl() - map_df() 这些类型特定的映射函数中的每一个都返回一个向量,而不是map()lapply()返回的列表。如果您正在处理嵌套列表的向量,则可以使用这些类型特定的映射函数直接提取向量,并将向量强制转换为int、dbl、chr向量。基本R版本看起来像as.numeric(sapply(...))as.character(sapply(...))等。 map_<type>函数还具有有用的特性,即如果它们无法返回所指示类型的原子向量,则会失败。当定义严格的控制流时,这非常有用,其中您希望函数在生成错误对象类型时失败。
3. 除了方便之外,lapplymap [稍微]快一些
使用purrr的便利函数,如@F。Privé指出会稍微减慢处理速度。让我们比赛上面提到的4种情况。
# devtools::install_github("jennybc/repurrrsive")
library(repurrrsive)
library(purrr)
library(microbenchmark)
library(ggplot2)

mbm <- microbenchmark(
  lapply       = lapply(got_chars[1:4], function(x) x[[2]]),
  lapply_2     = lapply(got_chars[1:4], `[[`, 2),
  map_shortcut = map(got_chars[1:4], 2),
  map          = map(got_chars[1:4], function(x) x[[2]]),
  times        = 100
)
autoplot(mbm)

这里输入图片描述

获胜者是......

lapply(list, `[[`, 2)

简而言之,如果你追求原始速度:base::lapply(虽然它并不比其他方法快多少)
对于简单的语法和表达性:purrr::map
这个优秀的purrr教程突出了使用purrr时无需显式编写匿名函数的方便之处以及类型特定的map函数的好处。

2
请注意,如果您使用function(x) x[[2]]而不是仅使用2,它会更快。所有这些额外的时间都是由于lapply不执行检查所致。 - F. Privé
21
你不一定需要匿名函数。[[ 是一个函数。你可以使用 lapply(list, "[[", 3) - Rich Scriven
@RichScriven,那很有道理。这简化了使用lapply覆盖purrr的语法。 - Rich Pauloo
as.numeric(sapply(...)) 做起来有点奇怪。使用 vapplyvapply(..., FUN.VALUE = numeric(1))。这是从 apply 函数返回向量的基本 R 方法,还可以强制类型(如果函数返回不正确则会抛出错误)。这也比 sapply/lapply 更具性能优势,因为整个向量可以一次性分配。map_type 的唯一额外优势是它对初学者更友好一些。 - Erik A

55

如果不考虑口味(否则这个问题应该被关闭)或语法一致性、风格等方面,答案是否定的,使用 map 没有比使用 lapply 或其他 apply 家族变种(如更严格的 vapply)更特别的原因。

附注:对于那些毫无理由地给出负评的人,请记住 OP 写道:

我在这里询问的并不是关于人们对语法的喜好或厌恶、其他 purrr 提供的功能等方面,而是纯粹比较 purrr::map 和 lapply,在假定使用标准评估的情况下

如果不考虑 purrr 的语法和其他功能,使用 map 没有特别的原因。我自己使用 purrr 并且我对 Hadley 的答案感到满意,但讽刺的是,它涉及了 OP 最开始就说过不想问的内容。


我来这里问一下,lapplymap 之间是否有需要担心的差异,还是它们可以在某种程度上互换使用。你是回答我的人,谢谢 :) 我的使用情况与同事编写的充满 map 的脚本有关,我想要使用 future.apply::future_lapply(我知道)或者 furrr::future_map(我不知道)。现在我知道我可以安全地用另一个替换它,就是这样。再次感谢! - jena

6

tl;dr

我不是在询问关于 purrr 提供的语法或其他功能的喜好或厌恶。

选择符合你使用情况并最大化你的生产力的工具。对于优先考虑速度的生产代码,请使用 *apply,对于需要小内存占用的代码,请使用 map。基于人体工程学,map 对于大多数用户和大多数一次性任务来说可能更可取。

方便

2021 年 10 月更新 由于接受的回答和第二个最受欢迎的帖子都提到了语法的便利性:

R 版本 4.1.1 及更高版本现在支持简写匿名函数 \(x) 和管道符号 |> 语法。要检查你的 R 版本,请使用 version[['version.string']]

library(purrr)
library(repurrrsive)
lapply(got_chars[1:2], `[[`, 2) |>
  lapply(\(.) . + 1)
#> [[1]]
#> [1] 1023
#> 
#> [[2]]
#> [1] 1053
map(got_chars[1:2], 2) %>%
  map(~ . + 1)
#> [[1]]
#> [1] 1023
#> 
#> [[2]]
#> [1] 1053

purrr 方法的语法通常更短,如果您的任务涉及对类似列表的对象进行多个操作。

nchar(
"lapply(x, fun, y) |>
      lapply(\\(.) . + 1)")
#> [1] 45
nchar(
"library(purrr)
map(x, fun) %>%
  map(~ . + 1)")
#> [1] 45

考虑到一个人在他们的职业生涯中可能会写数万或数十万个这样的调用,这种语法长度差异相当于写1或2部小说(平均小说80,000个字),假设代码是打出来的。进一步考虑 你的 代码输入速度(~65个单词每分钟?),你的 输入准确性(你是否经常打错某些语法 (\"< ?)),你的 对函数参数的记忆力,那么你可以公平地比较使用一种风格或两种风格的生产力。

另一个考虑因素可能是你的目标受众。就我个人而言,我发现解释purrr::map如何工作比解释lapply更难,正是因为它的简洁语法。

1 |>
  lapply(\(.z) .z + 1)
#> [[1]]
#> [1] 2

1 %>%
  map(~ .z+ 1)
#> Error in .f(.x[[i]], ...) : object '.z' not found

but,
1 %>%
  map(~ .+ 1)
#> [[1]]
#> [1] 2

速度

在处理类似列表的对象时,通常会执行多个操作。需要注意的是,在大多数代码 - 处理大型列表和使用情况时,purrr 的开销是微不足道的。

got_large <- rep(got_chars, 1e4) # 300 000 elements, 1.3 GB in memory
bench::mark(
  base = {
    lapply(got_large, `[[`, 2) |>
      lapply(\(.) . * 1e5) |>
      lapply(\(.) . / 1e5) |>
      lapply(\(.) as.character(.))
  },
  purrr = {
    map(got_large, 2) %>%
      map(~ . * 1e5) %>%
      map(~ . / 1e5) %>%
      map(~ as.character(.))
  }, iterations = 100,
)[c(1, 3, 4, 5, 7, 8, 9)]

# A tibble: 2 x 7
  expression   median `itr/sec` mem_alloc n_itr  n_gc total_time
  <bch:expr> <bch:tm>     <dbl> <bch:byt> <int> <dbl>   <bch:tm>
1 base          1.19s     0.807    9.17MB   100   301      2.06m
2 purrr         2.67s     0.363    9.15MB   100   919      4.59m

这个差异会随着操作次数的增加而扩大。如果你正在编写被某些用户常规使用或其他包依赖的代码,速度可能是在选择基础R和purr之间需要考虑的重要因素。请注意,purr 的内存占用略低。
然而,有一个反驳的观点:如果你想要速度,就去使用低级语言。

2
我认为大部分观点都被提到了,但我想强调的是,从用户的角度来看,使用 lapply() 的速度提升变得更加显著,特别是当你升级到 mclapply() (来自于parallel包,据我所知不适用于Windows系统并且永远也不会)。如果你在一开始就使用 lapply() 编写代码,那么你只需要在函数调用前面键入 "mc" 并指定要使用的核心数,就可以无需改变代码的任何其他方面。如果你使用 lapply() 将作业分解成可并行处理的块,则与 lapply() 相比的加速因子将约为使用的处理器核心数。如果你在正确的服务器或集群上运行代码,这可能会将几个小时的工作时间缩短为几秒钟。

1
顺便提一下,由于lapply()有一个对应的mclapply(),所以purrr::map()和其他purrr函数也有一个furrr对应函数来并行处理数据,例如furrr::future_map()(只需在purrr函数名称前添加future_即可)。 - Carlos Luis Rivera

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接