Road to Haskeller #22 - DeepSeq

Last Edited: 7/31/2024

The blog post introduces how we can force full evaluation using DeepSeq in Haskell.

Haskell & DeepSeq

Recap

In the last article, we discussed how the seq function forces evaluation of the first argument until it is in weak head normal form (WHNF), which is different from normal form. But how can we force evaluation until an expression is in normal form or fully evaluated? This is where DeepSeq comes in.

NFData

In Control.DeepSeq, there is a typeclass NFData which stands for normal form data. There are many predefined datatypes that are already defined as valid instances of NFData within the package.

import Control.DeepSeq
 
instance NFData Bool
instance NFData Char
instance NFData Maybe 
-- etc.

For a datatype to be a valid instance of the NFData typeclass, you will need to define the rnf (reduce to normal form) function, which forces full evaluation of the argument in the data type.

data PeaNum = Succ PeaNum | Zero deriving Show
 
instance NFData PeaNum where
  rnf Zero = ()
  rnf (Succ a) = rnf a

In the above example, we defined PeaNum recursively and made it an instance of NFData by defining rnf that recursively apply rnf until it reaches Zero. This can fully evaluates PeaNum. Let's look at another example.

data Tree a = Node (Tree a) a (Tree a) | Leaf deriving Show
 
-- You need `a` to be also a valid instance of NFData for 
-- fully evaluating Tree a
instance NFData a => NFData (Tree a) where
  rnf Leaf = ()
  rnf (Node l v r) = rnf l `seq` rnf v `seq` rnf r

The example above uses the seq function to force the evaluation of the left-subtree before evalating v and then evalating r.

deepseq

Once we make a data type a valid instance of NFData we can use the deepseq function to convert the data to normal form by calling rnf.

five = Succ $ Succ $ Succ $ Succ $ Succ Zero
 
-- ghci> :sprint five
-- five = _
-- ghci> deepseq five ()
-- ()
-- ghci> :sprint five
-- five = Succ (Succ (Succ (Succ (Succ Zero))))

Instead of the seq function that only evaluates until it is in WHNF (five = Succ _), the deepseq function evaluates fully until it is in normal form (five = Succ (Succ (Succ (Succ (Succ Zero))))).

Why DeepSeq?

In some cases, we really need to fully evaluate an expression, such as in the IO action like below.

import System.IO
import Control.DeepSeq
 
main = do
    h <- openFile "f" ReadMode
    s <- hGetContents h
    s `deepseq` hClose h
    return s

In the above IO action, we open a file, get the content in the file, and close the file. Before we close the file, we need to make sure that we have obtained the content by fully evaluating hGetContents h so that we are not left with a thunk that cannot be obtained after closing the file. In such cases, we can use deepseq.

As we have discussed already, Haskell code should remain lazy and only has strictness where it is unavoidable. Be sure to use it wisely.

Exercises

This is an exercise section where you can test your understanding of the material introduced in the article. I highly recommend solving these questions by yourself after reading the main part of the article. You can click on each question to see its answer.

Resources