Skip to content

Commit e1aa0ec

Browse files
committed
Initial implementation of Maybe
1 parent ea580e2 commit e1aa0ec

File tree

3 files changed

+456
-0
lines changed

3 files changed

+456
-0
lines changed

lib/concurrent.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
require 'concurrent/delay'
2020
require 'concurrent/future'
2121
require 'concurrent/ivar'
22+
require 'concurrent/maybe'
2223
require 'concurrent/mvar'
2324
require 'concurrent/promise'
2425
require 'concurrent/scheduled_task'

lib/concurrent/maybe.rb

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
module Concurrent
2+
3+
# A `Maybe` encapsulates an optional value. A `Maybe` either contains a value
4+
# of (represented as `Just`), or it is empty (represented as `Nothing`). Using
5+
# `Maybe` is a good way to deal with errors or exceptional cases without
6+
# resorting to drastic measures such as exceptions.
7+
#
8+
# `Maybe` is a replacement for the use of `nil` with better type checking.
9+
#
10+
# For compatibility with {Concurrent::Obligation} the predicate and
11+
# accessor methods are aliased as `fulfilled?`, `rejected?`, `value`, and
12+
# `reason`.
13+
#
14+
# ## Motivation
15+
#
16+
# A common pattern in languages with pattern matching, such as Erlang and
17+
# Haskell, is to return *either* a value *or* an error from a function
18+
# Consider this Erlang code:
19+
#
20+
# ```erlang
21+
# case file:consult("data.dat") of
22+
# {ok, Terms} -> do_something_useful(Terms);
23+
# {error, Reason} -> lager:error(Reason)
24+
# end.
25+
# ```
26+
#
27+
# In this example the standard library function `file:consult` returns a
28+
# [tuple](http://erlang.org/doc/reference_manual/data_types.html#id69044)
29+
# with two elements: an [atom](http://erlang.org/doc/reference_manual/data_types.html#id64134)
30+
# (similar to a ruby symbol) and a variable containing ancillary data. On
31+
# success it returns the atom `ok` and the data from the file. On failure it
32+
# returns `error` and a string with an explanation of the problem. With this
33+
# pattern there is no ambiguity regarding success or failure. If the file is
34+
# empty the return value cannot be misinterpreted as an error. And when an
35+
# error occurs the return value provides useful information.
36+
#
37+
# In Ruby we tend to return `nil` when an error occurs or else we raise an
38+
# exception. Both of these idioms are problematic. Returning `nil` is
39+
# ambiguous because `nil` may also be a valid value. It also lacks
40+
# information pertaining to the nature of the error. Raising an exception
41+
# is both expensive and usurps the normal flow of control. All of these
42+
# problems can be solved with the use of a `Maybe`.
43+
#
44+
# A `Maybe` is unambiguous with regard to whether or not it contains a value.
45+
# When `Just` it contains a value, when `Nothing` it does not. When `Just`
46+
# the value it contains may be `nil`, which is perfectly valid. When
47+
# `Nothing` the reason for the lack of a value is contained as well. The
48+
# previous Erlang example can be duplicated in Ruby in a principled way by
49+
# having functions return `Maybe` objects:
50+
#
51+
# ```ruby
52+
# result = MyFileUtils.consult("data.dat") # returns a Maybe
53+
# if result.just?
54+
# do_something_useful(result.just) # or result.value
55+
# else
56+
# logger.error(result.nothing) # or result.reason
57+
# end
58+
# ```
59+
#
60+
# @example Returning a Maybe from a Function
61+
# module MyFileUtils
62+
# def self.consult(path)
63+
# file = File.open(path, 'r')
64+
# Concurrent::Maybe.just(file.read)
65+
# rescue => ex
66+
# return Concurrent::Maybe.nothing(ex)
67+
# ensure
68+
# file.close if file
69+
# end
70+
# end
71+
#
72+
# maybe = MyFileUtils.consult('bogus.file')
73+
# maybe.just? #=> false
74+
# maybe.nothing? #=> true
75+
# maybe.nothing #=> #<Errno::ENOENT: No such file or directory @ rb_sysopen - bogus.file>
76+
#
77+
# maybe = MyFileUtils.consult('README.md')
78+
# maybe.just? #=> true
79+
# maybe.nothing? #=> false
80+
# maybe.just #=> "# Concurrent Ruby\n[![Gem Version..."
81+
#
82+
# @example Using Maybe with a Block
83+
# result = Concurrent::Maybe.from do
84+
# Client.find(10) # Client is an ActiveRecord model
85+
# end
86+
#
87+
# # -- if the record was found
88+
# result.just? #=> true
89+
# result.just #=> #<Client id: 10, first_name: "Ryan">
90+
#
91+
# # -- if the record was not found
92+
# result.just? #=> false
93+
# result.nothing #=> ActiveRecord::RecordNotFound
94+
#
95+
# @example Using Maybe with the Null Object Pattern
96+
# # In a Rails controller...
97+
# result = ClientService.new(10).find # returns a Maybe
98+
# render json: result.or(NullClient.new)
99+
#
100+
# @see https://hackage.haskell.org/package/base-4.2.0.1/docs/Data-Maybe.html Haskell Data.Maybe
101+
# @see https://github.com/purescript/purescript-maybe/blob/master/docs/Data.Maybe.md PureScript Data.Maybe
102+
class Maybe
103+
include Comparable
104+
105+
# Indicates that the given attribute has not been set.
106+
# When `Just` the {#nothing} getter will return `NONE`.
107+
# When `Nothing` the {#just} getter will return `NONE`.
108+
NONE = Object.new.freeze
109+
110+
# The value of a `Maybe` when `Just`. Will be `NONE` when `Nothing`.
111+
attr_reader :just
112+
113+
# The reason for the `Maybe` when `Nothing`. Will be `NONE` when `Just`.
114+
attr_reader :nothing
115+
116+
private_class_method :new
117+
118+
# Create a new `Maybe` using the given block.
119+
#
120+
# Runs the given block passing all function arguments to the block as block
121+
# arguments. If the block runs to completion without raising an exception
122+
# a new `Just` is created with the value set to the return value of the
123+
# block. If the block raises an exception a new `Nothing` is created with
124+
# the reason being set to the raised exception.
125+
#
126+
# @param [Array<Object>] args Zero or more arguments to pass to the block.
127+
# @yield The block from which to create a new `Maybe`.
128+
# @yieldparam [Array<Object>] args Zero or more block arguments passed as
129+
# arguments to the function.
130+
#
131+
# @return [Maybe] The newly created object.
132+
#
133+
# @raise [ArgumentError] when no block given.
134+
def self.from(*args)
135+
raise ArgumentError.new('no block given') unless block_given?
136+
begin
137+
value = yield(*args)
138+
return new(value, NONE)
139+
rescue => ex
140+
return new(NONE, ex)
141+
end
142+
end
143+
144+
# Create a new `Just` with the given value.
145+
#
146+
# @param [Object] value The value to set for the new `Maybe` object.
147+
#
148+
# @return [Maybe] The newly created object.
149+
def self.just(value)
150+
return new(value, NONE)
151+
end
152+
153+
# Create a new `Nothing` with the given (optional) reason.
154+
#
155+
# @param [Exception] error The reason to set for the new `Maybe` object.
156+
# When given a string a new `StandardError` will be created with the
157+
# argument as the message. When no argument is given a new
158+
# `StandardError` with an empty message will be created.
159+
#
160+
# @return [Maybe] The newly created object.
161+
def self.nothing(error = '')
162+
if error.is_a?(Exception)
163+
nothing = error
164+
else
165+
nothing = StandardError.new(error.to_s)
166+
end
167+
return new(NONE, nothing)
168+
end
169+
170+
# Is this `Maybe` a `Just` (successfully fulfilled with a value)?
171+
#
172+
# @return [Boolean] True if `Just` or false if `Nothing`.
173+
def just?
174+
! nothing?
175+
end
176+
alias :fulfilled? :just?
177+
178+
# Is this `Maybe` a `nothing` (rejected with an exception upon fulfillment)?
179+
#
180+
# @return [Boolean] True if `Nothing` or false if `Just`.
181+
def nothing?
182+
@nothing != NONE
183+
end
184+
alias :rejected? :nothing?
185+
186+
alias :value :just
187+
188+
alias :reason :nothing
189+
190+
# Comparison operator.
191+
#
192+
# @return [Integer] 0 if self and other are both `Nothing`;
193+
# -1 if self is `Nothing` and other is `Just`;
194+
# 1 if self is `Just` and other is nothing;
195+
# `self.just <=> other.just` if both self and other are `Just`.
196+
def <=>(other)
197+
if nothing?
198+
other.nothing? ? 0 : -1
199+
else
200+
other.nothing? ? 1 : just <=> other.just
201+
end
202+
end
203+
204+
# Return either the value of self or the given default value.
205+
#
206+
# @return [Object] The value of self when `Just`; else the given default.
207+
def or(other)
208+
just? ? just : other
209+
end
210+
211+
private
212+
213+
# Create a new `Maybe` with the given attributes.
214+
#
215+
# @param [Object] just The value when `Just` else `NONE`.
216+
# @param [Exception, Object] nothing The exception when `Nothing` else `NONE`.
217+
#
218+
# @return [Maybe] The new `Maybe`.
219+
#
220+
# @!visibility private
221+
def initialize(just, nothing)
222+
@just = just
223+
@nothing = nothing
224+
end
225+
end
226+
end

0 commit comments

Comments
 (0)