Skip to content
Ruby port of some Scala's monads
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
benchmarks
examples
lib
spec
.gitignore
.rspec
.rubocop.yml
.travis.yml
.yardopts
CHANGELOG.md
Gemfile
Gemfile.lock
LICENSE.txt
README.md
Rakefile
fear.gemspec

README.md

Fear

Build Status Gem Version

This gem provides Option, Either, and Try monads implemented an idiomatic way. It is highly inspired by scala's implementation.

Installation

Add this line to your application's Gemfile:

gem 'fear'

And then execute:

$ bundle

Or install it yourself as:

$ gem install fear

Usage

Option (API Documentation)

Represents optional (nullable) values. Instances of Option are either an instance of Some or the object None.

The most idiomatic way to use an Option instance is to treat it as a collection

name = Fear.option(params[:name])
upper = name.map(&:strip).select { |n| n.length != 0 }.map(&:upcase)
puts upper.get_or_else('')

This allows for sophisticated chaining of Option values without having to check for the existence of a value.

A less-idiomatic way to use Option values is via pattern matching

Fear.option(params[:name]).match do |m|
  m.some { |name| name.strip.upcase }
  m.none { 'No name value' }
end

or manually checking for non emptiness

name = Fear.option(params[:name])
if name.empty?
 puts 'No name value'
else
 puts name.strip.upcase
end

Alternatively, include Fear::Option::Mixin to use Option(), Some() and None() methods:

include Fear::Option::Mixin 

Option(42) #=> #<Fear::Some get=42>
Option(nil) #=> #<Fear::None>

Some(42) #=> #<Fear::Some get=42>
Some(nil) #=> #<Fear::Some get=nil>
None() #=> #<Fear::None>

Option#get_or_else

Returns the value from this Some or evaluates the given default argument if this is a None.

Fear.some(42).get_or_else { 24/2 } #=> 42
Fear.none.get_or_else { 24/2 }   #=> 12

Fear.some(42).get_or_else(12)  #=> 42
Fear.none.get_or_else(12)    #=> 12

Option#or_else

returns self Some or the given alternative if this is a None.

Fear.some(42).or_else { Fear.some(21) } #=> Fear.some(42)
Fear.none.or_else { Fear.some(21) }   #=> Fear.some(21)
Fear.none.or_else { None }     #=> None

Option#inlude?

Checks if Option has an element that is equal (as determined by ==) to given values.

Fear.some(17).include?(17) #=> true
Fear.some(17).include?(7)  #=> false
Fear.none.include?(17)   #=> false

Option#each

Performs the given block if this is a Some.

Fear.some(17).each { |value| puts value } #=> prints 17
Fear.none.each { |value| puts value } #=> does nothing

Option#map

Maps the given block to the value from this Some or returns self if this is a None

Fear.some(42).map { |v| v/2 } #=> Fear.some(21)
Fear.none.map { |v| v/2 }   #=> None

Option#flat_map

Returns the given block applied to the value from this Some or returns self if this is a None

Fear.some(42).flat_map { |v| Fear.some(v/2) }   #=> Fear.some(21)
Fear.none.flat_map { |v| Fear.some(v/2) }     #=> None

Option#any?

Returns false if None or returns the result of the application of the given predicate to the Some value.

Fear.some(12).any? { |v| v > 10 }  #=> true
Fear.some(7).any? { |v| v > 10 }   #=> false
Fear.none.any? { |v| v > 10 }    #=> false

Option#select

Returns self if it is nonempty and applying the predicate to this Option's value returns true. Otherwise, return None.

Fear.some(42).select { |v| v > 40 } #=> Fear.success(21)
Fear.some(42).select { |v| v < 40 } #=> None
Fear.none.select { |v| v < 40 }   #=> None

Option#reject

Returns Some if applying the predicate to this Option's value returns false. Otherwise, return None.

Fear.some(42).reject { |v| v > 40 } #=> None
Fear.some(42).reject { |v| v < 40 } #=> Fear.some(42)
Fear.none.reject { |v| v < 40 }   #=> None

Option#get

