quickr semantics: types, shapes, and differences from R

quickr compiles a subset of R to Fortran. This vignette describes result types, operand shape checks, and differences to account for when writing compiled functions. It covers the supported numerical operations rather than all of R’s behavior.

What to expect

For supported inputs, quickr aims to preserve R’s values, types, and shapes. It rejects many unsupported operations at compile time. When an operand shape is only known at run time, supported conformability checks run in the compiled function. Error messages need not match R’s.

Compilation is not a general proof that a function behaves like R. The restrictions and known differences below still apply. In particular, callers must supply valid subscripts and dimensions, avoid missing values and integer overflow, and initialize local variables before they are read. quickr does not perform general definite-assignment or memory-safety analysis.

library(quickr)

Types

Value types follow R’s promotion order for the supported atomic modes:

logical < integer < double < complex

Character values are not supported (declaring a character argument is a compile error). Complex values support elementwise arithmetic and equality comparisons; order comparisons (<, <=, >, >=) and %% are errors, exactly as they are in R. Outside elementwise operations, complex support is narrower than the lattice suggests — see complex values under the differences below. Raw values cannot be used as arithmetic operands.

An operation’s result type is the “join” of its operands’ types — the highest type among them — except where the table says otherwise:

Operation Result type
+ - * (and unary + -) join of the operand types; logicals count as integers (in R, TRUE + TRUE is 2L)
/ double (complex when an operand is complex, as in R)
^ double (complex when an operand is complex, as in R; see note below)
%%, %/% join of the operand types; logicals count as integers
< <= > >= == != logical
&, |, !, &&, || logical (see logical operators below)
c() join of all argument types (c(TRUE, FALSE) stays logical, as in R)
multi-argument min() / max() / sum() join of all argument types; logicals count as integers (in R, sum(TRUE, TRUE) is 2L; see integer sum() overflow)
prod() join of the argument types; unlike R, an integer or logical product is integer (see prod())
single-argument min(), max(), and sum() type of x; logical counts as integer
abs() type of x, except complex input returns double; logical counts as integer
ifelse(test, yes, no) join of yes and no (but see ifelse below)
BLAS/LAPACK operations such as %*%, crossprod(), and solve() double for supported real inputs; complex operands are a compile error (see complex values)
t(x), diag(x) same type as x; supported identity forms such as diag(3L) are double (see length-one diag() and scalar t())
cbind() / rbind() join of all argument types

For example, mixing integer and double promotes:

f <- quick(function(x, y) {
  declare(type(x = integer(1)), type(y = integer(1)))
  x / y
})
f(5L, 2L)
#> [1] 2.5

and logical arithmetic produces integers, as in R:

f <- quick(function(x, y) {
  declare(type(x = logical(1)), type(y = logical(1)))
  x + y
})
r <- f(TRUE, TRUE)
r
#> [1] 2
typeof(r)
#> [1] "integer"

A note on ^: the result is double for real operands, as in R. When the exponent has integer type, quickr uses an integer exponent, which supports negative bases: (-2) ^ 3L is -8. A whole-valued double exponent remains a real exponent; see real exponents below.

Reassignment cannot narrow a type

R happily re-binds a name to a value of a wider type: after x <- x + 0.5, an integer x becomes double. Compiled code cannot re-type a variable, and silently truncating the value would change results, so quickr makes this a compile-time error:

quick(function(x) {
  declare(type(x = integer(1)))
  x <- x + 0.5
  x
})
#> Error: cannot reassign `x`: assignment would narrow double to integer;
#>   R would promote `x` to double

The same check applies to subassignment (x[i] <- ...) and superassignment (x <<- ..., x[i] <<- ...). Widening in the other direction (assigning an integer value to a double variable) is fine and converts the value to the variable’s fixed type. This matches R for subassignment. For whole-variable assignment, it is an intentional type difference: R replaces the binding with the RHS type, whereas quickr keeps the existing type. For example, x <- 1L with a double x leaves x double in quickr and makes it integer in R. The same distinction applies to whole-variable superassignment (x <<- 1L).

Shapes

