Skip to content

Instantly share code, notes, and snippets.

@zilkey
Last active September 14, 2018 20:05
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save zilkey/7162688 to your computer and use it in GitHub Desktop.
Save zilkey/7162688 to your computer and use it in GitHub Desktop.
curry example - a way to do dependency injection using lambdas
Gemfile.lock
junk.*

Functional programming in Ruby

This is an example of how to use a functional style of programming in Ruby.

The goal is to experiment with the ideas that Gary Bernhardt introduced in http://confreaks.com/videos/1314-rubyconf2012-boundaries related to having a functional core and separating your business objects.

The idea is to make each piece of logic in the chain easily testable and runnable in isolation, while also keeping the calling code simple.

The thing I find most interesting is that with Proc#curry you can easily wrap the lower level IO functions with API sugar to provide concise, high level functions.

require 'json'
require 'csv'
require 'nokogiri'
module MyIO # => imperative shell
ParsesCSVFile = ->(row_handler, path) {
result = []
CSV.foreach(path, headers: true) do |row|
result << row_handler[row]
end
result
}
ParsesJSONArrayFile = ->(object_handler, path) {
JSON.parse(File.read(path)).map do |object|
object_handler[object]
end
}
ParsesXMLFile = ->(doc_handler, path) {
file = File.open(path)
doc = Nokogiri::XML(file)
file.close
doc_handler[doc]
}
end
module MyApp # => functional core
ConvertsHumanizedHash = ->(row) {
row.to_hash.inject({}) do |result, (key, value)|
result[key.gsub(" ", "_").downcase] = value
result
end
}
ConvertsCamelCaseHash = ->(object) {
object.inject({}) do |result, (key, value)|
result[key.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase] = value
result
end
}
ConvertsXMLDoc = ->(doc) {
doc.xpath('//Person').map do |node|
{
'first_name' => node.xpath('FirstName').text,
'last_name' => node.xpath('LastName').text,
}
end
}
NormalizesHash = ->(converter, path) {
data = converter.call(path)
data.flat_map.with_index do |hash, index|
hash.map do |field_name, value|
[index, field_name, value]
end
end
}
ParsesCSV = MyIO::ParsesCSVFile.curry[ConvertsHumanizedHash]
ParsesJSON = MyIO::ParsesJSONArrayFile.curry[ConvertsCamelCaseHash]
ParsesXML = MyIO::ParsesXMLFile.curry[ConvertsXMLDoc]
NormalizesJSON = NormalizesHash.curry[ParsesJSON]
NormalizesCSV = NormalizesHash.curry[ParsesCSV]
NormalizesXML = NormalizesHash.curry[ParsesXML]
end
source 'https://rubygems.org'
gem 'nokogiri'
gem 'json'
require 'csv'
module MyIO # => imperative shell
class ParsesCSVFile
def initialize(row_handler)
@row_handler = row_handler
end
def call(path)
result = []
CSV.foreach(path, headers: true) do |row|
result << @row_handler.call(row)
end
result
end
end
end
module MyApp
class ConvertsHumanizedHash
def call(row)
row.to_hash.inject({}) do |result, (key, value)|
result[key.gsub(" ", "_").downcase] = value
result
end
end
end
class NormalizesHash
def initialize(converter)
@converter = converter
end
def call(path)
data = @converter.call(path)
data.flat_map.with_index do |hash, index|
hash.map do |field_name, value|
[index, field_name, value]
end
end
end
end
# if you wanted to hard-code the first param, you would do this:
ParsesCSV = MyIO::ParsesCSVFile.new(ConvertsHumanizedHash.new.freeze).freeze
# if you wanted to hard-code the first param, you would do this:
NormalizesCSV = NormalizesHash.new(ParsesCSV).freeze
end
PrintsData = ->(header, rows) {
puts "#{header}:"
rows.each do |row|
p row
end
puts
}
# procs make it easier to allow callers to extend without having to duplicate knowledge or create new types
PrintsData.call 'foo', MyApp::NormalizesCSV.call('people.csv')
PrintsParameters = ->(a, b, c, d, e) {
[a, b, c, d, e]
}
First Name Last Name
Miles Davis
John Coltrane
[
{"firstName": "Miles", "lastName": "Davis"},
{"firstName": "John", "lastName": "Coltrane"}
]
<?xml version="1.0"?>
<People>
<Person>
<FirstName>Miles</FirstName>
<LastName>Davis</LastName>
</Person>
<Person>
<FirstName>John</FirstName>
<LastName>Coltrane</LastName>
</Person>
</People>
module MyApp
BasePolicy = ->(rules, *args) {
rules.any? { |rule| rule.call(*args) }
}
CanSaveWorldPolicy = BasePolicy.curry(2).call(
[
->(user) { user == "superman" },
->(user) { user == "children" },
]
)
CanEatPolicy = BasePolicy.curry(3).call(
[
->(animal, food) { animal == "mouse" && food == "cheese" },
->(animal, food) { animal == "horse" && food == "hay" },
]
)
end
p MyApp::CanSaveWorldPolicy.call
p MyApp::CanSaveWorldPolicy.call("superman")
p MyApp::CanSaveWorldPolicy.call("children")
p MyApp::CanSaveWorldPolicy.call("batman")
p MyApp::CanEatPolicy.call
p MyApp::CanEatPolicy.call("mouse")
p MyApp::CanEatPolicy.call("mouse", "cheese")
p MyApp::CanEatPolicy.call("mouse", "beer")
p MyApp::CanEatPolicy.call("horse", "hay")
p MyApp::CanEatPolicy.call("horse", "beer")
require_relative 'curry'
PrintsData = ->(header, rows) {
puts "#{header}:"
rows.each do |row|
p row
end
puts
}
data_from_csv = MyApp::NormalizesCSV['people.csv']
data_from_json = MyApp::NormalizesJSON['people.json']
data_from_xml = MyApp::NormalizesXML['people.xml']
PrintsData['Data from CSV', data_from_csv]
PrintsData['Data from JSON', data_from_json]
PrintsData['Data from XML', data_from_xml]
# convenience method to print the code and the output
def pc(arg, offset = 0)
file, line = caller.first.split(":")
actual_line = line.to_i - 1 + offset
code = File.read(file).split("\n")[actual_line].strip.sub("pc ", "")
result = arg.is_a?(String) ? arg : arg.inspect
puts "Line ##{actual_line}:"
puts " #{[code, result].join(" # => ")}"
puts
end
# Let's start with a very simple lambda:
PrintsParameters = ->(a, b, c, d, e) {
[a, b, c, d, e]
}
# This lambda takes 5 parameters, and turns them into an array.
#
# You can call PrintsParameters 4 different ways:
pc PrintsParameters.yield(1, 2, 3, 4, 5)
pc PrintsParameters.call(1, 2, 3, 4, 5)
pc PrintsParameters.(1, 2, 3, 4, 5)
pc PrintsParameters[1, 2, 3, 4, 5]
# For the rest of this document, I'll use `.call`.
#
# Imagine you have some of code that looks like this:
PrintsParameters.call('Firsties!', 2, 3, 4, 5)
PrintsParameters.call('Firsties!', :b, :c, :d, :e)
PrintsParameters.call('Firsties!', 'b', 'c', 'd', 'e')
# That is, for some part of your code 'Firsties!' will always be passed as the
# first argument. You see that this code is not dry, and you want to create a
# new lambda where 'Firsties!' always comes first.
#
# That's pretty easy - you just write something like this:
PrintsFirsties = ->(b, c, d, e) {
PrintsParameters.call('Firsties!', b, c, d, e)
}
# Then you change your code to this:
PrintsFirsties.call(2, 3, 4, 5)
PrintsFirsties.call(:b, :c, :d, :e)
PrintsFirsties.call('b', 'c', 'd', 'e')
# So far so good, you DRYd your code up a little.
# Then you go to another place, and you see this:
PrintsParameters.call('Secondz', 2, 3, 4, 5)
PrintsParameters.call('Secondz', :b, :c, :d, :e)
PrintsParameters.call('Secondz', 'b', 'c', 'd', 'e')
# So you create another lambda like this:
PrintsSecondz = ->(b, c, d, e) {
PrintsParameters.call('Secondz!', b, c, d, e)
}
# Now let's say you see this happen a few other times in your codebase.
# You want to dry that up a bit, so you create a higher-order lambda, one that
# returns another lambda. Kind of like a lambda factory:
CreatesPrintLambda = ->(a) {
->(b, c, d, e) {
PrintsParameters.call(a, b, c, d, e)
}
}
# Now you can create as many of these specialized versions
# of PrintsParameters as you like:
PrintsTurtlz = CreatesPrintLambda.call('Turtlz')
pc PrintsTurtlz.call(2, 3, 4, 5)
# Then you come across some more code in your site that looks like this:
PrintsParameters.call('Hip', 'Cat', 3, 4, 5)
PrintsParameters.call('Hip', 'Cat', :c, :d, :e)
PrintsParameters.call('Hip', 'Cat', 'c', 'd', 'e')
# What! Now you have the same problem, but with both the first _and_ second
# parameter to PrintsParameters.
# You could use that same technique to just create a wrapper lambda,
# and then another wrapper lambda, like so:
PrintsHipCats = ->(a, b) {
->(c, d, e) {
PrintsParameters.call(a, b, c, d, e)
}
}
# But now what happens when you want a lambda that just hard-codes
# 'Hip', but not 'Cats'? Lambda-splosion! That's what.
# So you set out to create a function that will allow for much easier
# cascading of defaults. It turns out this isn't super-easy.
#
# Your first crack at it might look like this:
NestsAllAndCallsLast = ->(*args1) {
if args1.length == PrintsParameters.parameters.length
PrintsParameters.call(*args1)
elsif args1.length > PrintsParameters.parameters.length
raise ArgumentError
else
->(*args2) {
if (args1 + args2).length == PrintsParameters.parameters.length
PrintsParameters.call(*(args1 + args2))
elsif (args1 + args2).length > PrintsParameters.parameters.length
raise ArgumentError
else
->(*args3) {
if (args1 + args2 + args3).length == PrintsParameters.parameters.length
PrintsParameters.call(*(args1 + args2 + args3))
elsif (args1 + args2 + args3).length == PrintsParameters.parameters.length
raise ArgumentError
else
->(*args4) {
if (args1 + args2 + args3 + args4).length == PrintsParameters.parameters.length
PrintsParameters.call(*(args1 + args2 + args3 + args4))
elsif (args1 + args2 + args3 + args4).length == PrintsParameters.parameters.length
raise ArgumentError
else
->(*args5) {
if (args1 + args2 + args3 + args4 + args5).length == PrintsParameters.parameters.length
PrintsParameters.call(*(args1 + args2 + args3 + args4 + args5))
else
raise ArgumentError
end
}
end
}
end
}
end
}
end
}
# That is super gnarly, but does the job. Now you can write things like:
pc NestsAllAndCallsLast.call('Turtlz', 'Turtlz', 'Turtlz', 'Turtlz', 'Turtlz')
pc NestsAllAndCallsLast.call('Turtlz', 'Turtlz', 'Turtlz', 'Turtlz').call(5)
pc NestsAllAndCallsLast.call('Turtlz', 'Turtlz').call('Turtlz', 'Turtlz').call(5)
pc NestsAllAndCallsLast.call('Turtlz', 'Turtlz', 'Turtlz').call(4, 5)
pc NestsAllAndCallsLast.call('Turtlz', 'Turtlz').call(3, 4, 5)
pc NestsAllAndCallsLast.call('Turtlz').call(2, 3, 4, 5)
# and...
DoubleTurtlz = NestsAllAndCallsLast.call('Turtlz', 'Turtlz')
TripleTurtlz = DoubleTurtlz.call('Turtlz')
pc TripleTurtlz.call(4, 5)
# But this solution still has a few problems:
# - if you change the arity of PrintsParameters, your NestsAllAndCallsLast
# lambda will need another branch (no recursion)
# - if you want to do this for another lambda, you have to write it over again
# or figure out some way to generalize it
# So to solve the first problem (no recursion), you can create a recursive proc that will
# do the same thing:
RecursiveLambda = ->(*args) {
if args.length == PrintsParameters.parameters.length
PrintsParameters.call(*args)
elsif args.length > PrintsParameters.parameters.length
raise ArgumentError
else
->(*inner_args) {
RecursiveLambda.(*(args + inner_args))
}
end
}
pc RecursiveLambda.(1, 2, 3).(4, 5)
pc RecursiveLambda.(1)
pc RecursiveLambda.(1).(2)
pc RecursiveLambda.(1).(2).(3)
pc RecursiveLambda.(1).(2).(3).(4)
pc RecursiveLambda.(1).(2).(3).(4).(5)
# But this still has the problem that it's hard-coded to PrintsParameters. So
# you need to be able to generate that lambda:
Curry = ->(proc){
inner = ->(*args) {
if args.length == proc.parameters.length
proc.call(*args)
elsif args.length > proc.parameters.length
raise ArgumentError
else
->(*inner_args) {
args += inner_args
instance_exec *args, &inner
}
end
}
}
CurriedPrintsParameters = Curry.(PrintsParameters)
pc CurriedPrintsParameters.(1)
pc CurriedPrintsParameters.(1).(2)
pc CurriedPrintsParameters.(1).(2).(3)
pc CurriedPrintsParameters.(1).(2).(3).(4)
pc CurriedPrintsParameters.(1).(2).(3).(4).(5)
# Great! So now you have a basic working definition of curry.
# But it turns out that as of Ruby 1.9 there is built-in way to do all of this (and more)
# as the `Proc#curry` method.
#
# `Proc#curry` returns a proc. If you call the proc with the same arity
# as the original Proc, it just calls through to the original (as above):
pc PrintsParameters.curry.call(1, 2, 3, 4, 5)
# If you call it with more than the original params, it blows up:
begin
PrintsParameters.curry.call(1, 2, 3, 4, 5, 6)
rescue => e
pc e.message
end
# If you call it with fewer parameters than the original,
# it returns new another curry'd proc, just like NestsAllAndCallsLast.
pc PrintsParameters.curry.call('Turtlz', 'Turtlz', 'Turtlz', 'Turtlz').call(5)
pc PrintsParameters.curry.call('Turtlz', 'Turtlz').call('Turtlz', 'Turtlz').call(5)
pc PrintsParameters.curry.call('Turtlz', 'Turtlz', 'Turtlz').call(4, 5)
pc PrintsParameters.curry.call('Turtlz', 'Turtlz').call(3, 4, 5)
pc PrintsParameters.curry.call('Turtlz').call(2, 3, 4, 5)
# It handles all the recursion for you, and works on any Proc.
#
# As far as lambdas go, that's pretty much it for `Proc#curry`, but if you are
# using Proc.new or Kernel#proc, there's one more thing that curry gives you.
#
# You can tell #curry how many parameters the new proc should
# take, and it will pass nil to all the rest of the parameters.
# Take this example:
PrintsParametersProc = proc { |a, b, c, d, e, f|
[a, b, c, d, e, f]
}
pc PrintsParametersProc.curry.call(2)
pc PrintsParametersProc.curry.call(2).call(nil, nil, nil)
pc PrintsParametersProc.curry(1).call(1)
# Lambdas verify the arity that they are called with, but procs do not.
# Since PrintsParameters takes 5 parameters,
# unlike `Proc.new` or `Kernel#proc`, curry will raise an error unless
# the given arity matches exactly, so it's not that useful:
begin
PrintsParameters.curry(1)
rescue => e # => wrong number of arguments (1 for 5)
pc e.message, -2
end
begin
PrintsParameters.curry(2)
rescue => e # => wrong number of arguments (2 for 5)
pc e.message, -2
end
begin
PrintsParameters.curry(3)
rescue => e # => wrong number of arguments (3 for 5)
pc e.message, -2
end
begin
PrintsParameters.curry(4)
rescue => e # => wrong number of arguments (4 for 5)
pc e.message, -2
end
pc PrintsParameters.curry(5).call(1, 2, 3, 4, 5)
Foo = ->(a, &block) {
block.call
}
pc Foo.call('a') { 'this works' }
begin
Foo.curry.call('a').call { 'this does not work' }
rescue => e
pc e.message, -2
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment