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 b
For example:
println["hello"] >> println["world"]
returns a singleIO
which, when performed, will output "hello" and then "world".getln >> println
returns 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] #=> 20
Ruby Proc
s 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
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
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
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.
-
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 aString
and returns anIO 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 thatwithFile
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 anIO c
, which will eventually be returned bybracket
. 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"
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."
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 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"
-
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 anArray
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 theState
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 tof[s]
, wheref
is the given function ands
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"
-
unit :: ()
Singleton
()
value.include Rubio::Unit::Core unit.inspect #=> "()"
-
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 includingRubio::Functor::Core
.include Rubio::Maybe::Core reverse = proc(&:reverse) reverse % Just["Hello"] #=> Just "olleH" reverse % Nothing #=> Nothing
-
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
- 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 State monad to store data in memory that persists between requests