Modularisation is a key feature of psychTestR. By parcelling your code into reusable components, you can make your implementations quicker to write, easier to read, easier to maintain, and easier to use. This tutorial discusses a few strategies for modularisation in psychTestR.
The function is the basic unit of modularisation in computer programming. A function takes a set of inputs, performs some operation upon them, and (optionally) returns an output:
f <- function(person, fruit) {
sprintf("Greetings, %s. Would you like some %s?",
person, fruit)
}
f(person = "fellow researcher",
fruit = "apples")
## [1] "Greetings, fellow researcher. Would you like some apples?"
When creating psychTestR tests, functions are useful for creating series of test elements that all share a common schema. For example, suppose we wanted to ask someone about their preferences for different kinds of fruit. We could write our code like this:
join(
NAFC_page(
"apples",
"How much do you like apples, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
NAFC_page(
"pears",
"How much do you like pears, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
NAFC_page(
"plums",
"How much do you like plums, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
NAFC_page(
"apricots",
"How much do you like apricots, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
NAFC_page(
"pineapples",
"How much do you like pineapples, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
NAFC_page(
"cherries",
"How much do you like cherries, on a scale from 1 (not at all) to 7 (lots?)",
choices = as.character(1:7),
arrange_vertically = FALSE
),
)
Clearly this is very inefficient: almost all the code is copy-pasted from page to page. This code is slow to read because it takes up so much space, and slow to maintain, because any change to the general schema needs to be repeated six times.
Functions provide a much better way to do this. Consider the following:
ask_liking <- function(fruit) {
NAFC_page(
fruit,
sprintf("How much do you like %s, on a scale from 1 (not at all) to 7 (lots?)",
fruit),
choices = as.character(1:7),
arrange_vertically = FALSE
)
}
fruits <- c("apples", "pears", "plums", "apricots", "pineapples", "cherries")
lapply(fruits, ask_liking)
There are a couple of new functions here that you may or may not have seen before:
sprintf
- splices variables into a template
string.lapply
- takes a function and applies it to each
element of a list.Our new function ask_liking
takes one input,
fruit
, and splices it into a standardised schema for
eliciting a 7-point liking response from the participant. We apply it to
each element of the vector fruits
in turn, using the
function lapply
, and it returns the same list of elements
that we had before, but with many fewer lines of code.
Underneath everything, many psychTestR functions operate under similar principles: they programmaticaly generate test elements according to certain schema. If designed correctly, these functions can generalise well to many different applications.
In psychTestR, modules are ways of wrapping sequences of test elements into coherent logical units.1 Putting a sequence of test elements into a module has three main consequences:
?set_local
). This local environment is
protected from other modules, which is useful to avoid unexpected side
effects when multiple modules are chained together.Here is a simple example of module use using the
ask_liking
function from above:
ask_liking <- function(object) {
NAFC_page(
object,
sprintf("How much do you like %s, on a scale from 1 (not at all) to 7 (lots?)",
object),
choices = as.character(1:7),
arrange_vertically = FALSE
)
}
fruits <- c("apples", "pears", "plums")
animals <- c("dogs", "cats", "sheep")
fruit_module <- module(
label = "fruits",
lapply(fruits, ask_liking)
)
animal_module <- module(
label = "animals",
lapply(animals, ask_liking)
)
timeline <- join(
fruit_module,
animal_module,
elt_save_results_to_disk(complete = TRUE),
final_page("End.")
)
make_test(timeline)
If you run this code on your local computer, and complete the
resulting test, then you should find your response data stored as an RDS
file in output/results
. Call
as.list(readRDS(file))[1:2]
in your R console, replacing
file
with the path to the RDS file, and you should get
something like this:
## $fruits
## $fruits$apples
## [1] "5"
##
## $fruits$pears
## [1] "3"
##
## $fruits$plums
## [1] "2"
##
##
## $animals
## $animals$dogs
## [1] "7"
##
## $animals$cats
## [1] "5"
##
## $animals$sheep
## [1] "3"
As you can see, the results from the two modules have been organised
into two separate sections: one labelled fruits
, and one
labelled animals
.
It is often useful to wrap modules in functions that provide a degree
of customisation. For example, suppose we wish to make
fruit_module
to have customisable length. We could do
something like this:
fruit_module <- function(num_items) {
stopifnot(num_items > 0, num_items <= 6)
all_fruits <- c("apples", "pears", "plums", "apricots", "pineapples", "cherries")
chosen_fruits <- all_fruits[1:num_items]
lapply(chosen_fruits, ask_liking)
}
Now we can pass the desired number of items to our
fruit_module
function:
fruit_module(3) # a module with 3 fruits
In many cases modules will typically be used in one flat layer.
However, it is perfectly possible to nest modules to arbitrary depths;
at any point in time, only the local variables from the lowest-level
module will be visible. The results object will use a composite label
derived by concatenating the names of the modules, separated by periods,
for example parent.child.grandchild
.
Modules are not so important for one-off test implementations. However, they do become very useful when constructing batteries of multiple test implementations.
A simple way of distributing R code is by sharing R source files. The new user can read this source file into their R session and take advantage of its contents: for example, this source file could provide helper functions for creating particular page types, or it could define modules implementing entire psychological tests.
Packages are formalised ways of sharing R source files. They stipulate particular ways of organising and documenting these source files, and they encapsulate these source files within a namespace that prevents the resulting R objects from overwriting pre-existing objects in the user’s global namespace. It is outside the scope of this tutorial to provide an introduction to package creation in R, but there are excellent tutorials available online, for example Hadley Wickham’s ‘R packages’ book site. Packages are well-suited to distributing psychTestR helper functions and modules that implement specific psychological paradigms or measures. Here are some examples of such packages: