[personal profile] horrorcheck
Today, I worked with Steve McCarthy on my Haskell game. We decided to implement a really simple feature: listing all the game files (files ending in "exp") in the current directory. I want to share how we tackled this problem because it shows a lot of the steps in learning how to reason about Haskell when you're a beginner.

I'm using test-driven development as much as I can in this game, so we started with a test. Since I can't figure out how to test modules in Main (it imports the Main module in the test-suite instead), I pulled the function into its own module, Init.

This file was created in the test suite:

module InitSpec where

import Test.Hspec
import Init

main = hspec $
    describe "listGames" $
        it "lists the game files in the given directory" $
            listGames "games" `shouldBe` ["demo.exp", "example.exp"]
And this file in the library:

module Init where

listGames :: String -> [String]
listGames = undefined

As expected, this compiled but the test failed. "Yay, failure!"

So the next step is to figure out to make the test pass. Off to Hoogle! Hoogle says we can use getDirectoryContents, which has the type FilePath -> IO [FilePath]. FilePath to list of FilePaths! That's a way better description of what we need to do. We're stealing it.

import System.Directory

listGames :: FilePath -> IO [FilePath]
listGames dir = getDirectoryContents dir

So, we have a list of files, now we filter them by extension:

import Data.List (isSuffixOf)

listGames :: FilePath -> [FilePath]
listGames dir = filter ("exp" `isSuffixOf`) (getDirectoryContents dir)

Wow, it was so easy!

...or not.

    Couldn't match expected type `IO [FilePath]'
                with actual type `[[Char]]'
    In the return type of a call of `filter'
    In the expression:
      filter ("exp" `isSuffixOf`) (getDirectoryContents dir)
    In an equation for `listGames':
        listGames dir
          = filter ("exp" `isSuffixOf`) (getDirectoryContents dir)

    Couldn't match expected type `[[Char]]'
                with actual type `IO [FilePath]'
    In the return type of a call of `getDirectoryContents'
    In the second argument of `filter', namely
      `(getDirectoryContents dir)'
    In the expression:
      filter ("exp" `isSuffixOf`) (getDirectoryContents dir)

TWO type errors. Shoot. Okay, let's look at the first one. The type we said we expected in the type signature was IO [FilePath], but the return type of "filter" here is [[Char]] (aka [String]). The whole type of filter is (a -> Bool) -> [a] -> [a]. All those "a"'s have to be the same, so since we gave filter a function of type String -> Bool, it wants to give back a [String].

What about the second error? It seems to be the first in reverse. It's complaining that since the first argument of filter is a String, the second should be a list of Strings, and instead it's an IO list of filepaths.

How do we fix this?

Steve and I were pretty blank, but since I've seen stuff like this before I had a vague idea I needed fmap? or mapM? or something? And my usual technique is to throw a bunch of ideas around in ghci and figure out later why one works and another fails.

So I tried mapM_, and it gave me a crazy error, so I tried fmap, and then it compiled... yay... But WHY did it compile?

Let's look at the types of the parts of the filter that we are absolutely sure of and that make sense:

The directory listing:
getDirectoryContents "." :: IO [FilePath]
FilePath is a type synonym for String, so this is the same as IO [String]

and the filter:
filter (isSuffixOf "exp") :: [String] -> [String]`.

One way to think of the problem were were having is that we needed to "get out of IO". But another way to think of it is that the filter needs to get IN. We need to match up the types. fmap did that somehow. What is fmap's type?

fmap :: Functor f => (a -> b) -> f a -> f b

What's a Functor? Who cares? I don't want to get sucked into category theory when I'm trying to program! Instead, let's look at the rest of it. The first part, in parentheses, is a function from one type to another type. The next type is f a, so the first type inside some context. The final type is type b in the same context.

So in our function, this is what fmap is doing:

([String] -> [String]) -> IO [String] -> IO [String]

Exactly what we needed!

So, the function compiles in ghci, and now we understand why, but does our test pass?

NO. The test doesn't even compile! What?

    Couldn't match expected type `IO [FilePath]'
                with actual type `[[Char]]'
    In the second argument of `shouldBe', namely
      `["demo.exp", "example.exp"]'
    In the second argument of `($)', namely
      `listGames "./" `shouldBe` ["demo.exp", "example.exp"]'
    In the second argument of `($)', namely
      `it "lists the games in the directory"
       $ listGames "./" `shouldBe` ["demo.exp", "example.exp"]'
That's because we wrote this expecting to get a list of strings, not IO. We need to change the t a bit.

main = hspec $
    describe "listGames" $
        it "lists the games in the directory" $
            listGames "./" >>= (`shouldBe` ["demo.exp", "example.exp"])

This is how you test IO in Hspec, you stick a ">>=" in front of the shouldBe and wrap the rest in parens. Dunno exactly why, but it works. Now, let's try again!

$ runhaskell InitSpec.hs      

  lists the games in the directory

Finished in 0.0006 seconds
1 example, 0 failures

Tomorrow I'll add more about actually slotting this into the game... Hint: there were more type errors. :)



August 2015


Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags
Page generated Jun. 26th, 2017 08:38 am
Powered by Dreamwidth Studios