The Memory Model: Copy, Borrow, Ref

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.
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.
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.