I've posted to some extent about this in another topic (What should every programmer know about functional programming?), but basically four things, the first two of which are true of functional languages in general and the others of Haskell specifically.
As a brief summary, here are the lessons I've learned in hacking around with Haskell which apply to programming in general. It should be noted, however, that in many ways these are much easier to do with Haskell specifically:
- Eschew side-effects whenever possible. Strive to use immutable data structures. Write destructive methods only when absolutely necessary.
- Prefer lots of tiny functions which can be stitched together easily over a small number of monolithic functions.
- Try to avoid evaluating anything you don't have to. Prefer streams and generators to lists, arrays, and other iterables. Memoize as much as you can for expensive functions.
- Monads are pretty cool. It may be worthwhile (in terms of development time) to add monadic functionality to other languages.
At greater length:
What you have to gain from programming in a declarative style.
I'm talking here of eschewing side-effects as much as possible, regardless of whether you're using a language which enforces it.
It is so very much easier to work with a library of functions which you know will never secretly change something in the structures you give it or alter the control flow of the program entirely. When the project you're working on is still small, it can be convenient to have a generically-named function which makes these kinds of stateful changes because you happen to need those state changes in every context the function needs to be called, so it makes sense to combine them in. But as the project grows, I guarantee you that you will find uses for many of your functions in which it no longer makes sense to make these changes. In these cases, it becomes part of your boilerplate to undo the changes every time you call the function. You will forget from time to time, or make a typo in doing so, resulting in hours to days lost to debugging what should never have been a problem to begin with. Most commonly, this manifests as a deep copy of a structure before passing it in to a destructive function (i.e. one that changes the original instead of returning a new copy and leaving the original intact). When using a library you've written in an imperative language, I'm sure you find yourself having to repeatedly look up the internals of various functions to remember what lasting changes they make -- the stuff that's supposed to be abstracted away! But when programming in a declarative language, this is never required. Avoiding lookups to re-familiarize yourself with the gory details of commonly-used functions, compounded over a career in software development, add up to a tremendous savings.
Another huge benefit of using a declarative style reveals itself when writing unit tests. If you (or the language) has guaranteed that the same input will always produce the same output, you avoid entire classes of bugs pertaining to statement ordering. I've seen way too many data structures which keep some hidden internal state variable which dramatically changes the results and the state changes made by methods which operate on it; structures which can be "frozen" or "finalized" come most readily to mind. Stateful structures and methods explode the number of test cases you must make, and even then it's rare to nail down every last one. With immutable structures, you are guaranteed to never have this problem.
(You may think that heavy use of immutable structures will strain your garbage collector, but this is only really true in imperative languages because the runtime knows you could still make destructive changes if you wanted to. With functional languages, the assumption is instead that structures will not be modified, and so there are tons of optimizations which help structures share memory.)
These things can mean significant gains in development time and tons of saved debugging time. The latter cannot be understated.
How much easier your life is with lots of granular, easily-composable functions.
This is one is more difficult to see without hands-on experience with a functional programming language, but Haskell in particular has this knack for turning functions which are normally 20-30 lines into something like:
doAllTheThings input = foldl (foo . bar . baz) [] $ map (qux . quux) input
And that's it. I'm still regularly surprised about how concise many of my programs turn out to be. "That can't seriously be everything," I say to myself, and yet it is, edge cases included.
It tends to be much easier to do this in functional languages than imperative languages, even than in imperative languages which support high-level functions (i.e. functions which take other functions as arguments) and lambda expressions (i.e. "anonymous functions").
How common, complex, annoying programming patterns can be handled easily with monads.
I've run into this mentality in the past that new Haskellers tend to have about monads existing solely for the purpose of enabling the same kind of stateful operations that imperative languages have. While it's true that, for example, I/O has to be done through the IO monad, this is just the tip of the iceberg.
The two that most immediately come to mind, and which are easiest to understand, are the Maybe monad and lists.
The Maybe monad is great for gracefully handling failures in a sequence of error-prone operations.
Instead of code that looks like this:
func processFoo(foo Foo) Qux {
bar, err := transformFoo(foo)
if (err != nil) { return nil }
baz, err := transformBar(bar)
if (err != nil) { return nil }
qux, err := transformBaz(baz)
if (err != nil) { return nil }
return qux
}
You get code that looks like this:
processFoo foo =
transformFoo foo >>= transformBar >>= transformBaz
And the semantics are identical.
Lists can be useful as monads when you need non-determinism. In fact, you can basically get a breadth-first search for free with them.
For example, if you have a function which returns all of the next possible moves from a given position:
nextMoves :: Position -> [Position]
You can determine whether it's possible to reach one position from another with code that looks something like this:
reachable :: Position -> Position -> [Position] -> Bool
reachable start end curPositions
| end `elem` curPositions = True
| otherwise = reachable start end (curPositions >>= nextMoves)
(Definitely not the most efficient solution, but fast and easy to write and easily sufficient for a small problem space, e.g. for a Knight's Tour.)
For another neat trick using lists as monads, you can find the powerset for any set like so:
powerset :: [a] -> [[a]]
powerset = filterM (\_ -> [True,False])
You could think of this function as saying, "for every element in the set, return two universes: one in which the member is accepted and one on which it is not". When performed for every element, you get 2^n universes representing all combinations of list elements chosen or not chosen.
ghci> powerset [1..4]
[[1,2,3,4],[1,2,3],[1,2,4],[1,2],[1,3,4],[1,3],[1,4],[1],[2,3,4],[2,3],[2,4],[2],[3,4],[3],[4],[]]
Other useful monads include:
Lazy evaluation is free, convenient optimization.
One method of optimizing a program is to avoid evaluating expensive expressions unless you determine that you really need to. You might have some kind of planning phase which runs first, determines from given parameters which functionality is really needed, and then sets a bunch of control variables that the rest of the program will consult.
For (a bit of a pathological) example, let's say you're developing a database which divides its data into multiple (potentially huge) data files. You have a function which loads one of these data files into memory, including the meta-information stored at the beginning. When users begin complaining that getting a summary of the stored data takes too long, you realize that the entire database is being loaded and then almost all of it is being ignored. So you take a day or two to stick a planning phase in which will catch when the actual data is needed, and then modify your function to only load the data after the meta-information if it's actually going to be used. This works, reducing the time to get a summary by several orders of magnitude. As months fly by, you find more circumstances in which your database engine is doing more work than needed, and you build up a very complicated tree of conditional statements in your planning phase which set a dozen or more control variables meant to ensure that no CPU cycles or memory is wasted. It becomes infamous as more developers are added to the project and complain about having to maintain it, and you eventually need to bring in third-party solvers in order to verify that it's doing what you want it to do.
But if your runtime has lazy evaluation built-in, you get all of this for free; nothing, anywhere, ever, in your entire code base will be evaluated unless it actually turns out to be necessary. In all other cases, the runtime will lie about having evaluated the expression (saying that it did so instantaneously) and you will never know because you never end up trying to use the results. You basically get all of the above control in evaluating the minimum necessary, but much finer-grained and without all of that horrifying overhead or development/debugging time (although it can also be its own quirky mess if you want to optimize further).
In Haskell, lazy evaluation extends to I/O as well, so the source code of your database in the earlier example really could say "read the entire, humongous data file", and your runtime would come back instantly with "OK, done". When you ask for meta-information, it will read only enough to extract it before it stops reading, and give you the answer you wanted. If you never try to do any operations on the data, it will never waste time, disk seeks, or RAM by loading it.
Lazy evaluation has some miscellaneous other conveniences as well, such as infinitely-long lists. Using infinitely-long lists in simple expressions is a very common idiom in Haskell.
But the best part is that when you combine lazy evaluation with a declarative paradigm, you get the benefits of Memoization in all of your functions for free as well. Essentially, because the runtime knows that functions will always produce the same output for the same input, it can remember the output produced by any given input and then hand it back to you immediately without having to do all of the evaluation all over again. An imperative language could never have this kind of guarantee because the operation of any given function could always change based on some global control variables; you'd have to write it in for yourself every single time, as well as debug it and make sure the guarantees you're trying to make continue to hold as code changes.