26 October 2016

It's not hard to find plenty written about some poorly written software abstraction and how instead of serving its intended purpose of managing complexity, it just adds more complexity to the entire system. The idea that all abstractions are leaky feeds into this mindset, because if you have to learn about the intricacies of the abstraction in addition to what lies on top of it, then that's more total stuff to learn about.

Despite none of these authors actually advocating abandoning abstraction altogether, it's not hard to imagine someone coming to this conclusion. "Wow, look at all the harm that abstractions cause. Maybe if I never abstract anything then I'll avoid these problems." From what I've seen, there's very little mention of abstractions that are actually successful. Here I want to add a bit of force in that direction. Here are some abstractions that seem to serve their intended purpose, and do so well.

Resistors

The motivation of this post was from software abstractions, but software runs on electrical circuitry, so somewhere near the bottom of the stack of abstraction are circuit components. Resistors are perhaps the most basic of these.

I've made some breadboard circuits in the past, which naturally included resistors. However, I've never cared about the material that creates the resistance, nor have I cared about the exact dimensions of that resistor. What I did care about mostly came down to a single number: its resistance. On maybe one occaision I also cared about the resistor's power rating.

This is a successful abstraction in part because of the idea of an "ideal resistor". The component manufacturer is free to take whatever measures they want to approximate that ideal resistor, and I don't need to worry about those ideas when I'm plugging it into my breadboard.

Transistors

Okay, so this is essentially the same idea as resistors, but transistors are much more complicated. Just like with resistors, transistors package certain electrical behaviors into this little black box where I generally don't need to care about the insides. A datasheet has a lot more information than the single number that usually sufficed for resistors, but none of it forces me to think about the materials that were used to create the transistor. Instead, I get to think purely about the behavior in the terms that I want to: voltage, current, temperature, and so on.

For digital electronics, the word "transistor" is often used as synonymous with "digital switch", which only really describes a subset of the operating range of a transistor. A digital circuit works by keeping all of its transistors in the ranges where they really do look like switches, and when you restrict yourself to that range, you can even ignore most of the datasheet.

Logic Gates

Once you have transistors (or vacuum tubes, but I'm not going to worry about the historical perspective), it's very useful to create higher level building blocks in the form of logic gates. If you look at a datasheet for a chip implementing logic gates, it does include a schematic composed of more primitive parts, but for most applications, that can be entirely ignored.

Thinking about a digital circuit in terms of logic gates is completely different from thinking about one in terms of transistors. It's a straightforward exercise to implement binary addition in terms of logic gates. Try to do it with transistors directly, and I'd be surprised if you didn't start by composing transistors for much simpler tasks, and logic gates are a natural set of simple tasks to start with.

Processors

Logic gates are all well and good, but once you get much past the realm of basic arithmetic, they start to be too primitive for comfort. A processor can have a huge amount of engineering effort but into it, but as a programmer I don't need to worry about all of that. All I really care about is the instruction set it supports. I can write a program in the x86 instruction set, and expect it to work regardless of the exact processor that it's running on, barring things like the Pentium FDIV bug.

Modern x86 processors are a lot more complicated than their instruction set lets on, with optimizations like out of order processing, branch prediction, register renaming, and so on. I know very little about this domain, and for the most part I don't need to, because the processor manufacturers work hard to make their optimizations good for the usual use cases.

Random Access Memory

How does memory work in a computer? Well, that's really a complex question. At first glance, you can look at a RAM chip and understand that it provides access to a particular byte via its address. That's great, and you might be able to get away with thinking about memory in this way when you're programming, but there's quite a lot going on behind the scenes.

Firstly, not every memory read actually goes to the RAM chip. Reading memory is pretty slow relative to the other things that a processor is doing, so CPUs have caches that attempt to avoid reading from the actual RAM chip, and instead reading from memory that's located on the processor itself. As a programmer, I might sometimes care about the size of my CPU's cache, so that I can arrange for my program to play nicely with the CPU's caching, but by and large I can treat memory as memory and let the CPU do its thing.

But even that isn't the full story. What I've described so far is what we'd call physical memory. Modern operating systems don't provide programs with access to physical memory. Instead, it provides access to a virtual memory space. Usually, the size of this virtual memory space is independent of the actual amount of memory that's installed. This idea of virtual memory means that a program doesn't need to worry about conflicting with other programs that are running, and instead the operating system can ensure that they are all operating independently by leveraging the memory management unit.

Hash Tables

Most datastructures fall under the category of abstractions, but hash tables are probably one of the most successful at hiding their complexity from their users. People are generally taught that hash tables store items in linear space, with constant time inserts, lookups, and deletes. This clearly is at least a little bit of a lie, since storing the numbers from \(1\) to \(n\) in binary uses on the order of \(n \log n\) bits. There's a great number of subtle details that you'd need to think about if you really wanted to understand all of the intricacies of hash tables. But really, the slightly-lying simple version is good enough for most needs.

If you've written any software in your life, consider the following questions. When was the last time you used a hash table? When was the last time you were concerned with what hash function the hash table was using? When was the last time you cared about whether the hash table was using linear probing, quadratic probing, cuckoo hashing, robin hood hashing, or something else entirely? I expect that the answer to the first question is drastically lower than the answer to the other two. What you probably did care about was the time and space usage of the hash table operations, but not how those usage numbers were actually attained.

Networking

When you send data over the internet, there's several layers of abstractions putting in work. At the base, there's the Internet Protocol, which provides a way to attempt to deliver data from one machine to another. On top of that, we've built TCP and UDP. Then there's a number of application-specific protocols on top of those, such as HTTP.

I can honestly say that I have never interacted with IP directly. It has always been through some intermediate protocol, usually TCP or UDP, and sometimes with another intermediary in front of that. That is a sign that these abstractions are doing their job. They've taken care of the messy base that they've been built on, and presented me with a much cleaner starting point.

Cryptography

RSA, DSA, ECDSA, ElGamal, AES, DES. To many people, these names are just arbitrary labels for algorithms that they don't really understand. But those same people can still use them because they understand their interfaces. Cryptography provides us with definitions of cryptographic primities such as symmetric encryption, asymmetric encryption, public key encryption, digital signatures, cryptographic hash function, and so on. This provides abstraction in a very real way. If I'm using a particular symmetric encryption algorithm, and researchers suggest migrating to a new one, I don't need to understand the inner workings of each algorithm in order to do that.

Most of the time that people discuss the costs of abstractions, they're implicitly suggesting that you should avoid them. I think it is clear that many of the abstractions that have been built into various software projects are poor and add more complexity than they take away. But there are good abstractions out there, and the fact that abstraction can be poorly implemented isn't a real argument against it in general. We should be willing to try out abstractions like we try out other ideas in software. Don't be afraid to put an abstraction into your code, but also don't be afraid to take it out if it stops making sense. Let's not avoid good abstractions for the sake of avoiding bad ones.