Created
November 1, 2022 21:52
-
-
Save kddnewton/ab153aaaa4a4c1a0ddae2394648da3aa to your computer and use it in GitHub Desktop.
More pattern matching
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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