230 lines
8.0 KiB
Ruby
230 lines
8.0 KiB
Ruby
require 'concurrent/synchronization'
|
|
|
|
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
|