Not an idiomatic way of using Option at all. Returns values of raise NoSuchElementError error if option is empty.

Option#empty?

Returns true if the Option is None, false otherwise.

Fear.some(42).empty? #=> false
Fear.none.empty?   #=> true

@see https://github.com/scala/scala/blob/2.11.x/src/library/scala/Option.scala

Try (API Documentation)

The Try represents a computation that may either result in an exception, or return a successfully computed value. Instances of Try, are either an instance of Success or Failure.

For example, Try can be used to perform division on a user-defined input, without the need to do explicit exception-handling in all of the places that an exception might occur.

dividend = Fear.try { Integer(params[:dividend]) }
divisor = Fear.try { Integer(params[:divisor]) }
problem = dividend.flat_map { |x| divisor.map { |y| x / y } }

problem.match |m|
  m.success do |result|
    puts "Result of #{dividend.get} / #{divisor.get} is: #{result}"
  end

  m.failure(ZeroDivisionError) do
    puts "Division by zero is not allowed"
  end

  m.failure do |exception|
    puts "You entered something wrong. Try again"
    puts "Info from the exception: #{exception.message}"
  end
end

An important property of Try shown in the above example is its ability to pipeline, or chain, operations, catching exceptions along the way. The flat_map and map combinators in the above example each essentially pass off either their successfully completed value, wrapped in the Success type for it to be further operated upon by the next combinator in the chain, or the exception wrapped in the Failure type usually to be simply passed on down the chain. Combinators such as recover_with and recover are designed to provide some type of default behavior in the case of failure.

NOTE: Only non-fatal exceptions are caught by the combinators on Try. Serious system errors, on the other hand, will be thrown.

Alternatively, include Fear::Try::Mixin to use Try() method:

include Fear::Try::Mixin 

Try { 4/0 }  #=> #<Fear::Failure exception=...>
Try { 4/2 }  #=> #<Fear::Success value=2>

Try#get_or_else

Returns the value from this Success or evaluates the given default argument if this is a Failure.

Fear.success(42).get_or_else { 24/2 }                #=> 42
Fear.failure(ArgumentError.new).get_or_else { 24/2 } #=> 12

Try#include?

Returns true if it has an element that is equal given values, false otherwise.

Fear.success(17).include?(17)                #=> true
Fear.success(17).include?(7)                 #=> false
Fear.failure(ArgumentError.new).include?(17) #=> false

Try#each

Performs the given block if this is a Success. If block raise an error, then this method may raise an exception.

Fear.success(17).each { |value| puts value }  #=> prints 17
Fear.failure(ArgumentError.new).each { |value| puts value } #=> does nothing

Try#map

Maps the given block to the value from this Success or returns self if this is a Failure.

Fear.success(42).map { |v| v/2 }                 #=> Fear.success(21)
Fear.failure(ArgumentError.new).map { |v| v/2 }  #=> Fear.failure(ArgumentError.new)

Try#flat_map

Returns the given block applied to the value from this Successor returns self if this is a Failure.

Fear.success(42).flat_map { |v| Fear.success(v/2) } #=> Fear.success(21)
Fear.failure(ArgumentError.new).flat_map { |v| Fear.success(v/2) } #=> Fear.failure(ArgumentError.new)

Try#to_option

Returns an Some containing the Success value or a None if this is a Failure.

Fear.success(42).to_option                 #=> Fear.some(21)
Fear.failure(ArgumentError.new).to_option  #=> None

Try#any?

Returns false if Failure or returns the result of the application of the given predicate to the Success value.

Fear.success(12).any? { |v| v > 10 }                #=> true
Fear.success(7).any? { |v| v > 10 }                 #=> false
Fear.failure(ArgumentError.new).any? { |v| v > 10 } #=> false

Try#success? and Try#failure?

Fear.success(12).success? #=> true
Fear.success(12).failure? #=> true

Fear.failure(ArgumentError.new).success? #=> false
Fear.failure(ArgumentError.new).failure? #=> true

Try#get

Returns the value from this Success or raise the exception if this is a Failure.

Fear.success(42).get                 #=> 42
Fear.failure(ArgumentError.new).get  #=> ArgumentError: ArgumentError

Try#or_else

Returns self Try if it's a Success or the given alternative if this is a Failure.

Fear.success(42).or_else { Fear.success(-1) }                 #=> Fear.success(42)
Fear.failure(ArgumentError.new).or_else { Fear.success(-1) }  #=> Fear.success(-1)
Fear.failure(ArgumentError.new).or_else { Fear.try { 1/0 } }  #=> Fear.failure(ZeroDivisionError.new('divided by 0'))

Try#flatten

Transforms a nested Try, ie, a Success of Success, into an un-nested Try, ie, a Success.

Fear.success(42).flatten                         #=> Fear.success(42)
Fear.success(Fear.success(42)).flatten                #=> Fear.success(42)
Fear.success(Fear.failure(ArgumentError.new)).flatten #=> Fear.failure(ArgumentError.new)
Fear.failure(ArgumentError.new).flatten { -1 }   #=> Fear.failure(ArgumentError.new)

Try#select

Converts this to a Failure if the predicate is not satisfied.

Fear.success(42).select { |v| v > 40 }
  #=> Fear.success(21)
Fear.success(42).select { |v| v < 40 }
  #=> Fear.failure(Fear::NoSuchElementError.new("Predicate does not hold for 42"))
Fear.failure(ArgumentError.new).select { |v| v < 40 }
  #=> Fear.failure(ArgumentError.new)

Recovering from errors

There are two ways to recover from the error. Try#recover_with method is like flat_map for the exception. And you can pattern match against the error!

Fear.success(42).recover_with do |m|
  m.case(ZeroDivisionError) { Fear.success(0) }
end #=> Fear.success(42)

Fear.failure(ArgumentError.new).recover_with do |m|
  m.case(ZeroDivisionError) { Fear.success(0) }
  m.case(ArgumentError) { |error| Fear.success(error.class.name) }
end #=> Fear.success('ArgumentError')

If the block raises error, this new error returned as an result

Fear.failure(ArgumentError.new).recover_with do
  raise
end #=> Fear.failure(RuntimeError)

The second possibility for recovery is Try#recover method. It is like map for the exception. And it's also heavely relies on pattern matching.

Fear.success(42).recover do |m|
  m.case(&:message)
end #=> Fear.success(42)

Fear.failure(ArgumentError.new).recover do |m|
  m.case(ZeroDivisionError) { 0 }
  m.case(&:message)
end #=> Fear.success('ArgumentError')

If the block raises an error, this new error returned as an result

Fear.failure(ArgumentError.new).recover do |m|
  raise
end #=> Fear.failure(RuntimeError)

Try#to_either

Returns Left with exception if this is a Failure, otherwise returns Right with Success value.

Fear.success(42).to_either                #=> Fear.right(42)
Fear.failure(ArgumentError.new).to_either #=> Fear.left(ArgumentError.new)

Either (API Documentation)

Represents a value of one of two possible types (a disjoint union.) An instance of Either is either an instance of Left or Right.

A common use of Either is as an alternative to Option for dealing with possible missing values. In this usage, None is replaced with a Left which can contain useful information. Right takes the place of Some. Convention dictates that Left is used for failure and Right is used for Right.

For example, you could use Either<String, Fixnum> to #select_or_else whether a received input is a +String+ or an +Fixnum+.

in = Readline.readline('Type Either a string or an Int: ', true)
result = begin
  Fear.right(Integer(in))
rescue ArgumentError
  Fear.left(in)
end

result.match do |m|
  m.right do |x|
    "You passed me the Int: #{x}, which I will increment. #{x} + 1 = #{x+1}"
  end

  m.left do |x|
    "You passed me the String: #{x}"
  end
end

Either is right-biased, which means that Right is assumed to be the default case to operate on. If it is Left, operations like #map, #flat_map, ... return the Left value unchanged.

Alternatively, include Fear::Either::Mixin to use Left(), and Right() methods:

include Fear::Either::Mixin 

