Contexts and Signals

When you add two decimals, how many digits should the result keep? What happens when it doesn’t fit — round it, or error? And which way should it round? In decimal arithmetic, these are not hard-coded answers: they come from the context, an explicit, session-wide setting that governs every operation.

This vignette shows you how to inspect the context, change it (safely!), and work with the signals that operations raise along the way — conditions like inexact, division_by_zero, and overflow.

If you haven’t yet, start with vignette("decimal-values"): values themselves are immutable. Parsing and promotion to a finer shared scale are exact; an explicit coarser scale is a quantization request. The context shapes arithmetic and quantization, so changing it never changes values already created.

library(decimal)

The default context

You can look at the active context at any time:

get_decimal_context()
#> <decimal_context>
#>   precision: 28
#>   rounding:  half_even
#>   emin:      -999999
#>   emax:      999999
#>   clamp:     FALSE
#>   traps:     [division_by_zero, invalid_operation, overflow]
#>   flags:     []

The defaults are sensible for most work:

Changing the context, temporarily

You’ll rarely want to change the context for a whole session. Instead, scope the change, in the same spirit as withr::with_*() and withr::local_*().

with_decimal_context() applies a context to a single expression, then restores the previous one:

ctx <- decimal_context(precision = 2L, traps = character())

with_decimal_context(ctx, decimal("1.25") + decimal("0"))
#> <decimal[1]>
#> [1] 1.2
get_decimal_context()
#> <decimal_context>
#>   precision: 28
#>   rounding:  half_even
#>   emin:      -999999
#>   emax:      999999
#>   clamp:     FALSE
#>   traps:     [division_by_zero, invalid_operation, overflow]
#>   flags:     []

local_decimal_context() applies a context until the calling function returns, which is handy when several statements need it:

f <- function() {
  local_decimal_context(decimal_context(precision = 2L, traps = character()))
  decimal("1.234") + decimal("0")
}
f()
#> <decimal[1]>
#> [1] 1.2
get_decimal_context()
#> <decimal_context>
#>   precision: 28
#>   rounding:  half_even
#>   emin:      -999999
#>   emax:      999999
#>   clamp:     FALSE
#>   traps:     [division_by_zero, invalid_operation, overflow]
#>   flags:     []

Either way, the previous context comes back automatically when the scope ends — even if an error interrupts it.

Signals and sticky flags

Decimal operations signal noteworthy conditions: a result was rounded, a division hit zero, a value overflowed. What happens next depends on the context, and each signal gets one of three dispositions:

The flags are sticky: once raised, they stay raised until you clear them, so you can run a whole computation and check afterwards what happened along the way:

ctx <- decimal_context(precision = 2L, traps = character())
with_decimal_context(ctx, {
  clear_decimal_flags()
  decimal("1.25") + decimal("0")
  decimal("1") / decimal("8")
  decimal_flags()
})
#> [1] "inexact" "rounded"

Both operations had to round to fit two significant digits, raising inexact and rounded; the flags simply stay raised across the whole block. Use clear_decimal_flags() to start a fresh slate before a computation you want to audit.

Reporting signals

By default, any signal that isn’t trapped is also reported — surfaced as a warning as it occurs, so precision loss never passes silently:

withr::with_options(
  list(decimal.report_flags = TRUE),
  with_decimal_context(
    decimal_context(precision = 2L, traps = character()),
    decimal("1.25") + decimal("0")
  )
)
#> Warning: Decimal `+` raised signals: inexact, rounded.
#> <decimal[1]>
#> [1] 1.2

The warning is purely informational; the sticky flags accumulate either way. If you’d rather check flags on your own schedule, set options(decimal.report_flags = FALSE) and inspect decimal_flags() directly — that’s what this vignette does behind the scenes to keep the output focused.

A few operations are exempt from the warning, because for them inexact/rounded is the requested outcome, not a surprise: quantize() (and round() and signif(), which build on it) exist precisely to drop digits you named, and sqrt(), exp(), log(), and log10() are irrational for nearly every input. Division is not exempt — it’s only sometimes inexact, so the warning there is genuinely informative:

withr::with_options(
  list(decimal.report_flags = TRUE),
  with_decimal_context(decimal_context(precision = 10L, traps = character()), {
    sqrt(decimal("2"))          # inexact, but silent -- expected of sqrt()
    decimal("1") / decimal("3") # inexact, and warns -- not every division is
  })
)
#> Warning: Decimal `/` raised signals: inexact, rounded.
#> <decimal[1]>
#> [1] 0.3333333333

Exempted operations still update the sticky flags as usual; only the warning is suppressed.

Traps: turning signals into errors

A trapped signal stops the computation with a classed R error you can handle with tryCatch():

with_decimal_context(decimal_context(traps = "division_by_zero"), {
  decimal("1") / decimal("0")
})
#> Error in decimal_native_result(decimal_context_call(.Call, decimal_c_binary_op_strings, : Decimal trap during `/` at element 1: division_by_zero.

The default traps (division_by_zero, invalid_operation, overflow) catch the conditions that usually indicate a bug. invalid_operation groups the standard invalid subconditions, including undefined division such as 0 / 0. You can go stricter: for example, in a context where any loss of precision should be an error — reconciling ledgers, say — trap inexact too:

strict <- decimal_context(
  traps = c("division_by_zero", "invalid_operation", "overflow", "inexact")
)
with_decimal_context(strict, decimal("1") / decimal("3"))
#> Error in decimal_native_result(decimal_context_call(.Call, decimal_c_binary_op_strings, : Decimal trap during `/` at element 1: inexact.

Classifying values

The context even shapes how values are classified. A finite, nonzero value is subnormal when its exponent falls below the context’s emin, meaning fewer significant digits are available to it than precision allows; otherwise it’s normal. So classification depends on the active context, not on the value alone — below, 1E-3 is subnormal only because emin = -2L puts it out of the normal range:

ctx <- decimal_context(precision = 3L, emin = -2L)
with_decimal_context(ctx, {
  x <- decimal(c("1E-2", "1E-3", "-0", "NaN", "sNaN"))
  number_class(x)
})
#> [1] "+Normal"    "+Subnormal" "-Zero"      "NaN"        "sNaN"

For quick checks there’s a family of predicates:

x <- decimal(c("NaN", "sNaN", "Infinity", "-0", "1E-3"))
is_qnan(x)
#> [1]  TRUE FALSE FALSE FALSE FALSE
is_snan(x)
#> [1] FALSE  TRUE FALSE FALSE FALSE
is.infinite(x)
#> [1] FALSE FALSE  TRUE FALSE FALSE
is_zero(x)
#> [1] FALSE FALSE FALSE  TRUE FALSE
is_signed(x)
#> [1] FALSE FALSE FALSE  TRUE FALSE

is_signed() reports the sign bit directly, including on zero — it’s how you tell -0 apart from 0, since sign() treats both as 0.