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
Acknowledgements
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.
cppally features
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_symfor R symbolsC++ class
r_functionfor R functionsC++ class
r_dffor data framesDate & Date-Time classes using
std::chronoFast automatic garbage collector protection with
r_sexpFlexible coercion between types using
as<T>Visit type-erased
SEXPwithr_sexp_visitSIMD vectorisation
Multi-threaded algorithms
Random number generation with
random_streamRich 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 moreStatistical functions such as
sum(),var(),range(),quantile(), and moreC++ class
groupsfor fast group ID generation with group metadata membersRegular sequence generation
Installation
Install the CRAN release
install.packages("cppally")or the development version
pak::pak("NicChr/cppally")Registering C++ templates to R
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] 15NA 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] NASince 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.5Character 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 constraintsDesign choices
Templates
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++.
Scalar R types and custom methods
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) |
Scalar arithmetic
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
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:
- Find the common type between the two via
cppally::common_math_t - Work with the unwrapped C++ primitives
- Cast
lhsandrhsto the common integer type - Cast
lhsandrhsto their unsigned type, add together, and cast back to the common signed type - If
aandbhave identical sign butsdoes not, then there is integer overflow - If there was overflow or if either
lhsorrhsisNA, returnNA, otherwise return the added result
Since we are using same-width1 integers and branchless operations, integer addition with cppally is fast and easily SIMD vectorisable.
Automatic protection
Like the excellent cpp11 package, cppally also handles automatic protection for R objects. For more info see Automatic Protection.
ALTREP
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
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 both cppally and the R C API
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()).
Attributes
Attribute manipulation can be done using functions in the attr namespace via cppally::attr::
Views
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.
Why use views?
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
SEXPconstants: Creatingr_sexpinstances aroundSEXPobjects that are static constants and known to be protected. E.g.R_NilValue.Using
r_str_viewas an argument: Sincer_strimplicitly converts tor_str_view, a function with argumentr_str_viewwill implicitly accept bothr_strandr_str_view, avoiding any protection overhead.
How to use views
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:
Bad:
If in doubt, always use r_str. For more info on views see Automatic Protection
View a vector element
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_listA copy of an element view is still a view, and not protected.
If in doubt, use get()
Lossy coercion
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
#> Error:
#> ! Implicit NA coercion detected from r_str to r_int, please ensure data can be coerced without complete loss of informationThis is in contrast to R which returns an NA with a warning
as.integer("a")
#> Warning: NAs introduced by coercion
#> [1] NAThe 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.
foo(1.2345)
#> [1] 1The 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.
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] 30So 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] 40as.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 information64-bit integers
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.
as_int64(.Machine$integer.max) + 1L
#> integer64
#> [1] 2147483648Please 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.
Using R’s 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().
Large cpp files on Windows
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:
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.
Enabling OpenMP
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().
C++20 and RStudio
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
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.