cppally is a high-performance, header-only library providing a rich C++20 API for R.
It offers type-safe classes, C++20 concepts, template registration, and R-based scalars to make code readable, fast, and maintainable.
Multiple threads and Single-Instruction-Multiple-Data (SIMD)
vectorisation are used where possible. The number of threads used across
cppally can be set with cppally::set_threads() and queried with
cppally::get_threads().
For info on using cppally see Getting started with cppally
I first want to thank the authors and contributors of the fantastic cpp11 R package, without which I would not have been inspired to write this package. I’d also like to thank the authors and contributors of Rcpp for developing this ecosystem that has laid much of the groundwork for C++ and R integration.
Registering C++ templates to R
C++20 concepts based on R
Custom C++ scalar classes
NA-aware scalar algebra
C++ style R vectors with r_vector<T>
C++ class r_sym for R symbols
C++ class r_function for R functions
C++ class r_df for data frames
Date & Date-Time classes using std::chrono
Fast automatic garbage collector protection with r_sexp
Flexible coercion between types using as<T>
Visit type-erased SEXP with r_sexp_visit
SIMD vectorisation
Multi-threaded algorithms
Random number generation with random_stream
Rich math library
Fast name-lookup via hash maps
Factor manipulation
Attribute manipulation
Vectorisation of C++ functions using pmap()
Vectorised algebra
Fast sorting
Vector algorithms like unique, subset, and more
Statistical functions such as sum(), var(), range(),
quantile(), and more
C++ class groups for fast group ID generation with group metadata
members
Regular sequence generation
Install the CRAN release
install.packages("cppally")
or the development version
# pak::pak("NicChr/cppally")
install.packages("cppally", repos = c('https://nicchr.r-universe.dev', 'https://cloud.r-project.org'))
Example: Templated sum
template <RMathType T>
[[cppally::register]]
r_dbl cpp_sum(r_vector<T> x){
r_size_t n = x.length();
r_dbl out(0);
for (r_size_t i = 0; i < n; ++i){
out += x.get(i);
}
return out;
}
You can use cpp_source() to register this C++ function to R
cpp_source(code = '
#include <cppally.hpp>
using namespace cppally;
template <RMathType T>
[[cppally::register]]
r_dbl cpp_sum(r_vector<T> x){
r_size_t n = x.length();
r_dbl out(0);
for (r_size_t i = 0; i < n; ++i){
out += x.get(i);
}
return out;
}
')
cpp_sum(1:5)
#> [1] 15
NA values are handled like in R. In this case NA is returned if the vector contains one or more NA values.
cpp_sum(c(1, NA, 3))
#> [1] NA
Since it is a template, it can accept various classes (that satisfy
RMathType).
cpp_sum(c(T, F)) # Logicals
#> [1] 1
cpp_sum(c(1L, 2L, 3L)) # Integers
#> [1] 6
cpp_sum(c(1.5, 2.5, 3.5)) # Doubles
#> [1] 7.5
Character vector elements (r_str) don’t satisfy RMathType, so
r_vector<r_str> is rejected.
cpp_sum("A STRING")
#> Error:
#> ! Argument 1 of type character does not satisfy the template constraints
cppally uses templates a lot for generic programming. While this can be a flexible way to write generic functions, it can slow down compile times and increase binary sizes.
There are two limitations when it comes to registering C++ templates to R.
Templates must be written in header files
Template specialisations cannot be directly called from R
The first is a C++ limitation, though it only applies when that template is shared across more than 1 cpp file (translation unit).
The second is an R/C++ interoperability limitation. It means we can’t
call e.g. foo<T>(x) directly from R. Instead we rely on automatic
deduction, which the majority of templates use. Automatic deduction here
looks like foo(x) where T is deduced from x by a cppally template
dispatch layer between R and C++.
cppally offers scalar C++ types that mirror their R counterpart in the
sense that they are NA aware and match other R semantics specific to
that class.
Scalar classes that cppally offers
| Scalar class | R-counterpart |
|---|---|
r_lgl |
logical(1) |
r_int |
integer(1) |
r_dbl |
double(1) |
r_str |
character(1) |
r_cplx |
complex(1) |
r_int64 |
bit64::integer64(1) |
r_date |
as.Date(double(1)) |
r_psxct |
as.POSIXct(double(1)) |
r_raw |
raw(1) |
Many operators and methods have been written to ensure safe, correct,
and efficient behaviour. This includes binary arithmetic operators such
as: +, -, /, *, and many more.
For example, in standard C++, an integer divided by another integer
int / int always results in an integer int. With cppally, division
always results in an R double r_dbl, so r_int / r_int returns an R
double, matching R semantics.
Another example is the modulo operator %. With standard C++ integers,
this returns the remainder after integer division (truncated towards
zero), but R uses Knuth floored division, which cppally also implements
to match. (int) -5 % 2 returns -1 (truncated modulo), whereas
(r_int) -5 % 2 returns 1 (floored modulo).
Integer overflow with cppally integers is never undefined behaviour.
NA will always be returned where there is integer overflow. Let’s
look at how cppally’s + operator is currently defined to see this in
detail.
// First few lines of cppally::operator+
template <MathType T, MathType U>
requires (RMathType<T> || RMathType<U>)
inline constexpr auto operator+(T lhs, U rhs) noexcept {
using common_t = common_math_t<T, U>;
if constexpr (RIntegerType<common_t>){
using I = unwrap_t<common_t>;
using UI = std::make_unsigned_t<I>;
I a = static_cast<I>(unwrap(lhs));
I b = static_cast<I>(unwrap(rhs));
// Wraparound sum via unsigned: defined behaviour
I s = static_cast<I>(static_cast<UI>(a) + static_cast<UI>(b));
// Overflowed iff a and b share a sign that s does not
// a ^ s: Sign bit of the XOR is 1 when a and s have different signs, otherwise 0
// b ^ s: Same logic as a ^ s
// (a ^ s) & (b ^ s): bitwise AND, the sign bit of the result is 1 only when both a ^ s and b ^ s have their sign bit set
// Overflow can only happen when we are adding two numbers of the same sign
// and because this is C++20, two's complement applies, which means we can check whether the top bit is set via < 0
bool bad = (((a ^ s) & (b ^ s)) < 0) | internal::any_arithmetic_na(lhs, rhs);
return bad ? common_t::na() : common_t(s);
The steps are as follows:
cppally::common_math_t
lhs and rhs to the common integer typelhs and rhs to their unsigned type, add together, and cast
back to the common signed typea and b have identical sign but s does not, then there is
integer overflowlhs or rhs is NA, return
NA, otherwise return the added resultSince we are using same-width[^1] integers and branchless operations, integer addition with cppally is fast and easily SIMD vectorisable.
Relational operators like ==, <, etc, are all NA-aware as well, and
return r_lgl instead of bool.
For example r_int(5) == r_dbl(5) returns r_true, whereas
na<r_int>() == na<r_int>() returns r_na.
Like the excellent cpp11 package, cppally also handles automatic protection for R objects. For more info see Automatic Protection.
For performance reasons, ALTREP materialisation is eager by default, which means that ALTREP vectors are materialised on construction.
To preserve ALTREP compact representations, one can enable the
package-wide CPPALLY_PRESERVE_ALTREP flag. This can be done through
cppally::use_preserve_altrep_flag() or
cppally::cpp_source(..., preserve_altrep = TRUE). You can also
manually add the -DCPPALLY_PRESERVE_ALTREP flag to Makevars.
Copy-on-modify is an opt-in feature which can be enabled via
cppally::use_copy_on_modify() or by setting the
CPPALLY_COPY_ON_MODIFY Makevars flag directly.
When enabled, all modifications are first checked to ensure that the object being modified isn’t referenced or owned by another object. If it is referenced, a copy is taken first before modifying.
This safety check is inherently single-threaded which means that enabling copy-on-modify disables almost all parallelisation, degrading cppally performance. Enable if you want to prevent accidental modification of shared objects.
By default, copy-on-modify is disabled and hence all element setting is
done in-place. It is up to the user to ensure that the vector they are
modifying is safe to modify. Vectors you create with r_vector<T>(n)
are freshly constructed, and hence safe to modify at that point. You can
reliably check that it is safe to modify a vector by first calling
r_vector::is_exclusive(). If the vector is exclusive, it means it is
not referenced by another object and therefore is safe to modify
in-place. If it is not exclusive, you can call r_vector::copy() to
return a copied (fresh) vector.
Using the R C API alongside cppally is strongly discouraged for the following reasons.
If R throws an error via Rf_error() a ‘longjmp’ will occur, meaning
C++ destructors won’t run and memory that should have been released will
not be released.
Furthermore, due to the way cppally caches vector names, using R C API
functions like Rf_setAttrib() will set the vector’s names without
informing cppally, leading to synchronisation issues. cppally needs to
keep the names cache in sync with the R names attribute and the only way
it can do that is by detecting changes to the names via
cppally::r_vector::set_names() or cppally::attr::set_attr() (or
cppally::set_old_names()).
Attribute manipulation can be done using functions in the attr
namespace via cppally::attr::
A view in standard C++ can mean different things. With
std::string_view, it means we don’t own the string data, we are just
viewing its contents. r_str_view is similar, but the data it’s
referring to is the underlying SEXP. Views in cppally mean “I don’t
protect the SEXP”. Therefore, r_str_view is a view into the
CHARSXP, without taking protection ownership of it. r_str on the
other hand does protect the CHARSXP, via r_sexp.
We offer views for performance reasons. Protecting an object is a considerable overhead (about 5-10 ns per protect). We can avoid that overhead by using views. This is safe as long as we are sure that the view object is protected by another object.
Here are some common use-cases where one might use views:
Setting new vector elements: No need to protect newly created elements when they are going to be protected by their parent vector anyway
Using vector elements temporarily: It is fine if an element is used for a short-lived time, so long as the element does not outlive its parent vector
Referencing protected SEXP constants: Creating r_sexp
instances around SEXP objects that are static constants and known to
be protected. E.g. R_NilValue.
Using r_str_view as an argument: Since r_str implicitly
converts to r_str_view, a function with argument r_str_view will
implicitly accept both r_str and r_str_view, avoiding any
protection overhead.
r_str_view is a dedicated view type for r_str. As mentioned above it
can be used as function arguments for string inputs. This has the
benefit of flexibly accepting r_str and r_str_view, without the
compile cost of a template.
It is okay as an input, but generally not okay as an output.
Good:
r_str foo(r_str_view x){
return r_str(x);
}
Bad:
r_str_view foo(r_str_view x){
r_str my_str("hello");
return my_str;
}
If in doubt, always use r_str. For more info on views see Automatic
Protection
If you have a vector, you can use r_vector::view(). This will get the
element without re-protecting it. Just make sure that the element view
does not outlive its parent vector.
r_vector<r_sexp> my_list( {r_sexp(), r_sexp()} );
// Unprotected but implicitly protected by my_list
r_sexp element0 = my_list.view(0);
r_sexp element1 = my_list.view(1);
// element0 or element1 must not outlive my_list
A copy of an element view is still a view, and not protected.
r_sexp my_element = r_sexp(element0); // my_element is not protected
If in doubt, use get()
// Both element0 and element1 are protected themselves
r_sexp element0 = my_list.get(0);
r_sexp element1 = my_list.get(1);
r_sexp my_element = element0; // Copy here is very cheap (0.2-0.5 ns/copy)
Any coercion that results in complete information loss is an error
(partial is allowed, e.g. double -> int).
For example, string -> integer conversion may not be possible without complete information loss
as<r_int>(r_str("a"))
#> Error:
#> ! Implicit NA coercion detected from r_str to r_int, please ensure data can be coerced without complete loss of information
This is in contrast to R which returns an NA with a warning
as.integer("a")
#> Warning: NAs introduced by coercion
#> [1] NA
The benefit of cppally’s approach is that when registering C++ functions to R, inputs can be supplied flexibly without unexpected behaviour.
Let’s say you have a function foo that expects an r_int but you give
it an r_dbl without realising - this will implicitly coerce to r_int
without throwing an error.
[[cppally::register]]
r_int foo(r_int x){
return x;
}
foo(1.2345)
#> [1] 1
The double 1.2345 was implicitly converted to 1, an example of
partial lossy coercion. cppally allows this.
What cppally doesn’t allow is total lossy coercion, which can result in ambiguity. Take the following example of counting the occurrence of a value in a vector.
[[cppally::register]]
int count_val(r_vector<r_dbl> x, r_dbl value){
return x.count(value);
}
x <- c(rep(0, 20), rep(1, 30), rep(NA, 40))
count_val(x, 0)
#> [1] 20
count_val(x, 1)
#> [1] 30
count_val(x, NA)
#> [1] 40
# value can also implicitly coerce a string to a double
count_val(x, "0")
#> [1] 20
count_val(x, "1")
#> [1] 30
So far so good. But what happens if we implicitly coerced to NA and then counted occurrences?
count_val(x, as.double("1. 0")) # Wrong
#> Warning in count_val(x, as.double("1. 0")): NAs introduced by coercion
#> [1] 40
as.double("1. 0") was coerced to NA, count_val() then counted the
number of NA values and returned 40, even though we didn’t ask for a
count of NA, so the result should have been 0.
This is the main issue with allowing total lossy coercion to NA -
count_val() can’t distinguish between a true NA and an NA that has
been produced from a lossy coercion.
With cppally this ambiguity is impossible.
count_val(x, "1. 0")
#> Error:
#> ! Implicit NA coercion detected from r_str to r_dbl, please ensure data can be coerced without complete loss of information
All indexing is 0-based including subsetting vectors.
subset(make_vector<r_dbl>(10, 20, 30), make_vector<r_int>(1)); // index 1 = second element = 20
On the C++ side, 64-bit integers are fully supported, including vectors. To return 64-bit integers to R we need the bit64 package to be loaded. cppally delegates the handling of 64-bit integer vectors in R to bit64 by marking them with the “integer64” class.
library(bit64)
#> Warning: package 'bit64' was built under R version 4.6.1
[[cppally::register]]
r_int64 as_int64(r_int x){
return as<r_int64>(x);
}
as_int64(.Machine$integer.max) + 1L
#> integer64
#> [1] 2147483648
Please note that aliases of standard C++ signed 64-bit integer types
like int64_t, R_xlen_t and cppally’s r_size_t will convert to
64-bit integer vectors when returned to R.
as<r_size_t>(r_int(0))
#> integer64
#> [1] 0
as<R_xlen_t>(r_int(0))
#> integer64
#> [1] 0
as<int64_t>(r_int(0))
#> integer64
#> [1] 0
NULL
The cppally version of R’s R_NilValue is r_null which is of type
r_sexp. In an attempt to avoid the use of additional meta-programming
tactics to deal with r_null, we allow vectors the special ability to
hold r_null. This makes programming with attributes easier, since
attributes are sometimes vectors and sometimes NULL.
To check if a vector is NULL, use r_vector::is_null().
r_vector<r_int>(r_null)
#> NULL
r_vector<r_int>(r_null).is_null()
#> [1] TRUE
Because cppally is a template-heavy library, binary sizes can sometimes
get large. Once a .o file gets too big, Windows will throw a compiler
warning or error (the default limit is 65,279 addressable sections). To
increase this limit to 4,294,967,296, add the following flag:
PKG_CXXFLAGS = -Wa,-mbig-obj
If you are submitting an R package to CRAN, this will likely trigger a
CRAN note, so it is worth flagging this in your cran-comments.md file.
To benefit from OpenMP multi-threaded execution and SIMD vectorisation,
run cppally::use_openmp() which will set the appropriate Makevars
flags. Note that this is automatically set by cppally::use_cppally().
Threads can be controlled with set_threads() and get_threads().
At the moment C++20 is not fully supported via RStudio, so I would recommend using VSCode with the C/C++ for Visual Studio Code extension.
To get VSCode intellisense to work correctly, you will likely need to set some parameters in c_cpp_properties.json.
My json file looks like this:
{
"configurations": [
{
"name": "Win32",
"includePath": [
"${workspaceFolder}/**",
"${workspaceFolder}/src",
"${workspaceFolder}/inst/include",
"C:/Program Files/R/R-4.*/include",
"${env:LOCALAPPDATA}/R/win-library/4.*/cpp11/include",
"${env:LOCALAPPDATA}/R/win-library/4.*/Rcpp/include",
"${env:LOCALAPPDATA}/R/win-library/4.*/cppally/include"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE",
"STRICT_R_HEADERS"
],
"compilerPath": "C:\\rtools45\\x86_64-w64-mingw32.static.posix\\bin\\g++.exe",
"cppStandard": "gnu++20",
"intelliSenseMode": "gcc-x64"
}
],
"version": 4
}
As your R installation path may differ, you can find the exact path with
normalizePath(Sys.getenv("R_HOME"), winslash = "/")
Your R libraries can be found with
.libPaths()
The compiler bundled with RTools is likely found here
cxx <- system2(file.path(R.home("bin"), "R"),
c("CMD", "config", "CXX20"), stdout = TRUE)
cxx_bin <- trimws(strsplit(cxx, " ")[[1]][1])
Sys.which(cxx_bin)
#> g++
#> "C:\\rtools45\\X86_64~1.POS\\bin\\G__~1.EXE"
Once you have both paths, set compilerPath and the R include path in c_cpp_properties.json accordingly.
[^1]: We could also check 32-bit integers for overflow by first casting to 64-bit, but this is not as efficient as using 32-bit operations.