/rubio

Write pure, functional code that encapsulates side effects using the IO monad (and friends) in Ruby

Primary LanguageRubyThe UnlicenseUnlicense

Rubio

GitHub tag (latest SemVer) Build Status Maintainability Coverage Status

Write pure, functional code that encapsulates side effects using the IO monad (and friends) in Ruby.

Contents

1. Installation

Add the following line to your Gemfile.

gem "rubio", github: "12joan/rubio"

2. Usage and syntax

2.1 main :: IO

All programs written using Rubio are encouraged to construct and then invoke a main value which describes the entire behaviour of the program. IO#perform! is the equivalent of unsafePerformIO in Haskell, and must be explicitly called at the bottom of the program in order for the program to run.

require "rubio"

include Rubio::IO::Core

# agree :: String -> String
agree = ->(favourite) {
  "I like the #{favourite.chomp} monad too! 🙂"
}

# main :: IO
main = println["What's your favourite monad?"] >> getln >> (println << agree)
main.perform!

Rubio::IO::Core provides a number of standard IO operations, such as println and getln.

2.2 >> operator for monads

Monads are "composed" using the >> (bind) operator. Note that unlike in Haskell, >> behaves differently depending on whether the right operand is a function or a monad.

-- When the right operand is a function (equivalent to (>>=) in Haskell)
(>>) :: Monad m => m a -> (a -> m b) -> m b

-- When the right operand is a monad (equivalent to (>>) in Haskell)
(>>) :: Monad m => m a -> m b -> m b

