Reasoning about types is fun!
Mar. 30th, 2015 09:15 pm![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
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 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:
Wow, it was so easy!
...or not.
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:
and the filter:
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?
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!
Tomorrow I'll add more about actually slotting this into the game... Hint: there were more type errors. :)
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: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"]
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)
```
listGames :: FilePath -> [FilePath]
listGames dir = filter ("exp" `isSuffixOf`) (getDirectoryContents dir)
```
Wow, it was so easy!
...or not.
Init.hs:7:17:
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)
Init.hs:7:46:
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)
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)
Init.hs:7:46:
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?
InitSpec.hs:9:39:
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.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"]'
main = hspec $
describe "listGames" $
it "lists the games in the directory" $
listGames "./" >>= (`shouldBe` ["demo.exp", "example.exp"])
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
listGames
lists the games in the directory
Finished in 0.0006 seconds
1 example, 0 failures
Hooray!listGames
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. :)