pass a function a vector or undefined number of arguments
Asked Answered
T

2

6

I want to be able to pass a function an undefined number of arguments via ... but also to be able to pass it a vector. Here is a silly example:

library(tidyverse)
df <- data.frame(gear = as.character(unique(mtcars$gear)),
                 id = 1:3)
myfun <- function(...) {
  ids_lst <- lst(...)
  df2 <- bind_rows(map(ids_lst, function(x) 
    mtcars %>% 
      filter(gear == x) %>% 
      select(mpg)), .id = "gear") %>% 
    left_join(df)
  df2
}
#these all work:
myfun(3)
myfun(3, 4)
myfun(3, 4, 5)

Passing it a vector doesn't work though:

myvector <- unique(mtcars$gear)
myfun(myvector)

The problem is because of the way the function collects the arguments and how it returns them:

myfun_lst <- function(...) {
  ids_lst <- lst(...)
  ids_lst
}
myfun_lst(3, 4, 5)
# $`3`
# [1] 3

# $`4`
# [1] 4

# $`5`
# [1] 5

myfun_lst(myvector)
# $myvector
# [1] 4 3 5

I thought a fix would be to test if the input is a vector, something like:

myfun_final <- function(...) {
  if(is.vector(...) & !is.list(...)) {
    ids_lst <- as.list(...)
    names(ids_lst) <- (...)
  } else { 
    ids_lst <- lst(...)
  }
  df2 <- bind_rows(map(ids_lst, function(x) 
    mtcars %>% 
      filter(gear == x) %>% 
      select(mpg)), .id = "gear") %>% 
    left_join(df)
  df2
}

Now, passing the function a vector works but collecting the arguments doesn't:

myfun_final(3, 4, 5)
myfun_final(myvector)

What is a good way to solve this? Thanks

Tulley answered 28/12, 2021 at 15:32 Comment(4)
Have you tried using do.call(myfun_final, myvector) or exec(myfun_final, myvector)?Amphictyon
myfun_final(myvector) already works. myfun_final(3, 4, 5) doesn't work?Tulley
So the case is that you will pass either some scalers or a single vector to the function, is it?Amphictyon
thats it, I thought the if statement would be able to determine which one was supplied but its not!Tulley
A
4

How about testing if ... is of length 1 and if the only argument passed through is a vector? If not so, then consider ... a list of scalers and capture them with lst(...).

myfun_final <- function(...) {
  if (...length() == 1L && is.vector(..1))
    ids_lst <- `names<-`(..1, ..1)
  else
    ids_lst <- lst(...)
  
  df2 <- bind_rows(map(ids_lst, function(x) 
    mtcars %>% 
      filter(gear == x) %>% 
      select(mpg)), .id = "gear") %>% 
    left_join(df)
  df2
}

Test

> myfun_final(3)
Joining, by = "gear"
   gear  mpg id
1     3 21.4  2
2     3 18.7  2
3     3 18.1  2
4     3 14.3  2
5     3 16.4  2
6     3 17.3  2
7     3 15.2  2
8     3 10.4  2
9     3 10.4  2
10    3 14.7  2
11    3 21.5  2
12    3 15.5  2
13    3 15.2  2
14    3 13.3  2
15    3 19.2  2
> myfun_final(3,4,5)
Joining, by = "gear"
   gear  mpg id
1     3 21.4  2
2     3 18.7  2
3     3 18.1  2
4     3 14.3  2
5     3 16.4  2
6     3 17.3  2
7     3 15.2  2
8     3 10.4  2
9     3 10.4  2
10    3 14.7  2
11    3 21.5  2
12    3 15.5  2
13    3 15.2  2
14    3 13.3  2
15    3 19.2  2
16    4 21.0  1
17    4 21.0  1
18    4 22.8  1
19    4 24.4  1
20    4 22.8  1
21    4 19.2  1
22    4 17.8  1
23    4 32.4  1
24    4 30.4  1
25    4 33.9  1
26    4 27.3  1
27    4 21.4  1
28    5 26.0  3
29    5 30.4  3
30    5 15.8  3
31    5 19.7  3
32    5 15.0  3
> myfun_final(c(3,4,5))
Joining, by = "gear"
   gear  mpg id
1     3 21.4  2
2     3 18.7  2
3     3 18.1  2
4     3 14.3  2
5     3 16.4  2
6     3 17.3  2
7     3 15.2  2
8     3 10.4  2
9     3 10.4  2
10    3 14.7  2
11    3 21.5  2
12    3 15.5  2
13    3 15.2  2
14    3 13.3  2
15    3 19.2  2
16    4 21.0  1
17    4 21.0  1
18    4 22.8  1
19    4 24.4  1
20    4 22.8  1
21    4 19.2  1
22    4 17.8  1
23    4 32.4  1
24    4 30.4  1
25    4 33.9  1
26    4 27.3  1
27    4 21.4  1
28    5 26.0  3
29    5 30.4  3
30    5 15.8  3
31    5 19.7  3
32    5 15.0  3
Amphictyon answered 30/12, 2021 at 17:38 Comment(3)
thanks, that seems to work. can I ask about ...length() == 1L && is.vector(..1), when do you put ... before length(), I usually see it written as length(...)? Similarly, why is it ..1 and not ...? And finally do we need & !is.list(...) as is.vector on a list will return TRUE, see here?Tulley
@Tulley I will answer your last question first: It depends. Personally, I don't think there is a difference between passing c(1,2,3) and list(1,2,3) in this case so you do not have to obviate the latter case. However, if you do want to ensure that the argument passed through is not a list, add && !is.list(..1). For your first two questions, ..1 and ..length() (two dots, not three) are special objects used to access things inside .... See thisAmphictyon
@Tulley A minor nitpick: You might consider testing something other than is.vector(..1). In this case, gears is numeric but you would be allowing vectors of type character, raw, list, and expression and disallowing numeric vectors that have attributes other than names, like structure(1:3, a = NA). is.numeric(..1) or is.atomic(..1) && !is.object(..1) might be more appropriate tests, depending on your actual use case.Durand
C
4

Of course you can change your function so that it will work with both, regular arguments myfun(3, 4, 5) and a vector myfun(myvector), as shown in the answer above.

Another option is that you make use of argument splicing by unquoting with the bang bang bang operator !!!. This operator is only supported in certain {rlang} and {tidyverse} functions. In your example you evaluate the dots ... inside purrr::map which supports argument splicing. Therefore there might not be the need to rewrite your function:

library(tidyverse)

# your original function:
myfun <- function(...) {
        ids_lst <- lst(...)
        df2 <- bind_rows(map(ids_lst, function(x) 
                mtcars %>% 
                        filter(gear == x) %>% 
                        select(mpg)), .id = "gear") %>% 
                left_join(df)
        df2
}

myvector <- unique(mtcars$gear)

myfun(!!! myvector) # works

#> Joining, by = "gear"
#>    gear  mpg id
#> 1     4 21.0  1
#> 2     4 21.0  1
#> 3     4 22.8  1
#> 4     4 24.4  1
#> 5     4 22.8  1
#> 6     4 19.2  1
#> 7     4 17.8  1
#> 8     4 32.4  1
#> 9     4 30.4  1
#> 10    4 33.9  1
#> ...


myfun(3, 4, 5) # works

#> Joining, by = "gear"
#>    gear  mpg id
#> 1     3 21.4  2
#> 2     3 18.7  2
#> 3     3 18.1  2
#> 4     3 14.3  2
#> 5     3 16.4  2
#> 6     3 17.3  2
#> 7     3 15.2  2
#> 8     3 10.4  2
#> 9     3 10.4  2
#> 10    3 14.7  2
#> ...

Created on 2021-12-30 by the reprex package (v0.3.0)

You can read more about unquoting with the bang bang bang operator here.

Finally, you should think about the users of your function. If you are the only user then choose whatever suits you. In case there are other users you should think about how they expect the function to work. Probably users don't expect a function to work with several arguments and at the same time, alternatively, by providing those arguments in a vector. In the tidyverse argument splicing with !!! is a well established concept. In base R we would usually use do.call("myfun", as.list(myvector)) to achieve something similar.


To add another option:

The purrr package has a family of lift functions which can be used to alter the kind of arguments a function takes. The most prominent is lift_dl which transforms a function that takes dots as argument to a function that takes a list or vector as argument. This could also be used to solve the problem without the need to rewrite the function:

lift_dl(myfun)(myvector)

#> Joining, by = "gear"
#>    gear  mpg id
#> 1     4 21.0  1
#> 2     4 21.0  1
#> 3     4 22.8  1
#> 4     4 24.4  1
#> 5     4 22.8  1
#> 6     4 19.2  1
#> 7     4 17.8  1
#> 8     4 32.4  1
#> 9     4 30.4  1
#> 10    4 33.9  1
#> ...

Created on 2022-01-01 by the reprex package (v0.3.0)

Casta answered 30/12, 2021 at 21:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.