Thinking about names and memory

Gustavo BicalhoNov 22, 2020

(Epistemic status: heuristics drawn from writing and reading code over the years, informed a little bit by a reading of Pinker’s The Sense of Style.)

I want to compare three ways of writing a simple piece of code. Let’s see which way feels easier to understand, and try to probe at the reasons for that.

A simple toy problem

The toy problem is to get a customer’s data, add and remove a few properties, then serialize it. I will show code snippets in both clojure and javascript.

Naïve composition

The first way I want to tackle this is the straightforward “functional” way - build a result by applying functions to compose and transform several pieces of data:

(let [customer (get-customer-by-id 1)]
  ; customer => {:id 1 :name "Anne", birthdate: "1984-10-24"}
  (to-json (merge (dissoc customer :birthdate)
                  {:age           (compute-age customer)
                   :friend-ids    (get-friend-ids (:id customer))})))
const customer = getCustomerById(1)
// customer => {id: 1, name: "Anne", birthdate: "1984-10-24"}
toJSON({
    ..._.omit(customer, 'birthdate'),
    age: computeAge(customer),
    friendIds: getFriendIds(customer.id)
})

Wow, there’s a lot going on! Can you follow it in your head and decide what the output will look like?

Naming our composable parts

This can get a little better if we give meaningful names to each part:

(let [customer            (get-customer-by-id 1)
      ; customer => {:id 1 :name "Anne", birthdate: "1984-10-24"}
      customer-wo-bdate   (dissoc customer :birthdate)
      customer-extra-info {:age        (compute-age customer)
                           :friend-ids (get-friend-ids (:id customer))}
      customer-data       (merge customer-wo-bdate customer-extra-info)]
  (to-json customer-data))
const customer = getCustomerById(1)
// customer => {id: 1, name: "Anne", birthdate: "1984-10-24"}
const customerWithoutBirthdate = _.omit(customer, 'birthdate')
const customerExtraInfo = {
    age: computeAge(customer),
    friendIds: getFriendIds(customer.id)
}
const customerData = {
    ...customerWithoutBirthdate,
    ...customerExtraInfo,
}
return toJSON(customerData)

Now as long as you remember what each name refers to, each expression is pretty easy to understand. We could extract some of these locals to functions or methods and get even better.

Sequence of data transformations

The second way we could think about this is as a process where a single piece of data goes through additions, subtractions, and other transformations. This can be done both with immutable data structures, like in the clojure example below, or with mutable variables and objects, as in the javascript snippet:

; (get-customer-by-id 1) => {:id 1 :name "Anne", birthdate: "1984-10-24"}
(-> (get-customer-by-id 1)
    (as-> c
      (assoc c :age (compute-age c))
      (assoc c :friend-ids (get-friend-ids (:id c))))
    (dissoc :birthdate)
    (to-json))
// getCustomerById(1) => {id: 1, name: "Anne", birthdate: "1984-10-24"}
var c = getCustomerById(1)
c.age = computeAge(c)
c.friendIds = getFriendIds(c.id)
delete c.birthdate
return toJSON(c)

Notice I used a non-descriptive name c for the data in the places where we had to give it a name (clojure’s thread macro -> lets us get away with no name in several lines). I did so because the data actually changes shape and meaning as it goes to the sequence of transformations, so c doesn’t always point to a Customer (if we think about a Customer as the kind of structure returned by getCustomerById).

Even with a generic name c, I can follow these snippets very easily in my mind. The resulting JSON is clear. You might argue that it wouldn’t scale, and we will talk about that below, but for this specific example, this approach leads to code that is shorter and easier to understand.

What’s going on?

I think the last couple of snippets is easier to follow because the sequence of operations allows us to process the code one line at a time, and immediately forget about a line after we’ve mentally simulated it.

For example, after we dissoc the :birthdate, we know, because of the thread syntax, that that piece of information is out of the game. The same goes for the delete call in the javascript snippet. At first, we had to keep a single thing in mind: a customer with its birthday. After the first two transformations, we still have to keep a single (different) thing in mind: a customer with age, but no birthdate.

Compare with the Naming approach. You need to remember the original customer for several lines, and in the meanwhile we define two other names.

