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.
The argument arrives as a SEXP (an ordinary R object)
and is turned into an etr vector before the body runs.
SEXP contents land in
f’s own vector at a new address. Your R vector is unchanged
after the call.
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 bufferDrop the borrow_vec – plain
v |> type(vec(double)) – and x stays
c(1, 2, 3): the function worked on a copy.
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.
g gets its own
copy of the etr object. Whatever g writes
stays in g.
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.
| 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().
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.