TomP
TomP

Reputation: 108

Haskell Programming Style Preference

I've been doing Haskell for awhile (small projects ~ 3K LOC) and I still feel like a newbie. I don't believe that I have a good Haskell style; I generally go for map/filter/fold. No fancy monads / applicatives etc.

I'd like to improve. I had a simple requirement to generate the sub-harmonics of 377 megahertz, and print it out in table form with 8 columns (arbitrary) so I wrote it three ways. (I know I could use the 'boxes' package but this was an exercise for me).

I'd really like feedback as to which would be 'preferred' or another different way of doing it that's more 'Haskell'. (I found the list comprehension the most difficult as I was trying to do it without a 'map')

I was proud of myself .. for the first time I used an applicative!

Comments appreciated, including places I could see good Haskell style. I've looked at large packages (i.e. Megaparsec) and they use tricks and language extensions, that are difficult for me to follow. I'd like to be able to understand them eventually but it's overwhelming right now.

Thanks!

Tom

import Data.List (intercalate)
import Text.Printf
import Data.List.Split (chunksOf)

gen :: [Float]
gen =  pure (/) <*> [377] <*> [2,3..50]

main :: IO()
main = do

    -- Try One --  ... List function
    let ps =  map (\f -> printf "%7.2f\t" f) gen
    putStr $ concat (intercalate ["\n"] (chunksOf 8 ps))

    putStr "\n"
    
    -- Try Two -- ... IO Map
    mapM_ (\xs -> (mapM_ (\x -> printf "%7.2f\t" x) xs)
                       >> (printf  "\n")) (chunksOf 8 gen)

    -- Try Three -- ... List Comprehension
    putStr $ concat [ ys' | ys  <- (chunksOf 8 gen),
                            ys' <- (map (\y -> 
                               printf "%7.2f\t" y) ys) ++ ["\n"] ]


Upvotes: 0

Views: 256

Answers (2)

K. A. Buhr
K. A. Buhr

Reputation: 51224

One way of improving your style is to get in the habit of aggressively refactoring your code, making use of "laws" governing certain operations, and always seeking out existing functions (especially anything in base) that might replace something you've hand coded. This will not automatically lead to better style and may, in some cases, do the opposite. However, it will help you practice the skills you need to write Haskell code in a good style (or, more importantly, take Haskell code you've initially written in a poor style and improve it.)

For example, your applicative operation:

gen =  pure (/) <*> [377] <*> [2,3..50]

applies a pure function to applicative arguments. By the applicative laws, this can be simplified to a use of the <$> operator (an operator version of fmap):

gen = (/) <$> [337] <*> [2,3..50]

Your first argument, [337] is actually also pure. For the "list" applicative, pure values are singletons. Therefore, this can be refactored as:

gen = (/) <$> pure 337 <*> [2,3..50]

This is a step backward for readability, but it's also the case that an applicative application (i.e., <*>) of a pure function to a pure argument can be replaced with a pure partially applied function. In other words, the applicative laws mean that:

f <$> pure x <*> ... = pure f <*> pure x <*> ... = pure (f x) <$> ... = f x <$> ...

So, we have:

gen = (/) 337 <$> [2,3..50]

or, using a "section":

gen = (337 /) <$> [2,3..50]

Is this good style? I don't know. Maybe a list comprehension is better:

gen = [337 / n | n <- [2,3..50]]

But I do think that either of these is better than the original:

gen =  pure (/) <*> [377] <*> [2,3..50]

not because the original was terrible style but because both of these alternatives are semantically equivalent while being easier to read and/or understand, which should be one of the primary goals of programming style.

Above, I've made it sound like you've got to keep all these complicated "laws" in mind and consciously apply them, making refactoring a tedious and error-prone process. But, thanks to lots of applicative refactoring practice, I find this transformation completely automatic. I rewrote:

gen =  pure (/) <*> [377] <*> [2,3..50]

to:

gen = (337 /) <$> [2,3..50]

in one step because it was completely obvious to me, as it would be to any Haskell programmer who'd spent a little time refactoring applicative expressions. Well, okay... technically, I first rewrote it to:

gen = (/ 337) <$> [2,3..50]

but quickly fixed my mistake. Also, I eventually realized the step size was 1, leading me to rewrite the list from [2,3..50] to [2..50]. Arguably this isn't more readable, but the first version might suggest to experienced Haskell programmers that there's a step size other than 1 being used, causing a bit of confusion (as it did for me).

Similar "laws" governing function composition allow you to take a chunk of code like:

-- Try One --  ... List function
let ps =  map (\f -> printf "%7.2f\t" f) gen
putStr $ concat (intercalate ["\n"] (chunksOf 8 ps))
putStr "\n"

and instantly refactor it into:

putStr $ concat . intercalate ["\n"] . chunksOf 8 . map (printf "%7.2f\t") $ gen
putStr "\n"