Left(42)  #=> #<Fear::Left value=42>
Right(42)  #=> #<Fear::Right value=42>

Either#get_or_else

Returns the value from this Right or evaluates the given default argument if this is a Left.

Fear.right(42).get_or_else { 24/2 }         #=> 42
Fear.left('undefined').get_or_else { 24/2 } #=> 12

Fear.right(42).get_or_else(12)         #=> 42
Fear.left('undefined').get_or_else(12) #=> 12

Either#or_else

Returns self Right or the given alternative if this is a Left.

Fear.right(42).or_else { Fear.right(21) }           #=> Fear.right(42)
Fear.left('unknown').or_else { Fear.right(21) }     #=> Fear.right(21)
Fear.left('unknown').or_else { Fear.left('empty') } #=> Fear.left('empty')

Either#include?

Returns true if Right has an element that is equal to given value, false otherwise.

Fear.right(17).include?(17)         #=> true
Fear.right(17).include?(7)          #=> false
Fear.left('undefined').include?(17) #=> false

Either#each

Performs the given block if this is a Right.

Fear.right(17).each { |value| puts value } #=> prints 17
Fear.left('undefined').each { |value| puts value } #=> does nothing

Either#map

Maps the given block to the value from this Right or returns self if this is a Left.

Fear.right(42).map { |v| v/2 }          #=> Fear.right(21)
Fear.left('undefined').map { |v| v/2 }  #=> Fear.left('undefined')

Either#flat_map

Returns the given block applied to the value from this Right or returns self if this is a Left.

Fear.right(42).flat_map { |v| Fear.right(v/2) }         #=> Fear.right(21)
Fear.left('undefined').flat_map { |v| Fear.right(v/2) } #=> Fear.left('undefined')

Either#to_option

Returns an Some containing the Right value or a None if this is a Left.

Fear.right(42).to_option          #=> Fear.some(21)
Fear.left('undefined').to_option  #=> Fear::None

Either#any?

Returns false if Left or returns the result of the application of the given predicate to the Right value.

Fear.right(12).any? { |v| v > 10 }         #=> true
Fear.right(7).any? { |v| v > 10 }          #=> false
Fear.left('undefined').any? { |v| v > 10 } #=> false

Either#right?, Either#success?

Returns true if this is a Right, false otherwise.

Fear.right(42).right?   #=> true
Fear.left('err').right? #=> false

Either#left?, Either#failure?

Returns true if this is a Left, false otherwise.

Fear.right(42).left?   #=> false
Fear.left('err').left? #=> true

Either#select_or_else

Returns Left of the default if the given predicate does not hold for the right value, otherwise, returns Right.

Fear.right(12).select_or_else(-1, &:even?)       #=> Fear.right(12)
Fear.right(7).select_or_else(-1, &:even?)        #=> Fear.left(-1)
Fear.left(12).select_or_else(-1, &:even?)        #=> Fear.left(12)
Fear.left(12).select_or_else(-> { -1 }, &:even?) #=> Fear.left(12)

Either#select

Returns Left of value if the given predicate does not hold for the right value, otherwise, returns Right.

Fear.right(12).select(&:even?) #=> Fear.right(12)
Fear.right(7).select(&:even?)  #=> Fear.left(7)
Fear.left(12).select(&:even?)  #=> Fear.left(12)
Fear.left(7).select(&:even?)   #=> Fear.left(7)

Either#reject

Returns Left of value if the given predicate holds for the right value, otherwise, returns Right.

Fear.right(12).reject(&:even?) #=> Fear.left(12)
Fear.right(7).reject(&:even?)  #=> Fear.right(7)
Fear.left(12).reject(&:even?)  #=> Fear.left(12)
Fear.left(7).reject(&:even?)   #=> Fear.left(7)

Either#swap

If this is a Left, then return the left value in Right or vice versa.

Fear.left('left').swap   #=> Fear.right('left')
Fear.right('right').swap #=> Fear.left('left')

Either#reduce

Applies reduce_left if this is a Left or reduce_right if this is a Right.

result = possibly_failing_operation()
log(
  result.reduce(
    ->(ex) { "Operation failed with #{ex}" },
    ->(v) { "Operation produced value: #{v}" },
  )
)

Either#join_right

Joins an Either through Right. This method requires that the right side of this Either is itself an Either type. This method, and join_left, are analogous to Option#flatten

Fear.right(Fear.right(12)).join_right      #=> Fear.right(12)
Fear.right(Fear.left("flower")).join_right #=> Fear.left("flower")
Fear.left("flower").join_right        #=> Fear.left("flower")
Fear.left(Fear.right("flower")).join_right #=> Fear.left(Fear.right("flower"))

Either#join_right

Joins an Either through Left. This method requires that the left side of this Either is itself an Either type. This method, and join_right, are analogous to Option#flatten

Fear.left(Fear.right("flower")).join_left #=> Fear.right("flower")
Fear.left(Fear.left(12)).join_left        #=> Fear.left(12)
Fear.right("daisy").join_left        #=> Fear.right("daisy")
Fear.right(Fear.left("daisy")).join_left  #=> Fear.right(Fear.left("daisy"))

Future (API Documentation)

Asynchronous computations that yield futures are created with the Fear.future call

success = "Hello"
f = Fear.future { success + ' future!' }
f.on_success do |result|
  puts result
end

Multiple callbacks may be registered; there is no guarantee that they will be executed in a particular order.

The future may contain an exception and this means that the future failed. Futures obtained through combinators have the same error as the future they were obtained from.

f = Fear.future { 5 }
g = Fear.future { 3 }

f.flat_map do |x|
  g.map { |y| x + y }
end

Futures use Concurrent::Promise under the hood. Fear.future accepts optional configuration Hash passed directly to underlying promise. For example, run it on custom thread pool.

require 'open-uri'
pool = Concurrent::FixedThreadPool.new(5)
future = Fear.future(executor: pool) { open('https://example.com/') }
future.map(&:read).each do |body| 
  puts "#{body}"
end

Futures support common monadic operations -- #map, #flat_map, and #each. That's why it's possible to combine them using Fear.for, It returns the Future containing Success of 5 + 3 eventually.

f = Fear.future { 5 }
g = Fear.future { 3 }

Fear.for(f, g) do |x, y|
  x + y
end 

Future goes with the number of callbacks. You can register several callbacks, but the order of execution isn't guaranteed

f = Fear.future { ... } #  call external service
f.on_success do |result|
  # handle service response
end

f.on_failure do |error|
  # handle exception
end

or you can wait for Future completion

f.on_complete do |result|
  result.match do |m|
    m.success { |value| ... }
    m.failure { |error| ... }
  end
end 

In sake of convenience #on_success callback aliased as #each.

It's possible to get future value directly, but since it may be incomplete, #value method returns Fear::Option. So, there are three possible responses:

future.value #=>
# Fear::Some<Fear::Success> #=> future completed with value
# Fear::Some<Fear::Failure> #=> future completed with error
# Fear::None #=> future not yet completed

There is a variety of methods to manipulate with futures.

Fear.future { open('http://example.com').read }
  .transform(
     ->(value) { ... },
     ->(error) { ... },
  )

future = Fear.future { 5 }
future.select(&:odd?) # evaluates to Fear.success(5)
future.select(&:even?) # evaluates to Fear.error(NoSuchElementError)

You can zip several asynchronous computations into one future. For you can call two external services and then zip the results into one future containing array of both responses:

future1 = Fear.future { call_service1 }
future1 = Fear.future { call_service2 }
future1.zip(future2)

It returns the same result as Fear.future { [call_service1, call_service2] }, but the first version performs two simultaneous calls.

There are two ways to recover from failure. Future#recover is live #map for failures:

Fear.future { 2 / 0 }.recover do |m|
  m.case(ZeroDivisionError) { 0 }
end #=> returns new future of Fear.success(0)

If the future resolved to success or recovery matcher did not matched, it returns the future Fear::Failure.

The second option is Future#fallbock_to method. It allows to fallback to result of another future in case of failure

