Manual
This package defines julia implementations for the common types Option
(aka Maybe
), Either
and Try
, as well as one extra type ContextManager
which mimics Python's with
-ContextManager.
Unlike typical implementations of Option
, and Either
which define them as new separate type-hierarchies, in DataTypesBasic
both actually share a common parts: Identity
and Const
.
Identity
Identity
is the most basic container you can imagine. It just contains one value and always one value. It is similar to Base.Some
, however with one notable difference. While Some([]) != Some([])
(because it is treated more like Ref
), for Identity
we have Identity([]) == Identity([])
, as Identity
works like a Container.
julia> a = Identity(3)
Identity(3)
julia> map(x -> 2x, a)
Identity(6)
julia> foreach(println, a)
3
julia> for i in Identity("hello")
print("$i world")
end
hello world
Think of Identity
as lifting a value into the world of containers. DataTypesBasic
knows a lot about how to convert an Identity
container to a Vector
or else. This will come in handy soon. For now it is important to understand that wrapping some value 42
into Identity(42)
makes it interactable on a container-level.
Const
Const
looks like Identity
, but behaves like an empty container. Its name suggests that whatever is in it will stay constant. Alternatively, it can also be interpreted as aborting a program. Use Const(nothing)
to represent an empty Container. Const(...)
is in this sense an empty container with additional information.
struct Identity{T}
value::T
end
struct Const{T}
value::T
end
julia> a = Const(3)
Const(3)
julia> map(x -> 2x, a)
Const(3)
julia> foreach(println, a)
julia> for i in Identity("hello")
print("$i world")
end
Option
Option is a container which has either 1 value or 0. Having Identity
and Const{Nothing}
already at hand, it is defined as
Option{T} = Union{Identity{T}, Const{Nothing}}
Use it like
using DataTypesBasic
fo(a::Identity{String}) = a.value * "!"
fo(a::Const{Nothing}) = "fallback behaviour"
fo(Option("hi")) # "hi!"
fo(Option(nothing)) # "fallback behaviour"
fo(Option()) # "fallback behaviour"
The real power of Option
comes from generic functionalities which you can define on it. DataTypesBasic
already defines the following: Base.iterate
, Base.foreach
, Base.map
, Base.get
, Base.Iterators.flatten
, DataTypesBasic.iftrue
, DataTypesBasic.iffalse
, Base.isnothing
, DataTypesBasic.issomething
. Please consult the respective function definition for details.
Here an example for such a higher level perspective
using DataTypesBasic
flatten(a::Identity) = a.value
flatten(a::Const) = a
# map a function over 2 Options, getting an option back
function map2(f, a::Option, b::Option)
nested_option = map(a) do a′
map(b) do b′
f(a′, b′)
end
end
flatten(nested_option)
end
map2(Option("hi"), Option("there")) do a, b
"$a $b"
end # Identity("hi there")
map2(Option(1), Option()) do a, b
a + b
end # Const(nothing)
The package TypeClasses.jl
(soon to come) implements a couple of such higher level concepts of immense use (like Functors, Applicatives and Monads).
For further details, don't hesitate to consult the source code src/Option.jl
or take a look at the tests test/Option.jl
.
Either
Either
is exactly like Option
a container which has either 1 value or 0. In addition to Option
, the Either
data type captures extra information about the empty case.
As such it is defined as a union of the two types Identity
and Const
.
Either{Left, Right} = Union{Const{Left}, Identity{Right}}
It is typical to describe the Const
part as "Left" in the double meaning of being the left type-parameter as well as a hint about its semantics of describing the empty case, like "what is finally left". On the other hand "Right" also has its double meaning of being the right type-parameter, and also the "the right value" in the sense of correct (no abort).
Use it like
using DataTypesBasic
fe(a::Identity{Int}) = a.value * a.value
fe(a::Const{String}) = "fallback behaviour '$(a.value)'"
fe(Either{String}(7)) # 49
fe(Either{String}("some error occured")) # "fallback behaviour 'some error occured'"
myeither = either("error", 2 > 3, 42)
fe(myeither) # "fallback behaviour 'error'"
You also have support for Iterators.flatten
in order to work "within" Either, and combine everything correctly.
check_threshold(a) = a < 15 ? Const((a, "threshold not reached")) : Identity("checked threshold successfully")
map(check_threshold, Identity(30)) |> Iterators.flatten # Identity("checked threshold successfully")
map(check_threshold, Identity(12)) |> Iterators.flatten # Const((12, "threshold not reached"))
# when working within another Const, think of it as if the first abort always "wins"
map(check_threshold, Const("something different already happend")) |> Iterators.flatten # Const("something different already happend")
Similar like for Option
, there are many higher-level concepts (like Functors, Applicatives and Monads) which power unfold also over Either
. Checkout the package TypeClasses.jl
(soon to come) for a collection of standard helpers and interfaces.
For further details, don't hesitate to consult the source code src/Either.jl
or take a look at the tests test/Either.jl
.
Try
Try
is another special case of Either
, where Const
can only bear Exceptions.
Try{T} = Union{Const{<:Exception}, Identity{T}}
This is very handy. It gives you the possibility to work with errors just as you would do with other values, no need for dealing with try
-catch
.
Use it like
using DataTypesBasic
ft(a::Identity) = a.value * a.value
ft(a::Const{<:Exception}) = "got an error '$(a.value)'"
ft(@Try 1/0) # Inf
ft(@TryCatch ErrorException error("some error")) # "got an error 'Thrown(ErrorException(\"some error\"))'"
ft(@TryCatch ArgumentError error("another error")) # raises the error as normal
There are many higher-level concepts (like Functors, Applicatives and Monads) which power also applies to Try
. Checkout the package TypeClasses.jl
(soon to come) for a collection of standard helpers and interfaces.
For further details, don't hesitate to consult the source code src/Try.jl
or take a look at the tests test/Try.jl
.
ContextManager
Finally there is the context-manager. It is quite separate from the others, however still one of my major containers which I used a lot in my passed in other programming languages. For instance in Python context-managers have the extra with
syntax, which allows you to wrap code blocks very simply with some Initialization & Cleanup, handled by the context-manager.
The way we represent a contextmanager is actually very compact. """ function which expects one argument, which itself is a function. Think of it like the following:
struct ContextManager{Func}
f::Func
end
It just takes a function. However the function needs to follow some rules
function contextmanagerready_func(cont) # take only a single argument, the continuation function `cont`
# ... do something before
value = ... # create some value to work on later
result = cont(value) # pass the value to the continuation function (think like `yield`, but call exactly once)
# ... do something before exiting, e.g. cleaning up
result # IMPORTANT: always return the result of the `cont` function
end
Now you can wrap it into ContextManager(contextmanagerready)
and you can use all the context manager functionalities right away.
Let's create some ContextManagers
using DataTypesBasic
context_print(value) = ContextManager(function(cont)
println("initializing value=$value")
result = cont(value)
println("finalizing value=$value, got result=$result")
result
end)
# for convenience we also provide a `@ContextManager` macro which is the same as plain `ContextManager`,
# however you can leave out the extra parantheses.
context_ref(value) = @ContextManager function(cont)
refvalue = Ref{Any}(value)
println("setup Ref $refvalue")
result = cont(refvalue)
refvalue[] = nothing
println("destroyed Ref $refvalue")
result
end
# we can try out a contextmanager, by providing `identity` as the continuation `cont`
context_print("value")(identity) # 4
# initializing value=4
# finalizing value=4, got result=4
# or alternatively we can use Base.run
run(context_ref(4)) # Base.RefValue{Any}(nothing)
# setup Ref Base.RefValue{Any}(4)
# destroyed Ref Base.RefValue{Any}(nothing)
In a sense, these ContextManagers are a simple value with some sideeffects before and after. In functional programming it is key to cleanly captualize such side effects. That is also another reason why Option, Either, and Try are so common.
Using Monadic.jl
we can actually work on ContextManagers as they would be values.
using Monadic
flatmap(f, x) = Iterators.flatten(map(f, x))
combined_context = @monadic map flatmap begin
a = context_print(4)
b = context_ref(a + 100)
@pure a * b[]
end
run(combined_context) # 416
# initializing value=4
# setup Ref Base.RefValue{Any}(104)
# destroyed Ref Base.RefValue{Any}(nothing)
# finalizing value=4, got result=416
As you see, the contextmanager properly nest into one-another. And everything is well captured in the background by the @monadic
syntax. This working pattern is very common and can also be used for Option
, Either
and Try
. The syntax is called monadic
, as map
and flatmap
define what in functional programming is called a Monad
(think of it as a container which knows how to flatten out itself).
The package TypeClasses.jl
captures this idea in more depth. There you can also find a syntax @syntax_flatmap
which refers to exactly the above use of @monadic
.
For further details, don't hesitate to consult the source code src/ContextManager.jl
or take a look at the tests test/ContextManager.jl
.