---
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.
defaultCopied. The SEXP contents land in
f's own vector at a new address. Your R vector is unchanged after
the call.borrow_vec(double)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.
defaultCopied. The inner function g gets its
own copy of the etr object. Whatever g writes stays in
g.y |> type(vec(double)) |> ref()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.