Try.jl
Try
— ModuleTry.jl: zero-overhead and debuggable error handling
Features:
- Error handling as simple manipulations of values.
- Focus on inferrability and optimizability leveraging unique properties of the Julia language and compiler.
- Error trace for determining the source of errors, without
throw
. - Facilitate the "Easier to ask for forgiveness than permission" (EAFP) approach as a robust and minimalistic alternative to the trait-based feature detection.
- Generic and extensible tools for composing failable procedures.
For more explanation, see Discussion below.
See the Documentation for API reference.
Examples
Basic usage
For demonstration, let us import TryExperimental.jl to see how to use failable APIs built using Try.jl.
julia> using Try
julia> using TryExperimental # exports trygetindex etc.
Try.jl-based API returns either an OK
value
julia> ok = trygetindex(Dict(:a => 111), :a)
Try.Ok: 111
or an Err
value:
julia> err = trygetindex(Dict(:a => 111), :b)
Try.Err: KeyError: key :b not found
Together, these values are called result values. Try.jl provides various tools to deal with the result values such as predicate functions:
julia> Try.isok(ok)
true
julia> Try.iserr(err)
true
unwrapping function:
julia> Try.unwrap(ok)
111
julia> Try.unwrap_err(err)
KeyError(:b)
and more.
Error trace
Consider an example where an error "bubbles up" from a deep stack of function calls:
julia> using Try, TryExperimental
julia> f1(x) = x ? Ok(nothing) : Err(KeyError(:b));
julia> f2(x) = f1(x);
julia> f3(x) = f2(x);
Since Try.jl represents an error simply as a Julia value, there is no information on the source of this error by default:
julia> f3(false)
Try.Err: KeyError: key :b not found
We can enable the stacktrace recording of the error by calling Try.enable_errortrace()
.
julia> Try.enable_errortrace();
julia> y = f3(false)
Try.Err: KeyError: key :b not found
Stacktrace:
[1] f1
@ ./REPL[2]:1 [inlined]
[2] f2
@ ./REPL[3]:1 [inlined]
[3] f3(x::Bool)
@ Main ./REPL[4]:1
[4] top-level scope
@ REPL[7]:1
julia> Try.disable_errortrace();
Note that f3
didn't throw an exception. It returned a value of type Err
:
julia> Try.iserr(y)
true
julia> Try.unwrap_err(y)
KeyError(:b)
That is to say, the stacktrace is simply attached as "metadata" and Try.enable_errortrace()
does not alter how Err
values behave.
Limitation/implementation details: To eliminate the cost of stacktrace capturing when it is not used, Try.enable_errortrace()
is implemented using method invalidation. Thus, error trace cannot be enabled for Task
s that have been already started.
EAFP
As explained in EAFP and traits below, the Base
-like API defined in TryExperimental
does not throw when the method is not defined. For example, trygeteltype
and trygetlength
can be called on arbitrary objects (= "asking for forgiveness") without checking if the method is defined (= "asking for permission").
using Try, TryExperimental
function try_map_prealloc(f, xs)
T = @? trygeteltype(xs) # macro-based short-circuiting
n = @? trygetlength(xs)
ys = Vector{T}(undef, n)
for (i, x) in zip(eachindex(ys), xs)
ys[i] = f(x)
end
return Ok(ys)
end
mymap(f, xs) =
try_map_prealloc(f, xs) |>
Try.or_else() do _ # functional composition
Ok(mapfoldl(f, push!, xs; init = []))
end |>
Try.unwrap
mymap(x -> x + 1, 1:3)
# output
3-element Vector{Int64}:
2
3
4
mymap(x -> x + 1, (x for x in 1:5 if isodd(x)))
# output
3-element Vector{Any}:
2
4
6
Success/failure path elimination
Function using Try.jl for error handling (such as Try.first
) typically has a return type of Union{Ok,Err}
. Thus, the compiler can sometimes prove that some success and failure paths can never be taken:
julia> using TryExperimental, InteractiveUtils
julia> @code_typed(trygetfirst((111, "two", :three)))[2] # always succeeds for non empty tuples
Ok{Int64}
julia> @code_typed(trygetfirst(()))[2] # always fails for an empty tuple
Err{BoundsError}
julia> @code_typed(trygetfirst(Int[]))[2] # both are possible for an array
Union{Ok{Int64}, Err{BoundsError}}
Constraining returnable errors
We can use the return type conversion function f(...)::ReturnType ... end
to constrain possible error types. This is similar to the throws
keyword in Java.
This can be used for ensuring that only the expected set of errors are returned from Try.jl-based functions. In particular, it may be useful for restricting possible errors at an API boundary. The idea is to separate "call API" f
from "overload API" __f__
such that new methods are added to __f__
and not to f
. We can then wrap the overload API function by the call API function that simply declares the return type:
f(args...)::Result{Any,PossibleErrors} = __f__(args...)
Then, the API specification of f
can include the overloading instruction explaining that a method of __f__
(instead of f
) should be defined and can enumerate allowed set of errors.
Here is an example of a call API tryparse
with an overload API __tryparse__
wrapping Base.tryparase
. In this toy example, __tryparse__
can return InvalidCharError()
or EndOfBufferError()
as an error value:
using Try, TryExperimental
const Result{T,E} = Union{Ok{<:T},Err{<:E}}
# using TryExperimental: Result # (almost equivalent)
struct InvalidCharError <: Exception end
struct EndOfBufferError <: Exception end
const ParseError = Union{InvalidCharError, EndOfBufferError}
tryparse(T, str)::Result{T,ParseError} = __tryparse__(T, str)
function __tryparse__(::Type{Int}, str::AbstractString)
isempty(str) && return Err(EndOfBufferError())
Ok(@something(Base.tryparse(Int, str), return Err(InvalidCharError())))
end
tryparse(Int, "111")
# output
Try.Ok: 111
tryparse(Int, "")
# output
Try.Err: EndOfBufferError()
tryparse(Int, "one")
# output
Try.Err: InvalidCharError()
Constraining errors can be useful for generic programming if it is desirable to ensure that error handling is complete. This pattern makes it easy to report invalid errors directly to the programmer (see When to throw
? When to return
?) while correctly implemented methods do not incur any run-time overheads.
See also: julep: "chain of custody" error handling · Issue #7026 · JuliaLang/julia
Discussion
Julia is a dynamic language with a compiler that can aggressively optimize away the dynamism to get the performance comparable to static languages. As such, many successful features of Julia provide the usability of a dynamic language while paying attentions to the optimizability of the composed code. However, native throw
/catch
-based exception is not optimized aggressively and existing "static" solutions do not support idiomatic high-level style of programming. Try.jl explores an alternative solution embracing the dynamism of Julia while restricting the underlying code as much as possible to the form that the compiler can optimize away.
Focus on actions; not the types
Try.jl aims at providing generic tools for composing failable procedures. This emphasis on performing actions that can fail contrasts with other similar Julia packages focusing on types and is reflected in the name of the package: Try. This is an important guideline on designing APIs for dynamic programming languages like Julia in which high-level code should be expressible without managing types.
For example, Try.jl provides the APIs for short-circuit evaluation that can be used not only for Union{Ok,Err}
:
julia> Try.and_then(Ok(1)) do x
Ok(x + 1)
end
Try.Ok: 2
julia> Try.and_then(Ok(1)) do x
iszero(x) ? Ok(x) : Err("not zero")
end
Try.Err: "not zero"
but also for Union{Some,Nothing}
:
julia> Try.and_then(Some(1)) do x
Some(x + 1)
end
Some(2)
julia> Try.and_then(Some(1)) do x
iszero(x) ? Some(x) : nothing
end
Above code snippets mention constructors Ok
, Err
, and Some
just enough for conveying information about "success" and "failure."
Of course, in Julia, types can be used for controlling execution efficiently and flexibly. In fact, the mechanism required for various short-circuit evaluation can be used for arbitrary user-defined types by defining the short-circuit evaluation interface (experimental).
Dynamic returned value types for maximizing optimizability
Try.jl provides an API inspired by Rust's Result
type and Try
trait. However, to fully unlock the power of Julia, Try.jl uses the small Union
types instead of a concretely typed struct
type. This is essential for idiomatic clean high-level Julia code that avoids computing output type manually. However, all previous attempts in this space (such as ErrorTypes.jl, ResultTypes.jl, and Expect.jl) use a struct
type for representing the result value (see ErrorTypes.Result
, ResultTypes.Result
, and Expect.Expected
). Using a concretely typed struct
as returned type has some benefits in that it is easy to control the result of type inference. However, this forces the user to manually compute the type of the untaken paths. This is tedious and sometimes simply impossible. This is also not idiomatic Julia code which typically delegates output type computation to the compiler. Futhermore, the benefit of type-stabilization is at the cost of loosing the opportunity for the compiler to eliminate the success and/or failure branches (see Success/failure path elimination above). A similar optimization can still happen in principle with the concrete struct
approach with the combination of (post-inference) inlining, scalar replacement of aggregate, and dead code elimination. However, since type inference is the main driving force in the inter-procedural analysis and optimization in the Julia compiler, Union
return type is likely to continue to be the most effective way to communicate the intent of the code to the compiler (e.g., if a function call always succeeds, always return an Ok{T}
).
(That said, Try.jl also contains supports for concretely-typed returned value when Union
is not appropriate. This is for experimenting if such a manual "type-instability-hiding" is a viable approach at a large scale and if providing a pleasing uniform API is possible.)
Debuggable error handling
A potential usability issue for using the Result
type is that the detailed context of the error is lost by the time the user received an error. This makes debugging Julia programs hard compared to simply throw
ing the exception. To mitigate this problem, Try.jl provides an error trace mechanism for recording the backtrace of the error. This can be toggled using Try.enable_errortrace()
at the run-time. This is inspired by Zig's Error Return Traces.
EAFP and traits
TryExperiments.jl implements a limited set of "verbs" based on Julia Base
such as trytake!
as a demonstration of Try.jl API. These functions have a catch-all default definition that returns an error value of type Err{<:NotImplementedError}
. This lets us use these functions in the "Easier to ask for forgiveness than permission" (EAFP) manner because they can be called without getting the run-time MethodError
exception. Importantly, the EAFP approach does not have the problem of the trait-based feature detection where the implementer must ensure that declared trait (e.g., HasLength
) is compatible with the actual definition (e.g., length
). With the EAFP approach, the feature is declared automatically by defining of the method providing it (e.g., trygetlength
). Thus, by construction, it is hard to make the feature declaration and definition out-of-sync. Of course, this approach works only for effect-free or "redo-able" functions when naively applied. To check if a sequence of destructive operations is possible, the trait-based approach is very straightforward. One way to use the EAFP approach for effectful computations is to create a low-level two-phase API where the first phase constructs a recipe of how to apply the effects in an EAFP manner and the second phase applies the effect.
(Usage notes: An "EAFP-compatible" function can be declared with @tryable f
instead of function f end
. It automatically defines a catch-all fallback method that returns an Err{<:NotImplementedError}
.)
Side notes on hasmethod
and applicable
(and invoke
)
Note that the EAFP approach using Try.jl is not equivalent to the "Look before you leap" (LBYL) counterpart using hasmethod
and/or applicable
. Checking applicable(f, x)
before calling f(x)
may look attractive as it can be done without any manual coding. However, this LBYL approach is fundamentally unusable for generic feature detection. This is because hasmethod
and applicable
cannot handle "blanket definition" with "internal dispatch" like this:
julia> f(x::Real) = f_impl(x); # blanket definition
julia> f_impl(x::Int) = x + 1; # internal dispatch
julia> applicable(f, 0.1)
true
julia> hasmethod(f, Tuple{Float64})
true
Notice that f(0.1)
is considered callable if we trust applicable
or hasmethod
even though f(0.1)
will throw a MethodError
. Thus, unless the overload instruction of f
specifically forbids the blanket definition like above, the result of applicable
and hasmethod
cannot be trusted. (For exactly the same reason, the use of invoke
on library functions is problematic.)
The EAFP approach works because the actual code path "dynamically declares" the feature.
When to throw
? When to return
?
Having two modes of error reporting (i.e., throw
ing an exception and return
ing an Err
value) introduces a complexity that must be justified. Is Try.jl just a workaround until the compiler can optimize try
-catch
? ("Yes" may be a reasonable answer.) Or is there a principled way to distinguish the use cases of them? (This is what is explored here.)
Reporting error by return
ing an Err
value is particularly useful when an error handling occurs in a tight loop. For example, when composing concurrent data structure APIs, it is sometimes required to know the failure mode (e.g., logical vs temporary/contention failures) in a tight loop. It is likely that Julia compiler can optimize Try.jl's error handling down to a simple flag-based low-level code. Note that this style of programming requires a clear definition of the API noting on what conditions certain errors are reported. That is to say, such an API guarantees the detection of certain unsatisfied "pre-conditions" and the caller program is expected to have some ways to recover from these errors.
In contrast, if there is no way for the caller program to recover from the error and the error should be reported to a human, throw
ing an exception is more appropriate. For example, if an inconsistency of the internal state of a data structure is detected, it is likely a bug in the usage or implementation. In this case, there is no way for the caller program to recover from such an out-of-contract error and only the human programmer can take an action. To support typical interactive workflow in Julia, printing an error and aborting the whole program is not an option. Thus, it is crucial that it is possible to recover even from an out-of-contract error in Julia. Such a language construct is required for building programming tools such as REPL and editor plugins. In summary, return
-based error reporting is adequate for recoverable errors and throw
-based error reporting is adequate for unrecoverable (i.e., programmer's) errors.
Links
Similar packages
Other discussions
Result value manipulation API
Try.Ok
— TypeOk(value::T) -> ok::Ok{T}
Ok{T}(value) -> ok::Ok{T}
Ok() -> Ok(nothing)
Indicate that value
is a "success" in a sense defined by the API returning this value.
Examples
julia> using Try
julia> result = Ok(1)
Try.Ok: 1
julia> Try.unwrap(result)
1
Try.Err
— TypeErr(value::E) -> err::Err{E}
Err{E}(value) -> err::Err{E}
Indicate that value
is a "failure" in a sense defined by the API returning this value.
See: iserr
, unwrap_err
Examples
julia> using Try
julia> result = Err(1)
Try.Err: 1
julia> Try.unwrap_err(result)
1
Try.isok
— FunctionTry.isok(::Ok) -> true
Try.isok(::Err) -> false
Return true
on an Ok
; return false
on an Err
.
Examples
julia> using Try
julia> Try.isok(Try.Ok(1))
true
julia> Try.isok(Try.Err(1))
false
Try.iserr
— FunctionTry.iserr(::Err) -> true
Try.iserr(::Ok) -> false
Return true
on an Err
; return false
on an Ok
.
Examples
julia> using Try
julia> Try.iserr(Try.Err(1))
true
julia> Try.iserr(Try.Ok(1))
false
Try.unwrap
— FunctionTry.unwrap(Ok(value)) -> value
Try.unwrap(::Err) # throws
Unwrap an Ok
value; throws on an Err
.
To obtain a stack trace to the place Err
is constructed (and not where unwrap
is called), use Try.enable_errortrace
.
Examples
julia> using Try
julia> Try.unwrap(Try.Ok(1))
1
Try.unwrap_err
— FunctionTry.unwrap_err(Err(value)) -> value
Try.unwrap_err(::Ok) # throws
Unwrap an Err
value; throws on an Ok
.
Examples
julia> using Try
julia> Try.unwrap_err(Try.Err(1))
1
Try.oktype
— FunctionTry.oktype(::Type{Ok{T}}) -> T::Type
Try.oktype(::Ok{T}) -> T::Type
Get the type of the value stored in an Ok
.
Examples
julia> using Try
julia> Try.oktype(Ok{Symbol})
Symbol
julia> Try.oktype(Ok(:a))
Symbol
Try.errtype
— FunctionTry.errtype(::Type{Err{E}}) -> E::Type
Try.errtype(::Err{E}) -> E::Type
Get the type of the value stored in an Err
.
Examples
julia> using Try
julia> Try.errtype(Err{Symbol})
Symbol
julia> Try.errtype(Err(:a))
Symbol
Try.map
— FunctionTry.map(f, result) -> result′
Try.map(f, Ok(value)) -> Ok(f(value))
Try.map(_, err::Err) -> err
Try.map(f, Some(value)) -> Some(f(value))
Try.map(_, nothing) -> nothing
Try.map(f) -> result -> Try.map(f, result)
Apply f
in the value wrapped in the "successful" result.
Examples
julia> using Try
julia> Try.map(x -> x + 1, Ok(1))
Try.Ok: 2
julia> Try.map(x -> x + 1, Err(KeyError(:a)))
Try.Err: KeyError: key :a not found
julia> Try.map(x -> x + 1, Some(1))
Some(2)
julia> Try.map(x -> x + 1, nothing)
Short-circuit evaluation
Try.@?
— Macro@? result
Evaluates to an unwrapped "success" result value; return result
if it is a "failure."
If result
is an Ok
or a Some
, @?
is equivalent to unwrapping the value. If result
is an Err
or nothing
, @?
is equivalent to return
.
Invocation | Equivalent code |
---|---|
@? Ok(value) | value |
@? Err(value) | return value |
@? Some(value) | value |
@? nothing | return nothing |
See also: @and_return
, and_then
, or_else
.
Extended help
Examples
using Try, TryExperimental
function try_map_prealloc(f, xs)
T = @? trygeteltype(xs) # macro-based short-circuiting
n = @? trygetlength(xs)
ys = Vector{T}(undef, n)
for (i, x) in zip(eachindex(ys), xs)
ys[i] = f(x)
end
return Ok(ys)
end
Try.unwrap(try_map_prealloc(x -> x + 1, 1:3))
# output
3-element Vector{Int64}:
2
3
4
Try.@and_return
— MacroTry.@and_return result
Evaluate to a "success" value or return
a "failure" value.
Use @return
to return unwrapped value.
Invocation | Equivalent code |
---|---|
@and_return ok::Ok | return ok |
@and_return Err(value) | value |
@and_return some::Some | return some |
@and_return nothing | nothing |
See also: @?
, @return
, and_then
, or_else
.
Extended help
Examples
Let's define a function nitems
that works like length
but falls back to iteration-based counting:
using Try, TryExperimental
function trygetnitems(xs)
Try.@and_return trygetlength(xs)
Ok(count(Returns(true), xs))
end
nitems(xs) = Try.unwrap(trygetnitems(xs))
nitems(1:3)
# output
3
nitems
works with arbitrary iterator, including the ones that does not have length
:
ch = foldl(push!, 1:3; init = Channel{Int}(3))
close(ch)
nitems(ch)
# output
3
Try.@return
— MacroTry.@return result
Return
an unwrapped "success" value or evaluate to an unwrapped "failure" value.
Unlike @and_return
, the values are unwrapped.
Invocation | Equivalent code |
---|---|
@return Ok(value) | return value |
@return Err(value) | value |
@return Some(value) | return value |
@return nothing | nothing |
See also: @?
and_then
, or_else
.
Extended help
Examples
Let's define a function nitems
that works like length
but falls back to iteration-based counting:
using Try, TryExperimental
function nitems(xs)
Try.@return trygetlength(xs)
count(Returns(true), xs)
end
nitems(1:3)
# output
3
nitems
works with arbitrary iterator, including the ones that does not have length
:
ch = foldl(push!, 1:3; init = Channel{Int}(3))
close(ch)
nitems(ch)
# output
3
Try.or_else
— FunctionTry.or_else(f, result) -> result′
Try.or_else(f) -> result -> result′
Return result
as-is if it is a "successful" value; otherwise, unwrap a "failure" value in result
and then evaluate f
on it.
Invocation | Equivalent code |
---|---|
or_else(f, ok::Ok) | ok |
or_else(f, Err(value)) | f(value) |
or_else(f, some::Some) | some |
or_else(f, nothing) | f(nothing) |
See also: @?
@and_return
, and_then
.
Extended help
Examples
Let's define a function nitems
that works like length
but falls back to iteration-based counting:
using Try, TryExperimental
nitems(xs) =
Try.or_else(trygetlength(xs)) do _
Ok(count(Returns(true), xs))
end |> Try.unwrap
nitems(1:3)
# output
3
nitems
works with arbitrary iterator, including the ones that does not have length
:
ch = foldl(push!, 1:3; init = Channel{Int}(3))
close(ch)
nitems(ch)
# output
3
Try.and_then
— FunctionTry.and_then(f, result) -> result′
Try.and_then(f) -> result -> result′
Evaluate f(value)
if result
is a "success" wrapping a value
; otherwise, a "failure" value
as-is.
Invocation | Equivalent code |
---|---|
and_then(f, Ok(value)) | f(value) |
and_then(f, err::Err) | err |
and_then(f, Some(value)) | f(value) |
and_then(f, nothing) | nothing |
See also: @?
@and_return
, or_else
.
Extended help
Examples
using Try, TryExperimental
try_map_prealloc(f, xs) =
Try.and_then(trygetlength(xs)) do n
Try.and_then(trygeteltype(xs)) do T
ys = Vector{T}(undef, n)
for (i, x) in zip(eachindex(ys), xs)
ys[i] = f(x)
end
return Ok(ys)
end
end
Try.unwrap(try_map_prealloc(x -> x + 1, 1:3))
# output
3-element Vector{Int64}:
2
3
4
Try.@or
— MacroTry.@or(expressions...)
Evaluate to the first "successful" result of one of the expressions
and do not evaluate the rest of the expressions
. Otherwise, evaluate all expressions
and return the last result.
Extended help
Examples
julia> using Try
julia> Try.@or(Err(1), Ok(2), (println("this is not evaluated"); Err(3)))
Try.Ok: 2
julia> Try.@or(Err(1), Err(2), Err(3))
Try.Err: 3
julia> Try.@or(nothing, Some(2), (println("this is not evaluated"); Some(3)))
Some(2)
julia> Try.@or(nothing, nothing, nothing)
Try.@and
— MacroTry.@and(expressions...)
Evaluate to the first "failure" result of one of the expressions
and do not evaluate the rest of the expressions
. Otherwise, evaluate all expressions
and return the last result.
Extended help
Examples
julia> using Try
julia> Try.@and(Ok(1), Ok(2), Ok(3))
Try.Ok: 3
julia> Try.@and(Ok(1), Err(2), (println("this is not evaluated"); Ok(3)))
Try.Err: 2
julia> Try.@and(Some(1), Some(2), Some(3))
Some(3)
julia> Try.@and(Some(1), nothing, (println("this is not evaluated"); Some(3)))
Try.or
— FunctionTry.or(results...)
Return the first "successful" result or the last result if all results are "failures."
Extended help
Examples
julia> using Try
julia> Try.or(Err(1), Ok(2), Err(3))
Try.Ok: 2
julia> Try.or(Err(1), Err(2), Err(3))
Try.Err: 3
julia> Try.or(nothing, Some(2), Some(3))
Some(2)
julia> Try.or(nothing, nothing, nothing)
Try.and
— FunctionTry.and(results...)
Return the first "failure" or the last result if all results are "success."
Extended help
Examples
julia> using Try
julia> Try.and(Ok(1), Ok(2), Ok(3))
Try.Ok: 3
julia> Try.and(Ok(1), Err(2), Ok(3))
Try.Err: 2
julia> Try.and(Some(1), Some(2), Some(3))
Some(3)
julia> Try.and(Some(1), nothing, Some(3))
See also: Customizing short-circuit evaluation.
Debugging interface (error traces)
Try.enable_errortrace
— FunctionTry.enable_errortrace()
Enable stack trace capturing for each Err
value creation for debugging.
See also: Try.disable_errortrace
Examples
julia> using Try, TryExperimental
julia> trypush!(Int[], :a)
Try.Err: Not Implemented: tryconvert(Int64, :a)
julia> Try.enable_errortrace();
julia> trypush!(Int[], :a)
Try.Err: Not Implemented: tryconvert(Int64, :a)
Stacktrace:
[1] convert
@ ~/.julia/dev/Try/src/core.jl:28 [inlined]
[2] Break (repeats 2 times)
@ ~/.julia/dev/Try/src/branch.jl:11 [inlined]
[3] branch
@ ~/.julia/dev/Try/src/branch.jl:27 [inlined]
[4] macro expansion
@ ~/.julia/dev/Try/src/branch.jl:49 [inlined]
[5] (::TryExperimental.var"##typeof_trypush!#298")(a::Vector{Int64}, x::Symbol)
@ TryExperimental.Internal ~/.julia/dev/Try/lib/TryExperimental/src/base.jl:69
[6] top-level scope
@ REPL[4]:1
Try.disable_errortrace
— Function