vignettes/optional-types-with-pmatch.Rmd
optional-types-with-pmatch.Rmd
Some programming languages, e.g. Swift, have special “optional” types. These are types the represent elements that either contain a value of some other type or contain nothing at all. It is a way of computing with the possibility that some operations cannot be done and then propagating that along in the computations.
We can use pmatch
to implement something similar in R. I will use three types instead of two, to represent no value, NONE
, some value, VALUE(val)
, or some error ERROR(err)
:
We can now define a function that catches exceptions and translate them into ERROR()
objects:
try <- function(expr) {
rlang::enquo(expr)
tryCatch(VALUE(rlang::eval_tidy(expr)),
error = function(e) ERROR(e))
}
With this function the control flow when we want to compute something that might go wrong can be made a bit simpler. We no longer need a callback error handler; instead we can inspect the value returned by try
in a case_func
call:
unpack(try(x + 42)) # x isn't defined...
#> <simpleError in rlang::eval_tidy(expr): object 'x' not found>
Computing on optional values is more interesting if we can make it relatively transparent that this is what we are doing. For arithmetic expressions we can do this by defining operations on these types. A sensible way is to return errors if we see those, then NONE
if we see one of those, and otherwise use VALUE
:
pair := ..(first, second)
wrap <- case_func(
.Generic, # underlying generic
..(ERROR(err), .) -> ERROR(err),
..(., ERROR(err)) -> ERROR(err),
..(NONE, .) -> NONE,
..(., NONE) -> NONE,
..(VALUE(v1), VALUE(v2)) -> VALUE(do.call(.Generic, list(v1, v2))),
..(VALUE(v1), v2) -> VALUE(do.call(.Generic, list(v1, v2))),
..(v1, VALUE(v2)) -> VALUE(do.call(.Generic, list(v1, v2)))
)
Ops.OPT <- function(x, y) wrap(..(x, y), .Generic)
The last two cases here handles when we combine an optional value with a value from the underlying type. Because of the last two cases we do not need to explicitly translate a value into a VALUE()
. With this group function defined we can use optional values in expressions.
VALUE(12) + VALUE(6)
#> VALUE(val = 18)
NONE + VALUE(6)
#> NONE
ERROR("foo") + NONE
#> ERROR(err = foo)
VALUE(12) + ERROR("bar")
#> ERROR(err = bar)
VALUE(12) + 12
#> VALUE(val = 24)
12 + NONE
#> NONE
12 + try(42 + x)
#> ERROR(err = Error in rlang::eval_tidy(expr): object 'x' not found
#> )
For mathematical functions, such as log
or exp
, we can also define versions for optional types:
Arithmetic is one thing, but we are probably more likely to use optional values for more complex computations? We can wrap expressions in a function to propagate options:
to_val <- case_func(
escape,
ERROR(err) -> escape(ERROR(err)),
NONE -> escape(NONE),
VALUE(val) -> val,
val -> val
)
to_opt <- case_func(
escape,
ERROR(err) -> ERROR(err),
NONE -> NONE,
VALUE(val) -> VALUE(val),
val -> VALUE(val)
)
with_values <- function(...) {
optionals <- rlang::enquos(...)
n <- length(optionals)
body <- optionals[[n]]
optionals <- optionals[-n]
ev <- rlang::child_env(rlang::get_env(body))
try_ <- function(q, ev) {
suppressWarnings(
tryCatch(VALUE(rlang::eval_tidy(q, data = ev)),
error = function(e) ERROR(e))
)
}
callCC(function(escape) {
for (q in optionals) {
if (rlang::quo_is_symbol(q)) {
var_name <- as.character(q[[2]])
ev[[var_name]] <- to_val(
rlang::eval_tidy(q, data = ev),
escape
)
} else if (rlang::quo_is_call(q)) {
stopifnot(rlang::call_name(q) == "<-" ||
rlang::call_name(q) == "==")
var_name <- as.character(q[[2]][[2]])
val_expr <- q[[2]][[3]]
ev[[var_name]] <- to_val(
try_(val_expr, ev),
escape
)
} else {
stop("Optional values must be names of assignments.")
}
}
to_opt(rlang::eval_tidy(body, data = ev))
})
}
x <- VALUE(1)
y <- VALUE(2)
with_values(
x, y,
z <- VALUE(3),
w <- x + y + z,
(w - x - y) / z
)
#> VALUE(val = 1)