Write pure, functional code that encapsulates side effects using the IO monad (and friends) in Ruby.
- 1. Installation
- 2. Usage and syntax
- 3. Built-in modules
- 4. Examples
Add the following line to your Gemfile.
gem "rubio", github: "12joan/rubio"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.
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 bFor example:
println["hello"] >> println["world"]returns a singleIOwhich, when performed, will output "hello" and then "world".getln >> printlnreturns an IO which prompts for user input (as perKernel#gets) and passes the result toprintln.getln >> ->(x) { println[x] }is equivalent togetln >> println.
Note that whereas getln is a value of type IO, println is a function of type String -> IO.
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] #=> 20Ruby 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] #=> 15Rubio 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 #=> NothingFor 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 endTo 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 endCurrently, 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}` } }-
pureIO :: a -> IO aWraps a value in the
IOmonad.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
inputtakes aStringand returns anIO String.include Rubio::IO::Core main = getln >> ->(input) { pureIO[input.reverse] } >> println main.perform!
-
println :: String -> IOEncapsulates
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 :: IOEncapsulates
Kernel#gets.include Rubio::IO::Core doSomethingWithUserInput = ->(input) { println["You just said: #{input}"] } main = getln >> doSomethingWithUserInput main.perform!
-
openFile :: String -> String -> IO FileEncapsulates
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 -> IOEncapsulates
File#close. Note thatwithFileis generally preferred. -
readFile :: File -> IO StringEncapsulates
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 cPattern 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 anIO c, which will eventually be returned bybracket. Finally, the second argument is called to release the resource.This pattern is used by
withFileto 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 aAcquires 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"
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."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."Alternatively, Maybe#get_or_else(y) will return x in the case of Just[x], or y in the case of Nothing.
include Rubio::Maybe::Core orEmptyString = ->(maybe) { maybe.get_or_else("") } orEmptyString[ Just["hello"] ] #=> "hello" orEmptyString[ Nothing ] #=> ""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."-
Just :: a -> Maybe aConstructs a
Justvalue for the given argument.include Rubio::Maybe::Core maybe = Just[5] maybe.inspect #=> "Just 5"
-
Nothing :: MaybeSingleton
Nothingvalue.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 aAlias for
Just.include Rubio::Maybe::Core maybe1 = Just[5] maybe1.inspect #=> "Just 5" maybe2 = pureMaybe[5] maybe2.inspect #=> "Just 5"
Rubio defines to_maybe on Object and NilClass.
hash = { a: 1, b: 2, c: 3 } maybe1 = hash[:a].to_maybe maybe1.inspect #=> "Just 1" maybe2 = hash[:e].to_maybe maybe2.inspect #=> "Nothing"-
State :: (s -> (a, s)) -> State s aConstructs a
Stateobject with the given function. Note that since Ruby does not support tuples, you are expected to use anArrayas 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
Stateobjects using the functions listed below is preferable to calling theStateconstructor directly. -
pureState :: a -> State s aConstructs a
Stateobject 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 sA
Stateobject 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
Stateobject 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
Stateobject 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 aConstructs a
Stateobject that sets the result equal tof[s], wherefis the given function andsis 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
Stateobject 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 -> aAs 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 -> sAs 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"
-
StateIO :: (s -> IO (a, s)) -> StateIO s IO aIOvariety ofState.include Rubio::State::Core include Rubio::IO::Core operation = StateIO[ ->(s) { println["The current state is #{s.inspect}"] >> pureIO[ ["result", s.reverse] ] } ] io = runStateT[operation][ [1, 2, 3] ] #=> IO io.perform! # The current state is [1, 2, 3] #=> ["result", [3, 2, 1]]
-
liftIO :: IO a -> StateIO s IO aLift an
IOinto theStateIOmonad. Useful for performingIOoperations during a computation.include Rubio::State::Core include Rubio::IO::Core operation = (liftIO << println)["Hello world!"] io = execStateT[operation][ [1, 2, 3] ] #=> IO io.perform! # Hello world! #=> [3, 2, 1]
-
pureStateIO :: a -> StateIO s IO aIOvariety ofpureState. -
getIO :: StateIO s IO sIOvariety ofget. -
putIO :: s -> StateIO s IO ()IOvariety ofput. -
modifyIO :: (s -> s) -> StateIO s IO ()IOvariety ofmodify. -
getsIO :: (s -> a) -> StateIO s aIOvariety ofgets. -
runStateT -> StateIO s IO a -> s -> IO (a, s)IOvariety ofrunState.include Rubio::State::Core include Rubio::IO::Core operation = putIO["final state"] >> pureStateIO["final result"] io = runStateT[ operation ][ "initial state" ] #=> IO io.perform! #=> ["final result", "final state"]
-
evalStateT :: StateIO s IO a -> s -> IO aIOvariety ofevalState.include Rubio::State::Core include Rubio::IO::Core operation = putIO["final state"] >> pureStateIO["final result"] io = evalStateT[ operation ][ "initial state" ] #=> IO io.perform! #=> "final result"
-
execStateT :: StateIO s IO a -> s -> IO sIOvariety ofexecState.include Rubio::State::Core include Rubio::IO::Core operation = putIO["final state"] >> pureStateIO["final result"] io = execStateT[ operation ][ "initial state" ] #=> IO io.perform! #=> "final state"
-
unit :: ()Singleton
()value.include Rubio::Unit::Core unit.inspect #=> "()"
-
fmap :: Functor f => (a -> b) -> f a -> f bCalls
fmapon 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 includingRubio::Functor::Core.include Rubio::Maybe::Core reverse = proc(&:reverse) reverse % Just["Hello"] #=> Just "olleH" reverse % Nothing #=> Nothing
-
expose(method_name, value) -> valueDefines 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
- examples/fizz_buzz.rb - Examples of basic functional programming, basic IO, Maybe, fmap
- examples/repl.rb and examples/whileM.rb - Custom includable modules, looping, more nuanced use of IO
- examples/ruby_2.7_pattern_matching.rb - Using Maybe with the experimental pattern matching syntax in Ruby 2.7
- examples/rackio/ - A small Rack application built using Rubio; uses the StateIO monad to store data in memory that persists between requests