Reputation: 108
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
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:
printf
is just as capable of formatting 8-character cells as 7-character cellsunlines
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
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