Making Abstraction More Concrete
Abstractions aren’t vague messes — they’re useful tools
Quick: what’s an abstraction? If you’re new to programming, you’ll likely think of swirling colors, a misty outline, or an amorphous blob. But in programming, not only are abstractions more specifically defined, there are many distinct types of them. Counterintuitively, abstract code is frequently easier to understand and maintain. Or as Edsger Dijkstra put it,
Being abstract is something profoundly different from being vague … The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise.
Knowing the different forms of abstraction not only allows you to create better code, but also communicate better with other programmers. You can better articulate your intentions and grasp the subtleties of theirs. These skills are valuable when pairing with coworkers, reading comments, and documenting an open-source project.
There are a half-dozen or so different forms of abstraction, and virtually all of them transcend individual languages. Although the terminology I use will lean towards OOP, the message is appropriate for functional languages as well. Some examples refer to specific technologies, but you don’t need to be familiar with them to understand the essay.
Imagine the check out line at a grocery store, except there’s a separate cash register for every item. You have to find the carrot register, press the big orange button on it, and then repeat the process for each item in your cart. What a mess this would be! That’s why real cash registers don’t hold any preconceptions of what you’re buying. Instead of assuming you’re buying carrots, you (or the clerk) inform it of such. A real cash register is more complex than any single one-button machine, but it’s vastly better than having hundreds of them.
Parameterization is the process of making some chunk of information a parameter to a piece of code. Instead of buyCarrots() we’d say buy(“carrots”) using our very flexible buy function. Parameterization is everywhere in code, to the point that you don’t even notice. Of course a function that formats and displays a timestamp needs to know what time to display. Parameterized code may also take many such options; there’s an entire mini-language describing time formatting, like whether to put the month before the day.
By moving assumptions outside of code, it becomes more reusable, even if slightly more complex. Reusable code is less likely to be copied and pasted, which is a maintenance disaster. Many of the other forms of abstraction build on parameterization.
If you studied the lambda calculus in school, a lambda abstraction is strictly a parametrization. Bret Victor’s essay explains how to create “an abstraction by devising a representation that depicts the system across all values of a parameter”.
Let’s say I have many dogs, and I want to tell each one to bark. This is fine on its own, but what if I also have cats that need to meow? Now I either have to keep them separate and do a similar action twice, or inspect each animal before giving it a command. The better solution is to ensure that each pet knows how to make noise, without going into more detail than that. This makes it much easier to add a squawking bird later on.
An interface is an expectation or guarantee that an object or value behaves in a certain way — usually that it’s compatible with a particular message or function. It allows you to focus on what something does rather than what it is. Interfaces allow you to ignore an object’s class, or a value’s type, focussing instead on the behavior. You’ll often see words ending in -able used to indicate an interface, e.g. “enumerable” or “serializable”.
Statically typed languages require interfaces be explicitly declared: any class or type implementing them must say so, and this is checked by the compiler. In dynamically-typed languages, interfaces are implicit — a good design has many classes that just happen to respond to the same message.
An interface may consist of a single method, or many. Several languages have features where defining a small interface provides many more helpers automatically.
All of my pets know how to make noise, but the way they do so is very different. Maybe my cats decide whether to meow or hiss, and my dogs bark only if they don’t recognize the intruder.
Same method, different implementation — that’s the basis of polymorphism, which literally means “having many forms”. If an interface is about looking the same on the outside, polymorphism is about doing different things on the inside. Interfaces and polymorphism are frequently two sides of the same coin.
One common use of interfaces is to iterate over an array (or other data structure) using only the interface. The data structure can be heterogenous, containing any combination of objects, as long as they all implement the interface. For example, strings, ints, and other JSON-serializable types can implement a to_json() method appropriately for their type.
Overloading functions, especially operators, is another example of polymorphism. Machine integers, bignums, and complex numbers all have their own notion of addition. In some languages, arrays will respond to the plus sign with concatenation. This is a good example of polymorphism without an interface (you wouldn’t typically have a collection of addable things).
When we landed on the moon, we did so using a capsule. The astronauts, and everything they needed to survive, was protected inside the capsule. It didn’t matter that there was a vacuum and extreme temperatures outside the capsule, because inside the capsule was safe.
A purist will take the word encapsulation literally, to put something in a capsule, so that there’s an inside and an outside. In practice, encapsulation is quickly becomes synonymous with information hiding, which keeps your objects (or functions) from knowing too much about each other, thereby creating fragile dependencies.
One example is an object that represents a point in the plane, which provides accessors for not only x and y but also r and theta. It’s impossible to know whether the object stores its value in cartesian or polar coordinates — or both. It might change from one release to the next. Indeed, one hallmark of good OO is that it should be impossible to tell whether an attribute is stored directly (as an instance variable), or computed.
Code that is in a capsule doesn’t care how crazy the outside world is, because it’s got everything it needs in one place.
Let’s say I’m making a grocery shopping list, and I always want milk, eggs, and a few other essentials. Rather than always writing them down, I can just come up with a name for these items as a group and write that down. It’s a common, reusable little chunk of information that’s been factored out of my main list.
If encapsulation works on the level of classes and modules, then factoring out operates on the more granular level of methods and functions. It’s part of the good practice of using many small methods rather than a few large ones. It’s also called “abstracting out,” “extracting out”, and “DRYing up” code, with reference to the acronym Don’t Repeat Yourself.
Factoring out code that is unstable, or used in many places, allows you to change it easier. If you find yourself declaring the same local variable in multiple functions, it’s a good candidate for being DRYed up into a new function.
Removing duplication reduces the likelihood of error. You can test the new method individually, if it’s complex enough to warrant it, or through the other methods that rely on it. Private methods are often the result of factored out code.
Note that factoring out is not the same as “refactoring”, which (in proper usage) is a more methodical practice accompanied by testing to ensure behavior does not change.
The International Space Station was created by gluing small pieces together. Launched separately, each piece had to be self-contained while contributing value to the whole. During assembly, some pieces were present but others were not. Each of these pieces is called a module.
You know you’re dealing with modules when you have item-by-item customization. I want this, but not that, and every set of choices is valid. This means that modules have no shared dependencies among each other (although sometimes there’s core functionality that’s required). A modular system can be extended as the user sees fit, maybe even by writing more modules.
Computer networks are built on a stack of 5 or 7 layers (depending on who you ask), and is referred to as a modular architecture. This is puzzling — you can’t just leave out one of the layers. The key is that, although some protocol has to be present at each layer, you can change one without affecting the others. Your HTTP request doesn’t know about TCP and is unaffected by changing to IPv6. It relies on the the existence of a transport mechanism, but nothing more.
These are a language feature, not a design practice, and so are here mostly for completeness. They need to be understood in the context of inheritance, which is beyond the scope of this essay. Suffice it to say that abstract superclasses are a way of factoring out code shared between similar classes. They further muddy the meaning of “abstract” with the specific notion of a class that is not meant to be instantiated.
All Together Now
Let’s say I am writing and using a library to log messages of differing levels of severity (debug, info, warn, error). By placing all logging code in one place, it’s become encapsulated. Obviously the message to log and its level are parameterized. I might use multiple modules to handle these messages; perhaps one writes all messages to disk, another sends emails to sysadmins, and a third integrates with a third-party API. Each module implements an interface, to which I pass it a consistent set of information, and then responds polymorphically depending on which module it is. Maybe I used an abstract superclass to factor out common code between these modules.
As you can see, it’s quite common to use many forms of abstraction at once. Therefore, instead of nitpicking whether a form is or is not present, it’s far more productive to focus on the most prominent form, the one that guides design decisions. Is your primary goal to hide information, or to create a consistent interface? Modularity typically subsumes all other forms, but otherwise it’s a context-dependent decision.
Once you know the predominant form of abstraction in the code you’re writing, not only does that help with writing the code, but you can better communicate to your pair, your reviewers, and in your documentation.
Regardless of your skill level, I hope this guide has been helpful to you. If so, please recommend or share it so others can benefit from it too. Thanks.