Skip to content
108 changes: 96 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Maybe monad implementation for Ruby

```ruby
puts Maybe(User.find_by_id("123")).username.downcase.or_else { "N/A" }
puts Maybe(User.find_by_id("123")).username.downcase.get_or_else { "N/A" }

=> # puts downcased username if user "123" can be found, otherwise puts "N/A"
```
Expand All @@ -21,7 +21,7 @@ gem install possibly
```
require 'possibly'

first_name = Maybe(deep_hash)[:account][:profile][:first_name].or_else { "No first name available" }
first_name = Maybe(deep_hash)[:account][:profile][:first_name].get_or_else { "No first name available" }
```

## Documentation
Expand All @@ -38,14 +38,14 @@ Maybe(nil) => #<None:0x007ff7a852bd20>
Both `Some` and `None` implement four trivial methods: `is_some?`, `is_none?`, `get` and `or_else`

```ruby
Maybe("I'm a value").is_some? => true
Maybe("I'm a value").is_none? => false
Maybe(nil).is_some? => false
Maybe(nil).is_none? => true
Maybe("I'm a value").get => "I'm a value"
Maybe("I'm a value").or_else { "No value" } => "I'm a value"
Maybe(nil).get => RuntimeError: No such element
Maybe(nil).or_else { "No value" } => "No value"
Maybe("I'm a value").is_some? => true
Maybe("I'm a value").is_none? => false
Maybe(nil).is_some? => false
Maybe(nil).is_none? => true
Maybe("I'm a value").get => "I'm a value"
Maybe("I'm a value").get_or_else { "No value" } => "I'm a value"
Maybe(nil).get => RuntimeError: No such element
Maybe(nil).get_or_else { "No value" } => "No value"
```

In addition, `Some` and `None` implement `Enumerable`, so all methods available for `Enumerable` are available for `Some` and `None`:
Expand Down Expand Up @@ -116,6 +116,90 @@ when None
end
```

## or_else

`or_else` returns the current `Maybe` if it's a `Some`, but if it's a `None`, it returns the parameter that was given to it (which should be a `Maybe`).

Here's an example: Show "title", which is person's job title or degree if she doesn't have a job or "Unknown" if both are missing.

```ruby
maybe_person = Maybe(person)

title = maybe_person.job.title.or_else { maybe_person.degree }.get_or_else { "Unknown" }

title = if person && person.job && person.job.title.present?
person.job.title
elsif person && person.degree.present?
person.degree
else
"Unknown"
end

## `combine([maybes])`

With `combine` you can create a new `Maybe` which includes an array of values from combined `Maybe`s. If any of the combined `Maybe`s is a `None`, a `None` will be returned.

```
mparams = Maybe(params)

duration = Maybe
.combine(mparams[:start_date], mparams[:end_date])
.map { |(start, end)| Date.parse(end) - Date.parse(start) }
.get_or_else "Unknown"
```

## Laziness

Ruby 2.0 introduced lazy enumerables. By calling `lazy` on any enumerable, you get the lazy version of it. Same goes with Maybe.

```ruby

called = false
m = Maybe(2).lazy.map do |value|
called = true;
value * value;
end

puts called # => false
puts m.get # => 4 # Map is called now
puts called # => true
```

You can also initialize Maybe lazily by giving it a block.

```ruby
init_called = false
map_called = false

m = Maybe do
init_called = true
do_some_expensive_calculation # returns 1234567890
end.map do |value|
map_called = true;
"the value of expensive calculation: #{value}";
end

puts init_called # => false
puts map_called # => false
puts m.get # => "the value of expensive calculation: 1234567890 # Map is called now
puts init_called # => true
puts map_called # => true
```

Note that if you initialize a maybe non-lazily and inspect it, you see from the class that it is a Some:

```ruby
Maybe("I'm not lazy") => #<Some:0x007ff7ac8697b8 @value=2>
```

However, if you initialize Maybe lazily, we do not know the type before the lazy block is evaluated. Thus, you see a different output when printing the value

```ruby
Maybe { "I'm lazy" } => #<Maybe:0x0000010107a600 @lazy=#<Enumerator::Lazy: #<Enumerator: #<Enumerator::Generator:0x0000010107a768>:each>>>
```

This feature needs Ruby version >= 2.0.0.

## Examples

Instead of using if-clauses to define whether a value is a `nil`, you can wrap the value with `Maybe()` and threat it the same way whether or not it is a `nil`
Expand All @@ -134,7 +218,7 @@ end
With Maybe():

```ruby
number_of_friends = Maybe(User.find_by_id(user_id)).friends.count.or_else { 0 }
number_of_friends = Maybe(User.find_by_id(user_id)).friends.count.get_or_else { 0 }
```

Same in HAML view, without Maybe():
Expand All @@ -147,7 +231,7 @@ Same in HAML view, without Maybe():
```

```haml
= Maybe(@user).friends.count.or_else { 0 }
= Maybe(@user).friends.count.get_or_else { 0 }
```

## Tests
Expand Down
140 changes: 132 additions & 8 deletions lib/possibly.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ class Maybe
([:each] + Enumerable.instance_methods).each do |enumerable_method|
define_method(enumerable_method) do |*args, &block|
res = __enumerable_value.send(enumerable_method, *args, &block)
res.respond_to?(:each) ? Maybe(res.first) : res
res.respond_to?(:each) ? rewrap(res) : res
end
end

def initialize(lazy_enumerable)
@lazy = lazy_enumerable
end

def to_ary
__enumerable_value
end
Expand All @@ -15,10 +19,87 @@ def ==(other)
other.class == self.class
end
alias_method :eql?, :==

def get
__evaluated.get
end

def or_else(*args)
__evaluated.or_else(*args)
end

# rubocop:disable PredicateName
def is_some?
__evaluated.is_some?
end

def is_none?
__evaluated.is_none?
end
# rubocop:enable PredicateName

def lazy
if [].respond_to?(:lazy)
Maybe.new(__enumerable_value.lazy)
else
self
end
end

def combine(*lazys)
Maybe.from_block {
if lazys.all? { |maybe| maybe.is_some? }
[self.get] + lazys.map(&:get)
else
nil
end
}
end

def self.combine(*maybes)
first, *rest = *maybes
first.combine(*rest)
end

private

def __enumerable_value
@lazy
end

def __evaluated
@evaluated ||= Maybe(@lazy.first)
end

def rewrap(enumerable)
Maybe.new(enumerable)
end

def self.from_block(&block)
Maybe.new(lazy_enum_from_block(&block))
end

def self.lazy_enum_from_block(&block)
Enumerator.new do |yielder|
yielder << block.call
end.lazy
end
end

# Represents a non-empty value
class Some < Maybe

class SomeInnerValue

def initialize(value)
@value = value
end

def method_missing(method_sym, *args, &block)
Maybe(@value.send(method_sym, *args, &block))
end
end

def initialize(value)
@value = value
end
Expand All @@ -27,10 +108,14 @@ def get
@value
end

def or_else(*)
def get_or_else(*)
@value
end

def or_else(*)
self
end

# rubocop:disable PredicateName
def is_some?
true
Expand All @@ -47,30 +132,56 @@ def ==(other)
alias_method :eql?, :==

def ===(other)
other && other.class == self.class && @value === other.get
other && (other.class == self.class || other.class == Maybe) && @value === other.get
end

def self.===(other)
super || (other.class == Maybe && other.is_some?)
end

def combine(*maybes)
if maybes.all? { |maybe| maybe.is_some? }
Maybe([self.get] + maybes.map(&:get))
else
None()
end
end

def method_missing(method_sym, *args, &block)
map { |value| value.send(method_sym, *args, &block) }
end

def inner
SomeInnerValue.new(@value)
end

private

def __enumerable_value
[@value]
end

def rewrap(enumerable)
Maybe(enumerable.first)
end
end

# Represents an empty value
class None < Maybe
def initialize; end

def get
fail 'No such element'
end

def or_else(els = nil)
def get_or_else(els = nil)
block_given? ? yield : els
end

def or_else(els = nil, &block)
block ? block.call : els
end

# rubocop:disable PredicateName
def is_some?
false
Expand All @@ -81,10 +192,18 @@ def is_none?
end
# rubocop:enable PredicateName

def self.===(other)
super || (other.class == Maybe && other.is_none?)
end

def method_missing(*)
self
end

def combine(*)
self
end

private

def __enumerable_value
Expand All @@ -93,11 +212,16 @@ def __enumerable_value
end

# rubocop:disable MethodName
def Maybe(value)
if value.nil? || (value.respond_to?(:length) && value.length == 0)
None()
def Maybe(value = nil, &block)
if block && [].respond_to?(:lazy)
Maybe.from_block(&block)
else
Some(value)
value = block.call if block
if value.nil? || (value.respond_to?(:length) && value.length == 0)
None()
else
Some(value)
end
end
end

Expand Down
Loading