PL Usability: Name Fewer Things
If you follow me on Twitter or know me in person, you know I've been somewhat obsessed with programming languages lately. Why? In my career I've written high-performance desktop software, embedded controllers, 3D engines, scalable and low-latency web stacks, frontend JavaScript stacks... and at this stage in my life I'm tired of dealing with stupid human mistakes like null pointer exceptions, uninitialized data, unhandled cases, accidentally passing a customer ID where you meant an experiment ID, accidentally running some nontransactional code inside of a transaction, specifying timeouts with milliseconds when the function expects seconds...
All of these problems are solvable with type systems. Having learned and written a pile of Haskell at IMVU, I'm now perpetually disappointed with other languages. Haskell has a beautiful mix of a powerful type system, bidirectional type inference, and concise syntax, but it also has a reputation for being notoriously hard to teach.
In fact, getting Haskell adopted in the broader IMVU engineering organization was a significant challenge, with respect to both evangelization (why is this important) and education (how do we use it).
Why is that? If Haskell is so great, what's the problem? Well, there are a few reasons, but I personally interviewed nearly a dozen IMVU engineers to get their thoughts about Haskell and a common theme arose again and again:
There are too many NAMES in Haskell. String, ByteString, Text. Functor, Applicative, Monad. map, fmap. foldl, foldl', foldr. Pure let ... in syntax vs. monadic let syntax. Words words words.
Some people are totally okay with this. They have no problem reading reference documentation, memorizing and assigning precise meaning to every name. This is just a hypothesis, but I wonder whether mathematically-oriented people tend to fall into this category. Either way, it's definitely true that some people tolerate a proliferation of names less than others.
As someone who can't remember anything, I naturally stumbled into a guiding principle for API design: express concepts with a minimal set of names. This necessitates that the concepts be composable.
And now I'm going to pick on a few specific examples.
Maybe
My first example is Haskell's maybe function.
First of all, what kind of name is that. I bet that, if you didn't know what it did, you wouldn't be able to guess. (A classic usability metric is how accurately things can be guessed.)
Second of all, why? Haskell already has pattern matches. You don't need a tiny special function to pattern match Maybes. Compare, for some m :: Maybe Int
:
maybe (return ()) (putStrLn . show) m
with:
case m ofJust i -> putStrLn (show i)Nothing -> return ()
Sure, the latter is a bit more verbose, but I'd posit the explicit pattern match is almost always more readable in context. Why? Because maybe
is yet another word to memorize. You need to know the order of its arguments and its meaning. The standard library would be no weaker without this function. Multiple experienced Haskellers have told me to prefer it over explicit pattern matches, but it doesn't optimize for readability over the long term.
Pattern matches are great! They always work the same way (predictability!), and they're quite explicit about what's going on.
pathwalk
For my second example, the last thing I want to do is pick on this contributor, because the use case was valid and the pull request meaningful. (In the end, we found another way to provide the same functionality, so the contributor did add value to the project.)
But what I want to show is why I rejected this pull request. pathwalk was intended to be a small, light, learnable API that anyone could drop into a project. It exposes one function, pathWalk, with three variants, one for early exit, one for accumulation of a result, and one that uses lazy IO for convenience. All of these are intended to be, if not memorizable, quite predictable. The pull request, on the other hand, added a bunch of variations with nonobvious names, as well as six different callback types. For an idea as simple as directory traversal, introducing a pile of exported symbols would have a negative impact on usability. Thus, to keep the library's interface small and memorizable, I rejected the PR.
TypeScript
Now I'll discuss the situation that led me to writing this post. My team at Dropbox recently adopted TypeScript to prevent a category of common mistakes. For our own sanity, we were already annotating parameters and return types -- now we're simply letting the computer verify correctness for us.
However, as we started to fill in type annotations for the program, a coworker of mine expressed some concern. "We shouldn't write so many type definitions in this frontend code. JavaScript is a scripting language, not heavyweight like Java." That argument didn't make sense to me. Surely it doesn't matter whether we're writing high-performance backend code or frontend code -- type systems satisfy the same need in either situation. And all I was doing was annotating the parsed return type of a service request to prevent mistakes. The specific code looked something like this:
// at the top of the fileinterface ParsedResponse {users: UserModel[];payloads: PayloadModel[];}// ...// way down in the filefunction makeServiceRequest(request: Request): Promise<ParsedResponse> {return new Promise((resolve, reject) => {// ...resolve({users: parsedUserModels,payloads: parsedPayloadModels,});});}
At first I could not understand my coworker's reaction. But I realized my coworker's concerns weren't about the types -- after all, he would have documented the fields of the response in comments anyway, and, all else equal, who doesn't want compiler verification that their code accesses valid properties?
What made him react so negatively was that we needed to come up with a name for this parsed response thing. Moreover, the definition of the response type was located far away from the function that used it. This adds some mental overhead. It's another thing to look at and remember.
Fortunately, TypeScript uses structural typing (as opposed to nominal typing), and the advantage of structural typing is types are compatible if the fields line up, so you can avoid naming intermediate types. Thus, if we change the code to:
function makeServiceRequest(request: Request): Promise<{users: UserModel[],payloads: PayloadModel[],}> {return new Promise((resolve, reject) => {// ...resolve({users: parsedUserModels,payloads: parsedPayloadModels,});});}
Now we can have type safety as well as the concision of a dynamic language. (And if we had bidirectional type inference, it would be even better. Alas, bidirectional type inference and methods are at odds with each other.)
Other Examples
git has the concept of an "index" where changes can be placed in preparation for an upcoming commit. However, the terminology around the index is inconsistent. Sometimes it's referred to as the "cache", sometimes the "index", sometimes the "stage". I get it, "stage" is supposed to be the verb and "index" is the noun, but how does that help me remember which option I need to pass to git diff? It doesn't help that both stage and index are both nouns and verbs.
In the early 2000s I wrote a sound library which was praised for how easy it was to get up and running relative to other libraries at the time. I suspect some of this was due to needing to know only a couple concepts to play sounds or music. FMOD, for on the other hand, required that you learn about devices and mixing channels and buffers. Now, all of these are useful concepts, and every serious sound programmer will have to deeply understand them in the end. But from the perspective of someone coming in fresh, they just want to play a sound. They don't want to spend time studying a bunch of foundational concepts. Audiere had the deeper concepts too, but it was useful to provide a simpler API for the simple 80% use case.
Python largely gets this right. If you want to know the size of some x, for any x, you simply write len(x). You don't need a different function for each data type.
To summarize, whenever you provide an API or language of some kind, think hard about the names of the ideas, and whether you can eliminate any. It will improve the usability of your system.