Learning Haskell: Building Projects

posted 2 years ago

Modules

Haskell programs are organized into modules.

Modules contain the datatypes, type synonyms, typeclasses, typeclass instances, and values you've defined at the top level.

Modules allow you to import other modules into the scope of your program, and also allow you to export values to other modules.

Making Packages with Slack

The Haskell Cabal, or Common Architecture for Building Applications and Libraries, is a package manager.

A package is a program you're building, including all of its modules and dependencies.

Dependencies are the interlinked elements of that program, the other packages and libraries that it depends on and any tests and documentation associated with the project.

Stack is a cross-platform program for developing Haskell projects.

Stack relies on a long term support snapshot of Haskell packages from Stackage that are guaranteed to work together.

Working with a Basic Project

Let's make a basic project called hello.

First, make a new project using this command:

$ stack new hello

Change into the directory:

$ cd hello

You can edit the hello.cabal file if you'd like. For example, you can change "Author name here" to your name. Next, build the project:

$ stack build

Loading and Running the Code from the REPL

Fire up the REPL:

$ stack ghci
[1 of 2] Compiling Lib
[2 of 2] Compiling Main
Ok, two modules loaded.
Loaded GHCi configuration from /tmp/haskell-stack-ghci/657b6aaa/ghci-script
*Main Lib> main
someFunc

We successfully started a GHCi REPL And loaded the Main module. Then, we ran the main function.

stack exec

When we ran stack build earlier, Stack compiled an executable binary and linked to it. You can use this binary by doing the following:

$ stack exec -- hello-exe
someFunc

Stack knows what paths any executables might be located in, so using Stack's exec command saves you the hassle of typing out a potentially verbose path.

Executable Stanzas

Stack created an executable earlier because of this stanza in hello.cabal:

executable hello-exe
              [1]
  main-is: Main.hs
    [2]
  other-modules:
      Paths_hello
  hs-source-dirs:
       [3] 
      app
  ghc-options: -threaded -rtsopts -with-rtsopts=-N
  build-depends:
       [4]
      base >=4.7 && <5
    , hello
  default-language: Haskell2010
        [5]
  1. The name of the binary or executable.
  2. Execution of the binary begins by looking for a main function inside a file named Main with the module Main. The module names must match filenames. Your compiler will reject using a file that isn't a Main module as the entry point to executing the program. The compiler will look for Main.hs under the directories specified in hs-source-dirs.
  3. Tells the stanza where to look for source code; in this case, the app directory.
  4. The baseline dependencies in almost any Haskell project as you can't really get anything done with the base library. Your other installed dependencies will go here as well.
  5. Defines the version of the Haskell language to use.

Exposing Modules

Let's add a new module with a new IO action for our main action to run.

-- src/Greeting.hs
module Greeting (greet) where

greet :: IO ()
greet = do
  putStrLn "Hello"
  putStrLn "How are you?"

Then change the Main module to make use of it:

-- app/Main.hs
module Main where

import Greeting (greet)
import Lib (someFunc)

main :: IO ()
main = do
  someFunc
  greet

Make sure to expose it in hello.cabal:

library
  exposed-modules:
      Greeting
      Lib

Build it and run it!

$ stack build
$ stack exec -- hello-exe
someFunc
Hello
How are you?

Qualified Imports

What if you wanted to know where something you imported came from in the code that uses it?

Qualified imports make the names a bit more explicit.

The qualified keyword can be used to do this.

Sometimes you'll have stuff with the same name imported from two different module; qualifying imports is a common way of dealing with this.

For example:

Prelude> import qualified Data.Bool
Prelude> :t bool

<interactive>:1:1:
    Not in scope: 'bool'
    Perhaps you meant 'Data.Bool.bool'

Prelude> :t Data.Bool.bool
Data.Bool.bool :: a -> a -> Bool -> a

You can also provide aliases for modules when qualified:

Prelude> import qualified Data.Bool as B
Prelude> :t B.bool
B.bool :: a -> a -> Bool -> a

Making the Program Interactive

Let's make the program a bit more interactive. We will make it ask for your name, then greet you by name. First, rewrite greet to take an argument:

-- src/Greeting.hs
module Greeting (greet) where

greet :: String -> IO ()
greet name =
  putStrLn $ "Hello " ++ name ++ "!"

