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
std::optional arguments
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")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 constraintscppally 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_tlhs 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-width1 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_listA copy of an element view is still a view, and not protected.
r_sexp my_element = r_sexp(element0); // my_element is not protectedIf 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] 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.
[[cppally::register]]
r_int foo(r_int x){
return x;
}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.
[[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] 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 informationAll indexing is 0-based including subsetting vectors.
subset(make_vector<r_dbl>(10, 20, 30), make_vector<r_int>(1)); // index 1 = second element = 20On 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] 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.
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
NULLThe 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.
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.↩︎