and some knowledge of library functions allows you to further refactor into:

putStr $ unlines . map concat . chunksOf 8 . map (printf "%7.2f\t") $ gen

or even:

putStr $ unlines . map (concatMap (printf "%7.2f\t")) . chunksOf 8 $ gen

or even:

putStr $ unlines . (map . concatMap . printf) "%7.2f\t" . chunksOf 8 $ harmonics

Not all of these refactors will lead to better style. For example, that last one should probably only be used as a joke. But this kind of manipulation of Haskell programs is a prerequisite for knowing what options are available to you in more realistic programs as you try to improve the style of a particular block of code.

Also, when refactoring, you'll want to take a step back now and then and think about whether a large, complicated transformation you've performed can't be reimagined in a simpler way.

I took a look at your solution #1 and thought about it this way:

  • Tabs are a tool of the devil, and the output should use spaces only.
  • printf is just as capable of formatting 8-character cells as 7-character cells
  • To get 8-column rows, we can just smash everything together than grab the rows 64 characters at a time.
  • There's an unlines library function to make a multiline string out of a list of rows.

So:

main = putStr $ unlines . chunksOf (8*8) .
    concatMap (printf "%8.2f") . map (337 /) $ [2,3..50:Double]

Is this good style? Maybe for a quick programming exercise but for real code, emphatically "no, it's terrible". In production code, I'd probably write:

table :: Int -> Int -> [Double] -> String
table cols cellwidth = unlines . chunksOf linesize . concatMap cell
  where linesize = cols*cellwidth
        cell = printf "%*.2f" cellwidth

harmonics :: [Double]
harmonics =  map (337 /) [2..50]

main = putStr $ table 8 8 harmonics

which clearly separates the generation of the data in harmonics from the typesetting in table from the IO in putStr. It also gets rid of all the magic constants in the table code by making them explicit arguments with understandable names. After thinking about it a bit more, I might decide that the "smash everything together and use a fixed line length" is too much of hack, so probably:

-- Typeset list of values into table.
table :: Int -> Int -> [Double] -> String
table cols cellwidth = unlines . map (concatMap cell) . chunksOf cols
  where cell = printf "%*.2f" cellwidth

is better, even though the operation map (concatMap cell) is a confusing one. However, this probably doesn't matter if table has a reasonable comment and its arguments have nice names.

Anyway, the point is that even though not all refactoring leads automatically to good style, refactoring skills are absolutely necessary for consistently writing good style.

With respect to finding sources for good Haskell style, I found when I was learning Haskell that well written tutorials and blog posts were good sources, even if they weren't specifically about style. For example, Write You a Scheme and Intro to Parsing with Parsec in Haskell both have Haskell source written in a good style. They also have the advantage that they are aimed at people who don't know the tricks and language extensions that you'd find in real world Haskell libraries, plus you'll learn something really useful and interesting (e.g., how to write interpreters and Parsec parsers) while picking up their good style.

Upvotes: 1

amalloy
amalloy

Reputation: 92147

  • I'm a fan of Applicative style, but even I would write gen as map (377 /) [2..50].

  • All three versions write (\x -> printf "%7.2f\t" x), which is just a more complicated way to write (printf "%7.2f\t"). The fewer lambda parameters there are to mentally keep track of, the better.

  • Most of the effort in versions 1 and 3 is struggling to interleave the newlines in the middle of the string. But there's already a library function for that: unlines. With this function, the whole problem is easy:

    main = putStrLn . unlines . map renderRow . chunksOf 8 $ gen
      where renderRow = concatMap (printf "%7.2f\t")
    

    Save your brainpower for other problems.

  • I don't like version 2: it interleaves IO and pure code, while 1 and 3 stay nicely in the pure world. If you are going to do it this way, though, I would use for_ and do instead of mapM_:

    for_ (chunksOf 8 gen) $ \line -> do
      mapM_ (printf "%7.2f\t") line
      putStr "\n"
    

    I think the indentation and line separators help a lot with making the structure apparent, compared to the parenthesis and lambda soup of mapM_. My personal rule of thumb is that I don't like to pass lambdas to functions in the map family. It's great for existing functions, or for partial application, but if you have to write a lambda you're probably better off with a list comprehension, something from the for family, or some other alternative.

  • Version 3 seems okay, but it's weird to see a map in the middle of a list comprehension binding. One thing to consider: if you struggle to write list comprehensions, maybe give do a try. I know I personally have trouble writing list comprehensions because I have to write the "body" first, before I've figured out what variables I'm binding. So instead of [f y | x <- whatever, y <- something x], I usually write

    do
      x <- whatever
      y <- something x
      pure $ f y
    

    This makes it more of a hassle to have filter conditions - you have to import Control.Monad.guard - but I like the less dense layout and the reversed ordering.

Upvotes: 3

Related Questions