![Gitter](https://badges.gitter.im/Join Chat.svg)
Deterministic is to help your code to be more confident, by utilizing functional programming patterns.
This is a spiritual successor of the Monadic gem. The goal of the rewrite is to get away from a bit too forceful approach I took in Monadic, especially when it comes to coercing monads, but also a more practical but at the same time more strict adherence to monad laws.
Deterministic provides different monads, here is a short guide, when to use which
Success(1).to_s # => "1"
Success(Success(1)) # => Success(1)
Failure(1).to_s # => "1"
Failure(Failure(1)) # => Failure(1)
Maps a Result
with the value a
to the same Result
with the value b
.
Success(1).fmap { |v| v + 1} # => Success(2)
Failure(1).fmap { |v| v - 1} # => Failure(0)
Maps a Result
with the value a
to another Result
with the value b
.
Success(1).bind { |v| Failure(v + 1) } # => Failure(2)
Failure(1).bind { |v| Success(v - 1) } # => Success(0)
Maps a Success
with the value a
to another Result
with the value b
. It works like #bind
but only on Success
.
Success(1).map { |n| Success(n + 1) } # => Success(2)
Failure(0).map { |n| Success(n + 1) } # => Failure(0)
Maps a Failure
with the value a
to another Result
with the value b
. It works like #bind
but only on Failure
.
Failure(1).map_err { |n| Success(n + 1) } # => Success(2)
Success(0).map_err { |n| Success(n + 1) } # => Success(0)
Success(0).try { |n| raise "Error" } # => Failure(Error)
Replaces Success a
with Result b
. If a Failure
is passed as argument, it is ignored.
Success(1).and Success(2) # => Success(2)
Failure(1).and Success(2) # => Failure(1)
Replaces Success a
with the result of the block. If a Failure
is passed as argument, it is ignored.
Success(1).and_then { Success(2) } # => Success(2)
Failure(1).and_then { Success(2) } # => Failure(1)
Replaces Failure a
with Result
. If a Failure
is passed as argument, it is ignored.
Success(1).or Success(2) # => Success(1)
Failure(1).or Success(1) # => Success(1)
Replaces Failure a
with the result of the block. If a Success
is passed as argument, it is ignored.
Success(1).or_else { Success(2) } # => Success(1)
Failure(1).or_else { |n| Success(n)} # => Success(1)
Executes the block passed, but completely ignores its result. If an error is raised within the block it will NOT be catched.
Try failable operations to return Success
or Failure
include Deterministic::Prelude::Result
try! { 1 } # => Success(1)
try! { raise "hell" } # => Failure(#<RuntimeError: hell>)
You can easily chain the execution of several operations. Here we got some nice function composition. The method must be a unary function, i.e. it always takes one parameter - the context, which is passed from call to call.
The following aliases are defined
alias :>> :map
alias :<< :pipe
This allows the composition of procs or lambdas and thus allow a clear definiton of a pipeline.
Success(params) >>
validate >>
build_request << log >>
send << log >>
build_response
class Foo
include Deterministic
alias :m :method # method conveniently returns a Proc to a method
def call(params)
Success(params) >> m(:validate) >> m(:send)
end
def validate(params)
# do stuff
Success(validate_and_cleansed_params)
end
def send(clean_params)
# do stuff
Success(result)
end
end
Foo.new.call # Success(3)
Chaining works with blocks (#map
is an alias for #>>
)
Success(1).map {|ctx| Success(ctx + 1)}
it also works with lambdas
Success(1) >> ->(ctx) { Success(ctx + 1) } >> ->(ctx) { Success(ctx + 1) }
and it will break the chain of execution, when it encounters a Failure
on its way
def works(ctx)
Success(1)
end
def breaks(ctx)
Failure(2)
end
def never_executed(ctx)
Success(99)
end
Success(0) >> method(:works) >> method(:breaks) >> method(:never_executed) # Failure(2)
#map
aka #>>
will not catch any exceptions raised. If you want automatic exception handling, the #try
aka #>=
will catch an error and wrap it with a failure
def error(ctx)
raise "error #{ctx}"
end
Success(1) >= method(:error) # Failure(RuntimeError(error 1))
When creating long chains with e.g. #>>
, it can get cumbersome carrying
around the entire context required for every function within the chain. Also,
every function within the chain requires some boilerplate code for extracting the
relevant information from the context.
Similarly to, for example, the do
notation in Haskell and sequence
comprehensions or for comprehensions in Scala, #in_sequence
can be used to
streamline the same process while keeping the code more readable. Using
#in_sequence
provides all the benefits of using the Result
monad while
still allowing to write code that reads very much like standard imperative
Ruby.
Here's an example:
class Foo
include Deterministic::Prelude
def call(input)
in_sequence do
get(:sanitized_input) { sanitize(input) }
and_then { validate(sanitized_input) }
get(:user) { get_user_from_db(sanitized_input) }
let(:name) { user.fetch(:name) }
observe { log('user name', name) }
get(:request) { build_request(sanitized_input, user) }
observe { log('sending request', request) }
get(:response) { send_request(request) }
observe { log('got response', response) }
and_yield { format_response(response) }
end
end
def sanitize(input)
sanitized_input = input
Success(sanitized_input)
end
def validate(sanitized_input)
Success(sanitized_input)
end
def get_user_from_db(sanitized_input)
Success(type: :admin, id: sanitized_input.fetch(:id), name: 'John')
end
def build_request(sanitized_input, user)
Success(input: sanitized_input, user: user)
end
def log(message, data)
# logger.info(message, data)
end
def send_request(request)
Success(status: 200)
end
def format_response(response)
Success(response: response, message: 'it worked')
end
end
Foo.new.call(id: 1)
Notice how the functions don't necessarily have to accept only a single
argument (build_request
accepts 2). Also notice how the methods can be used
directly, without having to call #method
or having them return procs.
The chain will still be short-circuited when e.g. #validate
returns a
Failure
.
Here's what the operators used in this example mean:
get
- Execute the provided block and expect a Result
as its return value.
If the Result
is a Success
, then the Success
value is assigned to the
specified identifier. The value is then accessible in subsequent blocks by
that identifier. If the Result
is a Failure
, then the entire chain will
be short-circuited and the Failure
will be returned as the result of the
in_sequence
call.let
- Execute the provided block and assign its result to the specified
identifier. The result can be anything - it is not expected to be
a Result
. This is useful for simple assignments that don't need to be
wrapped in a Result
. E.g. let(:four) { 2 + 2 }
.and_then
- Execute the provided block and expect a Result
as its return
value. If the Result
is a Success
, then the chain continues, otherwise
the chain is short-circuited and the Failure
will be returned as the result
of the in_sequence
call.observe
- Execute the provided block whose return value will be ignored.
The chain continues regardless.and_yield
- Execute the provided block and expect a Result
as its return
value. The Result
will be returned as the result of the in_sequence
call.Now that you have some result, you want to control flow by providing patterns.
#match
can match by
Success(1).match do
Success() { |s| "success #{s}"}
Failure() { |f| "failure #{f}"}
end # => "success 1"
Note1: the variant's inner value(s) have been unwrapped, and passed to the block.
Note2: only the first matching pattern block will be executed, so order can be important.
Note3: you can omit block parameters if you don't use them, or you can use _
to signify that you don't care about their values. If you specify parameters, their number must match the number of values in the variant.
The result returned will be the result of the first #try
or #let
. As a side note, #try
is a monad, #let
is a functor.
Guards
Success(1).match do
Success(where { s == 1 }) { |s| "Success #{s}" }
end # => "Success 1"
Note1: the guard has access to variable names defined by the block arguments.
Note2: the guard is not evaluated using the enclosing context's self
; if you need to call methods on the enclosing scope, you must specify a receiver.
Also you can match the result class
Success([1, 2, 3]).match do
Success(where { s.is_a?(Array) }) { |s| s.first }
end # => 1
If no match was found a NoMatchError
is raised, so make sure you always cover all possible outcomes.
Success(1).match do
Failure() { |f| "you'll never get me" }
end # => NoMatchError
Matches must be exhaustive, otherwise an error will be raised, showing the variants which have not been covered.
You can use a core extension, to include Result in your own class or in Object, i.e. in all classes.
require 'deterministic/core_ext/object/result'
[1].success? # => false
Success(1).failure? # => false
Success(1).success? # => true
Failure(1).result? # => true
Some(1).some? # #=> true
Some(1).none? # #=> false
None.some? # #=> false
None.none? # #=> true
Maps an Option
with the value a
to the same Option
with the value b
.
Some(1).fmap { |n| n + 1 } # => Some(2)
None.fmap { |n| n + 1 } # => None
Maps a Result
with the value a
to another Result
with the value b
.
Some(1).map { |n| Some(n + 1) } # => Some(2)
Some(1).map { |n| None } # => None
None.map { |n| Some(n + 1) } # => None
Get the inner value or provide a default for a None
. Calling #value
on a None
will raise a NoMethodError
Some(1).value # => 1
Some(1).value_or(2) # => 1
None.value # => NoMethodError
None.value_or(0) # => 0
Add the inner values of option using +
.
Some(1) + Some(1) # => Some(2)
Some([1]) + Some(1) # => TypeError: No implicit conversion
None + Some(1) # => Some(1)
Some(1) + None # => Some(1)
Some([1]) + None + Some([2]) # => Some([1, 2])
Option.any?(nil) # => None
Option.any?([]) # => None
Option.any?({}) # => None
Option.any?(1) # => Some(1)
Option.some?(nil) # => None
Option.some?([]) # => Some([])
Option.some?({}) # => Some({})
Option.some?(1) # => Some(1)
Option.try! { 1 } # => Some(1)
Option.try! { raise "error"} # => None
Some(1).match {
Some(where { s == 1 }) { |s| s + 1 }
Some() { |s| 1 }
None() { 0 }
} # => 2
All the above are implemented using enums, see their definition, for more details.
Define it, with all variants:
Threenum = Deterministic::enum {
Nullary()
Unary(:a)
Binary(:a, :b)
}
Threenum.variants # => [:Nullary, :Unary, :Binary]
Initialize
n = Threenum.Nullary # => Threenum::Nullary.new()
n.value # => Error
u = Threenum.Unary(1) # => Threenum::Unary.new(1)
u.value # => 1
b = Threenum::Binary(2, 3) # => Threenum::Binary(2, 3)
b.value # => { a:2, b: 3 }
Pattern matching
Threenum::Unary(5).match {
Nullary() { 0 }
Unary() { |u| u }
Binary() { |a, b| a + b }
} # => 5
# or
t = Threenum::Unary(5)
Threenum.match(t) {
Nullary() { 0 }
Unary() { |u| u }
Binary() { |a, b| a + b }
} # => 5
If you want to return the whole matched object, you'll need to pass a reference to the object (second case). Note that self
refers to the scope enclosing the match
call.
def drop(n)
match {
Cons(where { n > 0 }) { |h, t| t.drop(n - 1) }
Cons() { |_, _| self }
Nil() { raise EmptyListError }
}
end
See the linked list implementation in the specs for more examples
With guard clauses
Threenum::Unary(5).match {
Nullary() { 0 }
Unary() { |u| u }
Binary(where { a.is_a?(Fixnum) && b.is_a?(Fixnum) }) { |a, b| a + b }
Binary() { |a, b| raise "Expected a, b to be numbers" }
} # => 5
Implementing methods for enums
Deterministic::impl(Threenum) {
def sum
match {
Nullary() { 0 }
Unary() { |u| u }
Binary() { |a, b| a + b }
}
end
def +(other)
match {
Nullary() { other.sum }
Unary() { |a| self.sum + other.sum }
Binary() { |a, b| self.sum + other.sum }
}
end
}
Threenum.Nullary + Threenum.Unary(1) # => Unary(1)
All matches must be exhaustive, i.e. cover all variants
The simplest NullObject wrapper there can be. It adds #some?
and #null?
to Object
though.
require 'deterministic/maybe' # you need to do this explicitly
Maybe(nil).foo # => Null
Maybe(nil).foo.bar # => Null
Maybe({a: 1})[:a] # => 1
Maybe(nil).null? # => true
Maybe({}).null? # => false
Maybe(nil).some? # => false
Maybe({}).some? # => true
If you want a custom NullObject which mimicks another class.
class Mimick
def test; end
end
naught = Maybe.mimick(Mimick)
naught.test # => Null
naught.foo # => NoMethodError
#attempt_all
was somewhat inspired by An error monad in Clojure (attempt all has now been removed)Add this line to your application's Gemfile:
gem 'deterministic'
And then execute:
$ bundle
Or install it yourself as:
$ gem install deterministic
git checkout -b my-new-feature
)git commit -am 'Add some feature'
)git push origin my-new-feature
)