Learning Haskell during AOC
Where to start🔗
In Haskell's official page there wasn't an official guide to learn Haskell, but there were some interesting links. I ended up reading Learn you a Haskell for Great Good, which did teach me a lot and helped me solve most of the problems I had during the AOC. I also read a lot of documentation at Hackage and used Cabal to help me run my programs and manage my dependencies.
The first thing that really got my attention was the automatic currying. Functions in Haskell can only accept one argument. Knowing that, it would be fair to ask "how can you write code with functions only taking one argument?". The answer relies in Higher Order functions. Here we can see a simple Haskell function with its corresponding signature.
add :: (Num a) => a -> a -> a add x y = x + y
If we take a closer look at the signature, we can see it takes something of a type
a which must implement the Num typeclass (more on that later), but then it returns another function
a -> a, which is the one that really calculates the final result. This might seem odd at first, but it allows us to do things like this.
map (add 1) [1, 2, 3] -- Adds 1 to each element of the list
In fact, if we know that all operators can be used as functions in Haskell, we can do the following:
map (+ 1) [1, 2, 3] -- Adds 1 to each element of the list
Which also works! This allows to express some operations in a very concise way.
Coming from a Rust background, another thing I noticed was that lot of the syntax and names were similar to the Rust equivalents. For example,
derive means more or less the same in both languages and both use
let as a keyword to declare new variables. But the similarities go much deeper than just syntax and names. Rust's traits mimic the behavior of Haskell's typeclasses and Rust's enums also remember Haskell's sum types, which also explains why Rust's
Option is so reminiscent of Haskell's
Maybe. Furthermore, both languages feature pattern matching and guards. It seems clear to me that Haskell and other functional programming languages really influenced Rust. Of course there are other things that are way different in Haskell.
Hard to read symbols🔗
Haskell can really differ from other languages, specially when you encounter symbols like
<*>, which are all commonly used in Haskell. To a newcomer they can be rather disorienting, even if they are easier to write when you already know the meaning. This got me thinking. In the end every language does this, it's just that Haskell forces that limit, resulting in more concise code, but harder to read for people who haven't been exposed to Haskell yet. It is difficult to say at which point the heavy usage of symbols in a language results in hard to read code, even for experienced people in that language. It may even vary with each person. I have read some code in APL and I don't think too many people will get angry at me if I say it is too much when you feel the need for an specific keyboard.
One powerful thing about Haskell is how easy it is to compose new functions using the ones you already have. The
. operator allows to write this type of code really easily. One example:
-- New function that adds 1 to each element of a list and then filters the numbers bigger than 2 foo = filter (> 2) . map (+ 1)
Of course, the
. operator alone wouldn't achieve this if it wasn't for functions like
Throughout the AOC I found some problems when reading the input for the day. Some days the input would be rather complicated and I would struggle to parse it correctly into the data structures I had chosen. That all went away when I stumbled on this page explaining parser combinators. They are really simple to use. For example, imagine we need to parse IPs, we could do the following:
parseIPv4 :: ReadP IP parseIPv4 = do a <- readOctet char '.' b <- readOctet char '.' c <- readOctet char '.' d <- readOctet return $ IPv4 [a, b, c, d] where readOctet = fmap read $ many1 $ satisfy isDigit
It works mostly in a declarative way. We specify our requirements and then the combinators do their best to find matches with what we specify. In this case we are specifying four octets separated by dots. Each octet is composed by at least one digit and the whole number is parsed to an integer, so the final IPv4 representation is a list with four numbers. Keep in mind that
return here doesn't mean the same that in other languages, but rather means the IPV4 we are returning will be wrapped in the
ReadP monad (More on monads later).
Of course, you could say that IPv4 can be expressed in different ways and that parsing them is more complex than what appears here... and you would be right! This is only an example, but it allows me to illustrate how clean and simple parser combinators are.
If we also wanted to parse IPv6, it would be as simple as defining the correspondent combinator for the IPv6 and then combining both:
data IP = IPv4 [Int] | IPv6 [String] deriving (Show) parseIP :: ReadP IP parseIP = parseIPv4 +++ parseIPv6
+++ operator parses the input with both combinators. We can access both results (if they are succesful) when we use the corresponding
ReadP. In this case we know an IPv4 will never have the same format as an IPv6.
I hope I have highlighted how useful parser combinators are. As I said this was really a game changer for parsing the different inputs of the AOC.
They say it is impossible to talk about Haskell without mentioning monads, so here we are. I had heard about monads, but I never got a clear idea about what they were, so it was nice finally knowing about them. I would lie if I said I know every aspect of them, but I have used a few of them. There are some that are somewhat simple like the
Maybe was the easiest one for me, because of its resemblance with Rust's
Option. The idea that a value can be there or not is something rather simple also, even if it does have some important implications.
From the monads I used, the one I had the most difficulties with was the
State monad. One important thing about Haskell is that normal functions are pure. They have a certain input and produce a certain output, but they don't store values of any kind. Nonetheless, there are certain cases in which such thing is useful and I happened to be in one of those situations during the AOC. My takeaway is that, even if stateful computations are possible in Haskell, they are harder to do, because they don't align well with Haskell philosophy. I also have to say they probably get way easier with practice and with a better understanding of monads.
Learning Haskell while doing the AOC challenges was hard, some days I felt like the language itself was designed for the task I had to do, but other days I just wanted to go back to a "normal" language. In the end, it was worth it. Even if I had already programmed in a functional style, Haskell still broke my preconceptions several times and forced me to think in a different manner, which I find really valuable. I really liked Haskell and would like another chance to use it.
And that's it! Hope you liked the post! It was more of a story than anything else, but it's something I wanted to share. If you want to check out my solutions to the AOC, you can do so here.