|
From Practices of the Python Pro by Dane Hillard This article covers
|
Take 37% off Practices of the Python Pro. Just enter fcchillard into the discount code box at checkout at manning.com.
Extensibility and Flexibility
At many established organizations, your day-to-day work as a developer involves not only writing new applications, but updating existing ones. When you’re tasked with adding a new feature to an existing application, your goal is to extend the functionality of that application with new behaviors. Extending software is the introduction of new behavior by the addition of code. Some applications are flexible to this kind of change, whereas others may fight you tooth and nail! Flexibility is how easily software can adapt to shifting requirements.
What is extensible code?
Think about a web browser like Google Chrome or Mozilla Firefox. You’ve probably installed something in one of these browsers to block advertisements or to easily save the article you’re reading to a notes tool like Evernote. Firefox calls these installable pieces of software add-ons, and Chrome calls them extensions (hint, hint). These additional behaviors are an example of a plugin system. Plugin systems are an implementation of extensibility; Chrome and Firefox weren’t built with ad blockers or Evernote in mind specifically, but they were designed to allow for such extensions to be built.
Massive projects like web browsers succeed when they can cater to the needs of hundreds of thousands of users. It’d be a massive feat to predict all those needs in advance, and an extensible system allows for solutions to those needs to be built after bringing the product to market. You won’t always need to be this forward-looking, but drawing on some of the same concepts helps you build better software.
As with many facets of software development, extensibility is a spectrum, and something you’ll iterate on. By practicing concepts like separation of concerns and loose coupling, you can improve code’s extensibility over time. As the extensibility of your code improves, you’ll find that adding new features becomes faster because you can focus almost entirely on that new behavior without worrying about how it affects the features around it. This also means you’ll have an easier time maintaining and testing your code, because features are more isolated and therefore less likely to introduce tricky bugs due to intermingled behavior.
Adding new behaviors
If you were to write the beginnings of a bookmarking application—let’s call it Bark, short for BookmARK—you might use a multitier architecture to separate the concerns of persisting, manipulating, and displaying bookmark data. You might build a small set of features on top of those layers of abstraction to make something useful. What happens when you’re ready to add new functionality?
In an ideal extensible system, adding new behavior involves strictly adding new code without changing existing code. Adding new behavior to an extensible system means adding new classes, methods, functions, or data that encapsulate the new behavior (figure1).
Figure 1. Adding new behavior to extensible code
Compare this with a less extensible system, where new functionality may require adding conditional statements to a function here, a method there, et al. (figure 2). That breadth and granularity of changes is sometimes referred to as shotgun surgery, because adding a feature requires peppering changes throughout your code like the pellets from a shotgun round (read more about shotgun surgery and other code smells in An Investigation of Bad Smells in Object-Oriented Design). This often points to a mixing of concerns or an opportunity to abstract/encapsulate in a different way. Code that requires these kinds of changes isn’t extensible; creating new behavior isn’t a straightforward endeavor. You need to go searching around the code for exactly the right lines to update.
Figure 2. Adding new behavior to code which isn’t extensible
A perfectly valid approach to extension is to duplicate some code and update that new copy to do what you need. I use this approach occasionally on the way to making the original code more extensible; if I first create a duplicate version, alter it, and see how the two versions differ, I can refactor that duplicated code back into a single, multipurpose version later. Remember that duplication is better than the wrong abstraction!
An ideal system, then, requires strictly adding code for new features. But because real systems are rarely ideal, you’ll still find yourself needing to make changes to existing code regularly (figure 3). How does flexibility apply in these situations?
Figure 3. How extensibility looks in practice
Modifying existing behaviors
You might need to change code you or someone else has already written for a number of reasons. You might need to change the behavior, in the case of fixing a bug or addressing a change in requirements. You might need to refactor the code, keeping the behavior consistent while making the code easier to work with. In these cases, you aren’t necessarily looking to extend the code with new behavior, but the flexibility of the code still plays a big role.
Kent Beck wittily said, “For each desired change, make the change easy (warning: this may be hard), then make the easy change.” Flexibility of code is a measure of its resistance to change. Ideal flexibility means that any piece of your code can be easily swapped out for another implementation. Code that requires shotgun surgery to change is rigid; it fights against change by making you work hard. Breaking down the resistance first—through practices like decomposition, encapsulation, etc.—paves the way to making the specific change you originally intended.
This manifests in my own work as little, continuous refactorings in the area of code which I’m working in. As an example, the code you work in may contain a complicated set of if/else
statements (listing 1). If you need to change a behavior in this set of conditionals, it’s likely you need to read the majority of it to get an understanding of where the change should be made. And if the change you want to make applies to the body of each conditional, you need to apply the change many times over.
Listing 1. A rigid mapping of conditions to outcomes
if choice == 'A': print('A is for apples') ❶ elif choice == 'B': ❷ print('B is for bats') ...
❶ This conditional needs to be updated properly for each choice
❷ The concerns of mapping an option to a message and printing the message are mixed
How could this be improved?
A. Extract information from the conditional checks and bodies into a dict
B. Use a for
loop to check against each available choice
Because each choice maps to a specific outcome, extracting the mapping of behaviors into a dictionary (A) is the right approach. By mapping the letter for the choice to the word which goes in the message, a new version of the code can retrieve the right word from the mapping agnostic of the choice picked. You no longer need to keep adding elif
statements to a conditional and define the behavior for the new case; you can instead add a single new mapping from the choice letter to the word used in the message, printing only in one place at the end (listing 2). The mapping of choices to messages acts like configuration, or information a program uses to determine how to execute. Configuration is often easier to understand than conditional logic.
Listing 2. A more flexible way to map conditions to outcomes
choices = { 'A': 'apples', ❶ 'B': 'bats', ... } print(f'{choice} is for {choices[choice]}') ❷
❶ Extracting a mapping of choices to messages simplifies adding a new option
❷ Outcome’s centralized, and printing behavior’s separated somewhat
This version of the code is more readable; whereas the former example requires you to understand the conditions and what each condition does, the latter is more clearly structured as a set of choices and a line that prints information about a specific choice. Adding more choices and changing the message that gets printed is also easier, because they’ve been separated. Extensibility helps you deliver value more quickly and more maintainably. If you find some code that isn’t extensible, first work to make it so before adding the desired behavior. This will pay dividends well into the lifetime of your code.
That’s all for this article. If you want to learn more about the book, check it out on liveBook here and see this slide deck.