Next, change main to get the user's name:

-- app/Main.hs
module Main where

import Greeting (greet)
import Lib (someFunc)

main :: IO ()
main = do
  name <- getLine
  greet name
  someFunc

We are using the do syntax here, which is syntactic sugar. We use do functions that return IO to sequence side effects in a convenient syntax.

Let's break it down:

main :: IO ()
main = do
       [1]
  name <- getLine
  [4]  [3]  [2]
  greet name
         [5]
  someFunc
     [6]
  1. The beginning of the do block.
  2. getLine has type IO String, because it performs I/O (input/output) side-effects to obtain the String.
  3. <- a bind (I will explain more about this in a later article).
  4. The result of binding over the IO String is String. It is bound to a variable called name.
  5. greet expects an argument String, which is the type of name but not getLine.
  6. someFunc expects nothing and is an IO action of type IO ().

Build it:

$ stack build

Run it:

$ stack exec -- hello-exe
Clayton -- input
Hello Clayton!
someFunc

Let's make it more user-friendly by adding a prompt:

-- app/Main.hs
module Main where

import Greeting (greet)
import Lib (someFunc)
import System.IO (BufferMode (NoBuffering), hSetBuffering, stdout)

main :: IO ()
main = do
  hSetBuffering stdout NoBuffering
  putStr "Please input your name: "
  name <- getLine
  greet name
  someFunc

I used putStr so that the input could be on the same line as the prompt.

I imported System.IO to use hSetBuffering, stdout, and NoBuffering.

That line of code is so that putStr isn't buffered and prints immediately.

Rebuild it and run it:

$ stack build
$ stack exec -- hello-exe
Please input your name: Clayton
Hello Clayton!
someFunc

Remove the NoBuffering line and see how it works for yourself.

do Syntax and IO

do blocks are convenient syntactic sugar that allow for sequencing actions, but because they are only syntactic sugar, they are not necessary.

However, these blocks make code much more readable and also hide the underlying nesting, and that can help you write code before understanding monads and IO.

The main executable in a Haskell program must always have the type IO (). The do syntax specifically allows us to sequence monadic actions. Monad is a typeclass that is advanced and I'll get to at a later point. The instance of Monad here though is IO. That is why main functions are usually do blocks.

This syntax also provides a way of naming values returned by monadic IO actions so that they can be used as inputs to actions that happen later in the program.

Let's break down a simple do block:

main = do
       [1]
  x1 <- getLine
 [2] [3]  [4]
  x2 <- getLine
          [5]
  return (x1 ++ x2)
   [6]       [7]
  1. do introduction.
  2. A variable representing a value obtained from getLine.
  3. <- binds the variable on the left to the result of the IO action on the right.
  4. getLine has the type IO String and takes user input of a string value. In this case, the string the user inputs will be the value bound to the x1 name.
  5. Another variable representing the value obtained from the second getLine.
  6. The concluding action of the do block, return.
  7. The value returned, the conjunction of the two strings obtained from the two getLine actions.

return

This function doesn't really do much.

However, the purpose it serves is important, given the way monads and IO work.

Prelude> :t return
return :: Monad m => a -> m a

return here gives us a way to add no extra function except putting the final value in IO.

Hangman game

Let's build a game. Use Stack's new command to create the project:

$ stack new hangman simple

This will generate a directory called hangman and put some default files into the directory.

We need a words file to get words from. Most Unix-based operating systems have a words list located at a directory like the following:

$ ls /usr/share/dict
american-english  cracklib-small          words
british-english   README.select-wordlist  words.pre-dictionaries-common

You may need to download one if you don't have one.

Let's put one of them in a new directory called data in the working directory.

Now we have this:

$ tree .
.
├── data
│   └── dict.txt
├── hangman.cabal
├── LICENSE
├── README.md
├── Setup.hs
├── src
│   └── Main.hs
└── stack.yaml

Now edit the hangman.cabal file with your own properties:

name:                hangman
version:             0.1.0.0
synopsis:            Playing some Hangman
homepage:            Clayton L Davidson
license:             BSD3
license-file:        LICENSE
author:              Clayton Davidson
maintainer:          clayton.davidson847@topper.wku.edu
category:            Game
build-type:          Simple
cabal-version:       >=1.10
extra-source-files:  README.md

