FP in Ruby

Andrew McCluskey

@ajmacca

data61

What is FP?

It's programming with functions

What's a function?

 

Mapping of inputs to outputs

Consequences: referential transparency / purity

Referential transparency

Replacing any call to a function with the function's return value results in a program with identical behavior.

def add(a, b)
  a + b
end

def lyf
  2 * add(18, 3)
end

def add(a, b)
  a + b
end

def lyf
  2 * 21
end

def add(a, b)
  # HERE BE EFFECTS!
  puts "Adding #{a} and #{b} and #{@c}"
  a + b + @c
end

def lyf
  2 * add(18, 3)
end

Why is referential transparency desirable?

  • Easy to reason about code
  • Easy to test
  • Fearless code changes

What am I giving up with referential transparency?

  • Mutation
  • Free variables
  • Familiarity

What am I not giving up?

  • State
  • IO
  • Anything you need to write great software

Our programs don't execute side effects, they produce computations as pure values that are executed by a runtime system.

def main(args)
  <<-MAIN
  num_echoes = #{args[0]}
  loop do
    s = $stdin.gets
    break if s.chomp == 'q'
    puts(s * num_echoes)
  end
  MAIN
end

eval(main(ARGV))

main = do
  args <- getArgs
  let
    e s =
      error $ "Unable to parse '" ++ s ++ "' as Int"
    go' =
      case args of
        (s:_) -> either (const (e s)) go $ readEither s
        _     -> error "Expected at least one arg"
    go _ "q" = pure ()
    go n s   = do
      (() <$) . replicateM n . putStrLn $ s
      getLine >>= go n
  getLine >>= go'

Types

Why are types desirable?

  • Allow you to more precisely represent the intentions of the programmer
  • Catch many errors before runtime
  • Eliminate need for many tests
  • Support you in writing complex code

data Vehicle = Car
             | Bicycle
             | MotorBike
             
describeVehicle :: Vehicle -> String
describeVehicle Car =
  "Closed in people container on 3 or more wheels"
describeVehicle Bicycle =
  "Person powers two wheels with pedals"
describeVehicle MotorBike =
  "Two wheels powered by a motor"

def describe_vehicle(v)
  case v
  when :car
    "Closed in people container on 3 or more wheels"
  when :bicycle
    "Person powers two wheels with pedals"
  when :motorbike
    "Two wheels powered by a motor"
  else
    raise "I don't know how to describe a '#{v}'"
  end
end

Types aren't bad - type systems are sometimes bad.

Functions are values

  • functions are first class - can be treated like any other value
  • functions that take/return functions are higher order

Abstraction

FP in Ruby

Higher order functions

Higher order functions are essential to FP. Ruby doesn't quite support them, but we can approximate them with blocks/Proc.

def map_block(a)
  b = []
  for x in a
    b << (yield x)
  end

  b
end

a = [1,2,3]
b = map(a) { |n| n * 3 }
# b == [3,6,9]

def map_block(a)
  b = []
  for x in a
    b << (yield x)
  end

  b
end

a = [1,2,3]
f = Proc.new { |n| n * 3 }
b = map(a, &f)
# b == [3,6,9]

def map_block_arg(a, &f)
  b = []
  for x in a
    b << f.call(x)
  end

  b
end

a = [1,2,3]
b = map(a) { |n| n * 3 }
# b == [3,6,9]

def map_proc(a, f)
  b = []
  for x in a
    b << f.call(x)
  end

  b
end

a = [1,2,3]
f = Proc.new { |n| n * 3 }
b = map_proc(a, f)
# b = [3,6,9]

Symbol#to_proc for instance methods

# You wrote:
[1,2,3].map { |n| n.to_s }

# Why not:
[1,2,3].map(&:to_s)
  • & prefix calls #to_proc
  • #to_proc on symbols returns a Proc that executes the named instance method

Referential transparency / purity

  • Immutability
  • No free variables
  • No side effects

Avoid methods with ! suffix.

# Unneccessary mutation
things = ['keyboard', 'mouse', 'display']
things.map! { |s| s.upcase }
# things == ['KEYBOARD', 'MOUSE', 'DISPLAY']

# Immutability
things2 = ['keyboard', 'mouse', 'display']
up_things2 = things.map { |s| s.upcase }
# things2 == ['keyboard', 'mouse', 'display']
# up_things2 == ['KEYBOARD', 'MOUSE', 'DISPLAY']

Avoid each and for loops. Instead use functions like:

  • select
  • map
  • inject
  • zip

select

nums = []
(1..10).each { |n| nums << n if n.even? }

# Better
nums = (1..10).select { |n| n.even? }

# Best
nums = (1..10).select(&:even?)

# => [2,4,6,8,10]

map

nums = [1,2,3]
squares = []
nums.each { |n| squares << n ** 2 }

# Better
squares = nums.map { |n| n ** 2 }

# => [1,4,9]

inject

# Bad
nums = [1,2,3]
total = 0
nums.each { |n| total += n }

# Good
total = nums.inject(0) { |sum, n| sum + n }

# Best
total = nums.inject(0, &:+)

# this...
nums = [1,2,3]
nums_to_squares = {}
nums.each { |n| nums_to_squares[n] = n ** 2 }

# ...or this
nums_to_squares = nums.inject({}) do |h, n|
  h[n] = n ** 2
  h
end

zip

odds = [1,3]
evens = [2,4]

pairs = []
(0..[odds.length, evens.length].min).each { |i|
  pairs << [odds[i], evens[i]]
}
# pairs == [[1,2], [3,4]]