In theory, you could forget about the customer after we define customer-extra-info, but there’s no way to know that. You have to keep the customer in mind until you finish reading the let block, because nothing in the syntax indicates that you are allowed to forget.

In other words, the number of entities to keep in mind grows with each line in the Naming approach, but it is kept constant in the Sequence approach. The latter effectively allows us humans to update in-place in our working memory, instead of loading more and more stuff into it.

I’ve noticed that this disadvantage of the Naming approach forces me to a different pattern of reading. I have to start from the end of the code block, to check the return expression. I take note of what names are used there, then read up to find the definition for each one, recursing where necessary. It’s an evaluation tree, the return statement being the root.

This allows me to “garbage collect” things. For example, I would try to find out what customer-wo-birthdate means, and notice that it depends on customer, which is the result of get-customer-by-id. I will hold the value of customer in my head just long enough to imagine what customer-wo-birthdate looks like, then forget about it. If some other branch of the bottom-to-top reading tree also depends on customer, I can compute it again.

It’s a processing/memory trade-off: by reading from bottom to top, I can decide to forget about some names and recompute them if necessary.

A relevant factor here is the amount of time it takes to lookup the meaning of a name. When I choose not to keep the meaning of customer in my working memory, it helps a lot if I can look it up quickly - it has to be nearby in the code, somewhere I can see with a quick eye glance. If that’s not the case, I need great tooling support to make up for that. If looking up customer takes even a few seconds, the flow is disrupted and understanding the code gets harder.

Even in the best case, though, each branch in the dependency tree that stems from the return expression adds a piece of information that the reader has to remember while they compute another branch. For example, customer-data depends on customer-wo-birthdate and customer-extra-info. So I have to find out what the first one means, then keep it in mind while I figure out what customer-extra-info means, and then I can merge them.

This is the same kind of work required to understand the Naïve composition snippets. The difference is that giving things names helps us to remember them. Good names might even allow us to be lazy and don’t read all the code. The number of entities is still the same, though.

Ok, so SEQUENCE ALL THE THINGS, right?

No.

The sequence approach worked great in the examples above because the c value, at each step, was simple enough to keep in mind with little effort. It was just a map with a few attributes that changed along the way, and we only had to deal directly with 3 of them (:birthdate, :age and :friend-ids). It also helps that these pieces of information are closely related: you can tie all of them to the single concept of a human being who has friends.

The exact number varies depending on how you think about it, but it’s something from 1 to at most 5 chunks of data to remember at each individual step (if you count each attribute + the map itself). This is within the normal capacity of human working memory.

That option is not always available. If you have some computation that depends on a customer personal data, the current availability of a product, and the price of Euros relative to US Dollars, you’ll have a hard time finding a unifying concept. The reader will need to keep track of these three things while reading the code that operates in them. Saying “before we had a product-availability-euro-price-raw-customer thing, now we have a product-availability-euro-price-customer-with-extra-info thing, so you can forget about the other thing” doesn’t help much.

When you get to that point, Naming is the only way to go. Find a good name (or set of names), use it consistently, maybe add some comments to make it easier for readers to learn what the name means.

Conclusion

As with most things involving human communication, nothing here is clear cut. I do think on it is possible to have too many names, with too small a meaning, in a given piece of code. In those cases we are better served with approaches like the one exemplified in the Sequence section above.

However, what exactly is “too many, too small” differs from codebase to codebase, depending on characteristics of the domain, the programming language and the tooling. Different readers may also have different thresholds, depending on their own working memory capacity at some point in time and how comfortable they are with the codebase, the domain, the programming language and the tooling.

The lesson I think we can draw here to keep names as short-lived as possible. If some piece of data or code absolutely has to be known and remembered by anyone trying to understand the system, please do give it an awesome name. Make it as descriptive and intuitive as possible. Look for unifying concepts, or even make one up and document it clearly.

If you don’t need to keep it around, though, try to make your code more Sequence-like, so that the reader can immediately forget about stuff that won’t be used downstream.

The middle ground, which is a lot (maybe most) of the time, are the names which are useful only in one or a few places. In those cases, we can try to make the scope of the name small and clear to the reader. For example, if a name is only useful inside a single function, we can define it in that function’s body - and that also speeds up the process of looking up the name. Failing that, we can mark names as private inside a module or class, a larger, but still limited scope.