future = Fear.future { fail 'error' }
fallback = Fear.future { 5 }
future.fallback_to(fallback) # evaluates to 5

You can run callbacks in specific order using #and_then method:

f = Fear.future { 5 }
f.and_then do
  fail 'runtime error'
end.and_then do |m|
  m.success { |value| puts value } # it evaluates this branch
  m.failure { |error| puts error.massage }
end

Testing future values

Sometimes it may be helpful to await for future completion. You can await either future, or result. Don't forget to pass timeout in seconds:

future = Fear.future { 42 }

Fear::Await.result(future, 3) #=> 42

Fear::Await.ready(future, 3) #=> Fear::Future.successful(42)

For composition (API Documentation)

Provides syntactic sugar for composition of multiple monadic operations. It supports two such operations - flat_map and map. Any class providing them is supported by For.

Fear.for(Fear.some(2), Fear.some(3)) do |a, b|
  a * b
end #=> Fear.some(6)

If one of operands is None, the result is None

Fear.for(Fear.some(2), None) do |a, b| 
  a * b 
end #=> None

Fear.for(None, Fear.some(2)) do |a, b| 
  a * b 
end #=> None

Lets look at first example:

Fear.for(Fear.some(2), None) do |a, b| 
  a * b 
end #=> None

it is translated to:

Fear.some(2).flat_map do |a|
  Fear.some(3).map do |b|
    a * b
  end
end

It works with arrays as well

Fear.for([1, 2], [2, 3], [3, 4]) { |a, b, c| a * b * c }
  #=> [6, 8, 9, 12, 12, 16, 18, 24]

it is translated to:

[1, 2].flat_map do |a|
  [2, 3].flat_map do |b|
    [3, 4].map do |c|
      a * b * c
    end
  end
end

If you pass lambda as a variable value, it would be evaluated only on demand.

Fear.for(proc { None }, proc { raise 'kaboom' } ) do |a, b|
  a * b
end #=> None

It does not fail since b is not evaluated. You can refer to previously defined variables from within lambdas.

maybe_user = find_user('Paul') #=> <#Option value=<#User ...>>

Fear.for(maybe_user, ->(user) { user.birthday }) do |user, birthday|
  "#{user.name} was born on #{birthday}"
end #=> Fear.some('Paul was born on 1987-06-17')

Pattern Matching (API Documentation)

Syntax

To pattern match against a value, use Fear.match function, and provide at least one case clause:

x = Random.rand(10)

Fear.match(x) do |m|
  m.case(0) { 'zero' }
  m.case(1) { 'one' }
  m.case(2) { 'two' }
  m.else { 'many' }
end

The x above is a random integer from 0 to 10. The last clause else is a “catch all” case for anything other than 0, 1, and 2. If you want to ensure that an Integer value is passed, matching against type available:

Fear.match(x) do |m|
  m.case(Integer, 0) { 'zero' }
  m.case(Integer, 1) { 'one' }
  m.case(Integer, 2) { 'two' }
  m.case(Integer) { 'many' }
end

Providing something other than Integer will raise Fear::MatchError error.

Pattern guards

You can use whatever you want as a pattern guard, if it respond to #=== method to to make cases more specific.

m.case(20..40) { |m| "#{m} is within range" }
m.case(->(x) { x > 10}) { |m| "#{m} is greater than 10" } 
m.case(:even?.to_proc) { |x| "#{x} is even" }
m.case(:odd?.to_proc) { |x| "#{x} is odd" }

It's also possible to create matcher and use it several times:

matcher = Fear.matcher do |m|
  m.case(Integer) { |n| "#{n} is a number" }
  m.case(String) { |n| "#{n} is a string" }
  m.else  { |n| "#{n} is a #{n.class}" }
end 

matcher.(42) #=> "42 is a number"
matcher.(10..20) #=> "10..20 is a Range"

Pattern extraction

It's possible to use special syntax to match against an object and extract a variable form this object. To perform such extraction, #xcase method should be used. The following example should give you a sense how extraction works.

matcher = Fear.matcher do |m|
  m.xcase('[1, *tail]') { |tail:| tail }
end

It matches only on an array starting from 1 integer, and captures its tail:

matcher.([1,2,3]) #=> [2,3]
matcher.([2,3]) #=> raises MatchError

If you want to match against any value, use _

matcher = Fear.matcher do |m|
  m.xcase('[1, _, 3]') { .. }
end

It matches against [1, 2, 3], [1, 'foo', 3], but not [1, 2]. It's also possible to capture several variables at the same time. Tho following example describes an array starting from 1, and captures second and third elements.

matcher = Fear.matcher do |m|
  m.xcase('[1, second, third]') { |second:, third: |.. }
end

Matching on deeper structures is possible as well:

matcher = Fear.matcher do |m|
  m.xcase('[["status", first_status], 4, *tail]') { |first_status:, tail: |.. }
end

If you want to capture variable of specific type, there is a type matcher for that case:

matcher = Fear.matcher do |m|
  m.xcase('[head : String, 2, *]') { |head: | head }
end
matcher.(['foo', 2]) #=> 'foo'
matcher.(['foo', 3]) #=> MatchError
matcher.([1, 2]) #=> MatchError

You can extract variables from more complex objects. Fear packed with extractors for monads and Date object:

Fear.matcher do |m|
  m.xcase('Date(year, 2, 29)', ->(year:) { year < 2000 }) do |year:|
    "#{year} is a leap year before Millennium"
  end
  
  m.xcase('Date(year, 2, 29)') do |year:|
    "#{year} is a leap year after Millennium"
  end
  
  m.case(Date) do |date|
    "#{date.year} is not a leap year"
  end
end

This matcher extracts values from date object and match against them at the same time

matcher.(Date.new(1996,02,29)) #=> "1996 is a leap year before Millennium"
matcher.(Date.new(2004,02,29)) #=> "1996 is a leap year after Millennium"
matcher.(Date.new(2003,01,24)) #=> "2003 is not a leap year"

Nothing tricky here. The extractor object takes an object and tries to give back the arguments. It's like constructor, but instead of construction an object, it deconstructs it.

An argument of an extractor may be also a pattern or even introduce a new variable.

matcher = Fear.matcher do |m|
  m.xcase('Some([status : Integer, body : String])') do |status:, body:|
    "#{body.bytesize} bytes received with code #{status}"
  end 
end

matcher.(Fear.some([200, 'hello'])) #=> "5 bytes received with code 200"
matcher.(Fear.some(['hello', 200])) #=> MatchError 

You can provide extractors for you own classes

Fear.register_extractor(User, Fear.case(User) { |user| [user.id, user.email] }.lift)
# is the same as
Fear.register_extractor(User, proc do |user|
  if user.is_a?(User)
    Fear.some([user.id, user.email])
  else
    Fear.none
  end
end)

Now extracting user's id and email is possible:

Fear.match(user) do |m|
  m.xcase('User(id, email)') { |id:, email:| }
end

Note, registered extractor should return either array of arguments, or boolean.

Extracting struct

There is predefined Struct extractor:

Envelope = Struct.new(:id, :receiver, :sender, :message)

Fear.matcher do |m|
  m.xcase('envelope @ Envelope(id, _, sender, _)') do |id:, sender:, envelope:|
    acknowledge(id, sender)
    process(acknowledge)
  end
end

How to debug pattern extractors?

You can build pattern manually and ask for failure reason:

Fear['Some([:err, 444])'].failure_reason(Fear.some([:err, 445]))
# =>
Expected `445` to match:
Some([:err, 444])
~~~~~~~~~~~~^

by the way you can also match against such pattern

Fear['Some([:err, 444])'] === Fear.some([:err, 445]) #=> false
Fear['Some([:err, 444])'] === Fear.some([:err, 445]) #=> true

More examples

Factorial using pattern matching

factorial = Fear.matcher do |m|
  m.case(->(n) { n <= 1} ) { 1 }
  m.else { |n| n * factorial.(n - 1) }
end

factorial.(10) #=> 3628800