zip_pairs = odds.zip(evens)
# zip_pairs == [[1,2], [3,4]]

odds = [1,3,5]
evens = [2,4,6]

foo = odds.zip(evens) { |(a,b)| a * b }

foo == nil!!!

odds = [1,3,5]
evens = [2,4,6]

foo = odds.zip(evens) { |(a,b)| a * b }

odds = [1,3,5]
evens = [2,4,6]

foo = []
odds.zip(evens) { |(a,b)| foo << a * b }
# foo == [2,12,30]

self methods

class Foo
  def self.do_the_thing
    # can't access instance variables or methods
  end
end

Struct

Foo = Struct.new(:bar, :baz, :whoozitz)

foo = Foo.new(1,2,3)

# => true
foo.bar + foo.baz == foo.whoozitz

# => Error!
foo.quux.nil?

Foo = Struct.new(:bar, :baz) do
  def with_bar(bar)
    self.new(bar, self.baz)
  end
  
  def total
    self.bar + self.baz
  end
end

Modules

module TicTacToe
  Game = Struct.new(:board, :next_symbol, :winner)
  
  def self.finished?(game)
    !game.winner.nil? || board_full?(game.board)
  end

  def self.with_winner(game, winner)
    self.new(game.board, game.next_symbol, winner)
  end
end

  • Any effects/IO at boundaries of application
  • Bring things in to application's data structures
  • Validate aggressively before core of app uses data
  • Write pure transformations on those data structures
  • Turn data structures into effects as late as possible

Abstraction

Optional data

addThreeMaybes h =
  liftA3 (\a b c -> a + b + c)
         (lookup h "foo")
         (lookup h "bar")
         (lookup h "baz")

-- List of keys can be arbitrarily long.
-- If any aren't present, we get `Nothing`.
addThreeMaybes h keys =
  fmap sum . traverse (`M.lookup` h) $ keys

if h[:foo]
  if h[:bar]
    if h[:baz]
      h[:foo] + h[:bar] + h[:baz]
    else
      nil
    end
  else
    nil
  end
else
  nil
end

def self.add_three_failures(h)
  a = h["a"]
  b = h["b"]
  c = h["c"]

  unless a.nil? || b.nil? || c.nil?
    a + b + c
  else
    nil
  end
end

def self.add_three_failures_list(h, keys)
  keys.inject(0) { |a, k|
    n = h[k]
    (a.nil? || n.nil?) ? nil : a + n
  }
end

What if the computation to get each value changes?

addThreeValidations h keys =
  let
    f k = maybe (AccFailure $ ["Couldn't find key: " <> k])
                AccSuccess
                (M.lookup k h)
  in
    fmap sum . traverse f $ keys
    
-- "foo" and "baz" aren't keys in the map
addThreeValidations someMap ["foo", "bar", "baz"]
-- => AccFailure [ "Couldn't find key: foo",
--               , "Couldn't find key: baz"
--               ]

addMaybes h keys =
  let
    f = (`M.lookup` h)
  in
    fmap sum . traverse f $ keys

addValidation h keys =
  let
    f k = maybe (AccFailure $ ["Couldn't find key: " <> k])
                AccSuccess
                (M.lookup k h)
  in
    fmap sum . traverse f $ keys

                  
     
                      
    
    fmap sum . traverse f $ keys

                      
     
                                                           
                          
                              
    
    fmap sum . traverse f $ keys

addThings f xs =
  fmap sum . traverse f $ xs

addMaybes h keys =
  addThings (`M.lookup` h) keys
  
addValidations h keys =
  let
    validatedLookup k =
      maybe (AccFailure $ ["Couldn't find key: " <> k])
            AccSuccess
            (M.lookup k h)
  in
    addThings validatedLookup keys

addMultiplesOf n ns =
  addThings (*) ns n
  
-- > addMultiplesOf 5 [1,2,3]
-- 30

def self.add_things(fmap, pure, lifta2, f, keys)
  fas = keys.inject(pure.call([])) { |fas, k|
    fa = f.call(k)
    lifta2.call(fa, fas) { |a, as| as << a }
  }

  fmap.call(fas) { |as| as.inject(0, &:+) }
end

def self.add_things_nil(h, keys)
  pure = Optional.method(:new)
  fmap = ->(o, &f){
    o.and_then { |x| pure.call(f.call(x)) }}
  lifta2 = ->(fa, fb, &g){
    fa.and_then { |a|
      fb.and_then { |b|
        pure.call(g.call(a, b))}}}
  f = ->(k){pure.call(h[k])}
  add_things(fmap, pure, lifta2, f, keys)
end

  • No types or compiler to guide me or tell me when I'm wrong
  • No existing abstractions to build on
  • Struggling against the language and all other Ruby libs

Wrapping up

Take away

  • Concepts from FP will give you more robust software
  • Some ideas from FP are achievable in Ruby
    • Immutability (sometimes)
    • Higher order functions via blocks/Proc
    • Effects at the boundaries
  • Ruby falls down with deeper abstractions
    • Against the design/intentions of the language
    • Lack of types/tool suppport

What now?

Try these ideas on your Ruby

Homework: write tic-tac-toe using these principles

  • Functional core, imperative shell
  • Immutable data in the core
  • Higher order functions
  • Closed structs

Learn more FP!

Data61 course in Brisbane 06-08 Feb

Register by 26 Jan (notify.qfpl.io)

Need help?

#qfpl on Freenode

Credits

Images

References

This talk