Abstraction by interpretation vs abstraction in code?
Recently, someone wrote online that the web platform has not made much progress in the past decade towards the elegance and power of React, so I teased "That's good. Let's keep it that way." Eventually, and quite predictably, I was asked to describe how I do vanilla without reinventing frameworks and libraries as many people imagine vanilla development looks like.
Since comments on social networks don't allow for enough space to cover all the things I do, and what they actually mean, I gave them a short list of general principles I follow. I feel one in particular deserves expanding on, so that's what I'm going to do here. The principle in question is:
Abstraction by interpretation over abstraction in code
(And I might expand the rest later.)
Interpretation?
Let me give you a random number: 1439. What does this number mean?
Before you go too deep, I'll just tell you. It means nothing and a lot of things at the same time.
It could be a year, either AD or BC. It could be altitude. Number of people that completed a marathon last month. Your high-score in a game. Volume. Number of files. Or file size. You get the point. Could be anything.
But at the same time, since I don't know which one of those things it means, it means nothing to me.
The physical reality of 1439 being a number, and possibly taking up a bit of memory in our computer, has no significance without interpretation.
In order to give this number an interpretation, I need context.
Abstraction?
The number 1439 could mean lots of things, which means that there's an entire cosmos of different contexts for us to interpret it. Depending on my needs, I pick exactly one context out of a set of all possible contexts. I basically narrow down the possibly unbounded set of contexts to a single one I care about. But how can this be an abstraction?
An abstraction is a selective perception of reality (in this case 1439 is a number with lots of possible meanings) such that I focus on the things I care about and ignore/discard the things I don't. By picking a specific interpretation, I also ignore/discard all other interpretations, and also the physical reality of the thing I'm interpreting: the physical thing is replaced by my interpretation.
If we decide that we are talking about history, for instance, the number 1439 now has a concrete meaning. The year 1439 might mean the year when kissing was banned in England to stop the spread of Black Death.
To code or not to code
In software, we usually have an abstract – usually but not necessarily – simplified interface that lets us specify or get things we care about, and ignore or hide the things we don't (a.k.a. implementation detail). But this approach is preceded by a thought about what we care about and what we don't care about in the given situation. In other words, software abstraction is an alias for the context.
So now we've got a questions. Why not encode the abstraction? Actually, what does that even look like?
Erm... concrete... example
In one of the tasks I was taking care of at work, I had to deal with time values for a time picker control. The existing code was already using Moment, which is a date + time abstraction with all kinds of bells and whistle. Although this library is popular, it's not well-suited for situations where I'm only dealing with time of day. (Not to mention that it's deprecated anyway.)
I'm going to first show you a more traditional approach to solving this problem, by developing a simpler in-code abstraction to represent time (and time intervals). Next I'm going to follow that up with an abstraction-by-interpretation approach so you can see the difference.
In real life, I have several use cases that these abstractions will need to support.
- Compare times (is X before Y, is X equal to Y, etc)
- Basic arithmetic (add 30 minutes to X)
- Convert it to string like '6:30'
- Convert from string like '6:30'
To keep these examples short, however, I'm going to implement just the first two, and demonstrate the usage by building a list of times that start with a specific time and contain times of day in 30-minute increments up until and including the end of day (23:59 / 11:59 pm).
Abstraction in code
I would need at least two classes to represent the values I want to work with. One is the time of day, and the other is an interval (e.g., when I say "add 30 minutes to X", I'm not adding two times of day, but adding an interval to a time of day).
The constructor will take hours and minutes, and will internally represent these as a single value in total minutes.
class TimeOfDay {
constructor(hours = 0, minutes = 0) {
this.value = hours * 60 + minutes
Object.freeze(this)
}
getHours() {
return Math.floor(this.value / 60)
}
getMinutes() {
return this.value % 60
}
add(interval) {
this.value += interval.value
}
diff(other) {
return new Interval(this.value - other.value)
}
equals(other) {
return this.value == other.value
}
lessThan(other) {
return this.value < other.value
}
lessThanOrEquals(other) {
return this.value <= other.value
}
// ....
}
The interval simply represents duration in minutes. It has no hour component as that's not needed in the particular use cases I have.
class Interval {
constructor(minutes = 0) {
this.value = minutes
Object.freeze(this)
}
add(interval) {
return new Interval(this.value + interval.value)
}
equals(interval) {
return this.value == interval.value
}
lessThan(interval) {
return this.value < interval.value
}
// ....
}
I'll now use these to create an array of times of day starting with a specific time and going up to end-of-day in 30 minute increments. This code for this may look like this:
let startTime = new TimeOfDay(6, 30),
interval = new Interval(30),
times = [startTime],
endOfDay = new TimeOfDay(23, 59),
t = startTime
for (let t = startTime; t.lessThanOrEquals(endOfDay); t = t.add(interval))
times.push(t)
Abstraction without code
The internal representation of the time of day in the TimeOfDay class, gives
you a clue about what the next implementation is going to be like. I'm going
to strip the previous example to it's essence, which is the value property.
Once ripped out of the class housing, the value property is nothing but an
integer that represents the number of minutes since midnight. To set the
stage for the operations involving this value, I'm simply going to mention
this in a comment:
// All times in this module are expressed as an intenger representing the minutes
// since midnight
And that's basically my 'abstraction'. I've clarified the context, and now I'm able to interpret the values accordingly.
I can now simply move straight to creating the list of times:
let startTime = 390, // 6:30
endOfDay = 1439, // 23:59
interval = 30, // minutes
times = []
for (let t = startTime; i <= endOfDay; i += interval)
times.push(t)
I essentially get the same thing as before, but without the additional code, and using only built-in operators.
Conclusion
Firstly, if you look at the first example, you see that I've spent an awful lot
of effort into converting the built-in operators like +, < or <= into
methods. The majority of the code is dedicated just to this. One way to avoid
this would be to just use the value property directly and re-wrap the result
into the TimeOfDay class, but then I'd basically be implementing the second
solution with the additional cruft of the class for no added benefit (other
than, perhaps, being able to specify hours and minutes separately, which isn't
that big of a benefit).
The abstraction in the first example essentially serves the same purpose as the comment in the second example, except that it uses the programming language syntax to encode the information about the context. The cost of doing this should be quite obvious by now. I had a lot more code in the first example compared to the second version.
The second approach is what I call "abstraction by interpretation": I abstract by verbally establishing a rule about how the values/code should be interpreted. The trade-off is that we incur the cognitive load of interpreting the values in our mind, but we get in return simpler code and much less of it. Since one of my goals is to do build-less front ends, as long as the cognitive load is not too high, this is a worthwhile trade-off.
In the introduction, I say 'abstraction by interpretation over abstraction in code'. This means that I'm not suggesting you should never create abstractions of any sort in code. After all, in our day-to-day work you are using hundreds and thousands of abstraction, JavaScript itself being one of them. What I'm suggesting is that it's worthwhile to explore ways to avoid code abstractions in favor of mental ones. When done right, abstraction by interpretation tends to reduce the amount of code, and the amount of work without making the code too much harder to understand.
This concept is also known under multiple different names such as "semantic abstraction" or "abstraction by contract".