Fibonacci number

fibonnaci = Fear.matcher do |m|
  m.case(0) { 0 }
  m.case(1) { 1 }
  m.case(->(n) { n > 1}) { |n| fibonnaci.(n - 1) + fibonnaci.(n - 2) }
end

fibonnaci.(10) #=> 55

Binary tree set implemented using pattern matching https://gist.github.com/bolshakov/3c51bbf7be95066d55d6d1ac8c605a1d

Monads pattern matching

You can use Option#match, Either#match, and Try#match method. It performs matching not only on container itself, but on enclosed value as well.

Pattern match against an Option

Fear.some(42).match do |m|
  m.some { |x| x * 2 }
  m.none { 'none' }
end #=> 84

pattern match on enclosed value

Fear.some(41).match do |m|
  m.some(:even?.to_proc) { |x| x / 2 }
  m.some(:odd?.to_proc, ->(v) { v > 0 }) { |x| x * 2 }
  m.none { 'none' }
end #=> 82

it raises Fear::MatchError error if nothing matched. To avoid exception, you can pass #else branch

Fear.some(42).match do |m|
  m.some(:odd?.to_proc) { |x| x * 2 }
  m.else { 'nothing' }
end #=> nothing

Pattern matching works the similar way for Either and Try monads.

In sake of performance, you may want to generate pattern matching function and reuse it multiple times:

matcher = Fear::Option.matcher do |m|
  m.some(42) { 'Yep' }
  m.some { 'Nope' }
  m.none { 'Error' } 
end

matcher.(Fear.some(42)) #=> 'Yep'
matcher.(Fear.some(40)) #=> 'Nope'

Under the hood

Pattern matcher is a combination of partial functions wrapped into nice DSL. Every partial function defined on domain described with guard.

pf = Fear.case(Integer) { |x| x / 2 }
pf.defined_at?(4) #=> true
pf.defined_at?('Foo') #=> false
pf.call('Foo') #=> raises Fear::MatchError
pf.call_or_else('Foo') { 'not a number' } #=> 'not a number'
pf.call_or_else(4) { 'not a number' } #=> 2
pf.lift.call('Foo') #=> Fear::None
pf.lift.call(4) #=> Fear.some(2)

It uses #=== method under the hood, so you can pass:

  • Class to check kind of an object.
  • Lambda to evaluate it against an object.
  • Any literal, like 4, "Foobar", :not_found etc.
  • Qo matcher -- m.case(Qo[name: 'John']) { .... }

Partial functions may be combined with each other:

is_even = Fear.case(->(arg) { arg % 2 == 0}) { |arg| "#{arg} is even" }
is_odd = Fear.case(->(arg) { arg % 2 == 1}) { |arg| "#{arg} is odd" }

(10..20).map(&is_even.or_else(is_odd))

to_integer = Fear.case(String, &:to_i)
integer_two_times = Fear.case(Integer) { |x| x * 2 }

two_times = to_integer.and_then(integer_two_times).or_else(integer_two_times)
two_times.(4) #=> 8
two_times.('42') #=> 84

Since matcher is just a syntactic sugar for partial functions, you can combine matchers with partial functions and each other.

handle_numbers = Fear.case(Integer, &:itself).and_then(
  Fear.matcher do |m|
    m.case(0) { 'zero' }
    m.case(->(n) { n < 10 }) { 'smaller than ten' }  
    m.case(->(n) { n > 10 }) { 'bigger than ten' }
  end
)

handle_strings = Fear.case(String, &:itself).and_then(
  Fear.matcher do |m|
    m.case('zero') { 0 }
    m.case('one') { 1 }
    m.else { 'unexpected' }
  end
)

handle = handle_numbers.or_else(handle_strings)
handle.(0) #=> 'zero'
handle.(12) #=> 'bigger than ten'
handle.('one') #=> 1

Testing

To simplify testing, you may use fear-rspec gem. It provides a bunch of rspec matchers.

Contributing

  1. Fork it ( https://github.com/bolshakov/fear/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Alternatives

You can’t perform that action at this time.