Skip to content

Commit a69d935

Browse files
New unnecessary_lambda_linter (#1541)
1 parent 67234eb commit a69d935

11 files changed

+235
-3
lines changed

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ Collate:
144144
'tree-utils.R'
145145
'undesirable_function_linter.R'
146146
'undesirable_operator_linter.R'
147+
'unnecessary_lambda_linter.R'
147148
'unneeded_concatenation_linter.R'
148149
'unreachable_code_linter.R'
149150
'unused_import_linter.R'

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export(trailing_blank_lines_linter)
111111
export(trailing_whitespace_linter)
112112
export(undesirable_function_linter)
113113
export(undesirable_operator_linter)
114+
export(unnecessary_lambda_linter)
114115
export(unneeded_concatenation_linter)
115116
export(unreachable_code_linter)
116117
export(unused_import_linter)

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424

2525
### New linters
2626

27+
* `unnecessary_lambda_linter()`: detect unnecessary lambdas (anonymous functions), e.g.
28+
`lapply(x, function(xi) sum(xi))` can be `lapply(x, sum)` and `purrr::map(x, ~quantile(.x, 0.75, na.rm = TRUE))`
29+
can be `purrr::map(x, quantile, 0.75, na.rm = TRUE)`. Naming `probs = 0.75` can further improve readability.
2730
* `redundant_equals_linter()` for redundant comparisons to `TRUE` or `FALSE` like `is_treatment == TRUE` (#1500, @MichaelChirico)
2831

2932
# lintr 3.0.1

R/unnecessary_lambda_linter.R

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#' Block usage of anonymous functions in iteration functions when unnecessary
2+
#'
3+
#' Using an anonymous function in, e.g., [lapply()] is not always necessary,
4+
#' e.g. `lapply(DF, sum)` is the same as `lapply(DF, function(x) sum(x))` and
5+
#' the former is more readable.
6+
#'
7+
#' @evalRd rd_tags("unnecessary_lambda_linter")
8+
#' @seealso [linters] for a complete list of linters available in lintr.
9+
#' @export
10+
unnecessary_lambda_linter <- function() {
11+
# include any base function like those where FUN is an argument
12+
# and ... follows positionally directly afterwards (with ...
13+
# being passed on to FUN). That excludes functions like
14+
# Filter/Reduce (which don't accept ...), as well as functions
15+
# like sweep() (where check.margin comes between FUN and ...,
16+
# and thus would need to be supplied in order to replicate
17+
# positional arguments). Technically, these latter could
18+
# be part of the linter logic (e.g., detect if the anonymous
19+
# call is using positional or keyword arguments -- we can
20+
# throw a lint for sweep() lambdas where the following arguments
21+
# are all named) but for now it seems like overkill.
22+
apply_funs <- xp_text_in_table(c(
23+
"lapply", "sapply", "vapply", "apply",
24+
"tapply", "rapply", "eapply", "dendrapply",
25+
"mapply", "by", "outer",
26+
"mclapply", "mcmapply", "parApply", "parCapply", "parLapply",
27+
"parLapplyLB", "parRapply", "parSapply", "parSapplyLB", "pvec",
28+
purrr_mappers
29+
))
30+
31+
# outline:
32+
# 1. match one of the identified mappers
33+
# 2. match an anonymous function that can be "symbol-ized"
34+
# a. it's a one-variable function [TODO(michaelchirico): is this necessary?]
35+
# b. the function is a single call
36+
# c. that call's _first_ argument is just the function argument (a SYMBOL)
37+
# - and it has to be passed positionally (not as a keyword)
38+
# d. the function argument doesn't appear elsewhere in the call
39+
# TODO(#1567): This misses some common cases, e.g. function(x) { foo(x) }
40+
default_fun_xpath <- glue::glue("
41+
//SYMBOL_FUNCTION_CALL[ {apply_funs} ]
42+
/parent::expr
43+
/following-sibling::expr[
44+
FUNCTION
45+
and count(SYMBOL_FORMALS) = 1
46+
and expr/OP-LEFT-PAREN/following-sibling::expr[1][not(preceding-sibling::*[2][self::SYMBOL_SUB])]/SYMBOL
47+
and SYMBOL_FORMALS/text() = expr/OP-LEFT-PAREN/following-sibling::expr[1]/SYMBOL/text()
48+
and not(SYMBOL_FORMALS/text() = expr/OP-LEFT-PAREN/following-sibling::expr[position() > 1]//SYMBOL/text())
49+
]")
50+
51+
# purrr-style inline formulas-as-functions, e.g. ~foo(.x)
52+
# logic is basically the same as that above, except we need
53+
# 1. a formula (OP-TILDE)
54+
# 2. the lone argument marker `.x` or `.`
55+
purrr_symbol <- "SYMBOL[text() = '.x' or text() = '.']"
56+
purrr_fun_xpath <- glue::glue("
57+
//SYMBOL_FUNCTION_CALL[ {xp_text_in_table(purrr_mappers)} ]
58+
/parent::expr
59+
/following-sibling::expr[
60+
OP-TILDE
61+
and expr[OP-LEFT-PAREN/following-sibling::expr[1][not(preceding-sibling::*[2][self::SYMBOL_SUB])]/{purrr_symbol}]
62+
and not(expr/OP-LEFT-PAREN/following-sibling::expr[position() > 1]//{purrr_symbol})
63+
]")
64+
65+
# path to calling function symbol from the matched expressions
66+
fun_xpath <- "./parent::expr/expr/SYMBOL_FUNCTION_CALL"
67+
# path to the symbol of the simpler function that avoids a lambda
68+
symbol_xpath <- "expr/expr[SYMBOL_FUNCTION_CALL]"
69+
70+
Linter(function(source_expression) {
71+
if (!is_lint_level(source_expression, "expression")) {
72+
return(list())
73+
}
74+
75+
xml <- source_expression$xml_parsed_content
76+
77+
default_fun_expr <- xml2::xml_find_all(xml, default_fun_xpath)
78+
79+
# TODO(michaelchirico): further message customization is possible here,
80+
# e.g. don't always refer to 'lapply()' in the example, and customize to
81+
# whether arguments need to be subsumed in '...' or not. The trouble is in
82+
# keeping track of which argument the anonymous function is supplied (2nd
83+
# argument for many calls, but 3rd e.g. for apply())
84+
default_call_fun <- xml2::xml_text(xml2::xml_find_first(default_fun_expr, fun_xpath))
85+
default_symbol <- xml2::xml_text(xml2::xml_find_first(default_fun_expr, symbol_xpath))
86+
default_fun_lints <- xml_nodes_to_lints(
87+
default_fun_expr,
88+
source_expression = source_expression,
89+
lint_message = paste0(
90+
"Pass ", default_symbol, " directly as a symbol to ", default_call_fun, "() ",
91+
"instead of wrapping it in an unnecessary anonymous function. ",
92+
"For example, prefer lapply(DF, sum) to lapply(DF, function(x) sum(x))."
93+
),
94+
type = "warning"
95+
)
96+
97+
purrr_fun_expr <- xml2::xml_find_all(xml, purrr_fun_xpath)
98+
99+
purrr_call_fun <- xml2::xml_text(xml2::xml_find_first(purrr_fun_expr, fun_xpath))
100+
purrr_symbol <- xml2::xml_text(xml2::xml_find_first(purrr_fun_expr, symbol_xpath))
101+
purrr_fun_lints <- xml_nodes_to_lints(
102+
purrr_fun_expr,
103+
source_expression = source_expression,
104+
lint_message = paste0(
105+
"Pass ", purrr_symbol, " directly as a symbol to ", purrr_call_fun, "() ",
106+
"instead of wrapping it in an unnecessary anonymous function. ",
107+
"For example, prefer purrr::map(DF, sum) to purrr::map(DF, ~sum(.x))."
108+
),
109+
type = "warning"
110+
)
111+
112+
c(default_fun_lints, purrr_fun_lints)
113+
})
114+
}
115+
116+
purrr_mappers <- c(
117+
"map", "walk",
118+
"map_raw", "map_lgl", "map_int", "map_dbl", "map_chr",
119+
"map_df", "map_dfr", "map_dfc"
120+
)

inst/lintr/linters.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ trailing_blank_lines_linter,style default
7272
trailing_whitespace_linter,style default
7373
undesirable_function_linter,style efficiency configurable robustness best_practices
7474
undesirable_operator_linter,style efficiency configurable robustness best_practices
75+
unnecessary_lambda_linter,best_practices efficiency readability
7576
unneeded_concatenation_linter,style readability efficiency configurable
7677
unreachable_code_linter,best_practices readability
7778
unused_import_linter,best_practices common_mistakes configurable executing

man/best_practices_linters.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/efficiency_linters.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/linters.Rd

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/readability_linters.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/unnecessary_lambda_linter.Rd

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
test_that("unnecessary_lambda_linter skips allowed usages", {
2+
linter <- unnecessary_lambda_linter()
3+
expect_lint("lapply(DF, sum)", NULL, linter)
4+
expect_lint("apply(M, 1, sum, na.rm = TRUE)", NULL, linter)
5+
6+
# the first argument may be ... or have a cumbersome name, so an anonymous
7+
# function may be preferable (e.g. this is often the case for grep() calls)
8+
expect_lint("sapply(x, function(xi) foo(1, xi))", NULL, linter)
9+
10+
# if the argument is re-used, that's also a no-go
11+
expect_lint("dendrapply(x, function(xi) foo(xi, xi))", NULL, linter)
12+
# at any nesting level
13+
expect_lint("parLapply(cl, x, function(xi) foo(xi, 2, bar(baz(xi))))", NULL, linter)
14+
15+
# multi-expression case
16+
expect_lint("lapply(x, function(xi) { print(xi); xi^2 })", NULL, linter)
17+
# multi-expression, multi-line cases
18+
expect_lint(
19+
trim_some("
20+
lapply(x, function(xi) {
21+
print(xi); xi^2
22+
})
23+
"),
24+
NULL,
25+
linter
26+
)
27+
expect_lint(
28+
trim_some("
29+
lapply(x, function(xi) {
30+
print(xi)
31+
xi^2
32+
})
33+
"),
34+
NULL,
35+
linter
36+
)
37+
# TODO(michaelchirico): I believe there's cases where it's impossible to avoid an anonymous function due to a
38+
# conflict where you have to pass FUN= to an inner *apply function but it gets interpreted as the outer *apply's FUN
39+
# argument... but it's escaping me now.
40+
})
41+
42+
test_that("unnecessary_lambda_linter blocks simple disallowed usage", {
43+
expect_lint(
44+
"lapply(DF, function(x) sum(x))",
45+
rex::rex("Pass sum directly as a symbol to lapply()"),
46+
unnecessary_lambda_linter()
47+
)
48+
49+
expect_lint(
50+
"rapply(l, function(x) is.data.frame(x))",
51+
rex::rex("Pass is.data.frame directly as a symbol to rapply()"),
52+
unnecessary_lambda_linter()
53+
)
54+
55+
expect_lint(
56+
"eapply(env, function(x) sum(x, na.rm = TRUE))",
57+
rex::rex("Pass sum directly as a symbol to eapply()"),
58+
unnecessary_lambda_linter()
59+
)
60+
})
61+
62+
test_that("unnecessary_lambda_linter doesn't apply to keyword args", {
63+
expect_lint("lapply(x, function(xi) data.frame(nm = xi))", NULL, unnecessary_lambda_linter())
64+
})
65+
66+
test_that("purrr-style anonymous functions are also caught", {
67+
# TODO(michaelchirico): this is just purrr::flatten(x). We should write another
68+
# linter to encourage that usage.
69+
expect_lint("purrr::map(x, ~.x)", NULL, unnecessary_lambda_linter())
70+
expect_lint("purrr::map_df(x, ~lm(y, .x))", NULL, unnecessary_lambda_linter())
71+
expect_lint("map_dbl(x, ~foo(bar = .x))", NULL, unnecessary_lambda_linter())
72+
73+
expect_lint(
74+
"purrr::map(x, ~foo(.x))",
75+
rex::rex("Pass foo directly as a symbol to map()"),
76+
unnecessary_lambda_linter()
77+
)
78+
expect_lint(
79+
"purrr::map_int(x, ~foo(.x, y))",
80+
rex::rex("Pass foo directly as a symbol to map_int()"),
81+
unnecessary_lambda_linter()
82+
)
83+
})

0 commit comments

Comments
 (0)