Elementwise arithmetic and comparisons, and the linear-algebra operations below, check operand conformability. Known incompatible dimensions produce compile-time errors. Symbolic dimensions that cannot be proven equal are checked at run time. These checks do not cover every assignment or constructor: keep a binding’s shape stable, and use valid, nonnegative constructor sizes. Do not rely on mismatched section replacements being rejected before a write.

Returned arrays are allocated before the generated function body runs. Use dimensions determined by unchanged inputs or constants. Body-dependent sizes and mutation of variables used in size expressions are not generally tracked safely.

For elementwise operations (arithmetic, comparisons, &, |), “compatible” means:

Operands Behavior
scalar with a nonempty array allowed; the scalar is broadcast
identical nonempty shapes allowed
vector with matrix, length(vec) == nrow(mat) allowed; the vector is recycled column-wise, exactly as in R
a 1x1 matrix in arithmetic with a vector of statically known length greater than 1 recycled; the result is a plain vector without dims, exactly as in R (which warns that this recycling is deprecated)
a 1x1 matrix in comparisons and & / | treated as a one-row matrix; mismatched shapes are rejected, and R errors here too
anything else — unequal lengths, even when one divides the other error: R-style recycling is not supported

A 1x1 matrix meeting a vector whose length is only known at run time is a case where R’s result shape depends on a runtime value: R keeps the 1x1 dims when the vector has length 1 and drops them for any other length. Whether the result has dimensions must be fixed at compile time, so quickr treats the 1x1 as a one-row matrix there instead: the compiled function checks that the vector has length 1 and returns a 1x1 matrix — matching R exactly — and errors on a longer vector, where R would perform the deprecated recycling.

In R, 1:6 + c(10, 20) recycles the shorter vector. quickr rejects this form of recycling:

quick(function(x, y) {
  declare(type(x = double(4)), type(y = double(2)))
  x + y
})
#> Error: elementwise vector operations require equal lengths or a scalar
#>   operand; R-style recycling is not supported

When lengths are not known at compile time, the same rule is enforced at run time:

f <- quick(function(x, y) {
  declare(type(x = double(n)), type(y = double(m)))
  x + y
})
f(c(1, 2), c(3, 4))
#> [1] 4 6
f(c(1, 2, 3), c(1, 2))
#> Error: elementwise vector operations require equal lengths or a scalar
#>   operand; R-style recycling is not supported

Scalars and vectors spanning the rows of a matrix can be recycled:

f <- quick(function(x, m) {
  declare(type(x = double(3)), type(m = double(3, 2)))
  x * m
})
f(c(1, 10, 100), matrix(as.double(1:6), 3, 2))
#>      [,1] [,2]
#> [1,]    1    4
#> [2,]   20   50
#> [3,]  300  600

Matrix multiplication and linear algebra

%*%, crossprod(), tcrossprod(), solve(), and the triangular solvers check conformability like R does. Dimensions known at compile time are checked at compile time; unknown dimensions are checked at run time before the underlying BLAS/LAPACK routine is called:

f <- quick(function(a, b) {
  declare(type(a = double(n, k)), type(b = double(m, j)))
  a %*% b
})
f(matrix(1, 2, 3), matrix(1, 2, 2))
#> Error: non-conformable arguments in %*%

solve(a, b) requires a square a, as in R: a statically rectangular system is a compile error, and when squareness depends on runtime sizes it is checked before the solve. Use qr.solve() for least-squares solutions of rectangular systems. The right-hand side must be a vector or matrix. R also accepts a scalar right-hand side for a 1x1 system; quickr refuses that rank-0 form. Declare a length-one vector when that case is required.

cbind() and rbind() support scalar recycling and otherwise require the common nonscalar extent to be provably equal at compile time. Arguments declared with the same symbolic extent satisfy that requirement. Distinct or otherwise unresolved extents are a compile error, even if their runtime values would be equal.

Differences from R

This section describes supported restrictions and known differences. It is not an exhaustive list of implementation defects. Please report unexpected results from supported numerical code.

Integer and logical prod() results retain their type

