When I was in high school, I didn’t have a typical job for my age. Instead of waiting tables or making sandwiches, I worked as a software developer. I couldn't believe my luck! I was making more money than my peers, doing something I loved.
However, that enthusiasm was short-lived. The code I was producing was a horrible, tangled mess, which made the job frustrating at times. My coworkers were just as inexperienced, and wrote code that wasn't much better than mine. Our mindset was, if the code worked by our deadline, then we did our job. We never gave much thought to formulating clean abstractions.
As time went on, my job became less and less fun. I was spending late nights trying to make sense of the code we were writing, and attempting to fix the numerous bugs that were popping up. I wasn't having fun anymore.
My original love for programming still compelled me to enroll in a computer science program in college, but the longer I spent working in the industry, the more seriously I began considering other career paths. Ultimately, the negative experiences I had working with messy code caused me to graduate from a different program entirely.
Eventually, I figured out how to make software fun again: write code you are proud of. I discovered that abstracting my code well not only produced work I could be proud of, but also made the code easier to read and work with.
"The compiler doesn't care whether the code is ugly or clean. But when we change the system, there is a human involved, and humans do care. A poorly designed system is hard to change. Hard because it is hard to figure out where the changes are needed. If it is hard to figure out what to change, there is a strong chance that the programmer will make a mistake and introduce bugs."
—Martin Fowler
When we talk about whether a system or code is “hard to figure out,” we’re talking about how properly formed abstractions can make code easier to think about.
Abstractions allow others to understand one component’s function without needing to know every detail. This same principle applies outside the world of software: you don’t have to know how a battery stores electricity in order to use one to power a flashlight.
Forming proper abstractions is an art, not a science. While there are many published guidelines and best practices on the topic, the popularity of the term “coding style” is evidence that there are many ways to form abstractions. I've found that if I write code that adheres to the following guidelines, writing software is an extremely enjoyable experience.
Don't obscure relevant information
If I asked you what your favorite sandwich was, I would be annoyed if you said, “I like the sandwiches that have two pieces of bread and some food in between.” While that is a perfectly valid response, you’re a jerk and you know why. The contents of the sandwich are still a mystery. How can I know if I’ll really be in the mood for one come lunchtime?
The same concept applies to code: don’t hide relevant information under an abstraction. Here is an example where important details are obscured:
# Figure 1
class DeliciousSandwich < Sandwich
def initialize
end
def prepare
make_bread
slice_bread
add_fixings(:delicious_fixings)
end
end
Sure, an overview of the sandwich-making process is provided here, but what kind of sandwich are we making? We can deduce that some state changed in order for this code to work, but not what that state looks like from the code provided here. All of the relevant details are hidden in DeliciousSandwich's super-class, or maybe even somewhere else. You know an abstraction has failed when you are forced to travel multiple levels deep just to determine relevant information.
I would consider side effects to be relevant when using an abstraction. Developers often complain that certain code can be "magical," meaning they have no idea what side effects an abstraction has. What if calling the make_bread
method lowers the available_electricity
value of an abstraction somewhere else in the codebase? What if available_electricity
hits a value below zero as a result of your call to make_bread
? A developer would have no idea if any of those things are possible when reading the code in Figure 1. If side effects were sequestered and evident in the naming of the abstraction, that magical feeling goes away.
Code should match the detail of the abstraction name
If I asked you how you make your delicious sandwiches and you started explaining the science behind why yeast eats sugar during the bread-baking process, I would peg you as a smartass. I don’t need to know how to invent bread when I can just buy it at the store.
The same idea applies to abstractions: write code at a level of detail that matches your abstraction.
# Figure 2
class DeliciousSandwich
def initialize(oven, knife, pantry, refrigerator)
@oven = oven
@knife = knife
@pantry = pantry
@fridge = refrigerator
end
def prepare
@oven.preheat(400)
sleep(10.minutes)
@oven.open_door
@oven.put(dough)
@oven.close_door
sleep(30.minutes)
@oven.open_door
baked_bread = @oven.remove_contents
@oven.close_door
@oven.shut_off
sliced_bread = @knife.slice(baked_bread)
sliced_bread.add(fixings)
end
private
def dough
[@pantry.get(:active_dry_yeast, 0.5, :teaspoon),
@fridge.get(:milk, 2, :tablespoon),
@fridge.get(:water, 0.75, :cup),
@pantry.get(:olive_oil, 1, :tablespoon),
@pantry.get(:flour, 2, :cup),
@pantry.get(:salt, 1.5, :teaspoon)]
end
def fixings
[@fridge.get(:prosciutto, 6, :thin_slice),
@fridge.get(:genoa_salami, 4, :slice),
@pantry.get(:sopresatta, 4, :slice),
@fridge.get(:truffle_mustard_vinaigrette, 1.5, :tablespoon),
@pantry.get(:red_wine_vinegar, 1, :tablespoon),
@fridge.get(:artichoke, 8, :half),
@fridge.get(:basil, 10, :leaf),
@fridge.get(:lettuce, 0.25, :head)]
end
end
As a reader of this code, when I see a generically named function like prepare
, I expect to see a high-level understanding of how a delicious sandwich is created. In the above example, I am instead bombarded with the details of how to work an oven. Consider the alternative:
# Figure 3
class DeliciousSandwich
def initialize(oven, knife, pantry, refrigerator)
@oven = oven
@knife = knife
@pantry = pantry
@fridge = refrigerator
end
def prepare
baked_bread = @oven.bake(dough)
sliced_bread = @knife.slice(baked_bread)
sliced_bread.add(fixings)
end
private
def dough
[@pantry.get(:active_dry_yeast, 0.5, :teaspoon),
@fridge.get(:milk, 2, :tablespoon),
@fridge.get(:water, 0.75, :cup),
@pantry.get(:olive_oil, 1, :tablespoon),
@pantry.get(:flour, 2, :cup),
@pantry.get(:salt, 1.5, :teaspoon)]
end
def fixings
[@fridge.get(:prosciutto, 6, :thin_slice),
@fridge.get(:genoa_salami, 4, :slice),
@pantry.get(:sopresatta, 4, :slice),
@fridge.get(:truffle_mustard_vinaigrette, 1.5, :tablespoon),
@pantry.get(:red_wine_vinegar, 1, :tablespoon),
@fridge.get(:artichoke, 8, :half),
@fridge.get(:basil, 10, :leaf),
@fridge.get(:lettuce, 0.25, :head)]
end
end
In this example, the oven's bake
abstraction hides the details made obvious by its name. This makes it really easy to understand the preparation process at a glance. In Figure 3, you have to inject an Oven
object to bake bread, which signals to a developer that the DeliciousSandwich
abstraction might affect an instance of an Oven
. If the same developer were to use the code from Figure 1 (above), he would have no idea.
Since the prepare
method's name is generic, it makes sense that its contents are also generically named. In Figure 2, low-level details of how an oven work clutter the sandwich code and make it harder to understand how to prepare a sandwich. If there is a generically named abstraction, its contents should be completely free of specific details.
When using an appropriate mental model to form an abstraction, the messy details will be hidden away; but its purpose can easily be recalled from the name, and can be used in other places in the code without taxing your own brain-power by trying to recall what it does.
It is easy for developers to let details slip into generically named abstractions when the two are related. However, if we follow that logic to a tee, an entire ticketing system could be built within a single abstraction named Ticket
. What’s the point of abstracting at the highest possible level? It doesn’t make the details any easier to understand.
There should be no ambiguity in the name
Effects of an abstraction should be clear from the name. If a state change or some other side effect is hidden behind an abstraction, its name should clearly indicate that. If an abstraction adds all the values in a given collection, its name should clearly indicate that.
If it gets difficult to name an abstraction, that abstraction is probably violating the Single Responsibility Principle. If you find yourself in that position, refactor until the names come to you more naturally.
Uncle Bob devotes chapter 2 of his book Clean Code to creating meaningful names. The highlights are:
- Use intention-revealing names
- Avoid disinformation
- Make meaningful distinctions
- Use pronounceable names
- Use searchable names
If you have read this much of the blog, I would highly recommend reading the Clean Code book. If you apply the lessons from that book, you will be able to compose clean and decoupled abstractions.
Let’s wrap this up
It seems crazy to think that I almost quit writing software for good. Working with and crafting well-formed abstractions brought the fun back into writing software. Hopefully, this post has sparked your interest in taking a well-abstracted-centric approach to writing software. My guidelines should point you in the right direction; however, it isn't enough to ensure well-formed abstractions will be created. There is a lot that goes into making decisions around abstractions. I suggest experimenting with how you abstract your code. That's half the fun of development anyway!