A Month of Haskell, Day 4 - hlint

Posted on May 5, 2017 by Chris Lumens in .

I got too busy last night and forgot to write about Haskell, so now day four occurs on the fifth of May. These things happen. Today I wanted to talk about something much quicker than the last installment. As you may remember from day 3 one of the many programs we installed was hlint. We made it work with the editor, but it’s easy to run it on its own.

hlint is a program that serves two purposes. First, it examines your source code and makes suggestions about how you could rewrite things. It does this by comparing source to a long lists of tests it has and seeing if anything matches. If so, it spits out a suggestion. Second, it tries to teach you how to write better and more stylistically correct Haskell code. By seeing these suggestions over and over, you’ll start to write your code that way without even really thinking about it.

There’s also two important notes I’d like to make before we get started. First, hlint is only smart enough to make small suggestions at a time. You may have to go through an hlint and edit cycle several times on the same file before it stops suggesting things. Second, don’t just blindly accept its suggestions. I find some of them really hurt readability. We’ll look at how to ignore suggestions later.

Installing

If you haven’t been following along, installing it is easy:

$ cabal install hlint
<lots of downloading, configuring, and installing>
Installed hlint-2.0.5

It’s got a bunch of command line options, but most of the time you can just run it with either a single source file or a directory. Let’s look at some source code and what hlint thinks.

Applying suggestions

map (id) [0..10]

This extremely dumb block of code gets hlint to complain about two different things:

-:1:1: Warning: Use id
Found:
  map (id)
Why not:
  id

-:1:5: Warning: Redundant bracket
Found:
  (id)
Why not:
  id

2 hints

Its first suggestion is that map id is a dumb thing to do, and an equivalent is just id. It can spot a lot of this kind of thing, as we will continue to see. Its second suggestion is that there’s no reason to add parens there. It’s pretty good at spotting unnecessary parens and dollar signs.


when (not $ null call)

This is slightly less dumb code, and is something I actually had in a project. I think it probably got there due to me refactoring part of it at one point. Anyway, hlint suggests:

-:1:1: Warning: Use unless
Found:
  when (not $ null call)
Why not:
  unless (null call)

1 hint

This is kind of a two step suggestion - We can get rid of the not by changing when to unless. It’s still the same program.


map (\tup -> toUpper (snd tup))
    [(1, 'a'), (2, 'b')]

Here, hlint suggests getting rid of a lambda that isn’t needed, making for more compact (therefore potentially more readable?) code:

-:1:6: Suggestion: Avoid lambda
Found:
  \ tup -> toUpper (snd tup)
Why not:
  toUpper . snd

1 hint

For the most part, I think these have been pretty obvious. hlint has tons of suggestions for these simple substitutions:

Some of the more unusual ones are where it suggests using >=> or ***, out of Control.Arrow. You can decide for yourself whether these are more or less readable.

\(a, b) -> (abs a, b)
-:1:1: Suggestion: Use first
Found:
  \ (a, b) -> (abs a, b)
Why not:
  Control.Arrow.first abs

1 hint

\tup -> (abs (fst tup), negate (snd tup))
-:1:9: Suggestion: Use ***
Found:
  (abs (fst tup), negate (snd tup))
Why not:
  (abs Control.Arrow.*** negate) tup

1 hint

Ignoring suggestions

Sometimes, it suggests things that I think do more harm than good. Two in particular are the functor law suggestion and the eta reduction suggestion. It would not be hard to end up with the following code if you are grabbing something from a network, passing it through a variety of filters, and returning it:

a <$> b <$> c <$> d

hlint says:

-:1:1: Warning: Functor law
Found:
  a <$> b <$> c
Why not:
  (a . b <$> c)

1 hint

If you were to apply that suggestion, you would end up with:

(a . b <$> c) d

I think that looks much more complicated. The chain of <$> isn’t the most visually pleasing piece of code ever written, but it is easy to figure out how it works. The suggested replacement involves two operators, function application, and remembering precedence rules.

You can tell hlint to knock it off by adding a special pragma to your source file. The manual goes into details about all the various ways to tell it to ignore things, but most of the time you should just put this in your source file:

{-# ANN module "HLint: ignore Functor law" #-}

If you add it in a module, you should either put it as the very first line or inside the module, after any imports. If you don’t, you’ll get the following unhelpful error message:

Slog/DB.hs:57:1: Error: Parse error: import
Found:
  > import Control.Applicative((<$>))
    
    import Control.Monad(void, when)
  

1 hint

The eta reduction ones can be obfuscating. Not every function benefits from having parameters removed just because they can be:

withInnerIndices size fn =
    forM_ (indices (1, 1) (size - 2, size - 2)) fn
./src/Game/Util/Array.hs:77:1: Warning: Eta reduce
Found:
  withInnerIndices size fn
    = forM_ (indices (1, 1) (size - 2, size - 2)) fn
Why not:
  withInnerIndices size = forM_ (indices (1, 1) (size - 2, size - 2))

Adding suggestions

You can also add your own suggestions by placing them into a .hlint.yaml file in the top level of your project’s source tree. The default set of suggestions is a good place to look for examples, though you don’t need to be as fancy as that file. The basic format you want to use is:

- warn: {lhs: "some haskell code to match", rhs: "suggested replacement"}

You can optionally provide a name if you want one displayed when matches are found. In your lhs and rhs, it’s important to know that hlint treats every single-letter identifier as a wildcard. Consider this rule:

- warn: {lhs: ($) . f, rhs: f, name: Redundant $}

When given the code negate $ 5, hlint suggests you use negate 5 instead. For the lhs, it sees the single-letter f identifier and matches that up with the 5. When it comes time to output a suggestion, it substitutes the 5 in place of the f everywhere in rhs that it occurs.

Back on day 2, I came up with a module that enforces uppercase string comparisons. When I originally did that in my source code, I went through and changed every instance of fromString $ T.unpack s to fromText s by hand. Of course, I missed a couple. I did something similar for the asText helper function I added, too. I missed a couple there.

The following hlint rules found all the places I missed, and will ensure I don’t miss any in the future:

- warn: {lhs: "Data.Text.pack (getUpperString x)", rhs: "Slog.UpperString.asText x"}
- warn: {lhs: "fromString (Data.Text.unpack x)", rhs: "Slog.UpperString.fromText x"}

hlint only imports certain modules by default. For everything else, you either need to add the import to your .hlint.yaml file or use the fully qualified path to the function. Another thing to think about is that I could be using pack and getUpperString in many different ways. All the following would be equivalent:

pack $ getUpperString x
(pack . getUpperString) x
pack $ getUpperString $ fn x
pack $ getUpperString (fn x)

You may need to experiment a bit, but the form I’ve listed seems to catch everything.

Missing suggestions

hlint only knows about operators in the Prelude module. If you use a module that defines its own operators (basically, anything with a fixity declaration), hlint will blow right over any lines using those operators without suggesting anything. For my UpperString rules above, lines like these were getting completely ignored:

set wStateEntry [ #text := T.pack (getUpperString st) ]

This is some fancy Haskell that uses the OverloadedLabels extension (that’s what #text is all about) and the gobject-introspection based GTK module to do some UI programming. Because of the fancy := operator, hlint was missing it entirely. The fix is to add a line like this to your .hlint.yaml file:

- fixity: infixr 0 :=

You can get the infixr 0 portion of that line from the definition of the operator. Here, I just looked it up in the documentation.

Conclusion

And that’s really all there is to it. With hlint integrated into vim (and sometimes running it standalone before committing), you’ll catch some dumb code that snuck through and learn more typically Haskell ways of doing things. And as you find yourself adding your own helper functions and types, you’ll think of new tests you could add to make your code better. It’s all about writing better code and eliminating the potential for bugs.