A Result type for Julia—it's like Nullables for Exceptions
Julia
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Failed to load latest commit information.
docs
src
test
.gitignore
.travis.yml
LICENSE.md
README.md
REQUIRE

README.md

ResultTypes

Stable Latest Build Status codecov

ResultTypes provides a Result type which can hold either a value or an error. This allows us to return a value or an error in a type-stable manner without throwing an exception.

Usage

Basic

We can construct a Result that holds a value:

julia> x = Result(2); typeof(x)
ResultTypes.Result{Int64,ErrorException}

or a Result that holds an error:

julia> x = ErrorResult(Int, "Oh noes!"); typeof(x)
ResultTypes.Result{Int64,ErrorException}

or either with a different error type:

julia> x = Result(2, DivideError); typeof(x)
ResultTypes.Result{Int64,DivideError}

julia> x = ErrorResult(Int, DivideError()); typeof(x)
ResultTypes.Result{Int64,DivideError}

Exploiting Function Return Types

We can take advantage of automatic conversions in function returns (a Julia 0.5 feature):

function integer_division(x::Int, y::Int)::Result{Int, DivideError}
    if y == 0
        return DivideError()
    else
        return div(x, y)
    end
end

This allows us to write code in the body of the function that returns either a value or an error without manually constructing Result types.

julia> integer_division(3, 4)
Result(0)

julia> integer_division(3, 0)
ErrorResult(Int64, DivideError())

Evidence of Benefits

Theoretical

Using the function above, we can use @code_warntype to verify that the compiler is doing what we desire:

julia> @code_warntype integer_division(3,2)
Variables:
  #self#::#integer_division
  x::Int64
  y::Int64

Body:
  begin
      unless (y::Int64 === 0)::Bool goto 4 # line 3:
      return $(Expr(:new, ResultTypes.Result{Int64,DivideError}, :($(Expr(:new, Nullable{Int64}, false))), :($(Expr(:new, Nullable{DivideError}, true, :($(QuoteNode(DivideError()))))))))
      4:  # line 5:
      SSAValue(1) = (Base.checked_sdiv_int)(x::Int64, y::Int64)::Int64
      return $(Expr(:new, ResultTypes.Result{Int64,DivideError}, :($(Expr(:new, Nullable{Int64}, true, SSAValue(1)))), :($(Expr(:new, Nullable{DivideError}, false)))))
  end::ResultTypes.Result{Int64,DivideError}

Experimental

Suppose we have two versions of a function where one returns a value or throws an exception and the other returns a Result type. We want to call the function and return the value if present or a default value if there was an error. For this example we can use div and our integer_division function as a microbenchmark (they are too simple to provide a realistic use case).

Here's our wrapping function for div:

function func1(x,y)
   local z
   try
       z = div(x,y)
   catch e
       z = 0
   end

   return z
end

and for integer_division:

function func2(x, y)
   r = integer_division(x,y)
   if iserror(r)
       return 0
   else
       return unwrap(r)
   end
end

Here are some benchmark results in the average case (on one machine), using BenchmarkTools.jl:

julia> using BenchmarkTools

julia> t1 = @benchmark for i = 1:10 func1(3, i % 2) end
BenchmarkTools.Trial:
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     299.175 μs (0.00% GC)
  median time:      340.283 μs (0.00% GC)
  mean time:        368.364 μs (0.00% GC)
  maximum time:     1.681 ms (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1

julia> t2 = @benchmark for i = 1:10 func2(3, i % 2) end
BenchmarkTools.Trial:
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     98.093 ns (0.00% GC)
  median time:      111.542 ns (0.00% GC)
  mean time:        120.148 ns (0.00% GC)
  maximum time:     537.122 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     946

julia> judge(mean(t2), mean(t1))
BenchmarkTools.TrialJudgement:
  time:   -99.97% => improvement (5.00% tolerance)
  memory: +0.00% => invariant (1.00% tolerance)

As we can see, we get a huge speed improvement without allocating any extra heap memory.

It's also interesting to look at the cost when no error occurs:

julia> t1 = @benchmark for i = 1:10 func1(3, 1) end
BenchmarkTools.Trial:
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     219.074 ns (0.00% GC)
  median time:      236.420 ns (0.00% GC)
  mean time:        252.231 ns (0.00% GC)
  maximum time:     1.049 μs (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     501

julia> t2 = @benchmark for i = 1:10 func2(3, 1) end
BenchmarkTools.Trial:
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     150.658 ns (0.00% GC)
  median time:      170.757 ns (0.00% GC)
  mean time:        181.448 ns (0.00% GC)
  maximum time:     1.096 μs (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     555

julia> judge(mean(t2), mean(t1))
BenchmarkTools.TrialJudgement:
  time:   -28.06% => improvement (5.00% tolerance)
  memory: +0.00% => invariant (1.00% tolerance)

It's still faster to avoid try and use Result, even when the error condition is never triggered.