In this post I hope to persuade you that Haskell is well-adapted to software engineering in the large. To motivate this post, I would like to begin with a simple thought experiment.
Suppose that we could measure short-term programmer productivity for two hypothetical programming languages named "X" and "Y". In practice, we cannot easily compare productivity between two programming languages, but just pretend that we can for the purpose of this example.
First, we will measure the short-term productivity of languages "X" and "Y" when the programmer carefully follows all best practices. These are made up numbers:
"Best Practices" (whatever that means):
7
+-----+
| | 5
Arbitrary | +-----+
Productivity | | |
Units | | |
| | |
| | |
+-----+-----+
X Y
Higher productivity is better, so from the above chart alone we might conclude that "X" is a better language than "Y", all other things equal.
However, all other things might not be equal and there might be other redeeming features of language "Y". Perhaps language "Y" excels when the programmer takes short cuts and throws best practices to the wind. Let's call this "worst practices" and measure developer productivity when cutting corners:
"Worst Practices" (whatever that means):
9
+-----+
| |
| |
Arbitrary | |
Productivity | |
Units | | 3
| +-----+
| | |
| | |
+-----+-----+
X Y
Weird! Language "X" still performs better! In fact, language "Y" did such a poor job that developer productivity decreased when they followed worst practices.
Therefore, the choice is clear: language "X" is strictly a better language, right?
Not necessarily!
Long-term productivity
I will actually argue that language "Y" is the superior language for large and long-lived projects.
All of our charts so far only measured short-term productivity. However, long-term productivity crucially depends on how much developers follow best practices. That's what makes them "best practices".
Perhaps we don't need to perform additional experiments to predict which language will perform better long-term. To see why, let's transpose our original two charts. This time we will plot short-term productivity for language "X" when following either best practices (i.e. "BP") or worst practices (i.e. "WP"):
Language "X":
9
+-----+
| | 7
| +-----+
| | |
Arbitrary | | |
Productivity | | |
Units | | |
| | |
| | |
+-----+-----+
WP BP
This chart highlights one major flaw in the design of language X: the language rewards worst practices! Therefore, developers who use language "X" will always be incentivized to do the wrong thing, especially when they are under deadline pressure.
In contrast, language "Y" rewards best practices!
Language "Y":
5
Arbitrary +-----+
Productivity 3 | |
Units +-----+ |
| | |
| | |
+-----+-----+
WP BP
A developer using language "Y" can't help but follow best practices. The more deadline pressure there is, the greater the incentive to do the right thing.
Developers using language "X" might compensate by creating a culture that shames worst practices and glorifies best practices in order to realign incentives, but this does not scale well to large software projects and organizations. You can certainly discipline yourself. You can even police your immediate team to ensure that they program responsibly. But what about other teams within your company? What about third-party libraries that you depend on? You can't police an entire language ecosystem to do the right thing.
Haskell
I believe that Haskell is the closest modern approximation to the ideal of language "Y". In other words, Haskell aligns short-term incentives with long-term incentives.
Best practices are obviously not a binary "yes/no" proposition or even a linear scale. There are multiple dimensions to best practices and some languages fare better on some dimensions and worse on others. Some dimensions where Haskell performs well are:
- Absence of
null
- Limitation of effects
- Immutability
- Generic types
- Strongly typed records
For each of the above points I will explain how Haskell makes it easier to do the right thing than the wrong thing.
Haskell isn't perfect, though, so in the interests of balance I will also devote a section to the following areas where Haskell actively rewards worst practices and punishes best practices!
- Poor performance
- Excessive abstraction
- Unchecked exceptions
Absence of null
Haskell has no null
/None
/nil
value built into the language but Haskell does have a Maybe
type somewhat analogous to Option
in Java. Maybe
is defined within the language as an ordinary data type like this:
data Maybe a = Just a | Nothing
Maybe
differs from null
-like solutions in other languages because Maybe
shows up in the type. That means that if I don't see Maybe
in the type then I know for sure that the value cannot possibly be empty. For example, I can tell from the following type that z
can never be None
or null
:
z :: String
This differs from many other languages where all values could potentially be null because there is no way to opt out of nullability.
The second thing that differentiates Maybe
from other solutions is that you can't forget to handle the empty case. If you have a function that expects an String
argument:
exclaim :: String -> String
exclaim str = str ++ "!"
... you can't accidentally apply that function to a value of type Maybe String
or you will get a type error:
>>> x = Just "Hello" :: Maybe String
>>> exclaim x -- TYPE ERROR!
Instead, you have to explicitly handle the empty case, somehow. For example, one approach is to use pattern matching and provide a default value when the argument is empty:
case x of
Just str -> exclaim str
Nothing -> "Goodbye!"
Haskell rewards best practices with respect to nullable values by making it easier to program without them:
- everything is non-null by default
- nullable types are longer because they must explicitly declare nullability
- nullable code is longer because it must explicitly handle nullability
... therefore Haskell programmers tend to use nullable values sparingly because because it's the path of least resistance!
Limitation of input/output effects
Haskell treats input/output effects (IO
for short) the same way that Haskell treats nullable values:
- Everything is
IO
-free by default IO
types are longer because they must explicitly declareIO
effectsIO
code is longer because it must explicitly propagate effects
Therefore Haskell programmers tend to err on the side of using effects sparingly because it's the path of least resistance!
Immutability
Haskell treats immutability the same way that Haskell treats nullable values and IO
:
- Everything is immutable by default
- Mutable types are longer because they must explicitly declare state/references
- Mutable code is longer because it must thread state/references
Therefore Haskell programmers tend to err on the side of using state and mutable references sparingly because it's the path of least resistance!
Generic types
By default, the Haskell compiler infers the most general type for your code. For example, suppose that I define the following top-level function:
addAndShow x y = show (x + y)
I can omit the type signature for that function because the compiler will already infer the following most general type for addAndShow
:
>>> :type addAndShow
addAndShow :: (Num a, Show a) => a -> a -> String
I actually have to go out of my way to provide a more specific type for that function. The only way I can get the compiler to infer a less general type is to provide an explicit type signature for addAndShow
:
addAndShow :: Int -> Int -> String
addAndShow x y = show (x + y)
In other words, I have to actively go out of my way to make functions less general. The path of least resistance is to just let the compiler infer the most general type possible!
Strongly typed records
If you come to Haskell from a Python or Clojure background you will definitely notice how painful it is to work with dictionaries/maps in Haskell. Haskell has no syntactic support for map literals; the best you can do is something like this:
myMap =
fromList
[ ("foo", "Hello!")
, ("bar", "1" )
, ("baz", "2.0" )
]
You also can't store different types of values for each key; all values in a map must be the same type.
In contrast, defining new records in Haskell is extremely cheap compared to other languages and each field can be a distinct type:
data MyRecord = MyRecord
{ foo :: String
, bar :: Int
, baz :: Double
}
myRecord = MyRecord
{ foo = "Hello"
, bar = 1
, baz = 2.0
}
Therefore, Haskell programmers will very predictably reach for records instead of maps/dictionaries because records are infinitely more pleasant to use.
Poor performance
Haskell doesn't always reward best practices, though. Haskell is one of the fastest functional programming languages in experienced hands, but the language very much rewards poor performance in beginner hands.
For example, Haskell's syntax and Prelude highly rewards linked lists over vectors/arrays. Even worse, the default String
type is a linked list of characters, which is terrible for performance.
There are more efficient solutions to this problem, such as:
- Using the
text
library for high-performance text - Using the
vector
library for high-performance arrays - Using the
OverloadedStrings
andOverloadedLists
extensions to overload list literal syntax and string literal syntax to support more efficient types
However, the path of least resistance is still to use linked lists and strings. This problem is so pervasive that I see even respected and experienced library authors who care a lot about performance succumb to the convenience of these less efficient types every once in a while.
Excessive abstraction
As you learn the language you begin to realize that Haskell makes some types of advanced abstraction very syntactically cheap and some people get carried away. I know that I'm guilty of this.
From an outside perspective, the typical progression of a Haskell programmer might look something like this:
- Week 1: "How r monad formed?"
- Week 2: "What is the difference between
Applicative
+Category
andArrow
?" - Week 3: "I'm beginning a PhD in polymorphic type family recursion constraints"
- Week 4: "I created my own language because Haskell's not powerful enough"
(Edit: I do not mean to downplay anybody's difficulty learning Haskell; this is just to illustrate that the language fosters and encourages "architecture astronauts")
The temptation to play with all of Haskell's abstract facilities is so strong for some (like me) because for those people abstraction is too fun!
Unchecked exceptions
Fun fact: did you know that the conventional way to throw checked exceptions in Haskell is to use monad transformers?
It's far more easy to just use throwIO
to lump all exceptions under the IO
type than to declare in the types all the possible exceptions that you might throw. That's at least partially checked in the sense that an IO
in the type signature warns you about the possibility of exceptions even if it won't tell you which specific exceptions might be thrown.
However, throwIO
is surprisingly not in the Prelude by default, but error
is!
error
is the worst way to throw a Haskell exception because it is:
- marked pure, so it can strike anywhere
- asynchronous, so it can strike at any time
- completely invisible at the type level
- triggered by accidental evaluation
A lot of people mistakenly use error
instead of throwIO
without realizing the consequences of doing so. I've seen otherwise very smart and talented Haskell programmers get this wrong.
Following exception best practices requires discipline and effort, which means that under pressure people will generally not take the time to propagate and catalog exceptions correctly.
Conclusions
Despite those flaws, I still believe that Haskell incentivizes the right behavior where it counts:
- minimizing null
- minimizing state
- minimizing effects
- minimizing weakly-typed maps/dictionaries
Those are the qualities that I see large projects fall down on time after time due to inexperience, developer churn, and time constraints.
More generally, programming languages should be designed with attention to incentives. Don't just focus on absolute productivity, but also pay attention to the relative productivity of best practices and worst practices. Worst practices should be hard!
"In contrast, defining new records in Haskell is extremely cheap compared to other languages"
ReplyDeleteNot only defining record types is equally chip in OCaml, F#, Rust, SML, Scala, but working with them is easier. So the question: what languages do you compare Haskell to?
Dynamic languages, like Python, Ruby, and Javascript
ReplyDelete