|
| 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