R returns a double from prod() when every argument is integer or logical. quickr currently returns an integer in those cases, so both the result type and integer-overflow behavior can differ. Use double inputs, for example prod(as.double(x)), when R’s double result is required.

Integer sum() can overflow

R promotes an integer sum() result to double when the total exceeds the integer range. quickr accumulates and returns an integer, so the result can overflow instead. Convert the input to double before summing when the total may exceed the integer range.

Floating sum() can differ by platform

R accumulates double sums in long double on builds where that type is wider than double. quickr emits a Fortran sum() over double values instead. With cancellation-sensitive inputs, the extra precision can change the result. For example, on an R build with wider long double, sum(c(1e16, 1, -1e16)) can return 1 in R and 0 in quickr. On platforms without wider long double precision, both can return 0.

Integer arithmetic can overflow

R detects overflow in integer +, -, and *, warns, and returns NA. quickr does not support NA and uses Fortran integer arithmetic directly, so an overflowing result can differ. Convert an operand to double when the calculation can exceed the integer range.

Zero divisors in %% and %/%

R can return NA, NaN, or an infinity for a zero divisor, depending on the operation and operand types. quickr rejects literal zero divisors in %% and %/%, but does not check computed divisors. Ensure these divisors are nonzero before calling compiled code.

Floating %/% can differ at quotient boundaries

For double operands, R corrects a quotient that rounded to an integer using the corresponding floating-point remainder. quickr floors the rounded double quotient directly. The results can differ at these boundaries: 1 %/% 0.2 returns 4 in R and 5 in quickr.

Empty min() and max() inputs are rejected

R returns Inf from min() and -Inf from max() when the input is empty. quickr rejects ordinary array reductions when every input is empty. Empty operands in a multi-argument call are ignored when another operand contributes a value. Scalar logical selections are not covered by these empty-input checks; avoid reductions over such selections.

Empty which.min() and which.max() inputs also differ. R returns integer(0); quickr returns the scalar index produced by Fortran’s minloc() or maxloc(). Do not pass empty inputs to these reductions.

min(), max(), sum(), and prod() accept only positional data arguments. Named arguments, including na.rm, are compile errors.

Whole-valued real exponents can differ for negative bases

R recognizes a whole-valued double exponent such as 3.0 when raising a negative base. quickr emits a real-to-real power for a double exponent, which can produce NaN instead of R’s value. Use an integer exponent when the exponent is integral and the base can be negative.

No NA

NA values are not supported, in inputs or as literals. quickr does not implement R’s missing-value semantics, and compiled code does not detect NA in inputs — passing NA produces undefined results. (For doubles, NaN and Inf flow through most arithmetic as in R; double %/% can return Inf where R returns NaN, but no guarantees are made for code that branches on them.)

Comparisons involving NaN do not implement R’s logical NA result. For example, for a declared double scalar x = NaN, R evaluates x == x to NA, while compiled code evaluates it to FALSE. Equality and ordering comparisons involving NaN, and logical results derived from them, are unsupported, even when the result is returned without branching. This applies both to input NaN values and to those produced by arithmetic inside the compiled function.

No partial recycling

As described above: operand lengths must match exactly or one operand must be a scalar (or a vector spanning a matrix’s rows). R’s general recycling rule — including the case where one length divides the other, which R accepts without a warning — is a compile-time error or a runtime error instead. This also covers a 1x1 matrix in arithmetic with a vector whose length is only known at run time: the compiled function requires length 1 (returning a 1x1 matrix, as R does) and errors on a longer vector, where R would recycle with a deprecation warning.

Non-fill array(data, dim) calls also refuse to recycle data when it is shorter than prod(dim).

One-dimensional arrays are not consistently distinguished from vectors. Do not rely on preserving their dim attribute through compiled operations.

matrix(data, nrow, ncol) requires both dimensions and refuses byrow = TRUE; R can infer an omitted dimension and supports row-wise filling.

c() accepts scalars and vectors only; it refuses matrix arguments instead of flattening them as R does.

Zero-length operands do not recycle to zero-length results

