Skip to content

Instantly share code, notes, and snippets.

@kddnewton
Created November 1, 2022 21:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kddnewton/ab153aaaa4a4c1a0ddae2394648da3aa to your computer and use it in GitHub Desktop.
Save kddnewton/ab153aaaa4a4c1a0ddae2394648da3aa to your computer and use it in GitHub Desktop.
More pattern matching
# Imagine we need to switch on some kind of input and do something based on some
# attribute of that input. We can do this with a case statement, where we inline
# each of the implementations of the interface that we're defining into the case
# statement. That would look something like:
class Input
attr_reader :type, :name
def initialize(type, name)
@type = type
@name = name
end
def deconstruct_keys(keys)
{ type: type, name: :name }
end
end
input = Input.new(:bar, "Ruby")
case input
in { type: :foo }
puts "executing foo for #{input.name}"
in { type: :bar }
puts "executing bar for #{input.name}"
in { type: :baz }
puts "executing baz for #{input.name}"
end
# This works when the implementation is small and the number of types is small
# as well. But as the number of types and the number of implementations grows,
# this becomes unwieldy. We can extract out the implementations into their own
# objects to make this slightly better, as in:
class Foo
attr_reader :input
def initialize(input)
@input = input
end
def execute
puts "executing foo for #{input.name}"
end
end
class Bar
attr_reader :input
def initialize(input)
@input = input
end
def execute
puts "executing bar for #{input.name}"
end
end
class Baz
attr_reader :input
def initialize(input)
@input = input
end
def execute
puts "executing baz for #{input.name}"
end
end
case input
in { type: :foo }
Foo.new(input)
in { type: :bar }
Bar.new(input)
in { type: :baz }
Baz.new(input)
end
.execute
# That works better, but the dispatch of the object is still distinct from the
# definition of the implementation. If I match a subset or superset of these
# types in another place in the code, I don't want to have to update multiple
# match statements.
# If instead, I define a === operator on each of the objects, then the case
# statement does what I expect it to, which is to match the first object that it
# finds that returns true from ===.
class Foo
def self.===(object)
object.deconstruct_keys(:type)[:type] == :foo
end
end
class Bar
def self.===(object)
object.deconstruct_keys(:bar)[:type] == :bar
end
end
class Baz
def self.===(object)
object.deconstruct_keys(:bar)[:type] == :baz
end
end
case input
in Foo
Foo.new(input)
in Bar
Bar.new(input)
in Baz
Baz.new(input)
end
.execute
# Now at least the dispatch is defined in the same place as the implementation.
# I don't get a lot of the niceties of pattern matching with this, but it gets
# me a lot closer to where I want to be. It's still annoying to list out each
# of these kinds of constructors like this though, since I know I'm just going
# to be creating the new object. I'd like to be able to just say "create a new
# object based on the first pattern match that you find". With a monkey-patch on
# object we can do that:
class Object
def match(*classes)
matched = classes.find { |clazz| clazz === self } or raise "Unknown type"
matched.new(self)
end
end
input.match(Foo, Bar, Baz).execute
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment