It’s easy to talk about how to make your code more readable and maintainable, but it’s another thing to put those ideas into practice. We’re finally showing some code in this post instead of discussing ideas. We’ll provide a concrete example of how to layer your code and add abstraction to make it easier to read and maintain. Follow along and see how much of a difference it can make.
We’ve talked about making your code easier to read by enabling readers to skip large portions. A key to accomplishing this is organizing code by conceptual level like you would lay out a book. Of course, we want to keep the low-level details that make our code function. However, we can make reading faster by wrapping our low-level details in higher-level abstractions and properly staging them. The highest layer should use the language of the problem space and might seem non-technical compared to underlying layers.
A Concrete Example
This example consists of code I wrote for a personal project. First, we’ll start with an early version of the main() function. Then I’ll show what I often do next: insert an outline via sparse comments for large blocks of code. Finally, I convert the comments into function calls and classes that contain the previous code almost verbatim. In other words, I re-organize the code to provide a higher conceptual flow on top of what’s already there.
int main(int argc, char** argv) { sigset_t set; int s; sigemptyset(&set); sigaddset(&set, SIGINT); s = pthread_sigmask(SIG_BLOCK, &set, NULL); if (s != 0) std::cout << "unable to block signals..." << '\n'; CliArgs args = { 0, }; Config cfg; DriveSpec dspec = cfg.get_drive_specification(); GpioManager<PiGpio> m; PiGpio* status_led_io = m.pin(18).dir(OUT).asserts(HI).por(CLR). create(); PiGpio* switch_io = m.pin(23).dir(INP).asserts(LO).pull_up().create(); Button service_button(switch_io); LedController status_led(status_led_io, 250); DiskMonitor hdd_mon(dspec); int c; while ((c = getopt(argc, argv, "dl:v")) != -1) { switch (c) { case 'd': args->daemonize = 1; break; case 'l': args->log_file = optarg; break; case 'v': args->debug_level++; break; default: abort(); break; } } if (args.daemonize) { daemon(NOCHDIR_OFF, NOCLOSE_OFF); } m.init(); status_led.start(); service_button.start(); hdd_mon.start(); s = pthread_sigmask(SIG_UNBLOCK, &set, NULL); if (s != 0) std::cout << "unable to unblock signals..." << '\n'; sighandler_t err = signal(SIGINT, kill_handler); err = signal(SIGTERM, kill_handler); std::cout << "reg code: " << err << '\n'; status_led.off(); int error; while (run_service) { cout << "Press button to enable service..." << '\n'; while (run_service && !service_button.pushed() && !hdd_mon.is_new_disk_present()) {} status_led.flash(); string command = "mount "; command += "-o " + dspec.options + " "; command += "-U " + dspec.blkid + " "; command += dspec.target; std::cout << __FUNCTION__ << ": " << command << '\n'; std::cout << "Mounting HDD..." << '\n'; error = system(command.c_str()); std::cout << "HDD mounted" << '\n'; std::cout << "Start avahi-daemon service..." << '\n'; error = system("service avahi-daemon start"); if (error) return error; std::cout << "avahi-daemon started" << '\n'; std::cout << "Start netatalk service..." << '\n'; error = system("service netatalk start"); if (!error) { std::cout << "netatalk service started" << '\n'; } status_led.on(); while (service_button.pushed()) {} cout << "Press button to disable service..." << '\n'; while (run_service && !service_button.pushed()) {} status_led.flash(); command = "umount " + dspec.target; std::cout << "Unmounting HDD..." << '\n'; error = system(command.c_str()); std::cout << "HDD unmounted" << '\n'; std::cout << "Stop netatalk service..." << '\n'; error = system("service netatalk stop"); if (error) return error; std::cout << "netatalk service stopped" << '\n'; std::cout << "Stop avahi-daemon service..." << '\n'; error = system("service avahi-daemon stop"); std::cout << "avahi-daemon stopped" << '\n'; status_led.off(); while (service_button.pushed()) {} } cout << "Exiting app..." << '\n'; status_led.stop(); service_button.stop(); hdd_mon.stop(); return 0; }
It can be challenging to see the forest for the trees as you write software, especially at first. In the early stages of writing software, we often focus on minor details: researching libraries, testing ideas, and building up sections of the design a portion at a time. As we complete pieces of the architecture, we can group low-level details into medium ones and then medium-level constructs into high-level building blocks. It’s helpful to step back and inspect our software at different levels as we author it. It may feel like slowing down in the short term, but it helps us avoid big mistakes and speed up our overall completion. But I want to stress: continue writing until you create enough abstraction others can devour your code rapidly. This last step is relatively cheap compared to the enormous benefit one receives.
This first version of main() is past the earliest phases of development. Many components work together to create a solution that works under ideal conditions. I wrote the code using strategies that make it easier to refactor. For example, I grouped statements according to their responsibility, tried minimizing variable lifetimes, and provided abstractions like LED controllers, HDD monitors, and CLI argument parsers. Imagine how many nitty-gritty details would riddle main() without that level of rigor! But we can do better. If I use our book analogy again, this code has statements and paragraphs but no chapters. Much of the good software I read is like this code.
Organizing and Collecting Our Thoughts…
The next version of the code includes the comments I referred to earlier in the post and series. Adding these comments helps me and others at little cost and almost no risk since I’m not changing code. This step may include reordering statements to get better groupings and to deduplicate code. If I make code changes like this, I review them meticulously to ensure I don’t introduce any bugs. I don’t need to do that in this version of the code. Perhaps we can go through more challenging examples in the future. Look at the code below and see if it enables you to read and navigate things more quickly.
int main(int argc, char** argv) { sigset_t set; int s; // Block ctrl-c from stopping application sigemptyset(&set); sigaddset(&set, SIGINT); s = pthread_sigmask(SIG_BLOCK, &set, NULL); if (s != 0) std::cout << "unable to block signals..." << '\n'; // Parse config file Config cfg; DriveSpec dspec = cfg.get_drive_specification(); // Setup LED and switch GpioManager<PiGpio> m; PiGpio* status_led_io = m.pin(18).dir(OUT).asserts(HI).por(CLR). create(); PiGpio* switch_io = m.pin(23).dir(INP).asserts(LO).pull_up().create(); Button service_button(switch_io); LedController status_led(status_led_io, 250); // Create disk monitor DiskMonitor hdd_mon(dspec); // Parse command line switches CliArgs args = { 0, }; int c; while ((c = getopt(argc, argv, "dl:v")) != -1) { switch (c) { case 'd': args->daemonize = 1; break; case 'l': args->log_file = optarg; break; case 'v': args->debug_level++; break; default: abort(); break; } } // Daemonize if asked by CLI switches if (args.daemonize) { daemon(NOCHDIR_OFF, NOCLOSE_OFF); } // Initialize LED, button, and disk monitor m.init(); status_led.start(); service_button.start(); hdd_mon.start(); // Unblock ctrl-c and register a handler s = pthread_sigmask(SIG_UNBLOCK, &set, NULL); if (s != 0) std::cout << "unable to unblock signals..." << '\n'; sighandler_t err = signal(SIGINT, kill_handler); err = signal(SIGTERM, kill_handler); std::cout << "reg code: " << err << '\n'; // Turn off status LED status_led.off(); int error; // Service runs through several states while (run_service) { // === Idle state === cout << "Press button to enable service..." << '\n'; // Block until the user presses a button AND a disk is present while (run_service && !service_button.pushed() && !hdd_mon.is_new_disk_present()) {} status_led.flash(); // Mount HDD string command = "mount "; command += "-o " + dspec.options + " "; command += "-U " + dspec.blkid + " "; command += dspec.target; std::cout << __FUNCTION__ << ": " << command << '\n'; std::cout << "Mounting HDD..." << '\n'; error = system(command.c_str()); std::cout << "HDD mounted" << '\n'; // Start AVAHI daemon (service advertising) std::cout << "Start avahi-daemon service..." << '\n'; error = system("service avahi-daemon start"); if (error) return error; std::cout << "avahi-daemon started" << '\n'; // Start Netatalk (Apple file sharing) std::cout << "Start netatalk service..." << '\n'; error = system("service netatalk start"); if (!error) { std::cout << "netatalk service started" << '\n'; } status_led.on(); // Wait until button is released while (service_button.pushed()) {} // === Sharing Disk State === cout << "Press button to disable service..." << '\n'; // Wait until the user presses the button while (run_service && !service_button.pushed()) {} status_led.flash(); // unmount HDD command = "umount " + dspec.target; std::cout << "Unmounting HDD..." << '\n'; error = system(command.c_str()); std::cout << "HDD unmounted" << '\n'; // Shutdown netatalk (Apple file sharing) std::cout << "Stop netatalk service..." << '\n'; error = system("service netatalk stop"); if (error) return error; std::cout << "netatalk service stopped" << '\n'; // Shutdown AVAHI (service advertising) std::cout << "Stop avahi-daemon service..." << '\n'; error = system("service avahi-daemon stop"); std::cout << "avahi-daemon stopped" << '\n'; status_led.off(); // Wait until button is released while (service_button.pushed()) {} } cout << "Exiting app..." << '\n'; // Shutdown LED, button, and HDD monitor status_led.stop(); service_button.stop(); hdd_mon.stop(); return 0; }
Adding “Chapters” to Our Code
And finally, I include the refactored code that adds a higher level of conceptualization. The new statements are the “chapters” of the book. In many cases, all I did was convert comments into function calls. This strategy encodes the higher-level thoughts in code rather than with comments, so we’ll be less likely to create comments that become invalid in the future. It also makes it easy to reuse portions of our code in other places and avoid the redundancy that often crops up as we write more and more lines of code. Finally, it helps us understand the program without requiring us to read and understand every minute detail. And when we need to understand particulars, we can quickly drill down to areas of interest by looking up function calls or classes within our editor, debuggers, and other tools. How much faster could you understand the program using this code version?
int main(int argc, char** argv) { auto ctrl_c_sig = block_ctrl_c(); auto cfg = parse_config_file(); UserInterface ui; DiskControl hdd_mon(cfg); auto args = parse_cli_switches(argc, argv); daemonize_if_asked_by_user(args); start_ui_and_hdd_monitor(ui, hdd_mon); unblock_and_handle_ctrl_c(ctrl_c_sig, kill_handler); // === Start in Idle Mode === ui.status_led_off(); // Cycle through different states until killed while (run_service) { wait_for_button_press_and_disk_present(ui, hdd_mon); if (!run_service) break; // === Active State === ui.status_led_flash(); hdd_mon.mount(); start_apple_sharing(); ui.status_led_on(); wait_for_button_release(ui); if (!run_service) break; // Could be a long time between... wait_for_button_press(ui); if (!run_service) break; // === Idle State === ui.status_led_flash(); hdd_mon.unmount(); stop_apple_sharing(); ui.status_led_off(); wait_for_button_release(ui); } cout << "Exiting app..." << '\n'; ui.stop(); hdd_mon.stop(); return 0; } // --------------- Helpers --------------- static sigset_t block_ctrl_c() { int error; sigset_t ctrl_c_sig; sigemptyset(&ctrl_c_sig); sigaddset(&ctrl_c_sig, SIGINT); error = pthread_sigmask(SIG_BLOCK, &ctrl_c_sig, NULL); if (error != 0) std::cout << "unable to block signals..." << '\n'; return ctrl_c_sig; } static CliArgs parse_cli_switches(int argc, char** argv) { CliArgs args; int c; while ((c = getopt(argc, argv, "dl:v")) != -1) { switch (c) { case 'd': args.daemonize = true; break; case 'l': args.log_file = optarg; break; case 'v': args.debug_level++; break; default: abort(); break; } } return args; } static daemonize_if_asked_by_user(const CliArgs& args) { if (args.daemonize) { daemon(NOCHDIR_OFF, NOCLOSE_OFF); } } static void start_ui_and_hdd_monitor(Ui& ui, DiskControl& hdd_mon) { ui.start(); hdd_mon.start(); } static void unblock_and_handle_ctrl_c(sigset_t& ctrl_c_sig, void (*handler)(int)) { int error; errro = pthread_sigmask(SIG_UNBLOCK, &set, NULL); if (error) std::cout << "unable to unblock signals..." << '\n'; sighandler_t err = signal(SIGINT, kill_handler); err = signal(SIGTERM, handler); std::cout << "reg code: " << err << '\n'; } static void wait_for_button_press(Ui& ui) { cout << "Press button to disable service..." << '\n'; while (run_service && !ui.button_is_pushed()) {} } static void wait_for_button_press_and_disk_present(Ui& ui, HddMonitor& hdd_mon) { cout << "Press button to enable service..." << '\n'; while (run_service && !ui.button_is_pushed() && !hdd_mon.is_disk_present()) {} } static void wait_for_button_release(Ui& ui) { while (ui.button_is_pushed()) {} } static int start_apple_sharing() { int error; std::cout << "Start avahi-daemon service..." << '\n'; error = system("service avahi-daemon start"); if (error) return error; std::cout << "avahi-daemon started" << '\n'; std::cout << "Start netatalk service..." << '\n'; error = system("service netatalk start"); if (!error) { std::cout << "netatalk service started" << '\n'; } return error; } static int stop_apple_sharing() { int error; std::cout << "Stop netatalk service..." << '\n'; error = system("service netatalk stop"); if (error) return error; std::cout << "netatalk service stopped" << '\n'; std::cout << "Stop avahi-daemon service..." << '\n'; error = system("service avahi-daemon stop"); std::cout << "avahi-daemon stopped" << '\n'; return error; } static void kill_handler(int signum) { std::cout << "Terminating daemon..." << '\n'; run_service = 0; }
Layers
I want to point out a few particulars about the new code. First, I include the most essential and highest-level code as early in the file as possible. Why? It’s the closest thing to a table of contents the code has. So putting it at the top of the file increases the chance readers will see it. Unlike a book, there’s no guarantee they’ll find something like that anywhere in the code, so they’re not prone to look for it based on standard practice. Finally, readers gain the most benefit from starting in main(), where they can easily attain the overall summary of the code or drill down into specific areas.
I put “helper” function prototypes ahead of main() only because the compiler requires it, but their definition goes after main(). In addition, some readers may want to avoid looking at them. So why make them skip over that section every time they open the file?
Look at line 8 in the code above. Why did I summarize such a small block with a higher-level statement? While it only reduces the number of lines the reader must parse a little, it provides conceptual symmetry with the lines around it. Much like you should group statements that focus on a particular task together, I like to group statements with a similar level of conceptualization. Avoid mixing nitty-gritty details with medium or high-level ideas in the same source code block. It reads better and makes refactoring easier in the future. Strictly assigning statements to their level isn’t a hard requirement, but it usually helps. I might have done better at this in line 14. However, please remember we’re not going to achieve perfection. Categorizing every line of code into layers you’ve imagined can be challenging. You’ll make many judgment calls and sometimes fail, but the result is likely far better if you succeed even more than half the time.
I recommend using an editor with symbol lookup to make it fast to drill down through any code path. Layered code will require more hopping around to view anything but the highest flow. Using your memory and/or recursive grep will be slow and taxing. Save your mental facilities for loftier challenges. I show the code’s highest and middle conceptual layers in this post but exclude large swaths of the lowest levels. You can view the complete code in my netabox repository.
Lastly, how many layers do you need? That depends on your code-base size, but staying moderate is best. I often find three layers good, but that’s not a rule of thumb. As I mentioned, create a high-level layer that could serve as a table of contents. But evaluate how many layers you need under that in each scenario. Please remember that most people cannot comfortably track more than three to five things simultaneously. Creating many conceptual layers will likely confuse others. If your layers are not easy to see and understand, they will break down as others modify or extend the code.
Summary
The final code could be better, and I still have a list of things I’d like to change. I have yet to polish line 14 any further, for example, because my to-do list includes items that will considerably impact it. But I think you’ll agree the main() function is much shorter and conveys what the program does much better.
It only took me a few minutes to rework this code into a form that will save readers much more time in the future. Also, I enjoy working on code like this much more, so it’ll be more likely to receive features and bug fixes to improve it. And lastly, if readers correctly understand the code faster, they’re more likely to make proper changes instead of hacking something that only partially works because they’re impatient or need more time than they have to read all the code. Good luck employing these ideas in a way that helps you and your team!