The SOLID Principles are a valuable collection of concepts to keep in mind when evaluating code, and designing complex systems. Unfortunately, most examples you find online regurgitate the exact same examples about Shapes and Rectangles that don't relate to the code you would actually write.
The Open/Closed Principle is primarily about making features of your software system extensible, so that your teammates, at any point in the future, can add new behaviors or properties for a feature without having to modify the core logic of how it operates.
Even that's vague, so let's look at an example.
The questions I ask myself when designing a feature is, "Is it possible that new options for this feature will be needed in the future? If so, how can I write code that will allow someone else to add an option that is isolated from the core code that makes the feature work."
One of the projects that I coach students through at NSS is a simple CLI application that displays menu options for a customer service representative to manage fictional products and orders for fictional customers.
To get them prepared, I show them a simplistic CLI app that involves a bank account, and an automated bank teller. The bank account provides methods for adding money to it, withdrawing money from it, and showing the balance.
import locale
class BankAccount():
def __init__(self):
self.balance = 0
self.account = None
def add_money(self, amount):
"""Add money to a bank account
Arguments:
amount - A numerical value by which the bank account's balance will increase
"""
self.balance += float(amount)
def withdraw_money(self, amount):
"""Withdraw money to a bank account
Arguments:
amount - A numerical value by which the bank account's balance will decrease
"""
pass
self.balance -= float(amount)
def show_balance(self):
"""Show formatted balance
Arguments:
None
"""
locale.setlocale( locale.LC_ALL, '' )
return locale.currency(self.balance, grouping=True)
The automated bank teller is the user interface for accessing the functionality of the bank account.
Here's how someone might start out building a menu system in Python. Notice the use of the multiple if
statements in the main_menu
method. This could very easily be a switch
statement as well.
import os
from bank import BankAccount
class Teller():
"""This class is the interface to a customer's bank account"""
def __init__(self):
self.account = BankAccount()
def build_menu(self):
"""Construct the main menu items for the command line user interface"""
# Clear the console first
os.system('cls' if os.name == 'nt' else 'clear')
# Print all the options
print("1. Add money")
print("2. Withdraw money")
print("3. Show balance")
print("4. Quit")
def main_menu(self):
"""Show teller options"""
# Build the menu
self.build_menu()
# Wait for user input
choice = input(">> ")
# Perform the appropriate actions corresponding to user choice
if choice == "1":
deposit = input("How much? ")
self.account.add_money(deposit)
if choice == "2":
withdrawal = input("How much? ")
self.account.withdraw_money(withdrawal)
if choice == "3":
print(self.account.show_balance())
input()
# If the user chose anything except Quit, show the menu again
if choice != "4":
self.main_menu()
if __name__ == "__main__":
teller = Teller()
teller.main_menu()
As soon as I write this code, I realize that every time my product owner wants to do one of the following actions:
- Change the order in which the menus are displayed
- Add a new menu option
- Remove a menu option
Then a developer would need to come into this core logic and start moving code around, adding code, or deleting code. The possible opportunities for introducing bugs in that scenario are vast. You also could potentially end up with a main_menu
method that is hundreds of lines long over years of development. That may never happen, but the possibility is always there.
Not only does this violate the Open/Closed Principle, but it also violates the Single Responsibility Principle. The main_menu
method should be responsible for building the menu - nothing more. All of the logic for each option should not be contained in this method.
In my mind, the Open/Closed Principle and the Single Responsibility Principle are like two sides of the same coin. They work in tandem to make an extensible system.
Ok, so now I lean back in my chair and start considering what are the different responsibilities in this context, and how can I refactor this code so that it is open for extension, and closed for modification as much as possible. Remember, there is no perfect solution for any of this, just your best effort every time. You keep getting better at it as the years go by.
Responsibilities
- Something should display a collection of menu options
- Something should provide a method to build a list of menu options
- Every option should perform the logic required to achieve the goal
That's it. I then have a plan.
- I need to write code that will look at a collection of menu options, regardless of what the user interface is, or the logic each needs to perform, and display a prompt.
- That means that I need to build a class representing a menu option. It must expose a property that holds the string prompt for the menu.
- I need a class who is responsible for constructing a collection of menu options.
The Menu Option
My first step is to create a class represents a menu item. I decide that a menu item has two parts.
- The prompt
- The logic to perform when it is chosen
class MenuItem():
def __init__(self, prompt, action):
self.prompt = prompt
self.action = action
This got me thinking what an action would be. How do I pass logic in as an argument to a method???
Hm, maybe I can pass in a class that follows a specific pattern that I can invoke when the logic needs to run. Luckily, Python classes have a dunder method named __call__
that allows you to invoke a class. It makes them callable. That's weird, but it works for me.
I'll start with the logic for showing the balance of a bank account.
import os
class ShowBalance():
def __init__(self, account):
self.account = account
def __call__(self):
os.system('cls' if os.name == 'nt' else 'clear')
print("\n Your balance is: {}".format(self.account.show_balance()))
input(">> Press enter to continue <<")
Now I can initialize this class and pass in a reference to the bank account, and then call the class later to run the logic.
Now for the deposit.
class Deposit():
def __init__(self, account):
self.account = account
def __call__(self):
amount = input("How much? ")
self.account.add_money(amount)
Lastly, withdrawal.
class Withdraw():
def __init__(self, account):
self.account = account
def __call__(self):
amount = input("How much? ")
self.account.withdraw_money(amount)
The Menu Builder
Time to define the object who will hold menu items, and then display them when needed.
import os
class MenuBuilder():
"""Responsible for building a command line menu system from MenuItems"""
def __init__(self, *args):
self.__menu = list()
for item in args:
self.__menu.append(item)
def add(self, menu_item=None, menu_items=None):
if menu_items is not None:
self.__menu.extend(menu_items)
if menu_item is not None:
self.__menu.append(menu_item)
def show(self):
# Clear the console
os.system('cls' if os.name == 'nt' else 'clear')
# Display each menu item
for index, menu_item in enumerate(self.__menu):
try:
print("{}. {}".format(index+1, menu_item.prompt))
except AttributeError:
raise AttributeError('Could not display the prompt for the current menu item {}'.format(str(menu_item)))
try:
choice = int(input(">> "))
# Invoke the class corresponding to the choice
for menu_item in self.__menu:
if choice == self.__menu.index(menu_item) + 1:
menu_item.action()
except KeyboardInterrupt: # Handle ctrl+c
exit()
except ValueError: # Handle any invalid choice
pass
self.show() # Display the MenuItems
Bank Teller
This makes the code for the bank teller much simpler.
from bank import BankAccount
from menu.actions import ShowBalance
from menu.actions import Deposit
from menu.actions import Withdraw
from menu.menubuilder import MenuBuilder
from menu.menuitem import MenuItem
class Teller():
"""This class is the interface to a customer's bank account"""
def __init__(self):
# Using composition to establish relationship between the bank
# and the teller, as well as the teller and the CLI menu that
# serves as the UI
self.account = BankAccount()
self.menu = MenuBuilder(
MenuItem("Add Money", Deposit(self.account)),
MenuItem("Withdraw Money", Withdraw(self.account)),
MenuItem("Show Balance", ShowBalance(self.account)),
MenuItem("Quit", exit)
)
self.menu.show()
Now the logic for each action in the menu is isolated into its own class, and the Teller class simply states which menu items should be displayed. This is definitely a step in the right direction. However, have I truly made this an Open/Closed system?
Not yet.
Any change to the menu system would still require a developer to open the Teller class and rearrange the logic. I've made progress, but there's more work to do. A developer should be able to add a MenuItem in the system, and the menu building system should be able to recognize it and show it where appropriate.
How can I do that in Python?
What if I made the menu actions a package, and the menu builder automatically imported everything from the package and built a menu from it? That's would be freaking cool, and since Python is awesome, you can do it easily.
/menu/actions/__init__.py
In the package init module, Python provides a way to retrieve every class in the package that inherits from a particular base class using the __subclasses__
dunder method.
import os
import pkgutil
import importlib
from .action import BaseAction
# Get the directory name of the current package
pkg_dir = os.path.dirname(__file__)
# Import each module
for (module_loader, name, ispkg) in pkgutil.iter_modules([pkg_dir]):
importlib.import_module('.' + name, __package__)
# Since each menu action class is a subclass of BaseAction, I can
# build a dictionary of all classes, in all modules, in this package
all_actions = {cls.__name__: cls for cls in BaseAction.__subclasses__()}
# Now anywhere I want to use all classes, I can use the following code
#
# import menu.actions
#
# for k,v in menu.actions.all_actions.items():
# ...do something awesome with each one
For this to work, each of the menu actions needs to have a parent class of BaseAction
.
action.py
class BaseAction(object):
pass
deposit.py
from .action import BaseAction
class Deposit(BaseAction):
def __init__(self, account):
self.account = account
@property
def prompt(self):
return "Add Money"
def __call__(self):
amount = input("How much? ")
self.account.add_money(amount)
My Teller class can now iterate over all of the classes in the menu.actions
package.
from bank import BankAccount
from menu.menubuilder import MenuBuilder
from menu.menuitem import MenuItem
import menu.actions
class Teller():
"""This class is the interface to a customer's bank account"""
def __init__(self):
# Using composition to establish the relationship between the bank
# and the teller, as well as the teller and the CLI menu that
# serves as the UI
self.account = BankAccount()
# Initialize the menu builder
self.menu = MenuBuilder()
# Iterate over all of the classes in menu.actions package
for action_class in menu.actions.all_actions.values():
# Initialize each menu action and pass in the bank account
action = action_class(self.account)
# Add the menu action to the menu builder
self.menu.add(MenuItem(action.prompt, action))
self.menu.add(MenuItem("Quit", exit))
self.menu.show()
First Open/Closed Run
My first attempt at running the code with this new system.
Holy shit, it works right away.
Now for the real test. I'm going to just drop a new module file into the package and let's see if it shows up on the menu. This new module is for applying for a loan that is basically the same as the deposit menu item, except the prompt is different.
Well, hot damn.
I've now got a menu building system that is closed for modification, but open for extension. To extend the functionality, all a developer needs to do is add a module to the package, with a class that inherits from BaseAction
. Then it will magically appear in the menu UI.
YMMV
Many senior developers suffer from The Curse of Knowledge.
Being able to think about code like this, and then design the code in a way that is extensible is not an easy path. It requires years and years of tinkering, failing, little successes, and then starting all over again.
Once a developer obtains enough context, skills, and knowledge to be able to do this, they immediately forget how hard it was to obtain the knowledge and expect that you should be able to do it - regardless of your skill level.
I'm here to encourage you to not give up. Ignore the Cursed Ones. Keep working at it, and you'll gain the knowledge eventually. Just be patient.