executable hangman
  hs-source-dirs:      src
  main-is:             Main.hs
  default-language:    Haskell2010
  build-depends:       base >= 4.7 && < 5
                       , random
                       , split

The most import part is that there are two added libraries: random and split.

Import the Modules

-- src/Main.hs

module Main where

import Control.Monad (forever) -- [1]
import Data.Char (toLower) -- [2]
import Data.List (intersperse) -- [3]
import Data.Maybe (isJust, fromMaybe) -- [4]
import System.Exit (exitSuccess) -- [5]
import System.Random (randomRIO) -- [6]
  1. forever from Control.Monad makes an infinite loop.
  2. toLower from Data.Char will allow us to convert all characters of the string to lowercase.
  3. isJust from Data.Maybe will be used to determine if every character in our puzzle has been discovered already or not. We will combine this with all from Prelude which answers the question, "Does it return True for all of them?". fromMaybe from Data.Maybe will return the Just value if an argument is Just, otherwise it will return the default value provided as the first argument.
  4. interperse from Data.List will be used to intersperse elements in a list. In this case, we're putting spaces between the characters guessed so far by the player.
  5. exitSuccess from System.Exit allows the program to exit successfully.
  6. randomRIO from System.Random will help us to select a word from our dictionary at random.

Generate a Word List

Here, we use the do syntax to read the contents of our dictionary into a variable named dict. We use the lines function to split the big blob string we read from the file into a list of string values each representing a single line. Each line is a single word.

type WordList = [String]

allWords :: IO WordList
allWords = do
  dict <- readFile "data/dict.txt"
  return (lines dict)

The next part is to set upper and lower bounds for the size of words we'll use in the puzzles. You can change these if you want to:

minWordLength :: Int
minWordLength = 5

maxWordLength :: Int
maxWordLength = 9

Next, let's take the output of allWords and filter it to fit the length criteria defined:

gameWords :: IO WordList
gameWords = do
  aw <- allWords
  return (filter gameLength aw)
  where
    gameLength w = let le = length (w :: String) in le >= minWordLength && le < maxWordLength

Now let's write a pair of functions that will pull a random word out of our word list for us, so that the puzzle player doesn't know what the word will be.

randomWord :: WordList -> IO String
randomWord wl = do
  randomIndex <- randomRIO (0, length wl - 1)
  return $ wl !! randomIndex

randomWord' :: IO String
randomWord' = gameWords >>= randomWord

The second function binds the gameWords list to the randomWord function so that the random word we're getting is from that list. I will explain the >>= in another article on Monad.

Making a Puzzle

We now have to make a puzzle for the player to solve.

We need a way to hide the word from the player and create a means of asking for letter guesses, determining if the guessed letter is in the word.

We should put the letter into the word if it is correct and put it into an "already guessed" list if it's not. We should also end the game when the word is fully guessed.

The datatype of the puzzle is a product of a String, a list of Maybe Char, and a list of Char.

data Puzzle =
  Puzzle String [Maybe Char] [Char]
--         [1]      [2]       [3]
  1. The word the player is trying to guess.
  2. The characters filled in so far.
  3. The letters guessed so far.

We are now going to write an instance of Show for Puzzle so we can display it a certain way.

instance Show Puzzle where
  show (Puzzle _ discovered guessed) =
  (intersperse ' ' $
   fmap renderPuzzleChar discovered)
  ++ " Guessed so far: " ++ guessed

Next, we will write a function that will take our puzzle word and turn it into a list of Nothing. This is going to be the first step in hiding the word from the player.

freshPuzzle :: String -> Puzzle
freshPuzzle str = Puzzle str (map (const Nothing) str) []

Now we need a function that looks at the Puzzle String and determines whether the character you guessed in an element of that string. We only need the first argument of the Puzzle so we just fill the other two with underscores.

charInWord :: Puzzle -> Char -> Bool
charInWord (Puzzle str _ _) c = c `elem` st

Next, we need a function to check and see if it is an element of the guessed list:

alreadyGuessed :: Puzzle -> Char -> Bool
alreadyGuessed (Puzzle _ _ guessed) c = c `elem` guessed

The next goal is to use Maybe to fill in renderPuzzleChar and allow it to permit two different outcomes. If the character has been guessed, we want to display that character so the player can see which positions they've correctly filled.