R arithmetic with a zero-length operand returns a zero-length result regardless of the other operand’s length: numeric(0) + 1:3 is numeric(0). In quickr, a zero length is a length like any other. A zero-length non-scalar operand is rejected, including when the other operand is a scalar or another zero-length vector. This is a compile error when the zero extent is known and a runtime error when it is not.

Some zero-sized linear-algebra outputs are not supported

%*%, crossprod(), and tcrossprod() raise an error when the result would have a zero extent, where R returns a zero-sized array. A zero contracted dimension remains supported when every output extent is nonzero, such as a 2x0 matrix multiplied by a 0x3 matrix; that result is a 2x3 matrix of zeros, as in R. outer() and %o% reject zero-sized outputs.

drop() requires a fixed result rank

The rank of a compiled result cannot depend on a runtime extent. When a matrix axis is not known at compile time, drop() accepts inputs whose runtime extent is not 1 and raises an error when it is 1. Declare singleton axes explicitly when drop() should remove them.

A variable’s type cannot change

x <- x + 0.5 on an integer x is a compile-time error, as described under Types. R instead promotes the variable to double.

Conversion upwards into an existing higher-mode binding is allowed: assigning 1L to a double x leaves x double. This is a non-error difference for whole-variable assignment, since R changes x to integer. Subassignment retains the higher type in both R and quickr.

Empty sapply() results

R returns list() for an empty sapply() input. quickr’s compiled output has a fixed atomic type, so it cannot reproduce that result. Use nonempty inputs; an empty input is not consistently rejected.

Complex values are elementwise-only

Elementwise arithmetic and equality comparisons handle complex values natively, including mixed integer, double, and complex operands: complex + double promotes to complex, as in R. Restrictions include:

  • Logical-complex pairs. +, -, *, ==, and != do not promote a logical operand to complex, so those pairs are compile errors where R returns a complex arithmetic or logical equality result. / and ^ do support logical-complex pairs.
  • Type joins. c(), ifelse(), and cbind()/rbind() do not mix complex with other types — c(x, y) with a complex x and a double y is a compile error where R returns a complex vector.
  • Coercion. as.double() refuses complex input instead of discarding the imaginary parts with a warning as R does.
  • Linear algebra. %*%, crossprod(), solve(), and chol() refuse complex operands at compile time; these lowerings are double-only. R supports complex operands for %*%, crossprod(), and solve(), but not for every linear-algebra routine.

(t(x) and diag(x) are mode-preserving and keep working on complex values; order comparisons and %% on complex values are errors in R itself, so refusing them is not a divergence.)

solve() uses LAPACK’s singularity result

Unlike R, solve() does not apply a reciprocal-condition tolerance. It only reports an exact LAPACK singularity, so an ill-conditioned system may return a result that R rejects as computationally singular.

runif() bound differences

Use a nonnegative scalar count and scalar bounds with runif(). R also accepts a vector count and recycled vector bounds; these forms are not supported reliably by quickr and are not consistently rejected.

R returns NaN values with a warning when runif() is called with min > max. quickr applies its uniform scaling formula without checking the bound order and returns values between the two bounds. Check the bounds before calling runif() when their order is not known statically.

When min and max are equal, R returns the bound without consuming an RNG value. quickr still draws and scales a uniform value, so the result is equal but the subsequent RNG state differs.

Keep RNG calls out of parallel regions. Direct runif() calls in parallel loops are rejected, but calls through nested local closures are not generally tracked for this purpose.

Return allocation and observable effects

C allocates returned arrays before the generated Fortran body runs. Operation-specific checks, such as matrix conformability, execute in Fortran before the native operation. An allocation error can therefore occur before an operation reports an invalid shape. quickr does not impose a memory budget.

Dimension arithmetic is not generally protected from overflow. Use sizes whose intermediate expressions and final array lengths are representable. On failing calls, compilation or allocation can also prevent prints or random draws that R would have performed. quickr does not promise the same first error or the same partial effects as R.

Scalar transpose

R’s t(1) returns a 1x1 matrix. quickr returns the literal scalar without dimensions. Transposing an argument declared as a length-one vector, such as double(1), can instead fail during native compilation. These scalar cases are separate from the supported vector and matrix transpose forms.

Length-one diag() arguments

