The main issue with this code is that basically everything “lives” in IO
. One of the advantages of Haskell over other programming languages is the ability to cleanly separate effects, and you should strive to implement as much of your code as possible in a pure context, then only actually use IO
at the top level of your program.
Breaking apart getNext
The most egregious example of this is getNext
, which has a pretty convoluted return value, IO (Maybe (IO String))
. This is IO
nested inside another IO
type, which is pretty convoluted. Ideally, that function shouldn’t need to be anything but completely pure, anyway, so it should really return something like Maybe String
.
Taking a step back for a moment, though, what does getNext
even do? The name getNext
is pretty vague. More than that, though, it has a lot of responsibilities. Let’s list them:
- It accepts the last word the player typed in, then finds what letter it ended with.
- It filters blacklisted words out of a larger wordlist.
- It finds the words that start with the letter found in step 1.
- Finally, it picks a random word from that list.
This is a lot of different responsibilities for just one function! Haskell works especially well when you define small, atomic functions that only do one thing at a time, then compose them together. Distilling the core functionality, this function should really probably just find all the words in a list that start with a particular character, then return all of them. You can implement this in a single line using isPrefixOf
from Data.List
:
import Data.List (isPrefixOf)
startingWith :: Char -> [String] -> [String]
startingWith c = filter (isPrefixOf [c])
(As an aside, this actually fixes a problem with your original code, which used head
. The head
function is partial; that is, it will crash if given an empty list. The isPrefixOf
function will handle empty lists properly.)
IO
and strong typing
Now, what about handling the other responsibilities? Well, picking a random element is probably the trickiest thing to do because it is sort of side-effectful. You could manually thread a random number generator state around, but that would be a bit cumbersome. One good way to handle this is the MonadRandom
typeclass from the package of the same name, which allows writing a pickRandom
function without IO
:
import Control.Monad.Random (MonadRandom(..))
pickRandom :: MonadRandom m => [a] -> m (Maybe a)
pickRandom [] = return Nothing
pickRandom xs = Just . (xs !!) <$> getRandomR (0, length xs - 1)
This has two improvements over the pick
function you found:
- It properly handles the case of empty lists by returning
Maybe a
. The version of pick
you found would simply crash.
- It does not depend on
IO
, just on MonadRandom
, which is significantly less powerful.
Haskell has lots of ways to be very precise about the type of things. Rather than making everything nullable, it has Maybe
. Rather than just passing strings around, Haskell favors using ADTs. Haskell also provides a way from distinguishing between pure and impure operations using the IO
type, but in many ways, IO
provides some very weak guarantees.
When a function returns IO
, it can effectively do anything. It can spawn threads, it can interact with the filesystem, and it can even send data over the network. Ideally, it would be nicer to have more fine-grained typing, just like we have with Haskell’s domain-specific, fine-grained ADTs.
To accomplish this, it’s often possible to use typeclasses like MonadRandom
, which encode a very specific set of capabilities. Functions that include a MonadRandom
constraint can do one thing: generate random numbers. Now, because MonadRandom
is a typeclass, not a datatype, it does not specify how those numbers are generated; it is up to the caller to decide that. The MonadRandom
typeclass actually has an instance for IO
, which allows generating numbers using the system random number generator, but it also has an instance for the Rand
type, which is a purely functional random number generation monad.
We haven’t decided how we’re going to generate random numbers yet. We might even use IO
, eventually. However, that’s not the point… the main point is that we’ve now written a function that can do nothing more than its type claims it can, and that’s a good thing.
Handling turns
Now that we have some extremely basic primitives, we can put them together to create handlers for user and computer turns. Each turn can result in one of two actions: giving up, or submitting a word. Therefore, let’s encode that into a datatype, then implement some extremely simple functions for each kind of turn:
data Turn = Word String | GiveUp
userTurn :: String -> Turn
userTurn "quit" = GiveUp
userTurn input = Word input
computerTurn :: MonadRandom m => String -> [String] -> m Turn
computerTurn lastWord wordList = maybeToTurn <$> maybeRandomWord
where lastChar = last lastWord
maybeRandomWord = pickRandom (startingWith lastChar wordList)
maybeToTurn = maybe GiveUp Word
The computerTurn
function is a little more complex, but it’s not too bad. It uses the maybe
function, which is a convenient helper for transforming a Maybe
value into a value of another type (in this case, Turn
), and it uses <$>
, which is just an infix alias for fmap
.
One nice thing about these implementations is, again, we are able to learn a lot about these functions just by looking at their types. The userTurn
function is extremely pure, and the computerTurn
function uses randomness, but nothing else.
Writing the main loop
Now that we have some primitives, we can write a top-level interpreter that will actually drive the game itself. This function will be a bit longer, since it will handle the actual imperative logic of the game, but it will also be extremely simple, since it’s basically just wiring things together.
One thing that we have pretty much eliminated is the concept of a word blacklist. After all, why not just remove words from the word list itself rather than maintaining two separate lists and threading them around everywhere? We can completely eliminate the need for a blacklist by just pulling words out of the computer’s vocabulary.
import Data.List (delete)
runGame :: [String] -> IO [String]
runGame wordList = do
userInput <- getLine
case userTurn userInput of
GiveUp -> exitSuccess
Word userWord -> do
let remainingWords = delete userWord wordList
computerResult <- computerTurn userWord remainingWords
case computerResult of
GiveUp -> putStrLn "I give up" >> exitSuccess
Word computerWord -> do
putStrLn computerWord
runGame (delete computerWord remainingWords)
This function may look a little complicated, but it’s really not so bad—by just following the types, the function effectively writes itself. We call userTurn
, then handle both potential Turn
cases. Next, we call computerResult
and handle both of its possible cases. Once that’s done, we just loop, and we’re done! The result of runGame
is just the number of words left in the computer’s vocabulary.
Now, all that’s left to do is invoke runGame
from main
:
main :: IO ()
main = void $ runGame vocab
The void
function just ignores the result of runGame vocab
, properly returning IO ()
, and it kicks off the main loop by passing in the initial vocab
list.
The final result
Here’s the final code after all of my changes:
module Main where
import Data.List (delete, isPrefixOf)
import Control.Monad (void)
import Control.Monad.Random (MonadRandom(..))
import System.Exit (exitSuccess)
data Turn = Word String | GiveUp
vocab :: [String]
vocab = ["alpha","beta","gamma"]
main :: IO ()
main = void $ runGame vocab
runGame :: [String] -> IO [String]
runGame wordList = do
userInput <- getLine
case userTurn userInput of
GiveUp -> exitSuccess
Word userWord -> do
let remainingWords = delete userWord wordList
computerResult <- computerTurn userWord remainingWords
case computerResult of
GiveUp -> putStrLn "I give up" >> exitSuccess
Word computerWord -> do
putStrLn computerWord
runGame (delete computerWord remainingWords)
userTurn :: String -> Turn
userTurn "quit" = GiveUp
userTurn input = Word input
computerTurn :: MonadRandom m => String -> [String] -> m Turn
computerTurn lastWord wordList = maybeToTurn <$> maybeRandomWord
where lastChar = last lastWord
maybeRandomWord = pickRandom (startingWith lastChar wordList)
maybeToTurn = maybe GiveUp Word
pickRandom :: MonadRandom m => [a] -> m (Maybe a)
pickRandom [] = return Nothing
pickRandom xs = Just . (xs !!) <$> getRandomR (0, length xs - 1)
startingWith :: Char -> [String] -> [String]
startingWith c = filter (isPrefixOf [c])
Formatting changes aside, the main differences from your original code are strengthening the types and separating concerns as much as possible to isolate effects. I have also removed do
from most of the function implementations, since do
tends to force code into a pseudo-imperative style that eliminates a lot of the declarative benefits of Haskell.
Some of the things in this answer might be a bit more advanced than you’ve been exposed to yet, and that’s okay! In truth, there are probably even fancier ways to accomplish what you want—a free monad came to mind when writing this answer, for example. However, the point of this is not to be either so far above you that you can’t understand it, nor to be precisely at your level. It’s alright if not all of this makes sense to you just yet, but I hope that by reaching for some more complicated concepts, you’ll be encouraged, not discouraged, to challenge yourself some more.