lib/concurrent-ruby/concurrent/maybe.rb
require 'concurrent/synchronization/object' module Concurrent # A `Maybe` encapsulates an optional value. A `Maybe` either contains a value # of (represented as `Just`), or it is empty (represented as `Nothing`). Using # `Maybe` is a good way to deal with errors or exceptional cases without # resorting to drastic measures such as exceptions. # # `Maybe` is a replacement for the use of `nil` with better type checking. # # For compatibility with {Concurrent::Concern::Obligation} the predicate and # accessor methods are aliased as `fulfilled?`, `rejected?`, `value`, and # `reason`. # # ## Motivation # # A common pattern in languages with pattern matching, such as Erlang and # Haskell, is to return *either* a value *or* an error from a function # Consider this Erlang code: # # ```erlang # case file:consult("data.dat") of # {ok, Terms} -> do_something_useful(Terms); # {error, Reason} -> lager:error(Reason) # end. # ``` # # In this example the standard library function `file:consult` returns a # [tuple](http://erlang.org/doc/reference_manual/data_types.html#id69044) # with two elements: an [atom](http://erlang.org/doc/reference_manual/data_types.html#id64134) # (similar to a ruby symbol) and a variable containing ancillary data. On # success it returns the atom `ok` and the data from the file. On failure it # returns `error` and a string with an explanation of the problem. With this # pattern there is no ambiguity regarding success or failure. If the file is # empty the return value cannot be misinterpreted as an error. And when an # error occurs the return value provides useful information. # # In Ruby we tend to return `nil` when an error occurs or else we raise an # exception. Both of these idioms are problematic. Returning `nil` is # ambiguous because `nil` may also be a valid value. It also lacks # information pertaining to the nature of the error. Raising an exception # is both expensive and usurps the normal flow of control. All of these # problems can be solved with the use of a `Maybe`. # # A `Maybe` is unambiguous with regard to whether or not it contains a value. # When `Just` it contains a value, when `Nothing` it does not. When `Just` # the value it contains may be `nil`, which is perfectly valid. When # `Nothing` the reason for the lack of a value is contained as well. The # previous Erlang example can be duplicated in Ruby in a principled way by # having functions return `Maybe` objects: # # ```ruby # result = MyFileUtils.consult("data.dat") # returns a Maybe # if result.just? # do_something_useful(result.value) # or result.just # else # logger.error(result.reason) # or result.nothing # end # ``` # # @example Returning a Maybe from a Function # module MyFileUtils # def self.consult(path) # file = File.open(path, 'r') # Concurrent::Maybe.just(file.read) # rescue => ex # return Concurrent::Maybe.nothing(ex) # ensure # file.close if file # end # end # # maybe = MyFileUtils.consult('bogus.file') # maybe.just? #=> false # maybe.nothing? #=> true # maybe.reason #=> #<Errno::ENOENT: No such file or directory @ rb_sysopen - bogus.file> # # maybe = MyFileUtils.consult('README.md') # maybe.just? #=> true # maybe.nothing? #=> false # maybe.value #=> "# Concurrent Ruby\n[![Gem Version..." # # @example Using Maybe with a Block # result = Concurrent::Maybe.from do # Client.find(10) # Client is an ActiveRecord model # end # # # -- if the record was found # result.just? #=> true # result.value #=> #<Client id: 10, first_name: "Ryan"> # # # -- if the record was not found # result.just? #=> false # result.reason #=> ActiveRecord::RecordNotFound # # @example Using Maybe with the Null Object Pattern # # In a Rails controller... # result = ClientService.new(10).find # returns a Maybe # render json: result.or(NullClient.new) # # @see https://hackage.haskell.org/package/base-4.2.0.1/docs/Data-Maybe.html Haskell Data.Maybe # @see https://github.com/purescript/purescript-maybe/blob/master/docs/Data.Maybe.md PureScript Data.Maybe class Maybe < Synchronization::Object include Comparable safe_initialization! # Indicates that the given attribute has not been set. # When `Just` the {#nothing} getter will return `NONE`. # When `Nothing` the {#just} getter will return `NONE`. NONE = ::Object.new.freeze # The value of a `Maybe` when `Just`. Will be `NONE` when `Nothing`. attr_reader :just # The reason for the `Maybe` when `Nothing`. Will be `NONE` when `Just`. attr_reader :nothing private_class_method :new # Create a new `Maybe` using the given block. # # Runs the given block passing all function arguments to the block as block # arguments. If the block runs to completion without raising an exception # a new `Just` is created with the value set to the return value of the # block. If the block raises an exception a new `Nothing` is created with # the reason being set to the raised exception. # # @param [Array<Object>] args Zero or more arguments to pass to the block. # @yield The block from which to create a new `Maybe`. # @yieldparam [Array<Object>] args Zero or more block arguments passed as # arguments to the function. # # @return [Maybe] The newly created object. # # @raise [ArgumentError] when no block given. def self.from(*args) raise ArgumentError.new('no block given') unless block_given? begin value = yield(*args) return new(value, NONE) rescue => ex return new(NONE, ex) end end # Create a new `Just` with the given value. # # @param [Object] value The value to set for the new `Maybe` object. # # @return [Maybe] The newly created object. def self.just(value) return new(value, NONE) end # Create a new `Nothing` with the given (optional) reason. # # @param [Exception] error The reason to set for the new `Maybe` object. # When given a string a new `StandardError` will be created with the # argument as the message. When no argument is given a new # `StandardError` with an empty message will be created. # # @return [Maybe] The newly created object. def self.nothing(error = '') if error.is_a?(Exception) nothing = error else nothing = StandardError.new(error.to_s) end return new(NONE, nothing) end # Is this `Maybe` a `Just` (successfully fulfilled with a value)? # # @return [Boolean] True if `Just` or false if `Nothing`. def just? ! nothing? end alias :fulfilled? :just? # Is this `Maybe` a `nothing` (rejected with an exception upon fulfillment)? # # @return [Boolean] True if `Nothing` or false if `Just`. def nothing? @nothing != NONE end alias :rejected? :nothing? alias :value :just alias :reason :nothing # Comparison operator. # # @return [Integer] 0 if self and other are both `Nothing`; # -1 if self is `Nothing` and other is `Just`; # 1 if self is `Just` and other is nothing; # `self.just <=> other.just` if both self and other are `Just`. def <=>(other) if nothing? other.nothing? ? 0 : -1 else other.nothing? ? 1 : just <=> other.just end end # Return either the value of self or the given default value. # # @return [Object] The value of self when `Just`; else the given default. def or(other) just? ? just : other end private # Create a new `Maybe` with the given attributes. # # @param [Object] just The value when `Just` else `NONE`. # @param [Exception, Object] nothing The exception when `Nothing` else `NONE`. # # @return [Maybe] The new `Maybe`. # # @!visibility private def initialize(just, nothing) @just = just @nothing = nothing end end end