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.
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.
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]
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
. app
directory.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?
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
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]
do
block.getLine
has type IO String
, because it performs I/O (input/output) side-effects to obtain the String
.<-
a bind (I will explain more about this in a later article).IO String
is String
. It is bound to a variable called name
.greet
expects an argument String
, which is the type of name
but not getLine
.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
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]
do
introduction.getLine
.<-
binds the variable on the left to the result of the IO action on the right.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.getLine
.do
block, return
.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
.
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.
-- 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]
forever
from Control.Monad
makes an infinite loop.toLower
from Data.Char
will allow us to convert all characters of the string to lowercase.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.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.exitSuccess
from System.Exit
allows the program to exit successfully.randomRIO
from System.Random
will help us to select a word from our dictionary at random.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
.
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]
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.
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/