I don't write a lot of Haskell. In fact, I don't really write any Haskell. My total lifetime output is well under 1000 lines. Every time I sit down to write some Haskell, though, I get reminded of why I like it so much.
reduce or newfangled stuff like
flatMap all over the place, but the language itself doesn't care about how sound your code is, it will do almost anything you ask and never complain if you're breaking rules to make things easier on yourself.
Haskell forces you stay in the box. Its draconian compiler means that your code doesn't run unless you've done it right. This feels like a limitation at first, but by forcing you to solve problems functionally it...well...forces you to solve problems functionally.
What's prompting this is yesterday's Dev.to challenge:
I had a long train ride yesterday, and figured a coding challenge would be a great way to pass the time. Immediately upon reading the problem spec, I had an outline of how to solve this problem. I've been spending most of my time over the last few weeks writing either C++ or Rust, so my mental outline was very imperative. You'd traverse the string iteratively, increment a score based on markers that are present, and then adjust that score based on any extra bonuses. I'm reasonably certain this instinctive solution would have worked with a little massaging.
Where's the fun in that, though?
So, of course, I decided to whip out my dusty old Haskell compiler and see if I still knew how to drive it.
I find the most useful one-sentence summary of "functional programming" to be that instead of telling the computer how to compute the result, you just tell the computer what the result is. This can be easier said than done, and requires you to re-frame how you think about the problem.
In this specific problem, we are given a string and must return how high that string scores per Scrabble rules. There are a few curveballs - the
* character is used to double or triple scores, the
^ indicates a blank so the previous letter shouldn't be scored, and words can be appended with a multiplier like
hello(t) to signal that the final result should be doubled or tripled.
The way to frame this is to think about what a scrabble score actually is. Instead of building up the score a piece at a time, say, adding the letters one by one and then checking to see if it needs adjustment, we want an equation that will score any word. This looks something like the following:
rawScore * wordMultiplier + sevenLetterBonus
This equation fits any input - we can just default the multiplier to 1 and the bonus to 0, so that most words that don't need these get
rawScore * 1 + 0, which is clearly equivalent to
So, that's what the answer is. We just need to manipulate the string passed in so that each of these values is correctly populated by the time we get there. The simplest part is
sevenLetterBonus. If the raw word is exactly 7 letters, we add 50 points. Our input string may have extra bits like the asterisk or the multiplier suffix, so just strip those to get the actual word:
stripMarkers :: String -> String stripMarkers = filter (\c -> c /= '*' && c /= '^') $ takeWhile (/= '(')
Good. Again, this function just describes what the end result is - it's the original word up to a
( character, with the marker characters filtered out. Quite declarative. Then we can build the bonus:
sevenLetterBonus = if (length $ stripMarkers w) == 7 then 50 else 0
Perfect, this now works on any input. The multiplier, too, is easy. Some inputs will have a suffix, and if so, check which. If not, the multiplier is 1:
wordMultiplier = let suffix = dropWhile (/= '(') w in if length suffix > 0 then case suffix !! 1 of 't' -> 3 'd' -> 2 _ -> 1 else 1
It only looks at any part of the string after a
( character, and acts accordingly. This also already handles any string we throw at it - most will hit that
else block because there is no
( present and get assigned a 1, which won't change the raw score.
The trickiest part of this for me functionally was handling the asterisks. My instincts tell me to solve this with an iterative loop, but Haskell is not going to let me get away with that. If it did, I likely would have been tempted to take the easy way out. But, of course, I couldn't.
Scoring a list of letters is easy - you just replace each letter with it's numerical value, and sum the list:
sum $ map (\c -> scores ! c) $ word
The mapping function is just performing a lookup in a mapping from characters to ints. In order for this to work, we need to have a string containing just the letters that will be scored. In order for this little snippet to work on any input, that input should be pre-processed to only contain letters. Any letter we want omitted can be, well, omitted, and letters to count multiple times can just appear multiple times. I don't know if I handled this as cleanly or as elegantly as a Haskeller would have, but this does the trick:
expandMarkers :: String -> String expandMarkers  =  expandMarkers (c:) = [c] expandMarkers (c:rest) = case head rest of '*' -> if (head $ tail rest) == '*' then [c] ++ [c] ++ [c] ++ (expandMarkers $ drop 2 rest) else [c] ++ [c] ++ (expandMarkers $ tail rest) '^' -> expandMarkers $ tail rest _ -> [c] ++ expandMarkers rest
It consumes the string recursively. On each letter, it looks one forward, and then continues the process based on what it finds. An asterisk will get removed and replaced with a copy of the letter we're on, unless the next one is also an asterisk, in which case it will replace both of them, and a carat will cause the character we're on to just not appear in the result. This function turns
heelooo - ready to be scored as is via the simple character-to-int substitution.
The full code just puts all this together:
import Data.Map (Map, (!)) import qualified Data.Map as Map scores :: Map Char Int scores = Map.fromList pairs where pairs = [ ('a', 1), ('b', 3), ('c', 3), ('d', 2), ('e', 1), ('f', 4), ('g', 2), ('h', 4), ('i', 1), ('j', 8), ('k', 5), ('l', 1), ('m', 3), ('n', 1), ('o', 1), ('p', 3), ('q', 10), ('r', 1), ('s', 1), ('t', 1), ('u', 1), ('v', 4), ('w', 4), ('x', 8), ('y', 4), ('z', 10)] scoreWord :: String -> Int scoreWord w = let sevenLetterBonus = if (length $ stripMarkers w) == 7 then 50 else 0 wordMultiplier = let suffix = dropWhile (/= '(') w in if length suffix > 0 then case suffix !! 1 of 't' -> 3 'd' -> 2 _ -> 1 else 1 -- preparedWord = expandMarkers $ takeWhile (/= '(') w rawScore = sum $ map (\c -> scores ! c) $ preparedWord in rawScore * wordMultiplier + sevenLetterBonus -- transform doubles, triples, carats -- if we hit an asterisk, replace it with the previous letter -- if we hit a carat, drop the previous letter expandMarkers :: String -> String expandMarkers  =  expandMarkers (c:) = [c] expandMarkers (c:rest) = case head rest of '*' -> if (head $ tail rest) == '*' then [c] ++ [c] ++ [c] ++ (expandMarkers $ drop 2 rest) else [c] ++ [c] ++ (expandMarkers $ tail rest) '^' -> expandMarkers $ tail rest _ -> [c] ++ expandMarkers rest -- remove suffix and all markers for deciding on the 7-letter bonus stripMarkers :: String -> String stripMarkers w = filter (\c -> c /= '*' && c /= '^') $ takeWhile (/= '(') w
This solution, that pre-processes every input into something that can be easily scored the same way, bears little to no resemblance to the code I wrote in my head when I read the spec. That's pretty cool, and I think it's a decent solution in any language.
Photo by Michal Vrba on Unsplash