Haskell Refactoring Step-Through
by @mjgpy3 on October 31, 2023
Background
Growing new Haskellers up to a point of productivity seems hard. But does it have to be?
Frecklers are always on the lookout for ways to level up our Haskell skills. We’ve run book clubs (going through Haskell Book and Maybe Haskell), had code sharing sessions and code reading discussions, paired often, and tried many more approaches!
Upon reflection some senior Haskellers concluded that, though these approaches have value, we’ve learned the most from our Haskell side projects. So, in an effort to pedagogically apply this learning strategy, we’ve starting a “Learn Haskell by Building” series. It’ll be a series of cumulative assignments that gradually introduce the language, tooling, and idioms. The idea is that this will create a space for newer Haskellers to grow familiar with the language and give more advanced/intermediate Haskellers a space to explore (e.g. new libraries, approaches).
First Assignment
The first assignment is a simple string match. Essentially, given a list of
keywords
, determine if any keyword is present in an input String
.
The heart of my implementation looks like
-- | Represents the results of a match
data MatchResult
= -- | Match found
HasMatch
| -- | No match found
Doesn'tHaveMatch
keywords :: [String]
keywords = ["Freckle", "Rulez"]
-- | Try to find 'keywords' in the given argument
match :: String -> MatchResult
match haystack
| any (`isInfixOf` haystack) keywords = HasMatch
| otherwise = Doesn'tHaveMatch
Playing around with alternative implementations, I came up with a fun
point-free version (bool
is from Data.Bool
)
match =
bool Doesn'tHaveMatch HasMatch
. (`any` keywords)
. flip isInfixOf
In this “simple” function there are a ton of interesting Haskellisms at play. In order to understand them, let’s informally convert this, step-by-step, to more beginner-friendly Haskell.
For the simplification, I’d recommend
- following along in your editor (if you’re a beginner or want some practice or fun)
- thinking of what you’d do differently (if you’re more advanced), and
- keeping Hoogle handy (since we’ll be using some definition substitution)
Simplification
Okay, first things first, let’s get rid of the point-free stuff by introducing an argument. To make this clear, let’s wrap the body in parenthesis
match =
- bool Doesn'tHaveMatch HasMatch
+ (bool Doesn'tHaveMatch HasMatch
. (`any` keywords)
- . flip isInfixOf
+ . flip isInfixOf)
We can now introduce an argument and immediately apply the function to it
-match =
+match haystack =
(bool Doesn'tHaveMatch HasMatch
. (`any` keywords)
- . flip isInfixOf)
+ . flip isInfixOf) haystack
Since what we have is a function (in parens) and an argument, we can replace the
parens with $
match haystack =
- (bool Doesn'tHaveMatch HasMatch
+ bool Doesn'tHaveMatch HasMatch
. (`any` keywords)
- . flip isInfixOf) haystack
+ . flip isInfixOf $ haystack
Now we have $
application of a bunch of “glued together” functions (composed
with .
) on the left. Let’s just use application everywhere!
match haystack =
bool Doesn'tHaveMatch HasMatch
- . (`any` keywords)
+ $ (`any` keywords)
- . flip isInfixOf $ haystack
+ $ flip isInfixOf $ haystack
Exercise for the reader: reason through how f . g $ x
and f $ g $ x
are the same… Use signatures/hoogle.
The definition of flip
is
flip :: (a -> b -> c) -> b -> a -> c
flip f x y = f y x
which can be written using lambdas instead of function arguments
flip = \f x y -> f y x
Let’s go ahead and replace flip
with this definition
bool Doesn'tHaveMatch HasMatch
$ (`any` keywords)
- $ flip isInfixOf $ haystack
+ $ (\f x y -> f y x) isInfixOf $ haystack
Now we can manually apply the lambda to isInfixOf
by simple substitution
bool Doesn'tHaveMatch HasMatch
$ (`any` keywords)
- $ (\f x y -> f y x) isInfixOf $ haystack
+ $ (\x y -> isInfixOf y x) $ haystack
Let’s do the same thing to haystack
(since $
is just application after all)
bool Doesn'tHaveMatch HasMatch
$ (`any` keywords)
- $ (\f x y -> f y x) isInfixOf $ haystack
+ $ (\y -> isInfixOf y haystack)
Okay! Now, to get rid of the lambda, let’s extract a where
binding
bool Doesn'tHaveMatch HasMatch
$ (`any` keywords)
- $ (\y -> isInfixOf y haystack)
+ $ infixOfHaystack
+ where
+ infixOfHaystack y = isInfixOf y haystack
any
is applied as a backtick-section, meaning it’s effectively just
a flip
(since keywords
is on the right).
We can either (1) make it into a flip
or (2) wrap it in a lambda. Let’s do the
latter
bool Doesn'tHaveMatch HasMatch
- $ (`any` keywords)
+ $ (\x -> x `any` keywords)
$ infixOfHaystack
where
infixOfHaystack y = isInfixOf y haystack
We can get rid of the backticks (which allow any
to be an infix), by moving
any
to prefix location
bool Doesn'tHaveMatch HasMatch
- $ (\x -> x `any` keywords)
+ $ (\x -> any x keywords)
$ infixOfHaystack
where
infixOfHaystack y = isInfixOf y haystack
Let’s manually apply the lambda
bool Doesn'tHaveMatch HasMatch
- $ (\x -> any x keywords)
- $ infixOfHaystack
+ $ any infixOfHaystack keywords
where
infixOfHaystack y = isInfixOf y haystack
bool
is defined in Data.Bool
as
bool :: a -> a -> Bool -> a
bool f _ False = f
bool _ t True = t
Let’s convert this to a case
expression
bool f t b =
case b of
False -> f
True -> t
A case
of Bool
is easily converted to an if
/ else
, be sure to flip
order (i.e. then t
should come first, not then f
)
bool f t b = if b then t else f
Time to lambda-ify
bool = \f t b -> if b then t else f
Subbing this in gives
- bool Doesn'tHaveMatch HasMatch
+ (\f t b -> if b then t else f) Doesn'tHaveMatch HasMatch
$ any infixOfHaystack keywords
where
infixOfHaystack y = isInfixOf y haystack
Manually applying the arguments, one-by-one, gives
\t b -> if b then t else Doesn'tHaveMatch
\b -> if b then HasMatch else Doesn'tHaveMatch
if any infixOfHaystack keywords then HasMatch else Doesn'tHaveMatch
Which gives us, re-formatted for readability
match :: String -> MatchResult
match haystack =
if any infixOfHaystack keywords
then HasMatch
else Doesn'tHaveMatch
where
infixOfHaystack y = isInfixOf y haystack
And there we go! Fun huh?
Conclusion
Though this example may seem trivial, it cuts to the core of Haskell’s power. We applied a series of small changes to transform a function without altering its behavior!
These tactics blur the line between the software engineering practice of
refactoring and algebraic simplification. Not only is this extremely cool but
it’s a microcosm of something greater. Safe refactoring practices scale from the
simplest functions (like match
) to our largest codebases.