It is often useful to tap information from a running R script. Obvious use cases include monitoring the consumption of resources (time, memory) and logging. Perhaps less obvious cases include tracking changes in R objects or collecting the output of unit tests. In this paper, we demonstrate an approach that abstracts the collection and processing of such secondary information from the running R script. Our approach is based on a combination of three elements. The first element is to build a customized way to evaluate code. The second is labeled local masking and it involves temporarily masking a user-facing function so an alternative version of it is called. The third element we label local side effect. This refers to the fact that the masking function exports information to the secondary information flow without altering a global state. The result is a method for building systems in pure R that lets users create and control secondary flows of information with minimal impact on their workflow and no global side effects.
The R language provides a convenient language to read, manipulate, and write data in the form of scripts. As with any other scripted language, an R script gives a description of data manipulation activities, one after the other, when read from top to bottom. Alternatively, we can think of an R script as a one-dimensional visualization of data flowing from one processing step to the next, where intermediate variables or pipe operators carry data from one treatment to the next.
We run into limitations of this one-dimensional view when we want to produce data flows that are somehow ‘orthogonal’ to the flow of the data being treated. For example, we may wish to follow the state of a variable while a script is being executed, report on progress (logging), or keep track of resource consumption. Indeed, the sequential (one-dimensional) nature of a script forces one to introduce extra expressions between the data processing code.
As an example, consider a code fragment where the variable x
is
manipulated.
> threshold] <- threshold
x[x is.na(x)] <- median(x, na.rm=TRUE) x[
In the first statement, every value above a certain threshold is
replaced with a fixed value, and next, missing values are replaced with
the median of the completed cases. It is interesting to know how an
aggregate of interest, say the mean of x
, evolves as it gets
processed. The instinctive way to do this is to edit the code by adding
statements to the script that collect the desired information.
<- mean(x, na.rm=TRUE)
meanx > threshold] <- threshold
x[x <- c(meanx, mean(x, na.rm=TRUE))
meanx is.na(x)] <- median(x, na.rm=TRUE)
x[<- c(meanx, mean(x, na.rm=TRUE)) meanx
This solution clutters the script by inserting expressions that are not necessary for its main purpose. Moreover, the tracking statements are repetitive, which validates some form of abstraction.
A more general picture of what we would like to achieve is given in
Figure 1. The ‘primary data flow’ is developed by a user
as a script. In the previous example, this concerns processing x
. When
the script runs, some kind of logging information, which we label the
‘secondary data flow’ is derived implicitly by an abstraction layer.
Creating an abstraction layer means that concerns between primary and secondary data flows are separated as much as possible. In particular, we want to prevent the abstraction layer from inspecting or altering the user code that describes the primary data flow. Furthermore, we would like the user to have some control over the secondary flow from within the script, for example, to start, stop, or parameterize the secondary flow. This should be done with minimum editing of the original user code, and it should not rely on global side effects. This means that neither the user nor the abstraction layer for the secondary data flow should have to manipulate or read global variables, options, or other environmental settings to convey information from one flow to the other. Finally, we want to treat the availability of a secondary data flow as a normal situation. This means we wish to avoid using signaling conditions (e.g., warnings or errors) to convey information between the flows unless there is an actual exceptional condition such as an error.
There are several packages that generate a secondary data flow from a
running script. One straightforward application concerns logging
messages that report on the status of a running script. To create a
logging message, users edit their code by inserting logging expressions
where desired. Logging expressions are functions calls that help to
build expressions, for example, by automatically adding a timestamp.
Configuration options usually include a measure of logging verbosity and
setting an output channel that controls where logging data will be sent.
Changing these settings relies on communication from the main script to
the functionality that controls the flow of logging data. In logger
(Daróczi 2021), this is done by manipulating a variable stored in
the package namespace using special helper functions. The logging
package (Frasca 2019) also uses an environment within the
namespace of the package to manage option settings, while
futile.logger (Rowe 2016) implements a custom global
option settings manager that is somewhat comparable to R’s own
options()
function.
Packages bench (Hester 2020a) and microbenchmark (Mersmann 2019) provide time profiling of single R expressions. The bench package also includes memory profiling. Their purpose is not to derive a secondary data flow from a running production script as in Figure 1 but to compare the performance of R expressions. Both packages export a function that accepts a sequence of expressions to profile. These functions take control of expression execution and insert time and/or memory measurements where necessary. Options, such as the number of times each expression is executed, are passed directly to the respective function.
Unit testing frameworks provide another source of secondary data flows.
Here, an R script is used to prepare, set up, and compare test data,
while the results of comparisons are tapped and reported. Testing
frameworks are provided by testthat (Wickham 2011), RUnit,
(Burger et al. 2018), testit (Xie 2021), unitizer
(Gaslam 2021), and tinytest (van der Loo 2020). The first
three packages (testthat, RUnit, and testit) all export assertion
functions that generate condition signals to convey information about
test results. Packages RUnit and testit use sys.source()
to run a
file containing unit test assertions and exit on the first error while
testthat uses eval()
to run expressions, capture conditions, and
test results and reports afterward. The unitizer framework is
different because it implements an interactive prompt to run tests and
explore their results. Rather than providing explicit assertions,
unitizer stores results of all expressions that return a visible
result and compares their output at subsequent runs. Interestingly,
unitizer allows for optional monitoring of the testing environment.
This includes environment variables, options, and more. This is done by
manipulating the code of (base) R functions that manage these settings
and masking the original functions temporarily. These masking functions
then provide parts of the secondary data flow (changes in the
environment). Finally, tinytest is based on the approach that is the
topic of this paper, and it will be discussed as an application below.
Finally, we note the covr package of Hester (2020b). This package is used to keep track of which expressions of an R package are run (covered) by package tests or examples. In this case, the primary data flow is a test script executing code (functions, methods) stored in another script, usually in the context of a package. The secondary flow consists of counts of how often each expression in the source is executed. The package works by parsing and altering the code in the source file, inserting expressions that increase appropriate counters. These counters are stored in a variable that is part of the package’s namespace.
Summarizing, we find that in logging packages, the secondary data flow is invoked explicitly by users while configuration settings are communicated by manipulating a global state that may or may not be directly accessible by the user. For benchmarking packages, the expressions are passed explicitly to an ‘expression runner’ that monitors the effect on memory and passage of time. In most test packages, the secondary flow is invoked explicitly using special assertions that throw condition signals. Test files are run using functionality that captures and administrates signals where necessary. Two of the discussed packages explicitly manipulate existing code before running it to create a secondary data flow. The covr package does this to update expression counters and the unitizer package to monitor changes in the global state.
The purpose of this paper is to first provide some insight into the problem of managing multiple data flows, independent of specific applications. In the following section, we discuss managing a secondary data stream from the point of view of changing the way in which expressions are combined and executed by R.
Next, we highlight two programming patterns that allow one to derive a secondary data stream, both in non-interactive (while executing a file) and in interactive circumstances. The methods discussed here do not require explicit inspection or modification of the code that describes the primary data flow. It is also not necessary to invoke signaling conditions to transport information from or to the secondary data stream.
We also demonstrate a combination of techniques that allow users to parameterize the secondary flow without resorting to global variables, global options, or variables within a package’s namespace. We call this technique ‘local masking’ with ‘local side effects’. It is based on temporarily and locally masking a user-facing function with a function that does exactly the same except for a side effect that passes information to the secondary data flow.
As examples, we discuss two applications where these techniques have been implemented. The first is the lumberjack package (Loo 2021), which allows for tracking changes in R objects as they are manipulated expression by expression. The second is tinytest (van der Loo 2020), a compact and extensible unit testing framework.
Finally, we discuss some advantages and limitations to the techniques proposed.
In this section we give a high-level overview of the problem of adding a second data flow to an existing one, and general way to think about a solution. The general approach was inspired by a discussion of Milewski (2018) and is related to what is sometimes called a bind operator in functional programming.
Consider as an example the following two expressions, labeled \(e_1\) and \(e_2\).
: x <- 10
e1: y <- 2*x e2
We would like to implement some kind of monitoring as these expressions
are evaluated. For this purpose, it is useful to think of \(e_1\) and
\(e_2\) as functions that accept a set of key-value pairs, possibly alter
the set’s contents, and return it. In R this set of key-value pairs is
an environment
, and usually, it is the global environment (the user’s
workspace). Starting with an empty environment \(\{\}\) we get:
\[\begin{aligned}
e_1(\{\}) &= \{(\texttt{"x"},\texttt{10})\}\\
e_2(e_1(\{\})) &= \{(\texttt{"x"}, \texttt{10}),(\texttt{"y"}, \texttt{20})\}
\end{aligned}\]
In this representation, we can write the result of executing the above
script in terms of the function composition operator \(\circ\):
\[\begin{aligned}
e_2(e_1(\{\})) = (e_2\circ e_1)(\{\}).
\end{aligned}\]
And in general, we can express the final state \(\mathcal{U}\) of any
environment after executing a sequence of expressions
\(e_1,e_2,\cdots,e_k\) as:
\[\label{eq:compose}
\begin{aligned}
\mathcal{U} = (e_k\circ e_{k-1}\circ \cdots\circ e_1)(\{\}),
\end{aligned} \tag{1}\]
where we assumed without loss of generality that we start with an empty
environment. We will refer to the sequence \(e_1\ldots e_k\) as the
‘primary expressions’ since they define a user’s main data flow.
We now wish to introduce some kind of logging. For example, we want to count the number of evaluated expressions, not counting the expressions that will perform the count. The naive way to do this is to introduce a new expression, say \(n\):
: if (!exists("N")) N <- 1 else N <- N + 1 n
And we insert this into the original sequence of expressions. This
amounts to the cumbersome solution:
\[\begin{aligned}
\mathcal{U}\cup\{(\texttt{"N"},k)\} = (n\circ e_k \circ n\circ e_{k-1}\circ n\circ \cdots n\circ e_{1})(\{\}),
\label{eq:insert}
\end{aligned} \tag{2}\]
where the number of executed expressions is stored in N
. We shall
refer to n
as a ‘secondary expression’, as it does not contribute to
the user’s primary data flow.
The above procedure can be simplified if we define a new function composition operator \(\circ_n\) as follows: \[\begin{aligned} a\circ_n b = a\circ n \circ b. \end{aligned}\] One may verify the associativity property \(a\circ_n(b\circ_n c)=(a\circ_n b)\circ_n c\) for expressions \(a\), \(b\), and \(c\), so \(\circ_n\) can, indeed, be interpreted as a new function composition operator. Using this operator we get \[\begin{aligned} \mathcal{U}\cup\{(\texttt{"N"},k-1)\} = (e_k\circ_n e_{k-1}\circ_n \cdots\circ_n e_1)(\{\}), \label{eq:compose1} \end{aligned} \tag{3}\] which gives the same result as Equation (2) up to a constant.
If we are able to alter function composition, then this mechanism can be used to track all sorts of useful information during the execution of \(e_1,\ldots, e_k\). For example, a simple profiler is set up by timing the expressions and adding the following expression to the function composition operator.
: if (!exists("S")) S <- Sys.time() else S <- c(S, Sys.time()) s
After running \(e_k\circ_s\cdots\circ_s e_1\), diff(S)
gives the timings
of individual statements. A simple memory profiler is defined as
follows.
: if (!exists("M")) M <- sum(memory.profile()) else M <- c(M, sum(memory.profile())) m
After running \(e_k\circ_m\cdots\circ_m e_1\), M
gives the amount of
memory used by R after each expression.
We can also track changes in data, but it requires that the composition operator knows the name of the R object that is being tracked. As an example, consider the following primary expressions.
: x <- rnorm(10)
e1: x[x<0] <- 0
e2: print(x) e3
We can define the following expression for our modified function composition operator.
: {
vif (!exists("V")){
<- logical(0)
V <- x
x0
}if (identical(x0,x)) V <- c(V, FALSE)
else V <- c(V, TRUE)
<- x
x0 }
After running \(e_3\circ_v e_2\circ_v e_1\), the variable V
equals
c(TRUE, FALSE)
, indicating that \(e_2\) changed x
, and \(e_3\) did not.
These examples demonstrate that redefining function composition yields a powerful method for extracting logging information with (almost) no intrusion on the user’s common workflow. The simple model shown here does have some obvious setbacks: first, the expressions inserted by the composition operator manipulate the same environment as the user expressions. The user- and secondary expressions can therefore interfere with each other’s results. Second, there is no direct control from the primary sequence over the secondary sequence: the user has no explicit control over starting, stopping, or parametrizing the secondary data stream. We demonstrate in the next section how these setbacks can be avoided by evaluating secondary expressions in a separate environment and by using techniques we call ‘local masking’ and ‘local side-effect’.
R executes expressions one by one in a read-evaluate-print loop (REPL).
In order to tap information from this running loop, it is necessary to
catch the user’s expressions and interweave them with our own
expressions. One way to do this is to develop an alternative to R’s
native source()
function. Recall that source()
reads an R script and
executes all expressions in the global environment. Applications include
non-interactive sessions or interactive sessions with repetitive tasks
such as running test scripts while developing functions. A second way to
intervene with a user’s code is to develop a special ‘forward pipe’
operator, akin to R’s |>
pipe, the magrittr pipe of
Bache and Wickham (2014), or the ‘dot-pipe’ of Mount and Zumel (2018). Since a user
inserts a pipe between expressions, it is an obvious place to insert
code that generates a secondary data flow.
In the following two subsections we will develop both approaches. As a running example, we will implement a secondary data stream that counts expressions.
source()
The source()
function reads an R script and executes all expressions
in the global environment. A simple variant of source()
that counts
expressions as they get evaluated can be built using parse()
and
eval()
.
<- function(file){
run <- parse(file)
expressions <- new.env(parent=.GlobalEnv)
runtime
<- 0
n for (e in expressions){
eval(e, envir=runtime)
<- n + 1
n
}message(sprintf("Counted %d expressions",n))
runtime }
Here, parse()
reads the R file and returns a list of expressions
(technically, an object of class ‘expression
’). The eval()
function
executes the expression while all variables created by or needed for
execution are sought in a newly created environment called runtime
. We
make sure that variables and functions in the global environment are
found by setting the parent of runtime
equal to .GlobalEnv
. Now,
given a file "script.R"
.
# contents of script.R
<- 10
x <- 2*x y
An interactive session would look like this.
> e <- run("script.R")
2 expressions
Counted > e$x
1] 10 [
So, contrary to the default behavior of source()
, variables are
assigned in a new environment. This difference in behavior can be
avoided by evaluating expressions in .GlobalEnv
. However, for the next
step, it is important to have a separate runtime environment.
We now wish to give the user some control over the secondary data
stream. In particular, we want the user to be able to choose when
run()
starts counting expressions. Recall that we demand that this is
done by direct communication to run()
. This means that side-effects
such as setting a special variable in the global environment or a global
option is out of the question. Furthermore, we want to avoid code
inspection: the run()
function should be unaware of what expressions
it is running exactly. We start by writing a function for the user that
returns TRUE
.
<- function() TRUE start_counting
Our task is to capture this output from run()
when start_counting()
is called. We do this by masking this function with another function
that does exactly the same, except that it also copies the output value
to a place where run()
can find it. To achieve this, we use the
following helper function.
<- function(fun, envir){
capture function(...){
<- fun(...)
out $counting <- out
envir
out
} }
This function accepts a function (fun
) and an environment (envir
).
It returns a function that first executes fun(...)
, copies its output
value to envir
, and then returns the output to the user. In an
interactive session, we would see the following.
> store <- new.env()
> f <- capture(start_counting, store)
> f()
1] TRUE
[> store$counting
1] TRUE [
Observe that our call to f()
returns TRUE
as expected but also
exported a copy of TRUE
into store
. The reason this works is that an
R function ‘remembers’ where it is created. The function f()
was
created inside capture()
, and the variable envir
is present there.
We say that this ‘capturing’ version of start_counting
has a local
side-effect: it writes outside of its own scope but the place where it
writes is controlled.
We now need to make sure that run()
executes the captured version of
start_counting()
. This is done by locally masking the user-facing
version of start_counting()
. That is, we make sure that the captured
version is found by eval()
and not the original version. A new version
of run()
now looks as follows.
<- function(file){
run <- parse(file)
expressions <- new.env()
store <- new.env(parent=.GlobalEnv)
runtime $start_counting <- capture(start_counting, store)
runtime<- 0
n for (e in expressions){
eval(e, envir=runtime)
if ( isTRUE(store$counting) ) n <- n + 1
}message(sprintf("Counted %d expressions",n))
runtime }
Now, consider the following code, stored in script1.R
.
# contents of script1.R
<- 10
x start_counting()
<- 2*x y
In an interactive session, we would see this.
> e <- run("script1.R")
1 expressions
Counted > e$x
1] 10
[> e$y
1] 20 [
Let us go through the most important parts of the new run()
function.
After parsing the R file, a new environment is created that will store
the output of calls to start_counting()
.
<- new.env() store
The runtime environment is created as before, but now we add the
capturing version of start_counting()
.
<- new.env(parent=.GlobalEnv)
runtime $start_counting <- capture(start_counting, store) runtime
This ensures that when the user calls start_counting()
, the capturing
version is executed. We call this technique local masking since the
start_counting()
function is only masked during the execution of
run()
. The captured version of start_counting()
as a side effect
stores its output in store
. We call this a ‘local side-effect’ because
store
is never seen by the user: it is created inside run()
and
destroyed when run()
is finished.
Finally, all expressions are executed in the runtime environment and
counted conditional on the value of store$counting
.
for (e in expressions){
eval(e, envir=runtime)
if ( isTRUE(store$counting) ) n <- n + 1
}
Summarizing, with this construction, we are able to create a file runner
akin to source()
that can gather and communicate useful process
metadata while executing a script. Moreover, the user of the script can
convey information directly to the file runner, while it runs, without
relying on global side-effects. This is achieved by first creating a
user-facing function that returns the information to be sent to the file
runner. The file runner locally masks the user-facing version with a
version that copies the output to an environment local to the file
runner before returning the output to the user.
The approach just described can be generalized to more realistic use
cases. All examples mentioned in the ‘Context’ section —time or memory
profiling, or logging changes in data, merely need some extra
administration. Furthermore, the current example emits the secondary
data flow as a ‘message
’. In practical use cases, it may make more
sense to write the output to a file connection or database or the make
the secondary data stream output of the file runner. In the Applications
section, both applications are discussed.
Pipe operators have become a popular tool for R users over the last
years, and R currently has a pipe operator (|>
) built-in. This pipe
operator is intended as a form of ‘syntactic sugar’ that, in some cases,
makes code a little easier to write. A pipe operator behaves somewhat
like a left-to-right ‘expression composition operator’. This, in the
sense that a sequence of expressions that are joined by a pipe operator
are interpreted by R’s parser as a single expression. Pipe operators
also offer an opportunity to derive information from a running sequence
of expressions.
It is possible to implement a basic pipe operator as follows.
`%p>%` <- function(lhs, rhs) rhs(lhs)
Here, the rhs
(right-hand side) argument must be a single-argument
function, which is applied to lhs
. In an interactive session we could
see this.
> 3 %p>% sin %p>% cos
1] 0.9900591 [
To build our expression counter, we need to have a place to store the
counter value hidden from the user. In contrast to the implementation of
the file runner in the previous section, each use of %p>%
is
disconnected from the other, and there seems to be no shared space to
increase the counter at each call. The solution is to let the secondary
data flow travel with the primary flow by adding an attribute to the
data. We create two user-facing functions that start or stop logging as
follows.
<- function(data){
start_counting attr(data, "n") <- 0
data
}<- function(data){
end_counting message(sprintf("Counted %d expressions", attr(data,"n")-1))
attr(data, "n") <- NULL
data }
Here, the first function attaches a counter to the data and initializes it to zero. The second function reports its value, decreased by one, so the stop function itself is not included in the count. We also alter the pipe operator to increase the counter if it exists.
`%p>%` <- function(lhs, rhs){
if ( !is.null(attr(lhs,"n")) ){
attr(lhs,"n") <- attr(lhs,"n") + 1
}rhs(lhs)
}
In an interactive session, we could now see the following.
> out <- 3 %p>%
+ start_counting %p>%
+ sin %p>%
+ cos %p>%
+ end_counting
2 expressions
Counted > out
1] 0.9900591 [
Summarizing, for small interactive tasks, a secondary data flow can be added to the primary one by using a special kind of pipe operator. Communication between the user and the secondary data flow is implemented by adding or altering attributes attached to the R object.
Generalizations of this technique come with a few caveats. First, the current pipe operator only allows right-hand side expressions that accept a single argument. Extension to a more general case involves inspection and manipulation of the right-hand side’s abstract syntax tree and is out of scope for the current work. Second, the current implementation relies on the right-hand side expressions to preserve attributes. A general implementation will have to test that the output of rhs(lhs) still has the logging attribute attached (if there was any) and re-attach it if necessary.
The lumberjack package (Loo 2021) implements a logging
framework to track changes in R objects as they get processed. The
package implements both a pipe operator, denoted %L>%
, and a file
runner called run_file()
. The main communication devices for the user
are two functions called start_log()
and dump_log()
.
We will first demonstrate working with the lumberjack pipe operator.
The function start_log()
accepts an R object and a logger object. It
attaches the logger to the R object and returns the augmented R object.
A logger is a reference object1 that exposes at least an $add()
method and a $dump()
method. If a logger is present, the pipe operator
stores a copy of the left-hand side. Next, it executes the expression on
the right-hand side with the left-hand side as an argument and stores
the output. It then calls the add()
method of the logger with the
input and output so that the logger can compute and store the
difference. The dump_log()
function accepts an R object, calls the
$dump()
method on the attached logger (if there is any), removes the
logger from the object and returns the object. An interactive session
could look as follows.
> library(lumberjack)
> out <- women %L>%
> start_log(simple$new()) %L>%
> transform(height = height * 2.54) %L>%
> identity() %L>%
> dump_log()
/home/mark/simple.csv
Dumped a log at > read.csv("simple.csv")
step time expression changed1 1 2019-08-09 11:29:06 transform(height = height * 2.54) TRUE
2 2 2019-08-09 11:29:06 identity() FALSE
Here, simple$new()
creates a logger object that registers whether an R
object has changed or not. There are other loggers that compute more
involved differences between in- and output. The $dump()
method of the
logger writes the logging output to a csv file.
For larger scripts, a file runner called run_file()
is available in
lumberjack. As an example, consider the following script. It converts
columns of the built-in women
data set to SI units (meters and
kilogram) and then computes the body-mass index of each case.
# contents of script2.R
start_log(women, simple$new())
$height <- women$height * 2.54/100
women$weight <- women$weight * 0.453592
women$bmi <- women$weight/(women$height)^2 women
In an interactive session, we can run the script and access both the logging information and retrieve the output of the script.
> e <- run_file("script2.R")
/home/mark/women_simple.csv
Dumped a log at > read.csv("women_simple.csv")
step time expression changed1 1 2019-08-09 13:11:25 start_log(women, simple$new()) FALSE
2 2 2019-08-09 13:11:25 women$height <- women$height * 2.54/100 TRUE
3 3 2019-08-09 13:11:25 women$weight <- women$weight * 0.453592 TRUE
4 4 2019-08-09 13:11:25 women$bmi <- women$weight/(women$height)^2 TRUE
> head(e$women,3)
height weight bmi1 1.4732 52.16308 24.03476
2 1.4986 53.07026 23.63087
3 1.5240 54.43104 23.43563
The lumberjack file runner locally masks start_log()
with a function
that stores the logger and the name of the tracked R object in a local
environment. A copy of the tracked object is stored locally as well.
Expressions in the script are executed one by one. After each
expression, the object in the runtime environment is compared with the
stored object. If it has changed, the $add()
method of the logger is
called, and a copy of the changed object is stored. After all
expressions have been executed, the $dump()
method is called, so the
user does not have to do this explicitly.
A user can add multiple loggers for each R object and track multiple
objects. It is also possible to dump specific logs for specific objects
during the script. All communication necessary for these operations runs
via the mechanism explained in the ‘build your own source()
’ section.
The tinytest package (van der Loo 2020) implements a unit testing framework. Its core function is a file runner that uses local masking and local side effects to capture the output of assertions that are inserted explicitly by the user. As an example, we create tests for the following function.
# contents of bmi.R
<- function(weight, height) weight/(height^2) bmi
A simple tinytest test file could look like this.
# contents of test_script.R
data(women)
$height <- women$height * 2.54/100
women$weight <- women$weight * 0.453592
women<- with(women, bmi(weight,height) )
BMI
expect_true( all(BMI >= 10) )
expect_true( all(BMI <= 30) )
The first four lines prepare some data, while the last two lines check
whether the prepared data meets our expectations. In an interactive
session, we can run the test file after loading the bmi()
function.
> source("bmi.R")
> library(tinytest)
> out <- run_test_file('test_script.R')
2 tests OK
Running test_script.R................. > print(out, passes=TRUE)
----- PASSED : test_script.R<7--7>
| expect_true(all(BMI >= 10))
call----- PASSED : test_script.R<8--8>
| expect_true(all(BMI <= 30)) call
In this application, the file runner locally masks the expect_*()
functions and captures their result through a local side effect. As we
are only interested in the test results, the output of all other
expressions is discarded.
Compared to the basic version described in the ‘build your own
source()
’ section, this file runner keeps some extra administration,
such as the line numbers of each masked expression. These can be
extracted from the output of parse()
. The package comes with a number
of assertions in the form of expect_*()
functions. It is possible to
extend tinytest by registering new assertions. These are then
automatically masked by the file runner. The only requirement on the new
assertions is that they return an object of the same type as the
built-in assertions (an object of class ‘tinytest
’).
The techniques demonstrated here have two major advantages. First, it allows for a clean and side-effect free separation between the primary and secondary data flows. As a result, the secondary data flow is composed with the primary data flow. In other words: a user that wants to add a secondary data flow to an existing script does not have to edit any existing code. Instead, it is only necessary to add a bit of code to specify and initialize the secondary stream, which is a big advantage for maintainability. Second, the current mechanisms avoid the use of condition signals. This also leads to code that is easier to understand and navigate because all code associated with the secondary flow can be limited to the scope of a single function (here: either a file runner or a pipe operator). Since the secondary data flow is not treated as an unusual condition (exception), the exception signaling channel is free for transmitting truly unusual conditions such as errors and warnings.
There are also some limitations inherent to these techniques. Although the code for the secondary data flow is easy to compose with code for the primary data flow, it is not as easy to compose different secondary data flows. For example, one can use only one file runner to run an R script and only a single pipe operator to combine two expressions.
A second limitation is that this approach does not recurse into the primary expressions. For example, the expression counters we developed only count user-defined expressions. They can not count expressions that are called by functions called by the user. This means that something like a code coverage tool such as covr is out of scope.
A third and related limitation is that the resolution of expressions may
be too low for certain applications. For example in R, ‘if
’ is an
expression (it returns a value when evaluated) rather than a statement
(like for
). This means that parse()
interprets a block such as
if ( x > 0 ){
<- 10
x <- 2*x
y }
as a single expression. If higher resolution is needed, this requires explicit manipulation of the user code.
Finally, the local masking mechanism excludes the use of the namespace
resolution operator. For example, in lumberjack, it is not possible to
use lumberjack::start_log()
since, in that case, the user-facing
function from the package is executed and not the masked function with
the desired local side-effect.
In this paper we demonstrated a set of techniques that allow one to add
a secondary data flow to an existing user-defined R script. The core
idea is that we manipulate way expressions are combined before they are
executed. In practice, we use R’s parse()
and eval()
to add
secondary data stream to user code, or build a special ‘pipe’ operator.
Local masking and local side effects allow a user to control the
secondary data flow without global side-effects. The result is a clean
separation of concerns between the primary and secondary data flow, that
does not rely on condition handling, is void of global side-effects, and
that is implemented in pure R.
This article is converted from a Legacy LaTeX article using the texor package. The pdf version is the official version. To report a problem with the html, refer to CONTRIBUTE on the R Journal homepage.
Text and figures are licensed under Creative Commons Attribution CC BY 4.0. The figures that have been reused from other sources don't fall under this license and can be recognized by a note in their caption: "Figure from ...".
For attribution, please cite this work as
Loo, "A Method for Deriving Information from Running R Code", The R Journal, 2021
BibTeX citation
@article{RJ-2021-056, author = {Loo, Mark P.J. van der}, title = {A Method for Deriving Information from Running R Code}, journal = {The R Journal}, year = {2021}, note = {https://rjournal.github.io/}, volume = {13}, issue = {1}, issn = {2073-4859}, pages = {42-52} }