Literal identity forms such as diag(3L) produce a double identity matrix. An argument declared as a length-one vector instead follows the diagonal-value form: diag(n) for n = 3L declared as integer(1) currently returns an integer 1x1 matrix containing 3, where R returns a 3x3 identity matrix. Use an explicit identity form such as diag(nrow = n) when the size is an argument.

Subscript bounds are only partially checked

R validates subscripts: x[0] drops elements, negative subscripts exclude, and out-of-range reads pad with NA. quickr’s checks cover only part of that behavior:

  • Subscripts that are provably out of range at compile time (literal values, ranges provably outside a known extent) are compile errors. Zero and negative literal subscripts are rejected. Read and write subscripts (x[i] and x[i] <- v) get the same validation.
  • A subscript whose value is only known at run time — a scalar x[i], or a range x[a:b] with symbolic bounds — is not checked, matching Fortran’s own contract: reading or writing out of bounds is undefined behavior, as in C. It will not return NA-padded results the way R does.
  • Logical masks must have the shape required by the indexed array. Mask extents and scalar logical subscripts are not generally validated; do not rely on R’s logical recycling or extension behavior.

Validate dynamic indices and mask shapes before entering compiled code.

Parallel loops require independent iterations

declare(parallel()) requests OpenMP execution. Each iteration must be independent: it must not update shared state that another iteration reads or writes. For example, x <- x + 1L on an enclosing binding is not a supported parallel reduction. It can race or have different binding behavior from R. Automatic detection of these writes and explicit reduction semantics remain separate work. Use serial execution for dependent iterations, and do not rely on the final values of private loop locals after a parallel region.

Local variables and closures

Initialize local variables on every path before reading them. Local closures use generated procedures and host association; general R environment lookup, runtime rebinding of function names, and mutation-sensitive argument/default evaluation are not fully implemented. Prefer distinct local names, simple captures, and arguments that do not depend on state changed by the callee.

ifelse()

Differences include:

  • Test type. R coerces numeric test values to logical. quickr requires test to already be logical and refuses other types.
  • Result type. In R, ifelse()’s result type can depend on which branches are actually reached at run time: ifelse(c(TRUE, TRUE), 1:2, c(0.5, 0.5)) is integer, because the no branch never materializes. quickr always promotes to the join of both branch types (double here), regardless of test’s values.
  • Result shape. The result takes the shape of test, as in R. A scalar test with array-valued branches (where R would return a length-1 result sliced out of the array) is a compile error.
  • Branch lengths. Branches shorter than test are recycled by R; quickr requires each branch to be a scalar or match test’s length (checked at run time when lengths are unknown). A branch known to be zero-length is a compile error.
  • Assignments. Assignment expressions in yes or no are not supported.
quick(function(t, a, b) {
  declare(type(t = logical(1)), type(a = double(3)), type(b = double(3)))
  ifelse(t, a, b)
})
#> Error: ifelse() result takes the shape of `test`; array-valued yes/no
#>   with scalar test is not supported

Logical operators require logical operands

R coerces numeric operands to logical (1 & 2 is TRUE and !1 is FALSE); quickr requires the operands of !, &, |, &&, and || to already be logical and errors otherwise.

any() and all() likewise require logical operands instead of coercing numeric inputs as R does.

&& and || otherwise follow R’s scalar semantics: operands must be length 1 (a compile error when the length is known, or a runtime error when it is not), and they short-circuit — the right operand is evaluated only when the left side does not decide the answer, so a guarded access like i >= 1L && i <= n && x[i] > 0 avoids reading outside a vector of length n. Use & and | for vectors; assignment expressions in && and || are not supported.

Local closures accept only pure argument expressions

R evaluates function arguments lazily. Local closures inside compiled functions (including functions passed to sapply()) accept only argument expressions that quickr can prove are pure, and evaluate accepted arguments eagerly. Calls with effectful expressions such as pair(runif(1), 2) are compile errors. Code that relies on an argument never being evaluated will behave differently.

Deprecation warnings are not reproduced

The supported recycling of a 1x1 matrix in arithmetic does not reproduce R’s deprecation warning.