I believe the most important value in a programming language is not performance, libraries, ecosystems, or tooling. While these are crucial to their adoption and success, a feature often overlooked is code comprehension. By this I mean: how easy is it for someone to read code and comprehend what it’s doing within this language. This line of reasoning has lead me to believe that pure, functional languages like Haskell are the best tools to keep teams performant, productive, happy, and successful by maximizing code comprehension.
Preface: I recognize that my opinions are directly formed by my (limited) experiences, and are therefore biased and not to be perceived as any universal truth. I also recognize my incredibly privileged position as a white, cis-gendered male; my history and interaction with software has been free of institutional barriers, misogyny, racism, and other forms of oppression. I mention this because my thesis resides on software and coding being a highly “social” activity, and like all social activities, the experience of a person in a position of privilege is incredibly skewed.
After completing my masters in Pure Mathematics and meandering around the digital media world, I got my first job as a Software Engineer. The first thing I realized was how social coding has become. Perhaps it’s always been this way, but I was inspired to try and name a single-author library, language, OS, or run-time of any significance. They don’t exist. Every piece of software you use has been written by groups of people. Groups that need to communicate, document, test, and understand each other’s code. As a software engineer we principally do one of two things: read other people’s code or write code that other people will read.
Therefore, the following questions are asked almost every single time you’re looking at code:
- What does this function do?
- What kind of data do I pass to it?
- What kind of data does it return?
When I think about programming languages, I think about the importance of maximizing the ability to answer those questions as quickly and coherently as possible. This, to me, is the single most important function a programming language can offer. Everything else is secondary. If Google can make JavaScript reach speeds within a factor of C, and if Oracle can bring a Garbage Collected runtime to the same level, I’m confident any “comprehensible” language can be optimized (or interfaced with C).
Dynamic Typing was my path to software liberty. After learning assembly, C, and C++ within a Numerical Methods context, I was profoundly inspired when I finally discovered Python. The sense of exploratory freedom that dynamic typing gives truly changed my life. However, the more I use dynamic languages, especially in a team setting, the more I realize they’ve hit a local minimum when it comes to comprehension. For example, this week I was working with a Sinatra-based web service that hadn’t been documented. I was told the code should “document itself.” I needed to know what kind of JSON was being returned from an endpoint and began reading the code. Because of Ruby’s dynamic, object-oriented foundation and the pervasive use of monkey-patching and 3rd-party libraries, what a simple REST-end point was returning became entirely obfuscated. I had to wrestle my way through various libraries, only to end up in a schema.rb file conjecturing that some .all method returned “all the sql rows” while simultaneously lacing tests with various puts to figure out what was happening. Eventually I found my answer, but not without much work and time. While documentation and deeper testing would have resolved my issues, the enforcement of these practices is left to developer discipline, rather than the language itself, begging the question: is there a better way? A smart language and compiler capable of keeping discipline in check without getting in the way?
When it comes to dynamic languages, here is how they score with respect to my comprehension questions:
.to_bar method.This looks like a failure when it comes to comprehension. The programmer is required to read the entire method, tracing any dependencies and assumptions through deeper methods. This is hardly optimal and the complexity grows with abstraction (rather than the other way around). Dynamic languages grant zero guarantees to the programmer. This is their power, but also their curse. As codebases grow, so do the implicit, non-enforced assumptions made in every method/function. This is unsustainable.
Side-effects are the real bane of code comprehension and they pollute both Dynamic and Statically typed, non-pure languages. By their very nature they are invisible, occurring “on the side,” often in methods encapsulating their behaviour away from you. Here’s how they answer those questions:
In the case of Static, Non-Pure languages, we gain slightly more comprehension, but not enough to reach a local maximum. We have a better idea of what our method takes and returns, but what our method does is still obfuscated. The method may require configuration for logging, or a database connection, or may interact with concurrent threads. It may be mutating global state that is important to the program, forcing us to maintain a growing stack of effects as we read the code.
We can rely on several ad-hoc methodologies to control this: code analysis, IDEs, discipline, documentation, testing, etc. But these are band-aids over a foundational flaw: the imperative nature of these languages requires you to perform side-effects in an uncontrolled manner. Further, these side-effects cannot be captured by the compiler and are only understood by reading all code. This puts a huge amount of pressure on engineers to maintain the logic of entire systems in their minds as they read code.
There must be a better way!
Haskell is a pure, functional programming language. It has a powerful type system, compiler and runtime. Data cannot be mutated and any side-effects are encoded in the type system. This may seem like a limitation, but thanks to the power of Abstract Data Types, higher-kinded types, higher-order functions, and type inference, the language ceases to get in the way.
Here is how Haskell fares against my questions:
a -> a can only do one thing. A method Bool -> Bool can only do 4 things.This approaches the local maximum. I can get very close to understanding everything about a method simply by looking at it’s type signature. We can’t get the whole way, but we’re given guarantees and bounds, and every method within our function must also grant these guarantees. There can be no magic dependencies that are not specified in the signature. Every entity required by the function is passed in or provided via Monadic context (both encoded in the type). Further, any side effects will be encoded in the type as well. If we see a function like:
foo :: a -> b -> IO a
We know right away that:
foo performs a side effect (hence the presence of IO in the type signature)foo makes no assumptions about b, either foo discards b or it performs a discarding side-effect using b.foo makes no assumptions on a or b, and therefore does not use any of their properties. This means that the kind of side-effects it performs on a and b are quite limited, if any. For example, foo cannot even log or print a or b as the method doesn’t require they implement the Show typeclass!a and b do not “inherit” from a basic type (like Object in Java), therefore there can be no runtime reflection or other magic done to them (unless they are assumed to have an instance of the Typeable typeclass in context, which would appear in the signature ala foo :: Typeable a => a -> b -> IO a).We get nowhere near this level of comprehension in a dynamic language or non-pure static language. Every piece of information we need to understand any method is in the type signature. We know exactly where and when side-effects happen and thanks to the immutability of data, we know exactly where state transitions take place and how they happen.
This is just the tip of the iceberg. In the case of Haskell, you are given incredible code comprehension for very little cost and gain an advanced type system capable of abstractions unheardof in other languages. If you want a taste, see: stm, pipes, lens, and Haxl.
As software engineers we should be concerned with efficiency and performance. As teams and companies grow, the need to read and comprehend code so that we can extend or debug it grows exponentially. Every bug written into our code, from someone on our team or a library we’re using, results in wasted time spent debugging. Any time spent not writing code, is time wasted not delivering a product. In a dynamic language we try to mitigate this by writing tests and documentation, but these methodologies are never complete and don’t result in fast and certain code comprehension. In a pure, functional language like Haskell, we lean on the compiler to do the debugging for us by enforcing static guarantees and we lean on the type system to document our code. Not only does this free programmers from the burden of comprehending ever-expanding code complexities, it also establishes a system of trust wherein engineers are no longer afraid of change in an uncertain codebase.
Ultimately, if you are a programmer you should care because the code you write will be used by others. If we want to maximize the ability to collaborate and enrich our ecosystems of abstraction, we need to maximize code comprehension. But mostly, if you’re running a company, you should care because it will save you money and keep your customers happy.