Make some improvements in the Pizza class and pizzashop file by refactoring.
Goals to achieve for the code are:
- Replace string literals with named constants.
- Rename amethods to use the Python naming convention.
- Move misplaced code to a better place (Extract Method and then Move Method). This improves encapsulation and makes the code more reusable.
- Replace "switch" (
if
...elif
...elif
) with object behavior.
Pizza describes a pizza with a size and optional toppings. The price depends on size and number of toppings. For example, large pizza is 280 Baht plus 20 Baht per topping.
pizza = Pizza('large')
pizza.add_topping("mushroom")
pizza.addtopping("pineapple")
print("The price is", pizza.get_price())
'The price is 320'
There are 2 files to start with:
pizza.py - code for Pizza class
pizzashop.py - create some pizzas and print them. Use to verify code.
Use Named Constants instead of Literals in Code.
In the Pizza class replace 'small', 'medium', and 'large" with named constants. Use your IDE's refactoring feature, not manual find and replace.
-
Select 'small' in Pizza.
- VSCode: right click -> Extract variable.
- Pycharm: right click -> Refactor -> Extract Constant
- Pydev: Refactoring -> Extract local variable.
-
Do the same thing for "medium" and "large".
-
In my tests, none of the IDE did exactly what I want. The constants SMALL, MEDIUM, and LARGE are top-level variables in
pizza.py
, but not part of the Pizza class.SMALL = 'small' MEDIUM = 'medium' LARGE = 'large' class Pizza: ...
We would prefer to encapsulate the sizes inside the Pizza class, e.g.
Pizza.SMALL
(I'm disappointed none of the IDE did this). However, we will eventually get rid of these constants, so leave the constants as top-level variables for now. -
When you are done, the strings 'small', 'medium', 'large' should only appear once in the code (in the Pizza class).
-
Did the IDE also change the sizes in
pizzashop.py
? If not, edit pizzashop.py and change sizes to references (Pizza.SMALL
)from pizza import * if __name__ == "__main__": pizza = Pizza(SMALL) ... pizza2 = Pizza(MEDIUM) ... pizza3 = Pizza(LARGE)
-
Run the code. Verify the results are the same.
-
getPrice
is not a Python-style name. Use refactoring to rename it toget_price
.- VSCode: right-click on method name, choose "Rename Symbol"
- Pycharm: right-click, Refactor -> Rename
- Pydev: "Refactoring" menu -> Rename
-
Did the IDE also rename
getPrice
inorder_pizza()
?- VSCode: no
- Pycharm: yes. Notification of dynamic code in preview.
- Pydev: yes (lucky guess)
- This is a limitation of tools for dynamic languages. The tool can't be sure that the "pizza" parameter in
order_pizza
is really a Pizza. To help it, use type annotations.
-
Undo the refactoring, so you have original
getPrice
. -
Add a type annotation in pizzashop.py so the IDE knows that parameter is really a Pizza:
def order_pizza(pizza: Pizza):
- Then do Refactoring -> Rename (in pizza.py) again.
- Does the IDE change
getPrice
toget_price
in pizzashop.py also?
-
Rename
addTopping
in Pizza toadd_topping
. Did the IDE also rename it in pizzashop?- If not, rename it manually.
- In this case, a smart IDE can infer that
addTopping
in pizzashop refers to Pizza.addTopping. Why?
-
Run the code. Verify the code works the same.
Perform refactorings in small steps. In this case, we extract a method first, then move it to a better place.
order_pizza
creates a string description
to describe the pizza. That is a poor location for this because:
- the description could be needed elsewhere in the application
- it relies on info about a Pizza that only the Pizza knows.
Therefore, it should be the Pizza's job to describe itself. This is also known as the Information Expert principle.
Try an Extract Method refactoring, followed by Move Method.
-
Select these statements in
order_pizza
that create the description:description = pizza.size if pizza.toppings: description += " pizza with "+ ", ".join(pizza.toppings) else: description += " plain pizza"
-
Refactor (Extract Method):
- VS Code: right click -> 'Extract Method'. Enter "describe" as method name. (This worked in 2020, but in current VS Code it does not.)
- PyCharm: right click -> Refactor -> Extract -> Method
- PyCharm correctly suggests that "pizza" should be parameter, and it returns the description. (correct!)
- PyDev: Refactoring menu -> Extract method. PyDev asks you if pizza should a parameter (correct), but the new method does not return anything. Fix it.
- All IDE: after refactoring, move the two comment lines from
order_pizza
todescribe
as shown here:
def describe(pizza): # create printable description of the pizza such as # "small pizza with muschroom" or "small plain pizza" description = pizza.size if pizza.toppings: description += " pizza with "+ ", ".join(pizza.toppings) else: description += " plain pizza" return description
Forgetting to move comments is a common problem in refactoring. Be careful.
-
Move Method: The code for
describe()
should be a method in the Pizza class, so it can be used anywhere that we have a pizza.- None of the 3 IDE do this correctly, so do it manually.
- Select the
describe(pizza)
method in pizzashop.py and CUT it. - Inside the Pizza class (pizza.py), PASTE the method.
- Change the parameter name from "pizza" to "self" (Refactor -> Rename).
-
Rename Method: In
pizza.py
renamedescribe
to__str__(self)
method. You should end up with this:# In Pizza class: def __str__(self): # create printable description of the pizza such as # "small pizza with muschroom" or "small plain pizza" description = self.size if self.toppings: description += " pizza with "+ ", ".join(self.toppings) else: description += " plain pizza" return description
-
Back in
pizzashop.py
, modify theorder_pizza
to get the description from Pizza:def order_pizza(pizza): description = str(pizza) print(f"A {descripton}") print("Price:", pizza.get_price())
-
Eliminate Temp Variable The code is now so simple that we don't need the
description
variable. Eliminate it:def order_pizza(pizza) print(f"A {str(pizza)}") print("Price:", pizza.get_price())
-
Test. Run the pizzashop code. Verify the results are the same.
This is the most complex refactoring, but it gives big gains in code quality:
- code is simpler
- enables us to validate the pizza size in constructor
- prices and sizes can be changed or added without changing the Pizza class
The get_price
method has a block like this:
if self.size == Pizza.SMALL:
price = ...
elif self.size == Pizza.MEDIUM:
price = ...
elif self.size == Pizza.LARGE:
price = ...
The pizza has to know the pricing rule for each size, which makes the code complex. An O-O approach is to let the pizza sizes compute their own price. Therefore, we will define a new datatype (class) for pizza size.
Python has an Enum
type for this.
An "enum" is a type with a fixed set of values, which are static instances of the enum type. Each enum member has a name and a value.
-
In
pizza.py
replace the named constants LARGE, MEDIUM, and SMALL with an Enum namedPizzaSize
:from enum import Enum class PizzaSize(Enum): # Enum members written as: name = value small = 120 medium = 200 large = 280 def __str__(self): return self.name
-
Write a short script (in pizza.py or another file) to test the enum:
if __name__ == "__main__": # test the PizzaSize enum for size in PizzaSize: print(size.name, "pizza has price", size.value)
This should print the pizza prices. But the code
size.value
doesn't convey it's meaning: it should be the price. but the meaning ofsize.value
is not clear. Add aprice
property to PizzaSize:# PizzaSize @property def price(self): return self.value
-
In Pizza.get_price(), eliminate the
if size == SMALL: elif ...
It is no longer needed. The Pizza sizes know their own price.def get_price(self): """Price of a pizza depends on size and number of toppings""" price = self.size.price + 20*len(self.toppings)
-
In
pizzashop.py
replace the constants SMALL, MEDIUM, and LARGE withPizzaSize.small
,PizzaSize.medium
, etc. -
Run the code. It should work as before. If not, fix any
Can you add a new pizza size without changing the Pizza class?
class PizzaSize(Enum):
...
jumbo = 400
# and in pizzashop.__main__:
pizza = Pizza(PizzaSize.jumbo)
Using an Enum instead of Strings for named values reduces the chance for error in creating a pizza, such as Pizza("LARGE")
.
For type safety, you can add an annotation and a type check in the Pizza constructor:
def __init__(self, size: PizzaSize):
if not isinstance(size, PizzaSize):
raise TypeError('size must be a PizzaSize')
self.size = size
What if the price of each topping is different? Maybe "durian" topping costs more than "mushroom" topping.
There are two refactorings for this:
- Pass whole object instead of values - instead of calling
size.price(len(toppings))
, usesize.price(toppings)
. - Delegate to a Strategy - pricing varies but sizes rarely change, so define a separate class to compute pizza price. (Design principle: "Separate the parts that vary from the parts that stay the same")
- The Refactoring course topic has suggested references.
- Refactoring: Improving the Design of Existing Code by Martin Fowler is the bible on refactoring. The first 4 chapters explain the fundamentals.