Project

yopt

0.0
No commit activity in last 3 years
No release in over 3 years
The Option type models the possible absence of a value. It lets us deal with the uncertainty related to such a value being there without having to resort to errors or conditional blocks.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

~> 1.10
~> 5.8
~> 10.0
 Project Readme

Gem Version Build Status Coverage Status

Yopt

A Scala inspired gem that introduces Options to Ruby while aiming for an idiomatic API.

Installation

Add this line to your application's Gemfile:

gem 'yopt'

And then execute:

$ bundle

Or install it yourself as:

$ gem install yopt

Basic usage

The Option type models the possible absence of a value. It lets us deal with the uncertainty related to such a value being there without having to resort to errors or conditional blocks.

Instances of Option are either an instance of Yopt::Some - meaning the option contains a value - or the object Yopt::None - meaning the option is empty.

require 'yopt'

some = Yopt::Some.new(42)
none = Yopt::None

We can access and manipulate the optional value by passing a block to Option#map.

some.map {|value| value + 2} # returns Some(44)
none.map {|value| value + 2} # returns None

When we are not interested in the result of a computation on the optional value, it is a good practice to use Option#each rather than Option#map. That will make our intention clearer.

some.each {|value| puts value} # prints 42 and returns `some`
none.each {|value| puts value} # does not print anything and returns `none`

Notice that #each returns the calling option thus supporting method chaining.

We can safely retrieve the optional value by passing a default value to Option#get_or_else

some.get_or_else {0} # returns 42
none.get_or_else {0} # returns 0

Notice how we are passing a block rather than an argument. This makes the evaluation of the default value lazy. In other words the block will only be evaluated if the caller in None.

This gives us the possibility to react in a special way to a None value without breaking the API fluency, e.g.

opt.each {|v| function_with_side_effects(v)}.get_or_else {log_failure}

We can also filter the optional value depending on how it evaluates against a block via Option#select

some.select {|value| value < 0} # returns None
none.select {|value| value < 0} # returns None
some.select {|value| value > 0} # returns Some(42)

We can easily turn any object into an Option by means of Option.call - aliased to Option[] for convenience. For instance, this is useful when dealing with functions that might return nil to express the absence of a result.

Yopt::Option[nil] # returns None
Yopt::Option[42] # returns Some(42)

A combination of the few methods just introduced already allows us to implement some pretty interesting logic. Checkout basics.rb in the docs folder to get some inspiration.

Why opt?

Using Options reduces the amount of branching in our code and lets us deal with exceptional cases in a seamless way. No more check-for-nil, no more rescue blocks, just plain and simple data transformation.

It also makes our code safer by treating the absence of something like a fully fledged object, and enables us to use the Null Object Pattern everywhere we want without the overhead of having to write specialized Null-type classes for different classes.

Advanced Usage

#reduce

Given an Option opt, a value c and a lambda f,

opt.reduce(c, &f)

returns c if opt is None, and f.(c, opt) otherwise.

This is a shortcut to

opt.map{|v| f.(c,v)}.get_or_else {c}`

#flatten and #flat_map

When working with functions returning Option, we might end up dealing with nested options...

maybe_sqrt = lambda {|x| Yopt::Option[x >= 0 ? Math.sqrt(x) : nil]}
maybe_increment = lambda {|x| Yopt::Option[x > 1  ? x + 1 : nil]}

maybe_sqrt.(4).map {|v| maybe_increment.(v)} # Some(Some(3.0))
maybe_sqrt.(1).map {|v| maybe_increment.(v)} # Some(None)

Usually, this is not what we want, so we call Option#flatten on the result

maybe_sqrt.(4).map {|v| maybe_increment.(v)}.flatten # Some(3.0)
maybe_sqrt.(1).map {|v| maybe_increment.(v)}.flatten # None

Option#flat_map combines the two calls into one

maybe_sqrt.(4).flat_map {|v| maybe_increment.(v)} # Some(3.0)
maybe_sqrt.(1).flat_map {|v| maybe_increment.(v)} # None

A difference to keep in mind is that #flatten will raise an error if the wrapped value does not respond to #to_ary

Yopt.Option[42].flatten # raises TypeError: Argument must be an array-like object. Found Fixnum

whereas #flat_map behaves like #map when the passed block does not return an array-like value

Yopt.Option[42].flat_map{|v| v} # returns Some(42)

#zip

When dealing with a set of Option instances, we might want to ensure that they are all defined - i.e. not empty - before continuing a computation...

email_opt.each(&send_pass_recovery) unless (email_opt.empty? or captcha_opt.empty?)

We can avoid empty? checks by using Option#zip

email_opt.zip(captcha_opt).each{|(email,_)| send_pass_recovery(email)}

Option#zip returns None if any of the arguments is None or if the caller is None

Yopt::None.zip Option[42] # None
Option[42].zip Yopt::None # None
Option[42].zip Option[0], Yopt::None, Option[-1] # None

When both the caller and all the arguments are defined then zip collects all the values in an Array wrapped in a Yopt::Some

Option[42].zip Option[0], Option["str"] # Some([42, 0, "str"])

#or_else

When writing an application whose functionality depends on the availability of other services/information, it's a good idea to prepare for the scenario where such services/information are not available.

#or_else lets us define a chain of block calls that will stop as soon as one of them returns a non-empty option.

get_postcode = -> email do
  get_from_cache[email]
    .or_else { get_from_db[email] }
    .or_else { get_from_remote[email] }
end

#grep

We often find ourselves filtering data before applying a transformation...

opt.select {|v| (1...10).include? v}.map {|v| v + 1}

In this scenario, Option#grep can sometimes make the code more concise

opt.grep(1...10) {|v| v + 1}

Option#grep supports lambdas as well

is_positive = lambda {|x| x > 0}

opt.grep(is_positive) {|v| Math.log(v)}
# is equivalent to
opt.select(&is_positive).map {|v| Math.log(v)}

Haskell Data.Maybe cheat sheet

Some (None?) might enjoy a comparison with Haskell's Maybe. Here is how the Data.Maybe API translate to Yopt.

maybe default f opt     -> opt.map(&f).get_or_else {default}
isJust opt              -> not opt.empty?
isNothing opt           -> opt.empty?
fromJust opt            -> opt.get
fromMaybe default opt   -> opt.get_or_else {default}
listToMaybe list        -> Option.ary_to_type list
maybeToList opt         -> opt.to_a
catMaybes listOfOptions -> listOfOptions.flatten
mapMaybe f list         -> list.flat_map &f

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/lbarasti/yopt. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.