For example:

  • println["hello"] >> println["world"] returns a single IO which, when performed, will output "hello" and then "world".
  • getln >> println returns an IO which prompts for user input (as per Kernel#gets) and passes the result to println.
  • getln >> ->(x) { println[x] } is equivalent to getln >> println.

Note that whereas getln is a value of type IO, println is a function of type String -> IO.

2.3 Function composition

Ruby Procs can be composed using the built-in >> and << operators.

add10 = ->(x) { x + 10 }
double = ->(x) { x * 2 }

add10_and_double = double << add10
double_and_add10 = double >> add10

add10_and_double[5] #=> 30
double_and_add10[5] #=> 20

2.4 Partially applying functions in Ruby

Ruby Procs are not curried by default. In order to partially apply a function, you must first call Proc#curry on it.

add = ->(x, y) {
  x + y
}.curry

add[6, 4] #=> 10
add[6][4] #=> 10

add10 = add[10] 

add10[5] #=> 15

2.5 % operator

Rubio monkey patches the % operator, which is an alias for fmap, onto Proc and Method.

include Rubio::Maybe::Core

reverse = proc(&:reverse)

reverse % Just["Hello"] #=> Just "olleH"
reverse % Nothing #=> Nothing

2.6 Expose/extend pattern

For a function or value defined in a module to be "includable" (either with include or extend), it must be wrapped inside a method.

module SomeStandardFunctions
  # not includable
  add = ->(x, y) { x + y }
  
  # includable
  def multiply
    ->(x, y) { x * y }
  end
end

module SomewhereElse
  extend SomeStandardFunctions
  
  add[4, 6] #=> NameError (undefined local variable or method `add' for SomewhereElse:Module)
  
  multiply[4, 6] #=> 24
end

To make the syntax for this nicer, Rubio provides the Rubio::Expose module.

module SomeStandardFunctions
  extend Rubio::Expose
  
	expose :add,      ->(x, y) { x + y }
  expose :multiply, ->(x, y) { x * y }
end

module SomewhereElse
  extend SomeStandardFunctions
  
  add[4, 6] #=> 10
  multiply[4, 6] #=> 24
end

3. Built-in modules

3.1 Rubio::IO::Core

3.1.1 Limitations

Currently, the Rubio::IO::Core module provides a limited subset of the functionality available via calling methods on Kernel. Custom IO operations can be defined as follows.

# runCommand :: String -> IO String
runCommand = ->(cmd) {
  Rubio::IO.new { `#{cmd}` }
}

Because of the way in which the >> operator is evaluated, extremely long chains of operations are likely to result in a SystemStackError. In practice, this tends to happen only when long or infinite loops are constructed.

3.1.2 Built-in functions

  • pureIO :: a -> IO a

    Wraps a value in the IO monad.

    include Rubio::IO::Core
    
    # io :: IO Integer
    io = pureIO[5]
    io.perform! #=> 5

    Useful for adhering to the contract of the bind operator. In the example below, the anonymous function defined on input takes a String and returns an IO String.

    include Rubio::IO::Core
    
    main = getln >> ->(input) { pureIO[input.reverse] } >> println
    main.perform!
  • println :: String -> IO

    Encapsulates Kernel#puts.

    include Rubio::IO::Core
    
    main = println["Hello world!"]
    main.perform!
    include Rubio::IO::Core
    
    main = pureIO["This works too!"] >> println
    main.perform!
  • getln :: IO

    Encapsulates Kernel#gets.

    include Rubio::IO::Core
    
    doSomethingWithUserInput = ->(input) {
      println["You just said: #{input}"]
    }
    
    main = getln >> doSomethingWithUserInput
    main.perform!
  • openFile :: String -> String -> IO File

    Encapsulates Kernel#open. First argument is the path to the file; second argument is the mode.

    include Rubio::IO::Core
    
    # io :: IO File
    io = openFile["README.md", "r"]
    io.perform! #=> #<File:README.md>
    require "open-uri"
    
    include Rubio::IO::Core
    
    main = openFile["https://ifconfig.me"]["r"] >> ->(handle) {
      readFile[handle] >> println >> hClose[handle]
    }
    
    main.perform! #=> "216.58.204.5"
  • hClose :: File -> IO

    Encapsulates File#close. Note that withFile is generally preferred.

  • readFile :: File -> IO String

    Encapsulates File#read.

    include Rubio::IO::Core
    
    # ...
    # someFile :: IO File
    
    main = someFile >> readFile >> println
    main.perform! #=> "Contents of someFile"
  • bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

    Pattern to automatically acquire a resource, perform a computation, and then release the resource.

    The first argument is performed to acquire the resource of type a. The resource is then passed to the third argument. This returns an IO c, which will eventually be returned by bracket. Finally, the second argument is called to release the resource.

    This pattern is used by withFile to automatically close the file handle.

    include Rubio::IO::Core
    
    # withFile :: String -> String -> (File -> IO a) -> IO a
    withFile = ->(path, mode) {
      bracket[ openFile[path, mode] ][ hClose ]
    }.curry
    
    withFile["README.md", "r"][readFile] #=> IO String
  • withFile :: String -> String -> (File -> IO a) -> IO a

    Acquires a file handle, performs a computation, and then closes the file handle.

    require "open-uri"
    
    include Rubio::IO::Core
    
    main = withFile["https://ifconfig.me"]["r"][readFile] >> println
    main.perform! #=> "216.58.204.5"

3.2 Rubio::Maybe::Core

3.2.1 Unwraping Maybe a -> a

3.2.1.1 Ruby < 2.7

Maybe#get! will return x in the case of Just[x], or nil in the case of Nothing.

If you call get!, you should explicitly handle the case where get! returns nil.

include Rubio::Maybe::Core

doSomethingWithMaybe = ->(maybe) {
  case
  when x = maybe.get!
    "You got #{x}!"
  else
    "You got nothing."
  end
}

doSomethingWithMaybe[ Just["pattern matching"] ] #=> "You got pattern matching!"
doSomethingWithMaybe[ Nothing ] #=> "You got nothing."
include Rubio::Maybe::Core

orEmptyString = ->(maybe) {
  maybe.get! || ""
}

orEmptyString[ Just["hello"] ] #=> "hello"
orEmptyString[ Nothing ] #=> ""

Note that if x is a "falsey" value, such as false or nil, you must explicitly check for Rubio::Maybe::JustClass or Rubio::Maybe::NothingClass.

include Rubio::Maybe::Core

doSomethingWithMaybe = ->(maybe) {
  case maybe
  when Rubio::Maybe::JustClass
    "You got #{maybe.get!}!"
  when Rubio::Maybe::NothingClass
    "You got nothing."
  end
}

doSomethingWithMaybe[ Just[false] ] #=> "You got false!"
doSomethingWithMaybe[ Nothing ] #=> "You got nothing."
3.2.1.2 Ruby >= 2.7

Ruby 2.7 introduces support for pattern matching, which allows for much nicer syntax when working with Maybe.

include Rubio::Maybe::Core

doSomethingWithMaybe = ->(maybe) {
  case maybe
  in Just[x]
    "You got #{x}!"
  in Nothing
    "You got nothing."
  end
}

doSomethingWithMaybe[ Just["even better pattern matching"] ] #=> "You got even better pattern matching!"
doSomethingWithMaybe[ Nothing ] #=> "You got nothing."

3.2.2 Built-in functions

  • Just :: a -> Maybe a

    Constructs a Just value for the given argument.

    include Rubio::Maybe::Core
    
    maybe = Just[5]
    maybe.inspect #=> "Just 5"
  • Nothing :: Maybe

    Singleton Nothing value.

    include Rubio::Maybe::Core
    
    divide = ->(x, y) {
      if y == 0
        Nothing
      else
        Just[x / y]
      end
    }.curry
    
    divide[12, 2] #=> Just 6
    divide[12, 0] #=> Nothing
    
    double = ->(x) { x * 2 }
    
    double % divide[12, 2] #=> Just 12
    double % divide[12, 0] #=> Nothing
  • pureMaybe :: a -> Maybe a

    Alias for Just.

    include Rubio::Maybe::Core
    
    maybe1 = Just[5]
    maybe1.inspect #=> "Just 5"
    
    maybe2 = pureMaybe[5]
    maybe2.inspect #=> "Just 5"

3.3 Rubio::State::Core

3.3.1 Built-in functions

  • State :: (s -> (a, s)) -> State s a

    Constructs a State object with the given function. Note that since Ruby does not support tuples, you are expected to use an Array as the return value of the function.

    include Rubio::State::Core
    include Rubio::Unit::Core
    
    # push :: a -> State [a] ()
    push = ->(x) { State[
      ->(xs) { [unit, [x] + xs] }
    ]}
    
    # pop :: State [a] a
    pop = State[
      ->(xs) { [ xs.first, xs.drop(1) ] }
    ]
    
    # pop :: State [a] a
    complexOperation = push[1] >> push[2] >> push[3] >> pop
    
    runState[ complexOperation ][ [10, 11] ] #=> [3, [2, 1, 10, 11]]

    Often, composing State objects using the functions listed below is preferable to calling the State constructor directly.

  • pureState :: a -> State s a

    Constructs a State object which sets the result and leaves the state unchanged.

    include Rubio::State::Core
    
    # operation :: State s Integer
    operation = pureState[123]
    runState[ operation ][ "initial state" ] #=> [123, "initial state"]
  • get :: State s s

    A State object that sets the result equal to the state.

    include Rubio::State::Core
    
    # operation1 :: State s Integer
    operation1 = pureState[123]
    runState[ operation1 ][ "initial state" ] #=> [123, "initial state"]
    
    # operation2 :: State s s
    operation2 = pureState[123] >> get
    runState[ operation2 ][ "initial state" ] #=> ["initial state", "initial state"]

    Often used to retrieve the current state just before >>.

    include Rubio::State::Core
    
    # operation :: State [a] [a]
    operation = get >> ->(state) {
      pureState[state.reverse]
    }
    
    runState[ operation ][ "initial state" ] #=> ["etats laitini", "initial state"]
  • put :: s -> State s ()

    Constructs a State object which sets the state.

    include Rubio::State::Core
    
    # operation :: State String ()
    operation = put["new state"]
    runState[ operation ][ "initial state" ] #=> [(), "new state"]
  • modify :: (s -> s) -> State s ()

    Constructs a State object which applies the function to the state.

    include Rubio::State::Core
    
    # reverse :: [a] -> [a]
    reverse = proc(&:reverse)
    
    # operation :: State [a] ()
    operation = modify[reverse]
    runState[ operation ][ "initial state" ] #=> [(), "etats laitini"]
  • gets :: (s -> a) -> State s a

    Constructs a State object that sets the result equal to f[s], where f is the given function and s is the state.

    include Rubio::State::Core
    
    # count :: [a] -> Integer
    count = proc(&:count)
    
    # operation :: State [a] Integer
    operation = gets[count]
    runState[ operation ][ [1, 2, 3, 4, 5] ] #=> [5, [1, 2, 3, 4, 5]]
  • runState :: State s a -> s -> (a, s)

    Runs a State object against an initial state. Returns a tuple containing the final result and the final state.

    include Rubio::State::Core
    
    # push :: a -> State [a] ()
    push = ->(x) {
      modify[ ->(xs) {
        [x] + xs
      }]
    }
    
    head = proc(&:first)
    tail = ->(xs) { xs.drop(1) }
    
    # pop :: State [a] a
    pop = gets[head] >> ->(x) {
      modify[tail] >> pureState[x]
    }
    
    # operation :: State [a] ()
    operation = pop >> ->(a) {
      pop >> ->(b) {
        push[a] >> push[b]
      }
    }
    
    runState[ operation ][ [1, 2, 3, 4] ] #=> [(), [2, 1, 3, 4]]
  • evalState :: State s a -> s -> a

    As per runState, except it only returns the final result.

    include Rubio::State::Core
    
    # operation :: State String String
    operation = put["final state"] >> pureState["final result"]
    evalState[ operation ][ "initial state" ] #=> "final result"
  • execState :: State s a -> s -> s

    As per runState, except it only returns the final state.

    include Rubio::State::Core
    
    # operation :: State String String
    operation = put["final state"] >> pureState["final result"]
    execState[ operation ][ "initial state" ] #=> "final state"

3.4 Rubio::Unit::Core

3.4.1 Built-in functions

  • unit :: ()

    Singleton () value.

    include Rubio::Unit::Core
    
    unit.inspect #=> "()"

3.5 Rubio::Functor::Core

3.5.1 Built-in functions

  • fmap :: Functor f => (a -> b) -> f a -> f b

    Calls fmap on the second argument with the given function.

    include Rubio::Functor::Core
    include Rubio::IO::Core
    
    io1 = pureIO["Hello"]
    
    reverse = proc(&:reverse)
    
    io2 = fmap[reverse][io1]
    io2.perform! #=> "olleH"
    include Rubio::Functor::Core
    include Rubio::Maybe::Core
    
    reverse = proc(&:reverse)
    
    fmap[reverse][ Just["Hello"] ] #=> Just "olleH"
    fmap[reverse][ Nothing ] #=> Nothing
    include Rubio::Functor::Core
    
    CustomType = Struct.new(:value) do
      def fmap(f)
        self.class.new( f[value] )
      end
    end
    
    obj = CustomType.new("Hello")
    
    reverse = proc(&:reverse)
    
    fmap[reverse][obj] #=> #<struct CustomType value="olleH">

    Note that the infix % operator can also be used without including Rubio::Functor::Core.

    include Rubio::Maybe::Core
    
    reverse = proc(&:reverse)
    
    reverse % Just["Hello"] #=> Just "olleH"
    reverse % Nothing #=> Nothing

3.6 Rubio::Expose

3.6.1 Methods

  • expose(method_name, value) -> value

    Defines a getter method for the given value.

    module StdMath
      extend Rubio::Expose
      
      add = ->(x, y) { x + y }.curry
      instance_methods.include?(:add) #=> false
      
      expose :add, add
      instance_methods.include?(:add) #=> true
    end
    
    include StdMath
    
    add[3, 4] #=> 7

4. Examples