renderPuzzleChar :: Maybe Char -> Char
renderPuzzleChar = fromMaybe '_'

Let's now make a function to insert a correctly guessed character into the string.

fillInCharacter :: Puzzle -> Char -> Puzzle
fillInCharacter (Puzzle word filledIn s) c =
  Puzzle word newFilledIn (c : s)
  where
    zipper guessed wordChar guessChar =
      if wordChar == guessed
        then Just wordChar
        else guessChar

    newFilledIn = zipWith (zipper c) word filledIn

The next function we will make will tell the player what they guessed:

handleGuess :: Puzzle -> Char -> IO Puzzle
handleGuess puzzle guess = do
  putStrLn $ "Your guess was: " ++ [guess]
  case ( charInWord puzzle guess,
         alreadyGuessed puzzle guess
       ) of
    (_, True) -> do
      putStrLn
        "You already guess that\
        \ character, pick \
        \ something else!"
      return puzzle
    (True, _) -> do
      putStrLn
        "This character was in the\
        \ word, filling in the word\
        \ accordingly"
      return $ fillInCharacter puzzle guess
    (False, _) -> do
      putStrLn
        "This character wasn't in\
        \ the word, try again."
      return $ fillInCharacter puzzle guess

Now we need a function to stop the game after a certain number of guesses:

gameOver :: Puzzle -> IO ()
gameOver (Puzzle wordToGuess _ guessed) =
  if length guessed > 7
    then do
      putStrLn "You lose!"
      putStrLn $ "The word was: " ++ wordToGuess
      exitSuccess
    else return ()

And to win:

gameWin :: Puzzle -> IO ()
gameWin (Puzzle _ filledInSoFar _) =
  if all isJust filledInSoFar
    then do
      putStrLn "You win!"
      exitSuccess
    else return ()

Finally, let's make a function to run the game and to give the player a new word:

runGame :: Puzzle -> IO ()
runGame puzzle = forever $ do
  gameOver puzzle
  gameWin puzzle
  putStrLn $ "Current puzzle is: " ++ show puzzle
  putStr "Guess a letter: "
  guess <- getLine
  case guess of
    [c] -> handleGuess puzzle c >>= runGame
    _ ->
      putStrLn
        "Your guess must\
        \ be a single character"

Give the player a word and run it:

main :: IO ()
main = do
  word <- randomWord'
  let puzzle =
        freshPuzzle $ fmap toLower word
  runGame puzzle

An example run:

Current puzzle is: _ _ _ _ _ Guessed so far: 
a
Guess a letter: Your guess was: a
This character wasn't in the word, try again.
Current puzzle is: _ _ _ _ _ Guessed so far: a
e
Guess a letter: Your guess was: e
This character wasn't in the word, try again.
Current puzzle is: _ _ _ _ _ Guessed so far: ea
i
Guess a letter: Your guess was: i
This character wasn't in the word, try again.
Current puzzle is: _ _ _ _ _ Guessed so far: iea
o
Guess a letter: Your guess was: o
This character was in the word, filling in the word accordingly
Current puzzle is: _ _ o _ _ Guessed so far: oiea
u
Guess a letter: Your guess was: u
This character was in the word, filling in the word accordingly
Current puzzle is: _ _ o u _ Guessed so far: uoiea
t
Guess a letter: Your guess was: t
This character wasn't in the word, try again.
Current puzzle is: _ _ o u _ Guessed so far: tuoiea
p
Guess a letter: Your guess was: p
This character wasn't in the word, try again.
Current puzzle is: _ _ o u _ Guessed so far: ptuoiea
c
Guess a letter: Your guess was: c
This character was in the word, filling in the word accordingly
You lose!
The word was: byous

Now it's your turn. Take the game and make it better. Heres some ideas:

Add constraints on what words it can pick, as it sometimes picks some bizarre words.

Give the player more guesses (sometimes the words are longer than seven!).

Fix the bug that tells you that you lost even if your 7th guess supplies the last letter in the word correctly.

Make it to where only incorrect guesses count towards the guess limit.

References

A Gentle Introduction to Haskell - https://www.haskell.org/tutorial/io.html

The Haskell Tool Stack - https://docs.haskellstack.org/en/stable/README/

Haskell Book - https://haskellbook.com/