Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pattern matching without Qo #28

Merged
merged 5 commits into from Mar 11, 2019
Merged

Conversation

bolshakov
Copy link
Owner

@bolshakov bolshakov commented Mar 7, 2019

This PR is not intended to replace Qo. It is intended to provide better abstraction for pattern matching.

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

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", etc.
  • Symbol -- it is converted to lambda using #to_proc method.
  • 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

To create custom pattern match use Fear.match method and case builder to define
branches. For instance this matcher applies different functions to Integers and Strings

Fear.match(value) do |m|
  m.case(Integer) { |n| "#{n} is a number" }
  m.case(String) { |n| "#{n} is a string" }
end

if you pass something other than Integer or string, it will raise Fear::MatchError error.
To avoid raising MatchError, you can use else method. It defines a branch matching
on any value.

Fear.match(10..20) 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 #=> "10..20 is a Range"

You can use anything as a guardian if it responds to #=== method:

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

If you pass a Symbol, it will be converted to proc using #to_proc method

m.case(:even?) { |x| "#{x} is even" }
m.case(:odd?) { |x| "#{x} is odd" }

It's also possible to pass several guardians. All should match to pass

m.case(Integer, :even?) { |x| ... }
m.case(Integer, :odd?) { |x| ... }

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"

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

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) }
  m.else { raise 'should be positive' }
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

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

pattern match on enclosed value

Some(41).match do |m|
  m.some(:even?) { |x| x / 2 }
  m.some(:odd?, ->(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

Some(42).match do |m|
  m.some(:odd?) { |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 = Option.matcher do |m|
  m.some(42) { 'Yep' }
  m.some { 'Nope' }
  m.none { 'Error' } 
end

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

Benchmarks

Short story long, it is slower then just Procs and Dry::Matcher, but provides reacher API for combination. If you generate matcher in advance and save it into constant, you'll gain significant performance boost (execution 4 times faster than construction)

See Rakefile

Recursive factorial implemented using Proc, Fear, and Qo

Calculating -------------------------------------
                Proc     26.801k (± 6.7%) i/s -    134.500k in   5.045648s
                Fear     13.608k (±12.2%) i/s -     67.160k in   5.033338s
                  Qo      2.482k (± 5.6%) i/s -     12.593k in   5.090770s

Comparison:
                Proc:    26801.3 i/s
                Fear:    13607.6 i/s - 1.97x  slower
                  Qo:     2482.2 i/s - 10.80x  slower

Option matcher

Calculating -------------------------------------
                  Qo     43.806k (± 3.2%) i/s -    222.588k in   5.086846s
     Fear::Some#math     89.335k (± 3.3%) i/s -    447.744k in   5.017562s
 Fear::Option.mather    400.967k (± 2.2%) i/s -      2.008M in   5.011302s
        Dry::Matcher    146.694k (± 3.7%) i/s -    739.536k in   5.049120s

Comparison:
 Fear::Option.mather:   400967.3 i/s
        Dry::Matcher:   146693.5 i/s - 2.73x  slower
     Fear::Some#math:    89335.0 i/s - 4.49x  slower
                  Qo:    43805.9 i/s - 9.15x  slower

@bolshakov bolshakov changed the title Feature/pattern matching without qo Pattern matching without Qo Mar 7, 2019
@bolshakov bolshakov force-pushed the feature/pattern-matching-without-qo branch 5 times, most recently from 1a2f86b to 00490d3 Compare March 9, 2019 14:15
@bolshakov bolshakov force-pushed the feature/pattern-matching-without-qo branch 5 times, most recently from d9a28ff to 06ef940 Compare March 10, 2019 12:32
@bolshakov bolshakov force-pushed the feature/pattern-matching-without-qo branch from 06ef940 to cfa92d5 Compare March 10, 2019 12:35
@bolshakov bolshakov merged commit d77915e into master Mar 11, 2019
@bolshakov bolshakov deleted the feature/pattern-matching-without-qo branch April 18, 2019 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant