diff --git a/README.md b/README.md index 2e1fcc1..428796c 100644 --- a/README.md +++ b/README.md @@ -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" ``` @@ -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 @@ -38,14 +38,14 @@ Maybe(nil) => # 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`: @@ -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") => # +``` + +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" } => #: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` @@ -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(): @@ -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 diff --git a/lib/possibly.rb b/lib/possibly.rb index a0287a6..fd4889e 100644 --- a/lib/possibly.rb +++ b/lib/possibly.rb @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/spec/spec.rb b/spec/spec.rb index 4c5c86b..8ef1b81 100644 --- a/spec/spec.rb +++ b/spec/spec.rb @@ -83,6 +83,16 @@ expect(Maybe === Some(2)).to be true expect(Maybe === None()).to be true expect(Some === Some(6)).to be true + + expect(Some(1) === Some(1).lazy).to be true + expect(Maybe(1) === Some(2).lazy).to be false + expect(None === Some(1).lazy).to be false + expect(None === None().lazy).to be true + expect(Some((1..3)) === Some(2).lazy).to be true + expect(Some(Integer) === Some(2).lazy).to be true + expect(Maybe === Some(2).lazy).to be true + expect(Maybe === None().lazy).to be true + expect(Some === Some(6).lazy).to be true end end @@ -121,6 +131,10 @@ def test_case_when(case_value, match_value, non_match_value) odd = ->(a) { a % 2 == 1 } test_case_when(Maybe(2), Some(even), Some(odd)) end + + it "matches to lazy" do + test_case_when(Maybe { 1 }, Some(1), Some(2)) + end end describe "to array" do @@ -136,17 +150,40 @@ def test_case_when(case_value, match_value, non_match_value) end end - describe "get and or_else" do + describe "get and get_or_else" do it "get" do expect { None.get }.to raise_error expect(Some(1).get).to eql(1) end - it "or_else" do - expect(None().or_else(true)).to eql(true) - expect(None().or_else { false }).to eql(false) - expect(Some(1).or_else(2)).to eql(1) - expect(Some(1).or_else { 2 }).to eql(1) + it "get_or_else" do + expect(None().get_or_else(true)).to eql(true) + expect(None().get_or_else { false }).to eql(false) + expect(Some(1).get_or_else(2)).to eql(1) + expect(Some(1).get_or_else { 2 }).to eql(1) + end + end + + describe "or_else" do + it "returns self if it's a Some" do + current = Maybe(true) + other = Maybe(true) + + expect(current.or_else(other)).to equal current + end + + it "returns other if it's a Some" do + current = Maybe(nil) + other = Maybe(true) + + expect(current.or_else(other)).to equal other + end + + it "takes also a block" do + current = Maybe(true) + other = Maybe(true) + + expect(current.or_else { other }).to equal current end end @@ -156,4 +193,88 @@ def test_case_when(case_value, match_value, non_match_value) expect(Some([1, 2, 3]).map { |arr| arr.map { |v| v * v } }.get).to eql([1, 4, 9]) end end -end \ No newline at end of file + + describe "inner" do + it "forwards all (also Enumerable) methods" do + expect(Some(["first", "second", "third"]).inner.first.upcase.get_or_else { false }).to eql("FIRST") + expect(Some(["first", "second", "third"]).inner.map(&:upcase).get_or_else { false }).to eql(["FIRST", "SECOND", "THIRD"]) + expect(Some([]).inner.first.upcase.get_or_else { "NONE" }).to eql("NONE") + end + end + + describe "combine" do + it "combines multiple Some values" do + expect(Maybe("foo").combine(Maybe("bar")).get).to eql(["foo", "bar"]) + expect(Maybe("foo").combine(Maybe("bar"), Maybe("baz")).get).to eql(["foo", "bar", "baz"]) + expect(Maybe.combine(Maybe("foo"), Maybe("bar"), Maybe("baz")).get).to eql(["foo", "bar", "baz"]) + expect(Maybe.combine(Maybe("foo"), Maybe("bar"), Maybe("baz")).map do |(foo, bar, baz)| + "#{foo} and #{bar} and finally, #{baz}" + end.get).to eql ("foo and bar and finally, baz") + end + + it "returns None if any None" do + expect(Maybe("foo").combine(Maybe(nil)).get_or_else { false }).to eql(false) + expect(Maybe("foo").combine(Maybe(nil)) { |a, b| "#{a} + #{b}" }.get_or_else { false }).to eql(false) + expect(Maybe.combine(Maybe("foo"), Maybe(nil)).get_or_else { false }).to eql(false) + expect(Maybe.combine(Maybe("foo"), Maybe(nil)) { |a, b| "#{a} + #{b}" }.get_or_else { false }).to eql(false) + end + + it "is lazy" do + called1, called2, called3, map_called = false, false, false, false + + m = Maybe { called1 = true ; 1 }.combine(Maybe { called2 = true ; 2 }, Maybe { called3 = true ; 3 }) + + sum = m.map { |(v1, v2, v3)| map_called = true ; v1 + v2 + v3 } + + expect(called1).to eql(lazy_enabled ? false : true) + expect(called2).to eql(lazy_enabled ? false : true) + expect(called3).to eql(lazy_enabled ? false : true) + expect(map_called).to eql(lazy_enabled ? false : true) + + expect(sum.get).to eql 6 + + expect(called1).to eql(true) + expect(called2).to eql(true) + expect(called3).to eql(true) + expect(map_called).to eql(true) + end + end + + describe "laziness" do + it "can be initialized lazily" do + init_called = false + map_called = false + + m = Maybe do + init_called = true + 2 + end.map do |v| + map_called = true + v * v + end + + expect(init_called).to eql(lazy_enabled ? false : true) + expect(map_called).to eql(lazy_enabled ? false : true) + expect(m.get).to eql(4) + expect(init_called).to eql(true) + expect(map_called).to eql(true) + end + + it "can be converted to lazy" do + map_called = false + + m = Maybe(2).lazy.map do |v| + map_called = true + v * v + end + + expect(map_called).to eql(lazy_enabled ? false : true) + expect(m.get).to eql(4) + expect(map_called).to eql(true) + end + end + + def lazy_enabled + @lazy_enabled ||= [].respond_to?(:lazy) + end +end