Search code examples
rpurrrpartial

R purrr::partial -- how does it handle partialized arguments?


I have been an enthusiastic user of R's purrr package for quite a while and recently ran into a question regarding purrr::partial. Suppose I define a two-argument function

f <- function(x, y) x + y

and partialize it by setting the y argument to some global variable's value:

yy <- 1
fp <- partial(f, y = !!yy)
fp(3)                       # 3 + 1 = 4

Unquoting yy (that is, using y = !!yy as opposed to y = yy) causes yy to be evaluated only once when fp is created; in particular, modifying yy after this step does not alter fp:

yy <- 2
fp(3)                       # still: 3 + 1 = 4

Here is my question: What exactly does partial do after evaluating yy? -- I see two possibilities:

  1. The value of yy is "hard-wired" into the body of fp, meaning that it is not passed as an argument when fp is called.
  2. The value of yy is more or less treated as if it was the y argument's default value (without the option of overriding the default), meaning that fp internally calls f (or a copy of it) to which the value of yy is silently passed as an argument matched with y. In this case fp is no more than a syntactic wrapper around f.

Trying to explore the second possibility I modfied the definition of f after defining fp. This does not alter fp, meaning that fp does not contain any outside reference to f; however, this does not rule out the (theoretical) possibility of fp containing a copy of the old version of f. (Conclusion: This approach does not help.)

Some practical background to motivate my question: In my current project I have defined lots of functions that use (a) arguments varying from call to call, (b) arguments representing "configuration data" or "domain knowledge". The data matched with the (b) arguments (which may be considerable amounts of data) do not change from call to call, but may change when I commit an update; in any case I believe this data should not be hard-coded in my functions. My strategy is to read the configuration data from some files at start-up time and integrate it into my functions by partializing the arguments in (b). Applying the partialized functions via purrr::pmap to some tibbles turned out to be kind of slow, which made me suspect that the configuration data may still be passed when the function is called -- hence my question. (If anyone has some thoughts on the "partialization strategy" briefly described above, I will be keenly interested in these, too.)


Solution

  • It seems like it is option 2. Try:

    f <- function(x, y) x + y
    yy <- 5
    fp1 <- partial(f, y = !! yy)
    debugonce(f)
    fp1(3)
    

    Here you can see that, if in RStudio, the debugger will open the original function f to which the arguments x = 3 and y = 5 are passed. However, the partialized function is not calling the real function f but a quoted copy of it. If you change f after it has been partialzed, the debugger won't find it anymore.

    f <- function(x, y) x + y
    yy <- 5
    fp1 <- partial(f, y = !! yy)
    f <- function(x, y) x + 2 * y
    debugonce(f)
    fp1(3) # debugger will not open
    

    It is possible to mimic the behavior of partial by constructing the function to partialize yourself. However, in this case neither f nor yy are captured so changing them will effect the output of your partialized function:

    f <- function(x, y) x + y
    yy <- 5
    
    # similar to `partial` but captures neither `f` nor `yy`
    fp2 <- function(x) f(x, yy) 
    fp2(3)
    #> [1] 8
    # so if yy changes, so will the output of fp2
    yy <- 10
    fp2(3)
    #> [1] 13
    # and if f changes, so will the output of fp2
    f <- function(x, y) x + 2 * y
    fp2(3)
    #> [1] 23
    

    Created on 2020-07-13 by the reprex package (v0.3.0)


    To better understand how partial is working we can construct a simple_partial function the following way:

    library(rlang)
    
    f <- function(x, y) x + y
    yy <- 5
    
    simple_partial <- function(.f, ...) {
      
      # capture arguments
      args <- enquos(...)
      # capture function
      fn_expr <- enexpr(.f)
      # construct call with function and supplied arguments 
      # in the ... go all arguments which will be supplied later
      call <- call_modify(call2(.f), !!! args, ... = )
      # turn call into a quosure (= expr and environment where it should be evaluated)
      call <- new_quosure(call, caller_env())
      # create child environment of current environment and turn it into a data mask
      mask <- new_data_mask(env())
      # return this function
      function(...) {
        # bind the ... from current environment to the data mask
        env_bind(mask, ... = env_get(current_env(), "..."))
        # evaluate the quoted call in the data mask where all additional values can be found
        eval_tidy(call, mask)
      }
    
    }
    
    fp3 <- simple_partial(f, y = !! yy)
    fp3(1)
    #> [1] 6
    

    Created on 2020-07-13 by the reprex package (v0.3.0)