Min-Maxing Your Haskell Imports (examples)

Posted on June 9, 2019 by Robert Djubek


Real Examples of Usage and Rationale#

In the previous post there were not a lot of examples of what the programs actually do or how to use them effectively. Here you will find more helpful information (hopefully). I will also attempt to explain reasons you should use minimal imports in your programs.

Maximal imports#

We’ll start with the simpler maximal-imports program. The effect on the imports is small but it does not require any changes to work.

An actual maximal-imports.hs#

{-# LANGUAGE OverloadedStrings #-}

module Main where

import Data.Maybe
import Data.Text (Text)
import qualified Data.Text
import qualified Data.Text.IO

filterImports :: Text -> Maybe Text
filterImports t =
  let a = fst $ Data.Text.breakOn "(" t
   in if Data.Text.isPrefixOf "import" a
        then Just $ Data.Text.strip a
        else Nothing

adjustText :: Text -> Text
adjustText oldText = newText
  where
    oldLines = Data.Text.lines oldText
    newLines = mapMaybe filterImports oldLines
    newText = Data.Text.unlines newLines

main :: IO ()
main = Data.Text.IO.interact adjustText

Example no. 1#

As you can see it is quite simple. Notice line 6 which reads import Data.Text (Text)

Let’s run the program on itself and see what happens.

To do this we use vim (or in emacs evil mode or your editors equivalent)

Make sure the program is compiled:

ghc -O2 maximal-imports.hs
vim maximal-imports.hs

In visual line mode (V) select these lines:

import Data.Maybe
import Data.Text (Text)
import qualified Data.Text
import qualified Data.Text.IO

Still in visual mode type

:!./maximal-imports

If you have put it on your path you may ommit the ./

Your imports will now read as follows:

import Data.Maybe
import Data.Text
import qualified Data.Text
import qualified Data.Text.IO

See the difference? import Data.Text (Text) has been changed to import Data.Text

This is not a large change and it may not be obvious why you would want to do this. It’s a trivial change to make if you had reason to so why do we need a program? Why would we even want to make the change in the first place? First, Let’s see the effect on a different set of imports. The following is from a minimal-imports.hs.

-- Main.imports
import Data.Maybe (isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text
  ( breakOnEnd
  , isPrefixOf
  , isSuffixOf
  , lines
  , pack
  , strip
  , unpack
  )
import qualified Data.Text.IO (getContents, putStrLn, readFile)
import Safe (headMay)
import System.Directory (getCurrentDirectory)
import System.Directory.Extra (listFilesRecursive)
import System.FilePath (takeFileName)

After we complete the same process of opening in vim, selecting the imports, and running :!maximal-imports (assuming it’s on your path this time) we end up with this:

import Data.Maybe
import Data.Text
import qualified Data.Text
import qualified Data.Text.IO
import Safe
import System.Directory
import System.Directory.Extra
import System.FilePath

This is a much more drastic change! As you can guess, that would take a bit longer to do by hand. If you were doing this often to files with many imports it would be tedious. (Thus the creation of these programs) It’s possible your program will not compile immediately after this. If more than one module imports something named the same you’ll get conflicts. All you need to do is modify the problematic import statements to resolve the conflict (Use qualified or explicit imports) This is actually one of the reasons to use minimal imports in the first place… see Rationale near end.

Why?#

The original form with all of the imports listed explicitly is ideal. That’s what we want the final result to look like. The downside is when the imports are in that form it makes it harder to develop. You constantly have to modify the imports when you want to use another function or data type from a module. Your editor might not provide as useful automatic completion if the whole module is not in scope. GHC will complain. Your program won’t compile without adding imports. It might at least suggest what you need to import but then you have to move to the top of the file and edit the list and break your workflow. It’s much easier to temporarily relax the imports while adding features and then go back to minimal (explicit) imports when you’re finished.

That’s great, but…#

I know, I know, now that you’ve removed all of the explicit imports, you have to add them back… which is a lot of extra work there wasn’t before. That’s why I made another program that does the opposite. :)

Minimal imports#

Prerequisites#

The maximal-imports program works without having to change any part of your process. You don’t have to modify compiler flags, you don’t have to add any comments; it just works. minimal-imports requires a tiny bit of setup on your part. You need to add 1 comment and enable and use the ghc flag -ddump-minimal-imports. You can do this with ghc, ghcid, or add it to your cabal file ghc-options, or however you add ghc flags to your build system.

The comment you need to add is of the form -- M.imports and it’s best to put this above your imports. Make sure to select it along with your imports when you run the program. (in the previous example it was -- Main.imports It’s the full name of your module. -- Foo.Bar.imports for a module in src/Foo/Bar.hs GHC will create a file with the required information. minimal-imports will use the comment to find the right file.

To summarize:

  • enable and compile your program with the GHC flag -ddump-minimal-imports
  • have a comment of the form -- Module.Name.imports that you select
  • use program the same as maximal-imports except instead type :!minimal-imports

We can compile the program (shown later):

ghc -O2 -ddump-minimal-imports minimal-imports.hs

It is important to have compiled your program more recently than you’ve last added any imports or you will not get correct results. This is why ghcid is useful here. You could do something like this:

ghcid --command="ghci -ddump-minimal-imports minimal-imports.hs"

I highly recommend ghcid in general but it is especially useful here.

If you forget to select the comment or if the file GHC creates can’t be found (maybe you forgot to enable the flag, or compile, or typed the module name wrong) you should end up with unchanged imports. If something bad happens make sure sure you can undo somehow. Nothing bad has happened yet but… the program is still new and unproven. :)

An actual minimal-imports.hs#

{-# LANGUAGE OverloadedStrings #-}

module Main where

-- Main.imports
import Data.Maybe (isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text
  ( breakOnEnd
  , isPrefixOf
  , isSuffixOf
  , lines
  , pack
  , strip
  , unpack
  )
import qualified Data.Text.IO (getContents, putStrLn, readFile)
import Safe (headMay)
import System.Directory (getCurrentDirectory)
import System.Directory.Extra (listFilesRecursive)
import System.FilePath (takeFileName)

findFile :: (FilePath -> Bool) -> FilePath -> IO (Maybe FilePath)
findFile p path = do
  names <- listFilesRecursive path
  return $ headMay (filter p names)

findImportsFile :: Text -> IO (Maybe FilePath)
findImportsFile f = do
  cd <- getCurrentDirectory
  findFile (\x -> takeFileName x == (Data.Text.unpack f :: FilePath)) cd

filterImportsName :: Text -> Maybe Text
filterImportsName t = name
  where
    isComment = Data.Text.isPrefixOf "--" t
    isImport = Data.Text.isSuffixOf ".imports" t
    name =
      if isComment && isImport
        then Just $ Data.Text.strip $ snd $ Data.Text.breakOnEnd "--" t
        else Nothing

main :: IO ()
main = do
  input <- Data.Text.IO.getContents
  let importFileName = mapMaybe filterImportsName $ Data.Text.lines input
  importFile <-
    case headMay importFileName of
      Nothing -> return Nothing
      Just file -> findImportsFile file
  importContents <-
    case importFile of
      Nothing -> return input
      Just file -> do
        x <- Data.Text.IO.readFile file
        return $ "-- " <> Data.Text.pack (takeFileName file) <> "\n" <> x
  let output = importContents
  Data.Text.IO.putStrLn output

Example no. 2#

The above program is the source of minimal-imports.hs after running minimal-imports on what looked more like the previous example of maximal-imports:

import Data.Maybe
import Data.Text
import qualified Data.Text
import qualified Data.Text.IO
import Safe
import System.Directory
import System.Directory.Extra
import System.FilePath

(The complete program has been formatted by hindent, the exact version before formatting may sometimes be on fewer lines but should otherwise be verbatim)

Rationale#

PVP Bounds#

If you use explicit/minimal imports you’re more resilient when it comes updates of your program dependencies. It’s a lot less likely an update to a module will break your program by introducing nameclashes. You can use major verison upper bounds instead of only minor version upper bounds (more) safely. Because you’re explicitly importing only the things you need a module can’t add something that conflicts with your other imports or your own program. This is a good thing and it’s expected you to do this.

More Maintainable#

When you can look at the program imports and see exactly what module something is being brought into scope from it makes it a lot easier to understand and maintain your codebase. As well as helping avoid potential nameclashes. It just makes sense to do it.

You Were Already Doing It#

This isn’t anything new. All it does is automate a process that you were (or should have) been already doing. Normally you would enable the GHC flag and copy paste if you wanted the compiler to do most of the work. Many people do it by hand. Both of these are somewhat error prone, tedious and time consuming. This helps make it a bit easier. I also had fun writing it… but mostly I was tired of doing it by hand to this very site. I needed to have something to write posts about, didn’t I?