To assist with job interviews at the NRAO we recently wrote a small “contest” program. Without giving away the details, the crux of the problem is to read a file with a list of scans and calculate the amount of time it takes to move the dishes and perform the scans, and report it. Candidates are required to write this in Java, but that restriction does not apply to me, so I of course had to write it in three languages that are not Java: Haskell, Common Lisp and Smalltalk.
I expect to learn something, or at least reinforce an existing fact, by doing these sorts of language games, but this time I really wasn't expecting what I found. I discovered that my enjoyment of Haskell is pretty much independent of Haskell's appropriateness or usability. In fact, it is more attributable to the way I feel when using it, in contrast to everything else. Let me show you a piece of code:
-- | Perform a scan and produce the scan log for it. performScan ∷ Scan → AntennaSimulator ScanLog performScan scan = wrapDuration $ do slewTime ← moveTo $ scanPosition scan -- move to the scan's position onSourceTime ← waitFor $ scanLength scan -- wait for the scan to finish return $ Log scan onSourceTime slewTime
Somehow, the other languages I've used to implement this problem never arrived at a piece of code quite this beautiful. Compare to the Smalltalk:
Scan ≫ runWith: anAntenna runWith: anAntenna "Performs this scan on the supplied antenna." | onSourceTime slewTime | slewTime := anAntenna moveTo: self position; lastActionDuration. onSourceTime := anAntenna wait: self duration; lastActionDuration. ^ ScanResult withScan: self slewTime: slewTime onSourceTime: onSourceTime
And the Lisp:
(defun run-scan (antenna scan) "Perform a scan on this antenna. Return a scan log." (let* ((slew-time (move-to antenna (scan-position scan))) (on-source-time (delay antenna (scan-length scan))) (total-time (+ on-source-time slew-time))) (with-slots (last-duration) antenna (setf last-duration total-time) (make-scan-log scan slew-time on-source-time))))
Strangely, despite the similarity of the Smalltalk, I still find the Haskell shorter and clearer. It's also more modular, though it doesn't look like it. The
wrapDuration command arranges for the next set of actions to count as one as far as the
lastActionDuration is concerned, but does not interfere with usages of
lastActionDuration within the wrapped action. I found this notion essentially impossible to port to the other languages. The Haskell version really feels more like a composable set of actions for driving the antenna, whereas the Smalltalk version never really stopped feeling like an arithmetic fabrication, and the abstraction leaked a lot in the Lisp version
The Lisp code is just atrocious. I was never really able to put my finger on what it is I dislike about Lisp before. It's this: while Lisp code is often clever and interesting, it also always feels like I'm tricking Lisp into doing what I want, rather than simply expressing what I want.
Neither Lisp nor Smalltalk really lend themselves to my style. I realize now, that my style is to try to break everything down into the smallest piece, a piece that doesn't look like work. Haskell encourages this style with the
where syntax, which encourages you to say, X is really Y + Z, with Y being this and Z being that. This kind of thing:
-- | To calculate the time to move between two positions, we take -- whichever is larger of the time to turn and the time to change -- elevation. timeToMoveTo ∷ Position → Position → Time timeToMoveTo (sourceAz, sourceEl) (destAz, destEl) = max rotationTime ascensionTime where rotationTime, ascensionTime ∷ Time rotationTime = timeToRotate sourceAz destAz ascensionTime = timeToAscend sourceEl destEl
This almost doesn't look like code to me. I would expect any programmer to be able to at least get some sense of what's going on here, even without knowing Haskell. I don't think I could say the same thing about this piece of Lisp:
(defun time-to-move (from-pos to-pos) "Calculates the total time to move between two positions, assuming rotation and ascension can occur simultaneously." (with-accessors ((from-az azimuth) (from-el elevation)) from-pos (with-accessors ((to-az azimuth) (to-el elevation)) to-pos (max (time-to-rotate from-az to-az) (time-to-ascend from-el to-el)))))
Lispers are fond of talking about the programmability of the syntax and how easy it is to learn. But the syntax is still there, it's just encoded positionally rather than with auxiliary words and symbols. For example, the words
elevation in the Lisp code denote accessor functions. The outer
with-accessors macro does something akin to this:
timeToMoveFrom: from_pos to: to_pos | from_az from_el | from_az := from_pos azimuth. from_el := from_pos elevation. ...
So the Lisp is wonderfully terse, but I still feel like I'm tricking something into doing what I want rather than expressing it. I think it's possible to write clearer Lisp code, I just don't know how, and I've relied on Haskell long enough now that Haskell's version of clarity is becoming my own version of clarity.
I think this is part of what makes my love for Haskell irrational. I really can't with a straight face tell you Haskell is a better language than every other language I know. I can't imagine anything more frustrating than trying to teach Haskell to pragmatic people who can already program. And my style grows ever more idiosyncratic as I use it more. (I consider most uses of monad transformers a failure of functional decomposition). I never lose sleep wondering if my code is going to break in strange ways in Haskell, even though it would, albeit probably less often.
Another thing which surprised me about this little experiment was discovering the degree to which I am dependent on the compiler or interpreter to program effectively. Despite idolizing Dijkstra and befriending John Shipman and Al Stavely, I'm lousy at analyzing my code before running it. This problem affects me in every language I use, but I think I am worse because I simply have no respect for languages other than Haskell. Haskell will catch me trying to do really absurd things much earlier in the process of my not understanding what I'm doing. With Lisp and Smalltalk, I often managed to write ten or twenty lines of code, code that parsed successfully, before realizing I was doing something completely stupid, designing myself into a corner or worse. I'm sure I could write elegant code with less waste in these languages, but Haskell really forces me to.
A good friend once said, you don't learn a foreign language for the sake of knowing it, you learn it to read the poetry. And we live in a world in which people meticulously study Hebrew, Greek and Arabic just for the sake of reading some of the best poetry we have. It would be absurd for me to claim that Haskell is always shorter, always more readable, or whatever; there are certain things I just don't like to go near when I'm using it (like web development). Is this not similar to preferring Biblical Hebrew for religious poetry over humor?
A lot of people use and encourage the teaching of languages like Java and C for their simplicity. My wife endured this in C, and I got to see first-hand that notions like function calling, sequential processing, and boolean logic are not intuitive. If I reach back far enough, they weren't intuitive to me either, I just crossed those bridges so long ago I seldom think about what it was like before, but I have enough of a glimpse that when obscure languages like Smalltalk and Haskell are discarded from the realm of possibility for teaching beginners, I find it upsetting. I didn't know anything about category theory before I learned Haskell, and I still know about that much, yet I am able to benefit from writing terse, correct programs in it, programs that I can reason about the performance of. Granted, my understanding of Haskell's execution model is mostly in terms of how it differs from C and every other language. But I still remember learning things about C's execution model that were shocking and amazing, and I still managed to learn and be productive in C before I understood those things, so why would it be different with Haskell?
I want to have a concise, cogent, interesting conclusion to insert here, but there really isn't one, because programming is not a destination and all of my conclusions are provisional. The more I learn, the more discomfort there is. For one thing, if Haskell is so wonderful, why is there no good dependency management? Why do languages like Lisp and Smalltalk depend so much on manual intervention with mistakes, why can't they have a strong, inferring type system like Haskell? More importantly to me, why am I never really able to commit to an environment like Smalltalk that embraces code evolution and failure, which seems like something I would like, and why do I like a system that tries so hard to achieve compile-time correctness when I depend on rapid iteration to write code?
One promising option I am looking forward to playing with more is Autotest, which detects which tests should be run after each change of the code and runs them, used in conjunction with TDD. This could potentially go much further than strong typing, but what if I'm testing something that has to do I/O? A question for another day.