--- title: "The Memory Model: Copy, Borrow, Ref" author: "Konrad Kraemer" output: html_document vignette: > %\VignetteIndexEntry{The Memory Model: Copy, Borrow, Ref} %\VignetteEngine{knitr::rmarkdown} \usepackage{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, eval = FALSE) ``` When does **ast2ast** duplicate your data, and when do writes inside a function stay visible to the caller? It depends on which boundary the argument crosses -- and the same keyword can mean different things on each. Base R never faces this question: vectors and lists are copy-on-modify, so a change a function makes to its argument never escapes. The one keyword that changes that -- `ref` -- has no counterpart in ordinary R, so it is worth being precise about where it applies. ## Boundary 1 -- R calls the translated function The argument arrives as a `SEXP` (an ordinary R object) and is turned into an `etr` vector before the body runs.
default vec in R holds [9, 5, 6] SEXP data · 0x…d2b0 copy on call [9, 5, 6] fresh copy · 0x…22b0 holds x in f
Copied. The SEXP contents land in f's own vector at a new address. Your R vector is unchanged after the call.
borrow_vec(double) vec in R points to one buffer, two names [9, 5, 6] SEXP data · 0x…d2b0 points to x in f
Shared. f's vector points straight into R's SEXP buffer -- same address, no copy. Without const(), a write here reaches the caller's storage.
```{r} scale_inplace <- function(v, k) { argtypes( v |> type(borrow_vec(double)), k |> type(double) ) for (i in seq_len(length(v))) { v[[i]] <- v[[i]] * k } return(v) } f <- ast2ast::translate(scale_inplace) x <- c(1, 2, 3) f(x, 10) x # c(10, 20, 30): x itself was modified, because f borrowed its buffer ``` Drop the `borrow_vec` -- plain `v |> type(vec(double))` -- and `x` stays `c(1, 2, 3)`: the function worked on a copy. ## Boundary 2 -- inside f, calling an inner fn() The argument is already an `etr` object, not a `SEXP` -- so `ref()` works here whatever `output` you translate to. In this sense inner `fn()` arguments behave like `XPtr` arguments.
default vec in f holds [9, 5, 6] etr object · 0x…d2b0 copy on call [9, 5, 6] fresh copy · 0x…22b0 holds y in g
Copied. The inner function g gets its own copy of the etr object. Whatever g writes stays in g.
y |> type(vec(double)) |> ref() vec in f points to same object, two names [9, 5, 6] etr object · 0x…d2b0 points to y in g
Shared. g works on the caller's object itself. After g returns, the changes are still there -- the one place ast2ast behaves unlike base R.
```{r} demo <- function(v) { argtypes(v |> type(vec(double))) double_it <- fn( argtypes(out |> type(vec(double)) |> ref()), return(void), { for (i in seq_len(length(out))) { out[[i]] <- out[[i]] * 2 } } ) double_it(v) # v is filled in place -- no copy, no allocation return(v) } f <- ast2ast::translate(demo) f(c(1, 2, 3)) # c(2, 4, 6) ``` Without `|> ref()` on `out`, `double_it` would double a private copy and `v` would come back unchanged. This is the mechanism behind the "preallocate a buffer once, let an inner function fill it every iteration" pattern -- it removes a per-call allocation from hot loops.
noteref() on an argument of the outer function is ignored, with a warning. Converting R's SEXP into an etr object has already happened, so there is no shared handle left to pass by reference. Use borrow_vec() / borrow_mat() to skip the copy at that boundary.
## In one table | Keyword | Boundary | Copies the data | Writes visible to caller | |---|---|:---:|:---:| | *(default)* | R → translated `f` | yes | no | | `borrow_vec()` / `borrow_mat()` | R → translated `f` | no | yes † | | *(default)* | `f` → inner `fn()` | yes | no | | `\|> ref()` | `f` → inner `fn()` | no | yes | | `\|> ref()` | R → translated `f` | \-- ignored, warning \-- | | † unless the argument is also marked `const()`. ## The short version **borrow** = don't copy the data. **ref** = don't copy the object. The observable rule, without pointers or `SEXP` in it: a *copied* argument's changes stop at the function; a *shared* argument's changes are still there after it returns. The addresses in the figures are shortened from a real run of `vec <- c(9, 5, 6)` -- `0x...d2b0` is its storage, `0x...22b0` a fresh allocation.