The customer will select a dish from the menu and add it to his order.
Chapter 11: Object-based Solution Design
Notes
- The previous chapter looked at creating useful objects
- In this chapter we’ll explore how to create systems comprising large numbers of different but related objects
- We’ll also look at connecting objects via their methods
Fashion Shop Application
- Consider the following scenario
A friend who runs a fashion shop would like you to build an application to help manage her stock. She sells a large range of clothing items and wants to be able to track inventory. Her workflow is as follows, stock arrives from suppliers, the details are entered in the system. When an item is sold it should be removed from the stock. She would also like to be produce reports indicating how many of each item are in stock.
Working with the client you define the following information about how her stock operates
Each item has a unique reference
Each reference contains:
- A description
- A price
- A number in stock
- A list of delivery amounts and dates, and sales
For now the client is happy to just print out the entire stock list
Has indicated in future they may wish to have more analytics, e.g.
- determine which item has the lowest stock
Our prototype interface is then,
Mary's Fashion Shop 1. Create a new stock item 2. Add stock to an existing item 3. Sell stock 4. Stock report 5. Exit Enter your command:The above options are all pretty straightforward for now
Application Data Design
Before designing the program we need to understand the data we have to represent
Our client tells us that each stock item requires,
- Stock reference id
- Price
- Colour
- Number in stock
The client also has specifics for different types of stock items
For Dresses we require
- Size
- Style
- Pattern
For pants we require
- Length
- Waist size
- Style
- Pattern
For hats we require
- Size
For blouses we require
- Size
- Style
- Pattern
We can map out some descriptions
Dress: stock reference: 'D0001' price: 100.0 colour: red pattern: swirly size: 12 Pants: stock reference: 'TR12327'price: 50 colour: black pattern: plain length: 30 waist: 30Now that we have our data requirements and some mock items, we want to carry out data design
Data design is the process of specifying how we represent a programs data
Object-oriented Design
- A design paradigm we could use is to represent each data object as a class
- This object-centric approach is called object-oriented programming
- Solution elements are mapped to software objects
- A way to formulate classes is to break a problem statement down into nouns
- nouns describe things which naturally translate to objects
e.g. a food service point-of-sale system might be described as
The four nouns above (written in red) could map to classes in the application
- This is only a starting point
- We would have to dive deeper into the design requirements with the client
Don’t write any code before you have completed your data design
Design mistakes are easiest to correct early in a project’s lifecycle. For this reason data design is almost always carried out and completed before we actually starting to right code.
If we were to continue developing the restaurant point of sale system above, we would have to work through the data requirements with the client. For example if we were developing a Customer class, we might map out paper variants of the class, and work through usage scenarios with the client to ensure all the details are captured.
For example if a customer needed to provide a telephone number we would want to capture this design requirement early.
- For our first design we could implement all these different stock items as different classes, (you can find the code in SeparateClasses.py)
# Example 11.1 Fashion Items as Separate Classes
#
# Mocks out the class-based implementation of the fashion items, treating
# each different item type as it's own standalone class
class Dress:
"""
Represents the inventory details for a Dress
"""
def __init__(self, stock_ref, price, colour, pattern, size):
"""
Creates a `Dress` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
dress price
colour : str
description of the dress colour
pattern : str
description of the dress pattern
size : int
dress size
"""
self.stock_ref = stock_ref
self.__price = price
self.__stock_level = 0
self.colour = colour
self.pattern = pattern
self.size = size
@property
def price(self):
"""
price : int | float
dress price
"""
return self.__price
@property
def stock_level(self):
"""
stock_level : int
amount of stock in inventory
"""
return self.__stock_level
class Pants:
"""
Represents the inventory details for a pair of Pants
"""
def __init__(self, stock_ref, price, colour, pattern, length, waist):
"""
Creates a `Pants` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
pants price
colour : str
description of the pants colour
pattern : str
description of the pants pattern
length: int
length of the pants
waist : int
pants waist size
"""
self.stock_ref = stock_ref
self.__price = price
self.__stock_level = 0
self.colour = colour
self.pattern = pattern
self.length = length
self.waist = waist
@property
def price(self):
"""
price : int | float
dress price
"""
return self.__price
@property
def stock_level(self):
"""
stock_level : int
amount of stock in inventory
"""
return self.__stock_level
x = Dress(stock_ref="D001", price=100, colour="Red", pattern="Swirly", size=12)
y = Pants(
stock_ref="TR12327", price=50, colour="Black", pattern="Plain", length=30, waist=25
)
print(x.price)
print(y.stock_level)100
0
- We define a
Dressand aPantsclass - Each class has to have an
__init__to set them up - Both classes have private price and stock levels
- These are important parts of the inventory system that should have controlled modification
- We don’t want the price being modified and overcharging customers
- We don’t want the stock level being off causing us to mis-order
- Both classes have properties to access price and stock levels
- Later we’ll make methods to control these
Avoid overusing block-copy
Using a text editor it might seem convenient to copy a large block of code when we have to reuse it elsewhere. Whenever you feel yourself copying lets of code to different sections this is usually a good indicator that something is not right. You should aim to write a piece of code once. Code written once is more maintainable, as we only need to modify it in one place. If the code is used multiple times, it is a good candidate to be converted into a function or a method
As mentioned block copying is liable to introduce bugs. If we need to slightly modify the code in the new section we have to make sure we do it correctly (which might be hard if you’ve copied a big chunk). Additionally if you latter find a bug, you may have to fix all the copies (which requires you to remember where they are).
Use this as advice, if you find yourself copying lots of code, its a good time to take a step back at look at your overall design
Creating superclasses and subclasses
Many languages (including python) let us use inheritance
Inheritance allows one class to base itself on another
- i.e. it inherits the behaviour of another class
The original class is called the superclass
Creating this new class is called extending the superclass
By default all python classes extend the
objectclassWe could write this explicitly
class Contact(object)We can replace the
objectin the above with the class we want to use as a superclassLooking at our data design we can see there is a bunch of behaviour common to all stock items
We can start by defining a
StockItemto act as a superclassStockItemstores all common attributes- Stock reference
- Price
- Colour
- Stock level
DressandPantsnow extendStockItem- The other stock items will do so as well
The diagram below shows what’s called a class diagram or inheritance hierarchy
---
title: Fashion Shop Class Diagram
---
classDiagram
class object
class StockItem {
str stock_ref
str item_name
str colour
number price
int stock_level
}
class Dress {
str pattern
int size
}
class Pants {
int length
str pattern
int waist
}
object <|-- StockItem
StockItem <|-- Dress
StockItem <|-- Pants
- We can see that both
PantsandDressare subclasses ofStockItem - The inverse relationship is
StockItemis the superclass ofPantsandDress - We call this inheritance because the subclasses inherit the attributes of the superclass
- When building an inheritance hierarchy you need to focus on your data
- Here we have a collection of related data items
- The basic behaviour of a data item is the same
- The related items also have some common data attributes
- We capture the common behaviour and data in a superclass
- Then extend with subclasses the specific behaviour of different data items
- Also means that if we add new common behaviour we only have to add it in one place
- i.e. the superclass
- Otherwise we would have to put it in all the classes
Abstraction in Software Design
- Abstraction is a term used to describe attempting to capture behaviours and data of a system at a higher level
- Here by introducing a
StockItemclass we are attempting to talk about the behaviour of stock items in generality as opposed to any specific type of stock itemi.e. We know that a stock item should be,
- Able to be added
- Able to be sold
- Find out what stock items we have
We do not capture the specifics of how these processes occur
- Just know that we need to capture them in our program
- Abstract lets us look at processes without getting caught up in the details
- Later we can go and fill those details in
- Typically as we move down a class hierarchy, we should move from the more abstract to the more concrete
- At the highest level a class or interface might just say what methods an object should have
- The next level down might implement some common attributes and methods (
StockItem) - The next level down might provide specialised attributes and specific methods (
DressandPants)
Code Analysis: Understanding Inheritance
Work through the following questions on object-oriented design and inheritance. It’s a good idea to consider your own thoughts on the topic
Why don’t we put all the attributes in one class and not bother with subclasses?
- We could add every possible attribute to one class
- However then we would have to handle the fact that some attributes are not defined for certain types
- e.g.
Dresshas nowaistattribute andPantshas nosize
- e.g.
- As we add more classes we would have to consider all the valid possible combinations of attributes and manage them
- Exactly the kind of thing that the subclass approach does naturally
- Additionally if we want to customise behaviour by type, we would have to add an attribute to track this
- Inheritance provides polymorphism as a way to do this naturally
Why is the superclass called super?
- It is derived from mathematical terminology around sets
- In maths \(A\) is a subset of \(B\) if \(A\) is entirely contained in \(B\)
- \(A\) is a superset of \(B\) if \(A\) contains \(B\)
- The idea carries onto the language of classes, where the superclass is called super because every subclass is also an instance of the superclass.
Which is most abstract, a superclass or a subclass?
- Recall, the concept of the class hierarchy
- Moving down into subclasses is getting more concrete (less abstract)
- Moving up into superclasses is getting more abstract
Can you extend a subclass?
- Yes
- We can see this in the class hierarchy
StockItemis a subclass ofObjectDressextendsStockItemto create a new subclass
Why is the
patternattribute not in theStockItemclass?- Looking at our current class diagram this does make sense
- Both
DressandPantshave a pattern attribute
- Both
- However our client had other types of items e.g. Hat that didn’t have a pattern attribute
- If we wanted to remove the duplication we might introduce a
PatternedItembetweenStockItemand theDressandPantsclasses- However for one specific attribute this is probably not necessary right now
- Especially as the
PatternedItemseems partially arbitrary rather than reflecting an actual category of item
- Looking at our current class diagram this does make sense
Will our system ever create a
StockItemobject?- Nothing prevents us from doing so
- However, in practice we there’s no real use case
StockItemis not representing an actual physical item- It represents the concept of a stock item
- If we wanted to do we could define
StockItemas an abstract class- Abstract classes can’t be instantiated
- They are good for defining the structure and behaviour of a class
- Implementation left to the subclasses
The client decides in future she may like to track which customers have bought which stock items. Here are three potential implementations. Which implementation makes the most sense?
- Extend the
StockItemclass to make aCustomersubclass that contains the customer details because customers buyStockItems - Add
Customerdetails to eachStockItem - Create a new
Customerclass that contains a list of theStockItemsthat theCustomerhas bought
- Class hierarchies should reflect an is-a relationship
- A Customer is not a Stock item
- So option 1 is out
- Multiple customers might buy the same stock item
- The stock item also represents a category of stock as opposed to one specific item
- So we don’t want to have a
Customerfield- We could have a list of customers if we wanted to do it this way
- However, in the future we might want to add more behaviour for interacting with a customer itself
- Thus makes sense to define a
Customerclass
- Thus makes sense to define a
- Extend the
Storing Data in a Class Hierarchy
- Now lets refactor our code to use a class hierarchy
- The naive implementation looks like,
from abc import ABC
class StockItem(ABC):
"""
Abstract base class representing a single inventory item.
Attributes
----------
stock_ref : str
reference id of the stock item
colour : str
description of the item's colour
"""
def __init__(self, stock_ref, price, colour):
"""
Creates a `StockItem` instance
Parameters
----------
stock_ref : str
stock reference id
price : int | float
stock price
colour : str
description of stock item's colour
"""
self.stock_ref = stock_ref
self.__price = price
self.colour = colour
self.__stock_level = 0
self.__stock_level = 0
@property
def price(self):
"""
price : int | float
dress price
"""
return self.__price
@property
def stock_level(self):
"""
stock_level : int
amount of stock in inventory
"""
return self.__stock_levelThe
StockItemlooks pretty standard for a classYou may observe that we import
ABCfrom theabcmoduleabcis a python module to provide abstract classesABCis the superclass for abstract classes
We inherit from
ABCto makeStockItemabstract- For now this only indicates that the user should not directly instantiate it
- We’ll see later the concept of abstract methods which can be used to prevent instantiation of an abstract class
We move the common attributes and properties to the class
Define an
__init__as usualNow lets define our
Dressclass, naively you might write,class Dress(StockItem): """ Represents the inventory details for a Dress Inherits from `StockItem` Attributes ---------- stock_ref : str dress reference id price : int | float dress price colour : str description of dress's colour pattern : str description of the dress pattern size : int dress size See Also -------- `StockItem` : Parent Class """ def __init__(self, stock_ref, price, colour, pattern, size): """ Creates a `Dress` instance Parameters ---------- stock_ref : str stock reference code price : int | float dress price colour : str description of the dress colour pattern : str description of the dress pattern size : int dress size """ self.pattern = pattern self.size = sizeDresssubclassesStockItemWe add the unique attributes
Currently no need to define any new behaviour
However, when we try to create a
Dressinstance and use it we seex = Dress(stock_ref="D0001", price=100, colour="red", pattern="swirly", size=12) print(x.pattern) print(x.price)swirly--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[5], line 3 1 x = Dress(stock_ref="D0001", price=100, colour="red", pattern="swirly", size=12) 2 print(x.pattern) ----> 3 print(x.price) Cell In[3], line 41, in StockItem.price(self) 35 @property 36 def price(self): 37 """ 38 price : int | float 39 dress price 40 """ ---> 41 return self.__price AttributeError: 'Dress' object has no attribute '_StockItem__price'
We see that we have no issue creating the object
Can also access the attributes defined in the subclass (
pattern)But we get an error, when we try to access a property on the superclass (
price)In fact the error tells us that we can’t find the attribute
_StockItem__priceWhy? Well if we look at the initializer we never seem to have set up the stock level, price, stock reference or colour
- We can’t just write
self.colour = colouretc - Because this adds an attribute on the subclass
- Also basically means we’ve rewritten the superclass
__init__again
- We can’t just write
Need a way to pass arguments to the
__init__method of the superclassWe can do this using
super()super()is likeself- Used to return a reference to the superclass instance
class Dress(StockItem):
"""
Represents the inventory details for a Dress
Inherits from `StockItem`
Attributes
----------
stock_ref : str
dress reference id
price : int | float
dress price
colour : str
description of dress's colour
pattern : str
description of the dress pattern
size : int
dress size
See Also
--------
`StockItem` : Parent Class
"""
def __init__(self, stock_ref, price, colour, pattern, size):
"""
Creates a `Dress` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
dress price
colour : str
description of the dress colour
pattern : str
description of the dress pattern
size : int
dress size
"""
super().__init__(stock_ref, price, colour)
self.pattern = pattern
self.size = size
x = Dress(stock_ref="D0001", price=100, colour="red", pattern="swirly", size=12)
print(x.pattern)
print(x.price)swirly
100
- We use
super()to get a reference to the super object - Then call the
__init__object on the instance and pass the required parameters - Key Takeaway: When initialising a subclass you must explicitly initialise the superclass too
- The complete code for our class hierarchy incorporating
Pantscan be found in ClassHierarchy.py
Manage the Item Name in the Fashion Shop Program
So far each class name matches the stock type being stored
We might not want to maintain this relationship
e.g. if we introduced an “Evening Dress” we can’t create a
Evening Dressclass- Since class names must be contiguous
The client thus wants us to provide a way of giving a user friendly string description off the item name
In the class diagram we defined a property called an
Item Namein theStock Item- Intended to hold the item name
Provides a user friendly string name
Implement as a class property
class StockItem(object): @property def item_name(self): """ stock_level : int amount of stock in inventory """ return "Stock Item"We can then override this attribute in the subclasses
- Overridden attributes are used inplace of the superclass implementation
e.g. For the
Dressclass we might writeclass Dress(StockItem): ... @property def item_name(self): return "Dress"Then calling
item_nameon aDressinstance should return"Dress"instead of"Stock Item"as demonstrated belowitem = StockItem() print("item name is", item.item_name) dress = Dress() print("dress name is", dress.item_name)item name is Stock Item dress name is Dress
Abstract Methods
- We’ve just seen how
StockItemdefines a propertyitem_namewhich returns a string - However, the intention is that this is overwritten by a subclass
- It would be good if we could force a subclass to define this method
- We saw that the
abcmodule allowed us to inherit a classABCthat meant the class should not be directly instantiated- Is there something similar for methods
- Turns out there is!
abccontains aabstractmethoddecorator- If a class has methods decorated with
@abstractmethodsubclasses can’t be instantiated unless they override all the abstract methods
- We can see this in practice below
import abc
class StockItem(abc.ABC):
@property
@abc.abstractmethod
def item_name(self):
"""
stock_level : int
amount of stock in inventory
"""
pass
class Dress(StockItem):
@property
def item_name(self):
return "Dress"
class Pants(StockItem):
passHere we define
StockItemwithitem_nameas an abstract property- We do this by using both the
@propertyand@abstractmethoddecorators on the method
- We do this by using both the
Since
item_nameis always overridden, rather than return a value, we simply usepassto indicate a placeholderWe next define
DressandPantsas two subclassesDressoverrides theitem_namemethodPantsdoes not
Let’s see what happens when we try to instantiate these
d = Dress() print(d.item_name) p = Pants() print(p.item_name)Dress--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[11], line 4 1 d = Dress() 2 print(d.item_name) ----> 4 p = Pants() 5 print(p.item_name) TypeError: Can't instantiate abstract class Pants without an implementation for abstract method 'item_name'
We can see that the
Dressinstantiation works as expectedWe can call
Dressand access the overriddenitem_namepropertyHowever, when we try to instantiate
pasPantswe get aTypeError!- The message tells us we need to implement the abstract method
item_name
- The message tells us we need to implement the abstract method
Add a __str__ Method to Classes
- As seen earlier objects can use the
__str__method to define a string representation - We should add these to
StockItemand it’s derived classes - Defining a
__str__method is technically overriding the__str__method on the parent- Including the
objectclass
- Including the
Make Something Happen: Method Overriding in Classes
Open the python interpreter and work through the following steps to understand method overriding
Enter the following statement
o = object()This creates an object instance referenced by o. If we print the value of o we are calling the __str__ method on the object class
Run the print statement as below
print(o)<object object at 0x7f4008fc2de0>
print requires a str argument, so o is converted to a string by calling its __str__ method. In this case the __str__ method of the object class. We can see the result is a message that indicates the type of the object and the memory address where the instance is stored.
This should be familiar to what we see in the earlier Contact class before we defined a __str__ method. What happened there was that when __str__ was called to convert a Contact to an string, it deferred to the superclass __str__ method which was object
To define different behaviour we have to provide our own __str__ method which is said to override the superclass __str__ method.
Define a new class StrTest by entering the statements below
class StrTest(object):
def __str__(self):
return "string from StrTest"Remember that we don’t have to explicitly inherit from object we’ve just done it to be clear
If we now print this new StrTest object, we can see that the new __str__ method on the StrTest is used.
Test this by running the line below
print(StrTest())string from StrTest
But what if we want to use the __str__ method from the superclass as part of the __str__ method of the subclass? Well we can do so by using super() to access the superclass instance associated with the subclass instance. We can then directly reference the __str__ method
Demonstrate this by defining a new class, as below
class StrTestSub(StrTest):
def __str__(self):
return super().__str__() + "..with sub"The __str__ method still overrides the superclass, but super() lets us incorporate the __str__ result from the StrTest superclass. We can see this behaviour if we try to print the object.
Call print on an instance of StrTestSub as below
print(StrTestSub())string from StrTest..with sub
Now let’s define
__str__methods for ourStockItemandSubclassesclass StockItem(abc.ABC): ... def __str__(self): template = """Stock Reference: {0} Type: {1} Price: {2} Stock level: {3} Colour: {4}""" return template.format( self.stock_ref, self.item_name, self.price, self.stock_level, self.colour )This
__str__item uses a template string which is formattedStockItemattributes are printed one per lineWhy are we making a
__str__method for theStockItemwhen we’re going to override this?Well the subclasses all have to refer to the common attributes in the
StockItemMakes sense to use the
__str__method for the superclass to handle how we represent these common attributesLet’s see how we put this into practice with the
Dresssubclass
class Dress(StockItem):
...
def __str__(self):
stock_details = super().__str__()
template = """{0}
Pattern: {1}
Size: {2}"""
return template.format(stock_details, self.pattern, self.size)- We use the
super()to get the initial part of the string - Then again use a template string
- First inject the superclasses string
- Then add on the new attributes in the same style
- Let us look at this in practice
x = Dress(stock_ref="D001", price=100, colour="red", pattern="swirly", size=12)
print(x)Stock Reference: D001
Type: Dress
Price: 100
Stock level: 0
Colour: red
Pattern: swirly
Size: 12
- We can the final result merges the superclasses string with the added attributes of the
Dressclass. You can see the complete code in Stock Items with Str
Code Analysis: Understanding Method Overriding
Consider the following questions about method overriding
How does method overriding work?
- When a method is called, python looks at the object to see if the method exists
- If it doesn’t python looks up the class hierarchy to see if it can find a matching method
- First matching method found is called
- If all the superclasses are exhausted then an
AttributeErroris raised
Is an overriding method forced to call the method it is overriding?
- No
- You only need to do it, if you need to access the superclasses implementation
__str__method in theDressclass calls the__str__in thesuperbecause the functionality is useful- Means if
StockItemis updated, we can update the__str__method and it automatically propagates through to theDressclass
- Means if
Version Management in the Fashion Shop Program
- Recall in the Time Tracker we versioned our
SessionandContactclasses - There the goal was to keep old data files usable
- We want to do the same for our Fashion Shop
- Where should the version numbers go?
For example
Dressis a subclass ofStockItem, so we could- Version in the
StockItemsuperclass - Version in the
Dresssuperclass - Version in both the
StockItemand theDresssuperclass
- Version in the
We want both, why?
StockItemmight change, but the subclasses don’t- We don’t want to bump every subclass version
Dressmight changeStockItemdoesn’t, so don’t want to bump it because then that would propagate through to all the other subclasses
- So we need to implement a
versionandcheck_versionfor both
class StockItem(abc.ABC):
def __init__(self, stock_ref, price, colour):
self.stock_ref = stock_ref
self.__price = price
self.colour = colour
self.__stock_level = 0
self.__StockItem_version = 1
...
def check_version(self):
"""
Checks the version of a StockItem instance and upgrades it if required
Returns
-------
None
"""
pass # for version 1, no need to check- For
StockItemwe add a version attribute labelled__StockItem_versionto distinguish from the version numbers of the subclasses- Version number is hard-coded by the constructor
- When we create a new version of the
StockItemthis is where we bump the version
- Currently there is only one version of the
StockItemclass, so the correspondingcheck_versionmethod doesn’t need to do anything- We still need to include it for the future API
- We just use
passto make it a placeholder method for now
- We can now define versions and version checking for our subclasses, e.g. for
Dress
class Dress(StockItem):
def __init__(self, stock_ref, price, colour, pattern, size):
super().__init__(stock_ref, price, colour)
self.pattern = pattern
self.size = size
self.__Dress_version = 1
...
def check_version(self):
"""
Checks the version of a `Dress` instance and upgrades it if required
Returns
-------
None
"""
super().check_version()- As before we add a version attribute (here
__Dress_version) - We define a new
check_version- Again, right now there is only one version of the
Dressso we don’t need to do any self upgrades - However,
Dressdoesn’t know about the version ofStockItemso we have to callsuperto runcheck_versionfor the superclass instance
- Again, right now there is only one version of the
- Together this means we can update
DressandStockItemindependently and saved objects will still be synchronised across both class definitions - The implementation for
Pantsis similar and can be found along with the complete implementation in Versioned Stock Items
Polymorphism in Software Design
- Overriding methods is an example of a more broader concept of polymorphism
- Polymorphism refers to the same behaviour being able to be applied differently depending on the specific context or object
- e.g. all python objects have a
__str__method - defines how they are converted to a string representation
- We can override the
__str__method to make different objects have different behaviours- e.g. default
objectprints the memory address, anintgives a string representation of its number, and ourStockItemclass prints its attributes as a newline separated list
- e.g. default
- This behaviour is said to be polymorphic, because different objects have different responses to the same behaviour (string conversion)
- e.g. all python objects have a
- Software is frequently polymorphic
- e.g. a Play button might be used to start playback of music, video or a slide show
- Same concept (play button)
- Different outcome
- e.g. a Play button might be used to start playback of music, video or a slide show
- Polymorphism and Abstraction are typically a partnership
- At an abstract level one might define a general set of behaviours a group of similar objects or classes need to do (e.g. “be played”)
- Then use polymorphic behaviour to capture that common concept in one function, say
playthat is implemented differently for each class e.g. music, video, slide show
Code Analysis: Understanding Polymorphism
Try and work through the following questions about polymorphism before reading the answers
Is polymorphism all about providing methods in a class hierarchy?
- No
- In this example we’ve used a class hierarchy
__str__behaves differently, but all objects have a__str__- However we have defined a
check_version StockItemandDressboth behave differently whencheck_versionis called- But another object say
Bookmight also have acheck_versionfunction - Again behaves completely differently
- But no direct method overwrite chain
- Would still say this behaviour is polymorphic
- But another object say
- Polymorphism is a broader concept that a class hierarchy
- Though we’ve seen, a class hierarchy is one way of structuring polymorphic behaviour
How do I know which methods in my application should be polymorphic?
- This is part of the design process
- Identify the similar behaviours performed by different objects that work differently for each
- e.g. in a video game all enemies might attack but different enemy types might do so differently
- Could then define a class hierarchy with some base enemy type
- This could then define
attack, etc.- Subclasses then override the behaviour polymorphically
- But we can still talk about “enemies” as a whole
Protect Data in a Class Hierarchy
When constructing our classes we made some attributes private
How does this carry through the class hierarchy? e.g.
class StockItem(abc.ABC): """ Abstract base class representing a single inventory item. Subclasses are expected to overwrite the `item_name` abstract property with a user friendly string description Attributes ---------- stock_ref : str reference id of the stock item colour : str description of the item's colour """ def __init__(self, stock_ref, price, colour): """ Creates a `StockItem` instance Parameters ---------- stock_ref : str stock reference id price : int | float stock price colour : str description of stock item's colour """ self.stock_ref = stock_ref self.__price = price self.colour = colour self.__stock_level = 0 self.__StockItem_version = 1The above shows the
__init__method forStockItemWe can see some public attributes
stock_refcolour
These can be accessed by anyone
- Including subclasses
Also some private ones
__price__stock_level__StockItem_version
Private means restricted to the class in which it was declared
- Subclasses are still regarded as other classes
- Private variables are thus not accessible in subclasses
Sometimes people refer to variables marked with
_i.e. one underscore as Protected- A protected variable is one that can be accessed by the class it is defined in, or any subclasses of that class
When designing a class hierarchy you should think about how the data may be used by subclasses
If you think you might need to customise behaviour on an attribute
- Consider a read-only property, or making it protected/public
Data Design Recap
Let’s recap our design
The client owns a fashion shop
She sells several different clothing types
She needs a stock management system
All clothing items have
- A stock reference
- Price
- Stock level
- Colour
Specific clothing items define additional attributes
To avoid duplicate code we define a
StockItemclassWe define subclasses for specific items
DressPantsHatBlouse
subclasses extend the attributes on a
StockItem
Each class has an
__init__to initialise it- Each subclass calls the superclass
__init__ - Sets up the superclass instance
- Each subclass calls the superclass
We independently version the sub and super classes
Code Analysis: Data Design
Try to answer the following questions about data design
Is the data design process now complete?
- No
- To account for this we’ve introduced versioning to a our data objects
- the version attributes and
check_versionshould allow us to add new attributes or modify the objects
- the version attributes and
What happens if the fashion shop owner decides to sell a new kind of stock item? Suppose that she wanted to start selling Jeans, which are a type of pants that also has a style, which can be flared, bootleg or straight. What’s the best way to do this?
We can add a new subclass
- We could subclass
StockItemas before- But then would have to duplicate the
Pantscode
- But then would have to duplicate the
- We can subclass
Pants
- We could subclass
class Jeans(Pants): """ Represents the inventory details for a pair of Jeans Inherits from `Pants` Attributes ---------- stock_ref : str pants reference id price : int | float pants price colour : str description of pants's colour pattern : str description of the pants pattern length : int length of the pants waist : int waist size of the pants style : str style of the pants See Also -------- Pants : Parent Class """ def __init__(self, stock_level, price, colour, pattern, length, waist, style): """ Creates a `Jeans` instance Parameters ---------- stock_ref : str stock reference code price : int | float pants price colour : str description of the pants colour pattern : str description of the pants pattern length: int length of the pants waist : int pants waist size style : str jeans style """ # pants constructor super().__init__(stock_level, price, colour, pattern, length, waist) self.style = style # new attribute self.__Jeans_version = 1 # versioning def __str__(self): pants_details = super().__str__() template = """{0} Style: {1}""" return template.format(pants_details, self.style) @property def item_name(self): return "Jeans" def check_version(self): """ Checks the version of a `Pants` instance and upgrades it if required Returns ------- None """ super().check_version()What happens if the fashion shop owner decides to store something new about the stock? For example suppose the client now adds a location attribute to stock items. Location is a string description of where in the store the stock item is stocked. She tells your her plan is to later provide a program that will allow customers to find where items are located in the store. How can we add this attribute and to which class would we add it?
- All items need this property so most appropriate to add to
StockItem - Should be added to the
__init__
class StockItem(abc.ABC): def __init__(self, stock_ref, price, colour, location): self.stock_ref = stock_ref self.__price = price self.__stock_level = 0 self.colour = colour self.location = location self.__StockItem_version = 2 # we have to bump the version numberThis has a problem that it breaks our program!
If we try create a new
Dresswe findd = Dress("D001", 100, "red", "swirly", 12)--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[23], line 1 ----> 1 d = Dress("D001", 100, "red", "swirly", 12) Cell In[22], line 3, in Dress.__init__(self, stock_ref, price, colour, pattern, size) 2 def __init__(self, stock_ref, price, colour, pattern, size): ----> 3 super().__init__(stock_ref, price, colour) 4 self.pattern = pattern 5 self.size = size TypeError: StockItem.__init__() missing 1 required positional argument: 'location'
Dressdoes not know about the newlocationargument to theStockItemconstructor- Thus generates an error when trying to call the constructor
To fix this we have to update the
Dress__init__method- Same for every other subclass
class Dress(StockItem): def __init__(self, stock_ref, price, colour, location, pattern, size): super().__init__(stock_ref, price, colour, location) self.pattern = pattern self.size = size self.__Dress_version = 1 # no attribute changes to `Dress`Now the instantiation should work
d = Dress("D001", 100, "red", "front shelf", "swirly", 12) print(d)<__main__.Dress object at 0x7f401043e1e0>The takeaway is that class hierarchies are very brittle to changes
- Especially changes high up in the abstraction hierarchy
Hence you should aim to be very sure of the design of your high level classes
An alternative approach is to use properties, e.g.
class StockItem(abc.ABC): @property def location(self): """ location : str location in the store where the stock item is stored """ result = getattr(self, "_location", None) return result @location.setter def location(self, location): self._location = locationWe define a getter and setter pair as usual. The setter looks pretty much as we would expect
The
locationget method is interesting thoughresult = getattr(self, "_location", None)getattris a python built-in functionTakes three arguments
- An object to get the attribute from
- The attribute to get (as a string)
- A default value to return if the attribute is not found
The default value is returned if the attribute has not been set yet
- Attempting to read the location from a
StockItemwithout one will thus returnNone
- Attempting to read the location from a
Noneis discussed in Chapter 7This approach is good because we can dynamically update objects
- However it has the downside of we now need to find a new in our program to make sure that the location is set
A third option would be to just dynamically add the attribute directly, e.g.
d = Dress("D001", price=100, colour="red", pattern="swirly", size=12) d.location = "Front of Shop"If we use this approach we would probably want to pair it with
hasattrhasattris a similar function togetattrTakes two arguments
- An object to check for the attribute
- name of the attribute (as a string)
hasattrreturnsTrueif the attribute exists, elseFalse
d = Dress("D001", 100, "red", "swirly", 12) d.location = "Front of Shop" e = Dress("D002", 100, "green", "swirly", 12) def demo_hasattr(obj): if hasattr(obj, "location"): print("The dress is location: ", d.location) else: print("The dress does not have location information") demo_hasattr(d) demo_hasattr(e)The dress is location: Front of Shop The dress does not have location informationOf the three methods, what are the pros and cons of each?
- Adding
locationto the__init__is the most robust, but requires the most changes to the class hierarchy- Enforces
locationdefined
- Enforces
- Is robust, and still maintains a cohesive well-defined class via properties, but requires external management of when to define a location
- Provides sensible behaviour if an attribute has not been set
- This behaviour is also hidden from the user
- Is simple and easy to write, but fragile since it relies on python’s dynamic attributes
- Requires a mental model of how we add those attributes
- Likely breaks a lot of tooling
- Adding
The third approach is easy and probably fine for small personal projects
- Not suitable for large professional applications
- Liable to break and hard to maintain
Generally when in doubt, the best approach is to use the
__init__- Makes it easy to follow since all the attribute code is in the same place
- All items need this property so most appropriate to add to
Make Something Happen: Instrumented Stock Items
A technique for following the flow of a program is to add instrumentation to code. The most basic form of instrumentation is to add print statements that show the flow of the program. Work through the following exercise to see how this works.
For example the below code demonstrates adding instrumentation to the StockItem class. We add a class level variable show_instrumentation that lets us toggle on or off the instrumentation. The most basic implementation just adds print statements that let us know which classes are called.
import abc
class StockItem(abc.ABC):
show_instrumentation = True # make it optional
def __init__(self, stock_ref, price, colour, location):
if StockItem.show_instrumentation:
print("**StockItem __init__ called")
self.stock_ref = stock_ref
self.__price = price
self.__stock_level = 0
self.__StockItem_version = 1
self.colour = colour
self.location = locationEach instrumentation call string is given by the ** prefix to distinguish it from the normal code.
The code for the instrumentation is replicated below, and can be found in Instrumented Stock Items. Work through the following code and consider the examples below
# Example 11.5 Fashion Items using Instrumentation
#
# Adds optional instrumentation to the StockItem hierarchy to demonstrate the
# control flow
import abc
class StockItem(abc.ABC):
"""
Abstract base class representing a single inventory item.
Subclasses are expected to overwrite the `item_name` abstract
property with a user friendly string description
Attributes
----------
stock_ref : str
reference id of the stock item
colour : str
description of the item's colour
Class Attributes
----------------
show_instrumentation : bool
Indicates if instrumentation should be printed
"""
show_instrumentation = True
def __init__(self, stock_ref, price, colour):
"""
Creates a `StockItem` instance
Parameters
----------
stock_ref : str
stock reference id
price : int | float
stock price
colour : str
description of stock item's colour
"""
if StockItem.show_instrumentation:
print("**StockItem __init__ called")
self.stock_ref = stock_ref
self.__price = price
self.colour = colour
self.__stock_level = 0
self.__StockItem_version = 1
def __str__(self):
if StockItem.show_instrumentation:
print("**StockItem __str__ called")
template = """Stock Reference: {0}
Type: {1}
Price: {2}
Stock level: {3}
Colour: {4}"""
return template.format(
self.stock_ref, self.item_name, self.price, self.stock_level, self.colour
)
@property
@abc.abstractmethod
def item_name(self):
"""
item_name : str
the stock item's name as a user friendly string
"""
if StockItem.show_instrumentation:
print("**StockItem item_name called")
pass
@property
def price(self):
"""
price : int | float
dress price
"""
if StockItem.show_instrumentation:
print("**StockItem get price called")
return self.__price
@property
def stock_level(self):
"""
stock_level : int
amount of stock in inventory
"""
if StockItem.show_instrumentation:
print("**StockItem get stock_level called")
return self.__stock_level
def check_version(self):
"""
Checks the version of a StockItem instance and upgrades it if required
Returns
-------
None
"""
if StockItem.show_instrumentation:
print("**StockItem check_version called")
pass # for version 1, no need to check
class Dress(StockItem):
"""
Represents the inventory details for a Dress
Inherits from `StockItem`
Attributes
----------
stock_ref : str
dress reference id
price : int | float
dress price
colour : str
description of dress's colour
pattern : str
description of the dress pattern
size : int
dress size
See Also
--------
StockItem : Parent Class
"""
def __init__(self, stock_ref, price, colour, pattern, size):
"""
Creates a `Dress` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
dress price
colour : str
description of the dress colour
pattern : str
description of the dress pattern
size : int
dress size
"""
if StockItem.show_instrumentation:
print("**Dress __init__ called")
super().__init__(stock_ref, price, colour)
self.pattern = pattern
self.size = size
self.__Dress_version = 1
def __str__(self):
if StockItem.show_instrumentation:
print("**Dress __str__ called")
stock_details = super().__str__()
template = """{0}
Pattern: {1}
Size: {2}"""
return template.format(stock_details, self.pattern, self.size)
@property
def item_name(self): # type: ignore
if StockItem.show_instrumentation:
print("**Dress get item_name called")
return "Dress"
def check_version(self):
"""
Checks the version of a `Dress` instance and upgrades it if required
Returns
-------
None
"""
if StockItem.show_instrumentation:
print("**Dress check_version called")
super().check_version()
class Pants(StockItem):
"""
Represents the inventory details for a pair of Pants
Inherits from `StockItem`
Attributes
----------
stock_ref : str
pants reference id
price : int | float
pants price
colour : str
description of pants's colour
pattern : str
description of the pants pattern
length : int
length of the pants
waist : int
waist size of the pants
See Also
--------
StockItem : Parent Class
"""
def __init__(self, stock_ref, price, colour, pattern, length, waist):
"""
Creates a `Pants` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
pants price
colour : str
description of the pants colour
pattern : str
description of the pants pattern
length: int
length of the pants
waist : int
pants waist size
"""
if StockItem.show_instrumentation:
print("**Pants __init__ called")
super().__init__(stock_ref, price, colour)
self.pattern = pattern
self.length = length
self.waist = waist
self.__Pants_version = 1
def __str__(self):
if StockItem.show_instrumentation:
print("**Pants __str__ called")
stock_details = super().__str__()
template = """{0}
Pattern: {1}
Length: {2}
Waist: {3}"""
return template.format(stock_details, self.pattern, self.length, self.waist)
@property
def item_name(self): # type: ignore
if StockItem.show_instrumentation:
print("**Pants get item_name called")
return "Pants"
def check_version(self):
"""
Checks the version of a `Pants` instance and upgrades it if required
Returns
-------
None
"""
print("**Pants check_version called")
super().check_version()
class Jeans(Pants):
"""
Represents the inventory details for a pair of Jeans
Inherits from `Pants`
Attributes
----------
stock_ref : str
jeans reference id
price : int | float
jeans price
colour : str
description of jeans colour
pattern : str
description of the jeans pattern
length : int
length of the jeans
waist : int
waist size of the jeans
style : str
style of the jeans
See Also
--------
Pants : Parent Class
"""
def __init__(self, stock_ref, price, colour, pattern, length, waist, style):
"""
Creates a `Jeans` instance
Parameters
----------
stock_ref : str
jeans reference id
price : int | float
jeans price
colour : str
description of jeans colour
pattern : str
description of the jeans pattern
length : int
length of the jeans
waist : int
waist size of the jeans
style : str
style of the jeans
"""
if StockItem.show_instrumentation:
print("**Jeans __init__ called")
super().__init__(stock_ref, price, colour, pattern, length, waist)
self.style = style
self.__Jeans_version = 1
def __str__(self):
if StockItem.show_instrumentation:
print("**Jeans __str__ called")
pants_details = super().__str__()
template = """{0}
Style: {1}"""
return template.format(pants_details, self.style)
@property
def item_name(self): # type: ignore
if StockItem.show_instrumentation:
print("**Jeans get item_name called")
return "Jeans"
def check_version(self):
if StockItem.show_instrumentation:
print("**Jeans check_version called")
super().check_version()First define a new Dress item as described
dress = Dress(stock_ref="D001", price=100, colour="Red", pattern="Swirly", size=12)**Dress __init__ called
**StockItem __init__ called
- We can see that first the
Dress__init__is called - Then the
StockItem__init__is called (from within the previous__init__)
Now define a new Jeans item as below
jeans = Jeans(
stock_ref="TR12327",
price=50,
colour="Black",
pattern="Plain",
length=30,
waist=25,
style="flared",
)**Jeans __init__ called
**Pants __init__ called
**StockItem __init__ called
We can see this time there are three
__init__calls, in order they areJeansPantsStockItem
Now lets see what happens when we try to print a
Jeansitem
print(jeans)**Jeans __str__ called
**Pants __str__ called
**StockItem __str__ called
**Jeans get item_name called
**StockItem get price called
**StockItem get stock_level called
Stock Reference: TR12327
Type: Jeans
Price: 50
Stock level: 0
Colour: Black
Pattern: Plain
Length: 30
Waist: 25
Style: flared
- We can see that when we the
__str__method is called from theJeanssubclass - We propagate up the class hierarchy calling
__str__forPantsthenStockItem - Within the
StockItem__str__we see that the getter foritem_nameis called- But it resolves to the method on the
Jeansobject - This is because
Jeansoverrides theitem_nameproperty
- But it resolves to the method on the
- Within the
StockItemwe also call the getters forpriceandstock_level- These are not overwritten by
Jeansand so resolve to the originalStockItem
- If we want to turn off the instrumentation we can change the value of
StockItem.show_instrumentatione.g.
StockItem.show_instrumentation = False
print(jeans)Stock Reference: TR12327
Type: Jeans
Price: 50
Stock level: 0
Colour: Black
Pattern: Plain
Length: 30
Waist: 25
Style: flared
- We can see now there is no instrumentation printed
- A more advanced form of instrumentation is called logging
- Logs are typically stored in a separate file
- Can provide more detailed information and triage of problems
Implement Application Behaviours
- We use our standard menu structure for the Fashion Shop
Mary's Fashion Shop
1: Create a new stock item
2: Add stock to an existing structure
3: Sell stock
4: Stock report
5: Exit
Enter your command:
Create a New Stock Item
If a user selects to create a new stock item we then need to decide what stock item to create
We do this by using a sub menu
Create a new stock item 1. Dress 2. Pants 3. Hat 4. Blouse 5. Jeans Enter item to add:The full code is given in Creating Stock Items
class StockItem(abc.ABC): """ Abstract base class representing a single inventory item. Subclasses are expected to overwrite the `item_name` abstract property with a user friendly string description Attributes ---------- stock_ref : str reference id of the stock item colour : str description of the item's colour location : str description of where the pants are located Class Attributes ---------------- show_instrumentation : bool Indicates if instrumentation should be printed min_price : int | float minimum price of any stock item max_price : int | float maximum price of any stock item """ show_instrumentation = True min_price = 0.5 max_price = 500 def __init__(self, stock_ref, price, colour, location): """ Creates a `StockItem` instance Parameters ---------- stock_ref : str stock reference id price : int | float stock price colour : str description of stock item's colour location : str description of where the pants are located """ if StockItem.show_instrumentation: print("**StockItem __init__ called") self.stock_ref = stock_ref self.__price = price self.colour = colour self.location = location self.__stock_level = 0 self.__StockItem_version = 2 def __str__(self): if StockItem.show_instrumentation: print("**StockItem __str__ called") template = """Stock Reference: {0} Type: {1} Price: {2} Stock level: {3} Location: {4} Colour: {5}""" return template.format( self.stock_ref, self.item_name, self.price, self.stock_level, self.location, self.colour, ) @property @abc.abstractmethod def item_name(self): """ item_name : str the stock item's name as a user friendly string """ if StockItem.show_instrumentation: print("**StockItem item_name called") pass @property def price(self): """ price : int | float dress price """ if StockItem.show_instrumentation: print("**StockItem get price called") return self.__price @property def stock_level(self): """ stock_level : int amount of stock in inventory """ if StockItem.show_instrumentation: print("**StockItem get stock_level called") return self.__stock_level def check_version(self): """ Checks the version of a `StockItem` instance and upgrades it if required Returns ------- None """ if StockItem.show_instrumentation: print("**StockItem check_version called") if self.__StockItem_version < 2: self.location = None self.__StockItem_version = 2 class Dress(StockItem): """ Represents the inventory details for a Dress Inherits from `StockItem` Attributes ---------- stock_ref : str dress reference id price : int | float dress price colour : str description of dress's colour pattern : str description of the dress pattern size : int dress size location : str place where the dress is located See Also -------- StockItem : Parent Class """ def __init__(self, stock_ref, price, colour, pattern, size, location): """ Creates a `Dress` instance Parameters ---------- stock_ref : str stock reference code price : int | float dress price colour : str description of the dress colour pattern : str description of the dress pattern size : int dress size location : str place where the dress is located """ if StockItem.show_instrumentation: print("**Dress __init__ called") super().__init__(stock_ref, price, colour, location) self.pattern = pattern self.size = size self.__Dress_version = 1 def __str__(self): if StockItem.show_instrumentation: print("**Dress __str__ called") stock_details = super().__str__() template = """{0} Pattern: {1} Size: {2}""" return template.format(stock_details, self.pattern, self.size) @property def item_name(self): # type: ignore if StockItem.show_instrumentation: print("**Dress get item_name called") return "Dress" def check_version(self): """ Checks the version of a `Dress` instance and upgrades it if required Returns ------- None """ if StockItem.show_instrumentation: print("**Dress check_version called") super().check_version() class Pants(StockItem): """ Represents the inventory details for a pair of Pants Inherits from `StockItem` Attributes ---------- stock_ref : str pants reference id price : int | float pants price colour : str description of pants's colour pattern : str description of the pants pattern length : int length of the pants waist : int waist size of the pants location : str description of where the pants are located See Also -------- StockItem : Parent Class """ def __init__(self, stock_ref, price, colour, pattern, length, waist, location): """ Creates a `Pants` instance Parameters ---------- stock_ref : str stock reference code price : int | float pants price colour : str description of the pants colour pattern : str description of the pants pattern length: int length of the pants waist : int pants waist size location : str description of where the pants are located """ if StockItem.show_instrumentation: print("**Pants __init__ called") super().__init__(stock_ref, price, colour, location) self.pattern = pattern self.length = length self.waist = waist self.__Pants_version = 1 def __str__(self): if StockItem.show_instrumentation: print("**Pants __str__ called") stock_details = super().__str__() template = """{0} Pattern: {1} Length: {2} Waist: {3}""" return template.format(stock_details, self.pattern, self.length, self.waist) @property def item_name(self): # type: ignore if StockItem.show_instrumentation: print("**Pants get item_name called") return "Pants" def check_version(self): """ Checks the version of a `Pants` instance and upgrades it if required Returns ------- None """ print("**Pants check_version called") super().check_version() class Jeans(Pants): """ Represents the inventory details for a pair of Jeans Inherits from `Pants` Attributes ---------- stock_ref : str jeans reference id price : int | float jeans price colour : str description of jeans colour pattern : str description of the jeans pattern length : int length of the jeans waist : int waist size of the jeans style : str style of the jeans location : str description of where the pants are located See Also -------- Pants : Parent Class """ def __init__( self, stock_ref, price, colour, pattern, length, waist, style, location ): """ Creates a `Jeans` instance Parameters ---------- stock_ref : str jeans reference id price : int | float jeans price colour : str description of jeans colour pattern : str description of the jeans pattern length : int length of the jeans waist : int waist size of the jeans style : str style of the jeans location : str description of where the jeans are located """ if StockItem.show_instrumentation: print("**Jeans __init__ called") super().__init__(stock_ref, price, colour, pattern, length, waist, location) self.style = style self.__Jeans_version = 1 def __str__(self): if StockItem.show_instrumentation: print("**Jeans __str__ called") pants_details = super().__str__() template = """{0} Style: {1}""" return template.format(pants_details, self.style) @property def item_name(self): # type: ignore if StockItem.show_instrumentation: print("**Jeans get item_name called") return "Jeans" def check_version(self): if StockItem.show_instrumentation: print("**Jeans check_version called") super().check_version() class Hat(StockItem): """ Represents the inventory details for a Hat Inherits from `StockItem` Attributes ---------- stock_ref : str hat reference id price : int | float hat price colour : str description of hats colour size : int Hat size in diameter location : str description of where the hat is located See Also -------- StockItem : Parent Class """ def __init__(self, stock_ref, price, colour, size, location): """ Creates a `Hat` instance Parameters ---------- stock_ref : str hat stock reference id price : int | float hat price colour : str hat colour size : int hat size in diameter location : str where the hat is located in the store """ if StockItem.show_instrumentation: print("**Hat __init__ called") super().__init__(stock_ref, price, colour, location) self.size = size self.__Hat_version = 1 def __str__(self): if StockItem.show_instrumentation: print("** Hat __str__ called") stock_details = super().__str__() template = """{0} Size: {1}""" return template.format(stock_details, self.size) @property def item_name(self): # type: ignore if StockItem.show_instrumentation: print("** Hat get item_name called") return "Hat" def check_version(self): """ Checks the version and upgrades a `Hat` instance as requires """ if StockItem.show_instrumentation: print("** Hat check_version called") super().check_version() class Blouse(StockItem): """ Represents the inventory details for a Blouse Inherits from `StockItem` Attributes ---------- stock_ref : str stock reference code price : int | float blouse price colour : str description of the blouse colour pattern : str description of the blouse pattern style : str description of the blouse style size : int blouse size location : str place where the blouse is located See Also -------- StockItem : Parent Class """ def __init__(self, stock_ref, price, colour, pattern, style, size, location): """ Creates a `Blouse` instance Parameters ---------- stock_ref : str stock reference code price : int | float blouse price colour : str description of the blouse colour pattern : str description of the blouse pattern style : str description of the blouse style size : int blouse size location : str place where the blouse is located """ if StockItem.show_instrumentation: print("** Blouse __init__ called") super().__init__(stock_ref, price, colour, location) self.pattern = pattern self.style = style self.size = size self.__Blouse_version = 1 def __str__(self): if StockItem.show_instrumentation: print("** Blouse __str__ called") stock_details = super().__str__() template = """{0} Size: {1} Style: {2} Pattern: {3}""" return template.format(stock_details, self.size, self.style, self.pattern) @property def item_name(self): # type: ignore if StockItem.show_instrumentation: print("** Blouse get item_name called") return "Blouse" def check_version(self): """ Checks the version and upgrades a `Blouse` instance as required Returns ------- None """ if StockItem.show_instrumentation: print("** Blouse check_version called") return super().check_version()We’ve implemented in all our remaining subclasses
HatandBlouseWe’ve also added the
locationvariableOur Menu looks pretty straight forward now
In
StockItemwe’ve also added extra class attributesmin_priceandmax_price- These are designed to be used by external validators
- We can add validation directly to the classes later
menu = """
Create a new stock item
1. Dress
2. Pants
3. Hat
4. Blouse
5. Jeans
Enter item to add: """
first_menu_option = 1
last_menu_option = 5
min_stock_item_size = 0
max_stock_item_size = 99
item = BTCInput.read_int_ranged(menu, first_menu_option, last_menu_option)
if item < first_menu_option or item > last_menu_option:
raise ValueError(
"Unexpected value {0} found in menu. Please raise a bug report".format(item)
)We define our standard menu framework
The
min_stock_item_sizeandmax_stock_item_sizeare variables that store the min and max of any size related properties like size, length, waist etc.We then get the user’s choice and check that it’s valid
- This is our usual mechanism of making the code robust to changes
- When we add a command
first_menu_optionand/orsecond_menu_optionshould be updated - Needs to be replicated through to getting user input
- The guard clause makes sure these align
Now we have a valid item we can get the common attributes for a
StockItem# now we have a valid item so get the common attributes stock_ref = BTCInput.read_text("Enter Stock reference: ") price = BTCInput.read_float_ranged( "Enter price: ", min_value=StockItem.min_price, max_value=StockItem.max_price ) colour = BTCInput.read_text("Enter colour: ") location = BTCInput.read_text("Enter location: ")Then we implement the menu choices as below
We report to the user what object we’re creating
We then ask for the attributes unique to that stock item type
Then create the appropriate object
if item == 1: print("Creating a Dress") pattern = BTCInput.read_text("Enter pattern: ") size = BTCInput.read_int_ranged( "Enter size: ", min_value=min_stock_item_size, max_value=max_stock_item_size ) stock_item = Dress(stock_ref, price, colour, pattern, size, location) elif item == 2: print("Creating a pair of Pants") pattern = BTCInput.read_text("Enter pattern: ") length = BTCInput.read_int_ranged( "Enter length: ", min_value=min_stock_item_size, max_value=max_stock_item_size ) waist = BTCInput.read_int_ranged( "Enter waist size: ", min_value=min_stock_item_size, max_value=max_stock_item_size, ) stock_item = Pants(stock_ref, price, colour, pattern, length, waist, location) elif item == 3: print("Creating a Hat") size = BTCInput.read_int_ranged( "Enter size: ", min_value=min_stock_item_size, max_value=max_stock_item_size ) stock_item = Hat(stock_ref, price, colour, size, location) elif item == 4: print("Creating a Blouse") pattern = BTCInput.read_text("Enter pattern: ") style = BTCInput.read_text("Enter style: ") size = BTCInput.read_int_ranged( "Enter size: ", min_value=min_stock_item_size, max_value=max_stock_item_size ) stock_item = Blouse(stock_ref, price, colour, pattern, style, size, location) elif item == 5: print("Creating a pair of Jeans") pattern = BTCInput.read_text("Enter pattern: ") style = BTCInput.read_text("Enter style: ") length = BTCInput.read_int_ranged( "Enter length: ", min_value=min_stock_item_size, max_value=max_stock_item_size ) waist = BTCInput.read_int_ranged( "Enter waist size: ", min_value=min_stock_item_size, max_value=max_stock_item_size, ) stock_item = Jeans( stock_ref, price, colour, pattern, length, waist, style, location ) else: stock_item = NoneAn example output from this program might then look like
Create a new stock item 1. Dress 2. Pants 3. Hat 4. Blouse 5. Jeans Enter item to add: 1 Enter Stock reference: DO001 Enter price: 100 Enter colour: Red Enter location: Shop Window Creating a Dress Enter pattern: Swirly Enter size: 12 Enter location: Front Window **Dress __init__ called **StockItem __init__ called **Dress __str__ called **StockItem __str__ called **Dress get item_name called **StockItem get price called **StockItem get stock_level called Stock Reference: D001 Type: Dress Price: 100 Stock level: 0 Location: Front Window Colour: Red Pattern: Swirly Size: 12
- In the future we will look at how to wrap these user interactions in a class
FashionShopShellApplication
Add Stock to an Existing Item
- When items are created they start with a default stock level of \(0\)
- We need some way to increase a stock item’s stock level when an order arrives
- We can’t directly modify
__stock_levelas it’s private - So we need to add a method
# Example 11.7 Updating Stock Levels on an Item
#
# Continues demonstrating behaviours of the fashion shop, here we highlight how
# to implement adding to stock levels
import abc
class StockItem(abc.ABC):
"""
Abstract base class representing a single inventory item.
Subclasses are expected to overwrite the `item_name` abstract
property with a user friendly string description
Attributes
----------
stock_ref : str
reference id of the stock item
colour : str
description of the item's colour
location : str
description of where the pants are located
Class Attributes
----------------
show_instrumentation : bool
Indicates if instrumentation should be printed
max_stock_add : int
maximum amount of stock that can be added to an item's stock level at a time
"""
show_instrumentation = True
max_stock_add = 10
def __init__(self, stock_ref, price, colour, location):
"""
Creates a `StockItem` instance
Parameters
----------
stock_ref : str
stock reference id
price : int | float
stock price
colour : str
description of stock item's colour
location : str
description of where the pants are located
"""
if StockItem.show_instrumentation:
print("**StockItem __init__ called")
self.stock_ref = stock_ref
self.__price = price
self.colour = colour
self.location = location
self.__stock_level = 0
self.__StockItem_version = 2
def __str__(self):
if StockItem.show_instrumentation:
print("**StockItem __str__ called")
template = """Stock Reference: {0}
Type: {1}
Price: {2}
Stock level: {3}
Location: {4}
Colour: {5}"""
return template.format(
self.stock_ref,
self.item_name,
self.price,
self.stock_level,
self.location,
self.colour,
)
@property
@abc.abstractmethod
def item_name(self):
"""
item_name : str
the stock item's name as a user friendly string
"""
if StockItem.show_instrumentation:
print("**StockItem item_name called")
pass
@property
def price(self):
"""
price : int | float
dress price
"""
if StockItem.show_instrumentation:
print("**StockItem get price called")
return self.__price
@property
def stock_level(self):
"""
stock_level : int
amount of stock in inventory
"""
if StockItem.show_instrumentation:
print("**StockItem get stock_level called")
return self.__stock_level
def check_version(self):
"""
Checks the version of a `StockItem` instance and upgrades it if required
Returns
-------
None
"""
if StockItem.show_instrumentation:
print("**StockItem check_version called")
if self.__StockItem_version < 2:
self.location = None
self.__StockItem_version = 2
def add_stock(self, count):
"""
Add stock to an item
Parameters
----------
count : int
amount of stock to add to an item
Returns
-------
None
Raises
------
Exception
raised if `count` < 0 or `count` > `StockItem.max_stock_add`
See Also
--------
StockItem.max_stock_add : maximum amount of stock that can be added to a `StockItem`
"""
if StockItem.show_instrumentation:
print("**StockItem add_stock called")
if count < 0 or count > StockItem.max_stock_add:
raise Exception("Invalid add amount")
self.__stock_level = self.__stock_level + count
class Dress(StockItem):
"""
Represents the inventory details for a Dress
Inherits from `StockItem`
Attributes
----------
stock_ref : str
dress reference id
price : int | float
dress price
colour : str
description of dress's colour
pattern : str
description of the dress pattern
size : int
dress size
location : str
place where the dress is located
See Also
--------
StockItem : Parent Class
"""
def __init__(self, stock_ref, price, colour, pattern, size, location):
"""
Creates a `Dress` instance
Parameters
----------
stock_ref : str
stock reference code
price : int | float
dress price
colour : str
description of the dress colour
pattern : str
description of the dress pattern
size : int
dress size
location : str
place where the dress is located
"""
if StockItem.show_instrumentation:
print("**Dress __init__ called")
super().__init__(stock_ref, price, colour, location)
self.pattern = pattern
self.size = size
self.__Dress_version = 1
def __str__(self):
if StockItem.show_instrumentation:
print("**Dress __str__ called")
stock_details = super().__str__()
template = """{0}
Pattern: {1}
Size: {2}"""
return template.format(stock_details, self.pattern, self.size)
@property
def item_name(self): # type: ignore
if StockItem.show_instrumentation:
print("**Dress get item_name called")
return "Dress"
def check_version(self):
"""
Checks the version of a `Dress` instance and upgrades it if required
Returns
-------
None
"""
if StockItem.show_instrumentation:
print("**Dress check_version called")
super().check_version()We add a new class attribute
max_stock_addwhich defines the maximum amount of stock that can be added to an item in one goWe then define an
add_stockmethoddef add_stock(self, count): """ Add stock to an item Parameters ---------- count : int amount of stock to add to an item Returns ------- None Raises ------ Exception raised if `count` < 0 or `count` > `StockItem.max_stock_add` See Also -------- StockItem.max_stock_add : maximum amount of stock that can be added to a `StockItem` """ if StockItem.show_instrumentation: print("**StockItem add_stock called") if count < 0 or count > StockItem.max_stock_add: raise Exception("Invalid add amount") self.__stock_level = self.__stock_level + countWe first validate the
countvariable- Raising an
Exceptionif invalid so it can be handled
- Raising an
Once validated we can directly update the private variable
Let us see how this works with a
Dressnowd = Dress( "D0001", price=100, colour="Red", pattern="Swirly", size=12, location="Shop Window" ) d.add_stock(5) print(d)**Dress __init__ called **StockItem __init__ called **StockItem add_stock called **Dress __str__ called **StockItem __str__ called **Dress get item_name called **StockItem get price called **StockItem get stock_level called Stock Reference: D0001 Type: Dress Price: 100 Stock level: 5 Location: Shop Window Colour: Red Pattern: Swirly Size: 12We can see the Stock Level is now \(5\)
What happens if we try to add more than
StockItem.max_stock_add?
d.add_stock(15)**StockItem add_stock called
--------------------------------------------------------------------------- Exception Traceback (most recent call last) Cell In[38], line 1 ----> 1 d.add_stock(15) Cell In[36], line 150, in StockItem.add_stock(self, count) 148 print("**StockItem add_stock called") 149 if count < 0 or count > StockItem.max_stock_add: --> 150 raise Exception("Invalid add amount") 151 self.__stock_level = self.__stock_level + count Exception: Invalid add amount
- Well as expected we get an Exception, showing our error-handling is working correctly
- As the above shows, by adding the method directly to
StockItemit is automatically available to all of the subclasses without the need to write extra code
Sell a Stock Item
Now we need to account for the opposite case where stock is sold
The stock level will correspondingly decrease
We do this with another method on
StockItemclass StockItem(abc.ABC): """ Stock Item for the Fashion Shop """ ... def sell_stock(self, count): if count < 1: raise Exception("Invalid number of items to sell") if count > self.__stock_level: raise Exception("Not enough stock to sell") self.__stock_level = self.__stock_level - countThe amount to sell is given by the
countparameterWe raise an exception in two cases
- The user tries to sell \(< 1\) items, since this physically doesn’t make sense
- The user tries to sell more items than are available i.e.
count\(>\)self.__stock_level
Let’s see how this plays out with the
Dressclassd = Dress( "D0001", price=100, colour="Red", pattern="Swirly", size=12, location="Shop Window" ) d.add_stock(5) d.sell_stock(1) print(d)**Dress __init__ called **StockItem __init__ called **StockItem add_stock called **StockItem sell_stock called **Dress __str__ called **StockItem __str__ called **Dress get item_name called **StockItem get price called **StockItem get stock_level called Stock Reference: D0001 Type: Dress Price: 100 Stock level: 4 Location: Shop Window Colour: Red Pattern: Swirly Size: 12And again what happens if we try to do something invalid like selling more stock than we have
d.sell_stock(10)**StockItem sell_stock called--------------------------------------------------------------------------- Exception Traceback (most recent call last) Cell In[41], line 1 ----> 1 d.sell_stock(10) Cell In[39], line 178, in StockItem.sell_stock(self, count) 176 raise Exception("Invalid number of items to sell") 177 if count > self.__stock_level: --> 178 raise Exception("Not enough stock to sell") 179 self.__stock_level = self.__stock_level - count Exception: Not enough stock to sell
As expected, an exception is raised
Objects as Components
- We have completed the
StockItemand it’s associated class hierarchy - All behaviours given by the fashion shop item data spec is now implemented by the class and its subclasses
StockItemis a purely self-contained and cohesive- We’ve done some basic testing of the external behaviours
- Later we’ll look at how to implement automatic testing of our objects
- Sometimes we call a cohesive, self-contained part a component
- E.g. in a car production line different parts of the line produce different parts, like the motor, panels, transmission etc.
- All are made separately and the final product is composed of all the parts
- We would like to do something similar with
StockItem- Move it around as it’s own component
- Can then potentially reuse it as it’s own feature in other projects
Self-contained components are a great way to build software
Breaking software projects down into individual components is a great design philosophy. When you’re working solo it lets you focus on a small completable part of the program and progressively build up the complexity. When working with a larger team, different parts of the team can be assigned to work on the different independent components without interfering with each others work.
For example in our fashion shop project someone could be building the StockItem while another person works on the UI
Create a FashionShop Component
We have implemented a complete
StockItemcomponent- Represents everything about a single item of stock
Now we want to create a component that handles the management of collections of stock
We will call this component
FashionShopWe identify the following requirements
- Create a new fashion shop
- Save the fashion shop stock data to a file
- Load the data from a file
- Store a new stock item
- Find a particular stock item
- Provide a listing of all stock items
We can start by stubbing out our class, (the template code is given by Fashion Shop Prototype)
# Example 11.9 Fashion Stock Prototype # # Provides a stubbed out template for the FashionShop Class class FashionShop: """ Represents the inventory management system of a Fashion Shop """ def __init__(self): """ Create a new `FashionShop` instance """ pass def save(self, filename): """ Save the `FashionShop` to a given file `FashionShop` is saved as a pickled binary file in the file given by `filename`. The file is created if it doesn't exist. If the file already exists it is overwritten Parameters ---------- filename : str path to the file to save Returns ------- None Raises ------ Exceptions raised if the file fails to save See Also -------- FashionShop.load : load a `FashionShop` object from a file """ pass @staticmethod def load(filename): """ Create a `FashionShop` instance from a pickled binary file Parameters ---------- filename : str path to a file containing pickled `FashionShop` data Returns ------- FashionShop the loaded `FashionShop` instance Raises ------ Exceptions raised if the file fails to load See Also -------- FashionShop.save : saves a `FashionShop` instance """ pass def store_new_stock_item(self, item): """ Store a new item in the reference system The provided `item` can be indexed by it's `stock_ref` parameter Parameters ---------- item : StockItem item to add to the inventory system Returns ------- None Raises ------ KeyError Raised if the item's `stock_ref` is already registered as a key """ pass def find_stock_item(self, stock_ref): """ Find the stock item with the corresponding reference id Parameters ---------- stock_ref : str stock reference id of the item to find Returns ------- StockItem | None Returns a `StockItem` with a matching `stock_ref` else `None` """ return None def __str__(self): return ""We’ll now start by filling in these methods one by one
We provide a complete docstring which documents what the function should do
- Means someone else could come and implement it if we were working in a team
Some methods return placeholder values
- These implement partial functionality
- e.g.
find_stock_itemalways returnsNoneat the moment
A program utilising
FashionShopneeds only call the methods based on the description- Does not need to know about the internals
Create a FashionShip Object Instance
Our first step is to define our data attributes and define the
__init__We’ll start by using a dictionary to store our items
class FashionShop: def __init__(self): self.__stock_dictionary = {}The
__init__takes no argumentsPurely creates an empty dictionary (
self.__stock_dictionary) to store future stock itemsWe make the dictionary private to avoid accidental modification
We can now create a new
FashionShopshop = FashionShop() print(shop)<__main__.FashionShop object at 0x7f4010337fe0>
Save the FashionShop Object
We’ll use the same approach we’ve used before of pickling our files
Works exactly as we’ve seen before
class FashionShop: def save(self, filename): with open(filename, "wb") as out_file: pickle.dump(self, out_file)We use
withto handle making sure the file is properly cleaned up once we’re doneWe pass
selftopickle.dumpto save the object instance on whichsaveis calledThe below example demonstrates creating an (empty)
FashionShopshopand then saving itshop = FashionShop() shop.save("FashionShop.pickle")
Load the FashionShop Object
The load in our template is marked as a
@staticmethodWe can’t make it a object method, because there is no object yet
Make it static so it’s still associated with the class
This means we want the
loadmethod to create and return a newFashionShopitemclass FashionShop: ... def load(filename): with open(filename, "rb") as input_file: shop = pickle.load(input_file) return shopIf we wanted to load the
FashionShopobject we had saved above we would write```python loaded_shop = FashionShop.load(“FashionShop.pickle”)
Store a New Item
FashionShopis effectively a container forStockItemobjectsAt the moment the underlying model is a dictionary
We can’t add new items directly to the dictionary for two reasons
- We’ve made it private so we can’t directly add them
- We want to prevent adding multiple items with the same reference
As mentioned above we want to prevent duplicates
How should we handle duplicates?
- We could use a status code, but as said, we should favour exceptions
- We’ll return a
KeyErrorif the key already exists - We then need to document this in the docstring
class FashionShop: ... def store_new_stock_item(self, item): """ Store a new item in the reference system The provided `item` can be indexed by it's `stock_ref` parameter Parameters ---------- item : StockItem item to add to the inventory system Returns ------- None Raises ------ KeyError Raised if the item's `stock_ref` is already registered as a key """ if item.stock_ref in self.__stock_dictionary: raise KeyError("This stock reference is already used") self.__stock_dictionary[item.stock_ref] = itemstore_new_stock_itemadds a new stock item to the containerIt does not create one, we have to do that separately
The method checks for duplicates, throwing a
KeyErrorif the stock reference is already usedIf no exception is raised, the item is inserted into the stock dictionary
Let us see this in practice,
dress = Dress(stock_ref="D001", price=100, colour="red", pattern="swirly", size=12, location="front") shop = FashionShop() shop.store_new_stock_item(dress)**Dress __init__ called **StockItem __init__ calledAnd if we try to add it again…
shop.store_new_stock_item(dress)--------------------------------------------------------------------------- KeyError Traceback (most recent call last) Cell In[46], line 1 ----> 1 shop.store_new_stock_item(dress) Cell In[44], line 93, in FashionShop.store_new_stock_item(self, item) 73 """ 74 Store a new item in the reference system 75 (...) 90 Raised if the item's `stock_ref` is already registered as a key 91 """ 92 if item.stock_ref in self.__stock_dictionary: ---> 93 raise KeyError("This stock reference is already used") 94 self.__stock_dictionary[item.stock_ref] = item KeyError: 'This stock reference is already used'
Find a Stock Item
- Finding stock is implemented as a dictionary lookup
- Dictionaries provide the
getmethod- Acts as a normal dictionary lookup
- However if the key is missing a default value is returned
- The default value for this default parameter is
None
class FashionShop: ... def find_stock_item(self, stock_ref): """ Find the stock item with the corresponding reference id Parameters ---------- stock_ref : str stock reference id of the item to find Returns ------- StockItem | None Returns a `StockItem` with a matching `stock_ref` else `None` """ return self.__stock_dictionary.get(stock_ref) - The calling code is responsible for checking that the returned value is a valid
StockItem - Let’s demonstrate this by trying to find the dress we just added, and one that didn’t exist
print(shop.find_stock_item("D001"))
print(shop.find_stock_item("AAAA"))**Dress __str__ called
**StockItem __str__ called
**Dress get item_name called
**StockItem get price called
**StockItem get stock_level called
Stock Reference: D001
Type: Dress
Price: 100
Stock level: 0
Location: front
Colour: red
Pattern: swirly
Size: 12
None
List the Stock Data
- Final step is to list the stock data
- We want this in the form a human-readable string
- We can use a similar approach seen with our
Session class in [Chapter 10](../10_UseClassesToCreateActiveObjects/Chapter_10.qmd#code-analysis-creating-a-session-class) -StockItemprovides astr` method- We can iterate over the container items to get all of these
- Then just have to nicely format the container
class FashionShop: ... def __str__(self): stock = map(str, self.__stock_dictionary.values()) stock_list = "\n".join(stock) template = """Items in Stock {0} """ return template.format(stock_list) - Let’s put everything we’ve put together in practice
- We’ll create two items and add them to the shop then print the contents
- You can find the complete implementation of the fashion shop class in Fashion Shop
- Like our instrumented version of
StockItem,FashionShopprovides instrumentation so you can view the program flow
- Like our instrumented version of
dress = Dress(
stock_ref="D001",
price=100,
colour="red",
pattern="swirly",
size=12,
location="front",
)
pants = Pants(
stock_ref="A002",
price=200,
colour="cream",
pattern="plain",
length=12,
waist=34,
location="back right corner",
)
shop = FashionShop()
shop.store_new_stock_item(dress)
shop.store_new_stock_item(pants)
print(shop)**Dress __init__ called
**StockItem __init__ called
**Pants __init__ called
**StockItem __init__ called
**FashionShop __init__ called
**FashionShop store new stock item called
**FashionShop store new stock item called
**Dress __str__ called
**StockItem __str__ called
**Dress get item_name called
**StockItem get price called
**StockItem get stock_level called
**Pants __str__ called
**StockItem __str__ called
**Pants get item_name called
**StockItem get price called
**StockItem get stock_level called
Stock Reference: D001
Type: Dress
Price: 100
Stock level: 0
Location: front
Colour: red
Pattern: swirly
Size: 12
Stock Reference: A002
Type: Pants
Price: 200
Stock level: 0
Location: back right corner
Colour: cream
Pattern: plain
Length: 12
Waist: 34
Exercise: Create a Banking Application
You can use a similar structure to the FashionShop inventory management class for any program that needs to manage a key-based lookup of items, examples may include a bank account management system, a doll collection or competition entries
Implement a basic bank account management system. The system should have three different account types
- Savings accounts
- Have an account number, a monthly interest rate, a balance, and a person associated with the account
- A savings account can have money deposited or withdrawn
- A savings account balance cannot be negative
- Every month a savings account balance is increased by the interest rate
- Long-term savings account
- Like a savings account
- However also has a start date, and a term maturation period
- Money cannot be withdrawn from a long-term savings account before the maturation date
- Once a long-term savings deposit has matured the interest rate is quartered
- An account holder can either reinvest a matured long-term savings deposit (starting a new term and maturation)
- Or close out a matured long-term deposit transferring it to another account
- Credit Account
- Have a maximum withdrawal limit
- A credit account balance cannot be positive
- Every month a credit account balance is increased by the interest rate (i.e. any unpaid credit is increased)
- A credit account can not have a negative balance whose magnitude is greater than the maximum withdrawal limit
The bank system should have a similar interface to the FashionShop, with the following,
- Create a new bank system
- Save the bank system data to a file
- Load the data from a file
- Store a new bank account
- Find a particular bank account
- Provide a listing of all accounts
- Find all accounts associated with a particular person
Lets start by mapping out a class hierarchy for accounts. We can see that all accounts have a number, interest rate, balance and an associated account holder, each also supports being able to withdraw or deposit money and have interest applied, however they each implement these methods differently. So we’ll define an abstract base class Account that provides these data attributes and declares these methods. Subclasses then overwrite the method.
Our first subclass will be a savings account which requires no additional data attributes. Our second is a long-term savings account. This acts like a saving account but also has a maturation period and a start date. There are additional restrictions on how the account operates depending on if it has matured or not, so we’ll inherit from a Savings Account. A credit account has different behaviours for its deposit and withdraw methods and has a maximum withdrawal limit, so will be a subclass derived from the base account class
---
title: Account Class Diagram
---
classDiagram
class object
class Account {
str account_number
str account_holder
number interest_rate
number balance
deposit()
withdraw()
apply_interest()
}
class SavingsAccount
class LongTermSavingsAccount {
date start_date
int term_period
}
class CreditAccount {
number max_withdrawal_limit
}
object <|-- Account
Account <|-- SavingsAccount
SavingsAccount <|-- LongTermSavingsAccount
Account <|-- CreditAccount
Our abstract base class is fairly simple, it defines the properties and the abstract methods. You’ll observe that we make the majority of data attributes read-only. This is because when dealing with people’s money we want to be really careful about inappropriate changes!
import datetime
class Account(abc.ABC):
"""
Abstract class representing a single account
Subclasses are expected to overwrite the `deposit`,
`withdraw` and `apply_interest` abstract methods
Attributes:
-----------
account_holder : str
name of the account owner
interest_rate : int | float
interest rate applied to the account
"""
def __init__(self, account_number, account_holder, interest_rate):
"""
Creates a new `Account` instance
`Account` is abstract and should never be called directly
Parameters
----------
account_number : str
Unique account number
account_holder : str
Name of the account holder
interest_rate : int | float
interest rate applied to the account
"""
self.__account_number = account_number
self.account_holder = account_holder.strip().lower()
self.interest_rate = interest_rate
self.__balance = 0
def __str__(self):
template = """Account Number: {0}
Account Holder: {1}
Interest Rate: {2}
Balance: ${3}"""
return template.format(
self.account_number, self.account_holder, self.interest_rate, self.balance
)
@property
def account_number(self):
"""
account_number : str
Unique account number
"""
return self.__account_number
@property
def balance(self):
"""
balance: int | float
account balance in dollars
"""
return self.__balance
@abc.abstractmethod
def deposit(self, amount):
"""
Deposit money in an account
Parameters
----------
amount : int | float
amount in dollars to deposit in the account
Returns
-------
None
Raises
------
ValueError
Raised if `amount` cannot be deposited
"""
self.__balance += amount
@abc.abstractmethod
def withdraw(self, amount):
"""
Withdraw money from an account
Parameters
----------
amount : int | float
amount to withdraw from the account in dollars
Returns
-------
None
Raises
------
ValueError
Raised if `amount` cannot be withdrawn
"""
self.__balance -= amount
@abc.abstractmethod
def apply_interest(self):
"""
Apply the interest rate to the account and update the balance
Returns
-------
None
"""
self.__balance += self.__balance * self.interest_rateAccountdefines__init__,__str__,withdraw,deposit,apply_interestfunctions__init__is a basic constructor__str__is the string representationwithdrawis an abstract method for decreasing a balance- Since
balanceitself is a private attribute this method does the actual adjustment - Subclasses are expected to override the to modify the behaviour and provide validation, then forward the final result onto the super function
- Since
depositis an abstract method for increasing a balance- Works like
withdrawin that it should be overridden
- Works like
apply_interestan abstract method that should be used to apply interest- Provides a simple default implementation
- We can then define our savings account
class SavingsAccount(Account):
"""
Represents a standard savings account
Savings accounts must have non-negative balances, and
have interest paid on their balances
See Also
--------
Account : Parent Class
"""
def __init__(self, account_number, account_holder, interest_rate):
"""
Creates a new `SavingsAccount` instance
Parameters
----------
account_number : str
Unique account number
account_holder : str
Name of the account holder
interest_rate : int | float
interest rate applied to the account
"""
super().__init__(account_number, account_holder, interest_rate)
def __str__(self):
template = """==Savings Account==
{0}"""
return template.format(super().__str__())
def deposit(self, amount):
if amount <= 0:
raise ValueError("A deposit must be a non-negative number")
super().deposit(amount)
def withdraw(self, amount):
"""
Withdraw money from an account
Parameters
----------
amount : int | float
amount to withdraw from the account in dollars
Returns
-------
None
Raises
------
ValueError
Raised if `amount` is non-negative or the greater than
the account balance
"""
if amount <= 0:
raise ValueError("A withdrawal must be a non-negative number")
if amount > self.balance:
raise ValueError("Cannot withdraw more than the account balance")
super().withdraw(amount)
def apply_interest(self):
super().apply_interest()The savings account overwrites the
__str__method to prepend the account type__init__just forwards to the base classdepositchecks that the argument is valid (> 0) then forwards it to the base class method- depositing a negative number is effectively a withdrawal
withdrawdoes the same but checks that the amount also does not exceed the balanceApply interest just uses the default implementation
We then derive from
SavingsAccountforLongTermSavingsAccount
class LongTermSavingsAccount(SavingsAccount):
"""
Represents a long term savings account
An account in which money cannot be withdrawn before the term limit expires.
After the term limit has expired a reduced interest rate is applied.
See Also
--------
SavingsAccount : Parent class
"""
def __init__(
self, account_number, account_holder, interest_rate, term_period_in_weeks
):
"""
Creates a new `Account` instance
`Account` is abstract and should never be called directly
Parameters
----------
account_number : str
Unique account number
account_holder : str
Name of the account holder
interest_rate : int | float
interest rate applied to the account
term_period_in_weeks : int
length of the high yield savings term in weeks
"""
self.__start_date = datetime.date.today()
self.__term_period = term_period_in_weeks
super().__init__(account_number, account_holder, interest_rate)
def __str__(self):
template = """{0}
Term Period: {1} weeks
Start Date: {2}
Maturation Date: {3}
Has matured? {4}"""
formatted = template.format(
super().__str__(),
self.term_period,
self.start_date,
self.maturation_date,
self.has_matured(),
)
return formatted.replace("Savings Account", "Long Term Savings Account")
@property
def start_date(self):
"""
start_date : datetime.date
date the current term period started
"""
return self.__start_date
@property
def term_period(self):
"""
term_period : int
length of the term in weeks
"""
return self.__term_period
@property
def maturation_date(self):
"""
maturation_date : datetime.date
date the account matures
"""
return self.__start_date + datetime.timedelta(weeks=self.__term_period)
def has_matured(self):
"""
Indicates if an account has matured
Returns
-------
`True` if the account has matured else, `False`
"""
return datetime.date.today() >= self.maturation_date
def withdraw(self, amount):
"""
Withdraw money from a Long Term Savings Account
Money cannot be withdrawn unless the account has matured
Parameters
----------
amount : int | float
amount to withdraw from the account in dollars
Returns
-------
None
Raises
------
ValueError
Raised if `amount` is non-negative or the greater than
the account balance.
ValueError
Raised if the account has not
yet matured
"""
if not self.has_matured():
raise ValueError("Cannot withdraw from an immature account")
super().withdraw(amount)
def apply_interest(self):
"""
Apply interest to a Long Term Savings Account
The applied interest for a Long Term Savings account is quartered
if the account has matured
Returns
-------
None
"""
effective_rate = self.interest_rate
if self.has_matured():
effective_rate /= 4
self.deposit(self.balance * effective_rate)
def manage_account(self, transfer_account=None):
"""
Manage a matured long term savings account
A mature long term savings account can be closed
by providing an alternate account to transfer the
balance into. Alternately if no account is provided
the account is reinvested and a new term starts.
The owner of the long term savings account and the
account to transfer into must be the same
Parameters
----------
transfer_account : Account, optional
account to transfer into, pass None to reinvest instead, by default None
Returns
-------
None
Raises
------
ValueError
Raised in attempting to manage an immature account
ValueError:
Could not transfer to the new account
"""
if not self.has_matured():
raise ValueError("Cannot manage an immature account")
if transfer_account is None:
self.__start_date = datetime.date.today()
else:
balance = self.balance
try:
self.withdraw(balance)
transfer_account.deposit(balance)
except ValueError as e:
# need to ensure balances preserved
if not self.balance == balance:
self.deposit(balance - self.balance)
raise ValueError(str(e))This class has two new attributes,
start_dateandterm_periodThe term period is passed to the constructor as an integer number of weeks
The start date is calculated at
__init__timeNote that we use the
datetimelibrary rather thantimedatetimeprovides similar time objects that we can perform comparisons and arithmetic ondatetime.date.today()is the equivalent oftime.localtime()and returns the currentdatedatemeans there is no hours, minutes, seconds etc.- If we want this we would use
datetime.datetime.today()instead
We provide a property
maturation_date- This is a bit different to other properties we’ve seen
- It doesn’t mask a private attribute
- Instead it quickly calculates the
maturation_dateon the fly - We use a
datetime.timedeltaobject which handles performing the arithmetic correctly
@property def maturation_date(self): """ maturation_date : datetime.date date the account matures """ return self.__start_date + datetime.timedelta(weeks=self.__term_period)We then define a simple helper function
has_maturedwhich checks if the account has maturedSince we’re using
datetimewe can just get the current date and compare the twodef has_matured(self): """ Indicates if an account has matured Returns ------- `True` if the account has matured else, `False` """ return datetime.date.today() >= self.maturation_dateThe Long Term Savings Account can just use the
SavingsAccountdeposit methodWe update the
withdrawmethod to throw an error if the account hasn’t matureddef withdraw(self, amount): """ Withdraw money from a Long Term Savings Account Money cannot be withdrawn unless the account has matured Parameters ---------- amount : int | float amount to withdraw from the account in dollars Returns ------- None Raises ------ ValueError Raised if `amount` is non-negative or the greater than the account balance. ValueError Raised if the account has not yet matured """ if not self.has_matured(): raise ValueError("Cannot withdraw from an immature account") super().withdraw(amount)We also have to redefine our
apply_interest- We apply a different effective rate for a matured account
- This does not fit the signature, so we can’t just use the super method
- Instead we calculate the effective rate and then use
deposit
def apply_interest(self): """ Apply interest to a Long Term Savings Account The applied interest for a Long Term Savings account is quartered if the account has matured Returns ------- None """ effective_rate = self.interest_rate if self.has_matured(): effective_rate /= 4 self.deposit(self.balance * effective_rate)We also add a new function for handling a matured account
def manage_account(self, transfer_account=None): """ Manage a matured long term savings account A mature long term savings account can be closed by providing an alternate account to transfer the balance into. Alternately if no account is provided the account is reinvested and a new term starts. The owner of the long term savings account and the account to transfer into must be the same Parameters ---------- transfer_account : Account, optional account to transfer into, pass None to reinvest instead, by default None Returns ------- None Raises ------ ValueError Raised in attempting to manage an immature account ValueError: Could not transfer to the new account """ if not self.has_matured(): raise ValueError("Cannot manage an immature account") if transfer_account is None: self.__start_date = datetime.date.today() else: balance = self.balance try: self.withdraw(balance) transfer_account.deposit(balance) except ValueError as e: # need to ensure balances preserved if not self.balance == balance: self.deposit(balance - self.balance) raise ValueError(str(e))This works in two ways
If no
transfer_accountis provided, a new term period is startedIf a
transfer_accountis provided, the method attempts to transfer the balance to this account- This can potentially fail
- We don’t want the user to lose money
- So we wrap this in a
try...exceptblock - If transfer fails we ensure that both accounts maintain their original balances
- Then report to the user
Lastly we define a
CreditAccountclassWorks very similar to a
SavingsAccountbut can only have negative balances
class CreditAccount(Account):
"""
Represents a basic credit account
Savings accounts must have non-positive balances, and
charge interest on their debts
See Also
--------
Account : Parent Class
"""
def __init__(
self, account_number, account_holder, interest_rate, max_withdrawal_limit
):
"""
Creates a new `SavingsAccount` instance
Parameters
----------
account_number : str
Unique account number
account_holder : str
Name of the account holder
interest_rate : int | float
interest rate applied to the account
max_withdrawal_limit : int | float
maximum account that can be loaned out at once
"""
self.__max_withdrawal_limit = max_withdrawal_limit
super().__init__(account_number, account_holder, interest_rate)
def __str__(self):
template = """==Credit Account==
{0}
Maximum Withdrawal Limit: {1}"""
formatted_string = template.format(
super().__str__(), self.__max_withdrawal_limit
)
return formatted_string.replace("Balance", "Balance owed").replace("$-", "-$")
def deposit(self, amount):
"""
Pay off a Credit loan
Parameters
----------
amount : int | float
amount to pay off in dollars
Returns
-------
None
Raises
------
ValueError
Raised if 1amount` is not a positive integer
ValueError
Raised if deposit is greater than the current debt
"""
if amount <= 0:
raise ValueError("A deposit must be a non-negative number")
if amount + self.balance > 0:
raise ValueError(
"Exceeded max deposit limit: {0}".format(-1 * self.balance)
)
super().deposit(amount)
def withdraw(self, amount):
"""
Take out a loan of credit
Parameters
----------
amount : int | float
amount to loan from the account in dollars
Returns
-------
None
Raises
------
ValueError
Raised if `amount` is non-negative or the greater than
the account balance
"""
if amount <= 0:
raise ValueError("A withdrawal must be a non-negative number")
if self.balance - amount < -1 * self.__max_withdrawal_limit:
raise ValueError("Cannot exceed withdrawal limit")
super().withdraw(amount)
def apply_interest(self):
"""
Applies interest to any loans
Returns
-------
None
"""
super().apply_interest()- We also add a new
max_withdrawal_limitattribute passed in via the__init__ - This limits how much credit can be withdrawn
depositcan now only be used to pay off a line of credit- Prevents the balance going positive
withdrawchecks that the withdrawal keeps the balance under the withdrawal limit
We can then define our AccountSystem, this is basically the FashionShop renamed. However we also add a second dictionary account_name_dictionary which stores all the accounts where the list of accounts associated with a specific account holder is keyed by that account holder. This means we can do fast lookup both by account number (to get a specific account) or by client to get all their associated accounts. We provide two methods get_account which performs the id based lookup and find_users_accounts which finds the accounts associated with a user
# Exercise 11.1b Account System
#
# Provides a class for managing and storing collections of accounts
import pickle
class AccountSystem:
"""
Represents the account management system of a bank
"""
def __init__(self):
"""
Create a new `AccountSystem` instance
"""
self.__account_dictionary = {}
self.__account_name_dictionary = {}
def __str__(self):
print_string = ""
for holder, accounts in self.__account_name_dictionary.items():
print_string += "Client: " + str(holder) + "\n"
account_list = "\n".join(map(str, accounts))
print_string += account_list + "\n"
return print_string
def save(self, filename):
"""
Save the `AccountSystem` to a given file
`AccountSystem` is saved as a pickled binary file in the file given
by `filename`. The file is created if it doesn't exist. If the file
already exists it is overwritten
Parameters
----------
filename : str
path to the file to save
Returns
-------
None
Raises
------
Exceptions
raised if the file fails to save
See Also
--------
AccountSystem.load : load a `AccountSystem` object from a file
"""
with open(filename, "wb") as output_file:
pickle.dump(self, output_file)
@staticmethod
def load(filename):
"""
Create an `AccountSystem` instance from a pickled binary file
Parameters
----------
filename : str
path to a file containing pickled `FashionShop` data
Returns
-------
AccountSystem
the loaded `AccountSystem` instance
Raises
------
Exceptions
raised if the file fails to load
See Also
--------
AccountSystem.save : saves an `AccountSystem` instance
"""
with open(filename, "rb") as input_file:
accounts = pickle.load(input_file)
return accounts
def add_new_account(self, account):
"""
Store a new account in the reference system
The provided `account` can be indexed by it's `account_number` parameter
Parameters
----------
account : Account
account to add to the inventory system
Returns
-------
None
Raises
------
KeyError
Raised if the accounts's `account_number` is already registered as a key
"""
if account.account_number in self.__account_dictionary:
raise KeyError("This account number is already in use")
self.__account_dictionary[account.account_number] = account
if account.account_holder in self.__account_name_dictionary:
self.__account_name_dictionary[account.account_holder].append(account)
else:
self.__account_name_dictionary[account.account_holder] = [account]
def get_account(self, account_number):
"""
Get the account with the corresponding account number
Parameters
----------
account_number : str
account_number of the account to find
Returns
-------
Account | None
Returns an `Account` with a matching `account_number` if it exists, else `None`
"""
return self.__account_dictionary.get(account_number)
def find_users_accounts(self, name):
"""
Find the accounts associated with a given user
Parameters
----------
name : str
account holder to search for
Returns
-------
List[Account]
list of accounts held by the given name, if there are no matches the list is empty
"""
name = name.strip().lower()
try:
return self.__account_name_dictionary[name]
except KeyError:
return []Below demonstrates how the code works
# Test Savings Account
new_saving = SavingsAccount(1, "Alice", 0.003)
new_saving.deposit(100)
new_saving.withdraw(50)
new_saving.apply_interest()
# Test long-term savings
new_long_term = LongTermSavingsAccount(2, "Alice", 0.012, 26)
new_long_term.deposit(100)
new_long_term.apply_interest()
# Test Credit
new_credit = CreditAccount(3, "Bob", 0.08, 1000)
new_credit.withdraw(500)
new_credit.apply_interest()
account_system = AccountSystem()
account_system.add_new_account(new_saving)
account_system.add_new_account(new_long_term)
account_system.add_new_account(new_credit)
print("Getting the account with a specific id")
print(account_system.get_account(1))
print("Getting all accounts associated with a specific client")
print(account_system.find_users_accounts("felix"))
print("Printing the entire system")
print(account_system)Getting the account with a specific id
==Savings Account==
Account Number: 1
Account Holder: alice
Interest Rate: 0.003
Balance: $50.15
Getting all accounts associated with a specific client
[]
Printing the entire system
Client: alice
==Savings Account==
Account Number: 1
Account Holder: alice
Interest Rate: 0.003
Balance: $50.15
==Long Term Savings Account==
Account Number: 2
Account Holder: alice
Interest Rate: 0.012
Balance: $101.2
Term Period: 26 weeks
Start Date: 2026-03-13
Maturation Date: 2026-09-11
Has matured? False
Client: bob
==Credit Account==
Account Number: 3
Account Holder: bob
Interest Rate: 0.08
Balance owed: -$540.0
Maximum Withdrawal Limit: 1000
Create a User Interface Class
We now have our data items (
StockItem) and how container for managing themFashionShopThe last step is to provide a component that handles the user interface
We’ll create a class
FashionShopShellApplicationto handle the UIThe class should
- Initialise the application, by loading from a file (or creating a new instance if this fails)
- Display the menu to the user
This class provides a text-based interface
In future we may swap this for a graphical interface
Initialise the User Interface
We’ll define our
__init__method to try and load from a fileIf the load fails we then pass an empty instance
We use an internal
__shopattribute to store the inventory management componentimport FashionShop class FashionShopApplication: def __init__(self, filename): """ Creates a new `FashionShopApplication` Attempts to load a `FashionShop` from the provided file. Otherwise an empty instance is created Parameters ---------- filename : str path to a file containing pickled `FashionShop` data See Also -------- FashionShop : Main class for handling inventory management """ FashionShopApplication.__filename = filename try: self.__shop = FashionShop.FashionShop.load(filename) except: # noqa: E722 print("Failed to load Fashion Shop") print("Creating an empty Fashion Shop") self.__shop = FashionShop.FashionShop()We might then declare our
FashionShopApplicationas followsui = FashionShopApplication("fashionshop.pickle")
Implement the User Interface Behaviours
Now we need to implement the menu options
We already partially implemented these before (see Implement Application Behaviours) we just need to reimplement them as part of the class, or provide a connection
For example, we write a
sell_stockwrapper as below,def sell_stock(self): print("Sell Stock") item = self.__shop.find_stock_item( BTCInput.read_text("Enter the stock reference: ") ) if item is None: print("Item not found") return print("Selling") print(item) number_sold = BTCInput.read_int_ranged( "How many to sell? (0 to abandon) - {0} in current stock: ".format( item.stock_level ), min_value=0, max_value=item.stock_level, ) if not number_sold: print("Sell item cancelled") return item.sell_stock(number_sold) print("Items sold")This walks through the sales process
First the user is prompted for a reference to get an item
Then the user is prompted for an amount to sell
We use the
read_input_rangedto ensure the provided number is validOnce validated we then forward to the item’s
sell_stockmethod
You will spend a lot of time writing code to deal with failure
The sell_stock method does the following,
- It handles invalid stock references
- It ensures that we do not sell more inventory than we hold
- It provides the user a way to back out of a sale
The actual “happy path” code, i.e. the code that is followed when everything works fine is a very small part of the entire function. Multiply this across all the functions and the majority of the code is probably taken up with input validation and handling failure cases.
- A complete working version of the code is found in 11_FashionShopApplication
- We’ve split the classes into their own files for readability purposes
- The program can be run by running FashionShopApplication.py
Exercise: Completing the Banking Application
Implement a banking application wrapper class to complete the Bank Account system created earlier. This class should work like FashionShopApplication attempting to load from a file, then providing a looping menu. The user should be able to,
- Create a new account
- Deposit into an account
- Withdraw from an account
- View an account
- View all their accounts
- Manage a matured long term savings account
- Apply interest to all accounts
We first start by templating out our BankApplication class using a similar framework to the FashionShop application
class BankAccountApplication:
"""
Provides a text-based interface for Bank Account Management
"""
def __init__(self, filename):
"""
Creates a new `BankAccountApplication`
Attempts to load an existing application from the provided file.
Otherwise an empty instance is created
Parameters
----------
filename : str
path to a file containing pickled `AccountSystem` data
See Also
--------
AccountSystem : Main class for handling bank accounts
"""
self.__filename = filename
try:
self.__accounts = AccountSystem.AccountSystem.load(filename)
except: # noqa: E722
print("Failed to load accounts")
print("Creating an empty instance")
self.__accounts = AccountSystem.AccountSystem()
def main_menu(self):
"""
Provides a looping main menu
Users are able to
1. Create a new account
2. Deposit into an account
3. Withdraw from an account
4. View an account
5. View all accounts associated with a name
6. Manage a matured long term savings account
7. Apply interest to all accounts
8. Exit
Returns
-------
None
Raises
------
ValueError
Raised if an invalid command is received. Should not arise in
production. Report if encountered
"""
prompt = """Account Management System
1. Create a new account
2. Deposit into an account
3. Withdraw from an account
4. View an account
5. View all accounts associated with a name
6. Manage a matured long term savings account
7. Apply interest to all accounts
8. Exit
Enter your command: """
while True:
command = BTCInput.read_int_ranged(prompt, 1, 8)
if command == 1:
self.create_new_account()
elif command == 2:
self.deposit_into_account()
elif command == 3:
self.withdraw_from_account()
elif command == 4:
self.view_account()
elif command == 5:
self.view_accounts_for_name()
elif command == 6:
self.manage_matured_long_term_savings()
elif command == 7:
self.__accounts.apply_interest()
elif command == 8:
self.__accounts.save(self.__filename)
print("Accounts saved")
break
else:
raise ValueError(
"Invalid command id {0} encountered in main menu! Please report!".format(
command
)
)The __init__ is simple as it first tries to load the accounts from a given file (storing this filename for later saving), else creating an empty AccountSystem if one is not found.
The menu also uses our standard numeric interface. Now we need to start implementing our methods. The most complicated for us being creating a new account. To start with we know that all accounts have an interest rate. Depending on if we are viewing this program as something that a bank owner would use to manage their internal system, or something that the a client uses we might want to implement this differently.
One question is around the interest rate we apply to accounts. If this was client facing. We probably don’t want them specifying their own interest rate. So instead the application provides some logic to determine the interest rate to apply to a new account. This is done by using class variables to define interest rates for SavingsAccounts and CreditAccounts. We then also provide a static method that calculates the interest rate for a long-term savings account based on the principle that the longer the term is the higher the interest.
class BankAccountApplication:
"""
Provides a text-based interface for Bank Account Management
Class Attributes
----------------
savings_account_interest : float
current interest rate on newly opened savings accounts
credit_account_interest : float
current interest rate on newly opened credit accounts
"""
savings_account_interest = 0.01
credit_account_interest = 0.10
@staticmethod
def calculate_long_term_interest(term_limit):
"""
Calculates the bonus interest assigned to a long term savings account
Parameters
----------
term_limit : int
proposed term limit in weeks
Returns
-------
float
interest rate for a long-term savings account
"""
term_contribution = (
term_limit / Account.LongTermSavingsAccount.max_term_limit * (0.1)
)
return BankAccountApplication.savings_account_interest + term_contributionSimilarly here, we don’t want a client to be able to put an arbitrary term limit in. So we’ll add class variables to the LongTermSavingsAccount for the min and max term limits, and provide a static validation method.
class LongTermSavingsAccount(SavingsAccount):
"""
Represents a long term savings account
An account in which money cannot be withdrawn before the term limit expires.
After the term limit has expired a reduced interest rate is applied.
Class Attributes
----------------
min_term_limit: int
minimum term limit in weeks
max_term_limit: int
maximum term limit in weeks
See Also
--------
SavingsAccount : Parent class
"""
min_term_limit = 12
max_term_limit = 156
@staticmethod
def validate_term_limit(term_limit):
"""
Validates a proposed term limit
Parameters
----------
term_limit : int
proposed term limit in weeks
Returns
-------
bool
`True` if the proposed term limit is valid, else `False`
"""
return (
LongTermSavingsAccount.min_term_limit
<= term_limit
<= LongTermSavingsAccount.max_term_limit
)Now we can move on to implementing our account creation function. Here the user has to specify the account type to create. Then they are prompted to provide an account holder. The next bit of fun we introduce is to have the user’s account number then be randomly generated. We’ll make the account number a 4 character alphanumeric string. (We keep it small to make it easy to demo)
def create_new_account(self):
"""
Create a new account and add it to the system
Prompts the user for the type of account to create and
the necessary descriptors of the item. The account is
then added to the system
Returns
-------
None
Raises
------
ValueError
Raised if an invalid account type id is encountered.
Should not arise in production, please report if found.
"""
menu = """Create New Account
1. Savings Account
2. Long Term Savings Account
3. Credit Account
Enter account type: """
def generate_account_number():
"""
Generates an account number
The generated account number is a 16 character random
alphanumeric string
Returns
-------
str
string representing a valid account number
"""
account_number_string_length = 4
account_number_string_tuple = (
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
)
account_number = "".join(
random.choices(
account_number_string_tuple, k=account_number_string_length
)
)
return account_number
account_type = BTCInput.read_int_ranged(menu, 1, 3)
if account_type < 1 or account_type > 3:
raise ValueError(
"Invalid account type id {0} encountered while creating account".format(
account_type
)
)
account_number = generate_account_number()
account_holder = BTCInput.read_text("Enter account holder: ")You can see we define a tuple that contains the possible characters. We then use random.choices to select the appropriate number of characters (with replacement) where the number of characters is defined by account_number_string_length (so we can easily modify it). To convert from a collection to a string we use "".join to join the characters together, the empty string means that no extra characters are embedded.
Now that we’ve got the account number, the account holder and the type of account we can proceed. For a SavingsAccount we have all the details required to create the account. For a LongTermSavingsAccount we have to get the term limit, (making sure it’s valid) and for a CreditAccount we have to get the credit limit. Once that’s done we then add it to the AccountSystem
if account_type == 1:
print("Creating a savings account")
account = Account.SavingsAccount(
account_number,
account_holder,
BankAccountApplication.savings_account_interest,
)
print("Created a new savings account {0}".format(account_number))
elif account_type == 2:
print("Creating a long-term savings account")
while True:
term_limit = BTCInput.read_int_ranged(
"Enter term limit ({0} - {1}): ".format(
Account.LongTermSavingsAccount.min_term_limit,
Account.LongTermSavingsAccount.max_term_limit,
),
Account.LongTermSavingsAccount.min_term_limit,
Account.LongTermSavingsAccount.max_term_limit,
)
if Account.LongTermSavingsAccount.validate_term_limit(term_limit):
break
account = Account.LongTermSavingsAccount(
account_number,
account_holder,
BankAccountApplication.calculate_long_term_interest(term_limit),
term_limit,
)
print("Created a new long-term savings account {0}".format(account_number))
elif account_type == 3:
print("Creating a credit account")
withdrawal_limit = BTCInput.read_float("Enter max withdrawal limit: ")
account = Account.CreditAccount(
account_number,
account_holder,
BankAccountApplication.credit_account_interest,
withdrawal_limit,
)
print("Created a new credit account {0}".format(account_number))
self.__accounts.add_new_account(account) # type: ignoreDeposit, withdraw and view account are all implemented using similar logic. First the user is prompted for an account number, then we perform a function. We’ll extract this get_account behaviour into a function
def get_account(self):
"""
Prompts the user for an account number and returns any match
Returns
-------
Account | None
Account is the account number matches, else `None`
"""
account_number = BTCInput.read_text("Enter account number: ").upper().strip()
return self.__accounts.get_account(account_number)The implementations for deposit, withdraw, and view then largely just need to forward onto the appropriate message on the account, deposit, withdraw and __str__ respectively
def deposit_into_account(self):
"""
Deposit into a user-prompted account
User is prompted for an account, if the account exists,
the user is then prompted for how much to deposit
Returns
-------
None
"""
print("Deposit into account")
account = self.get_account()
if account is None:
print("Account not found")
return
print("Depositing")
print(account)
try:
amount = BTCInput.read_float(
"Enter amount to deposit (Current balance: {0}): ".format(
account.balance
)
)
account.deposit(amount)
except ValueError as e:
print(e)
def withdraw_from_account(self):
"""
Withdraw from a user-prompted account
User is prompted for an account, if the account exists,
the user is then prompted for how much to withdraw
Returns
-------
None
"""
print("Withdraw from account")
account = self.get_account()
if account is None:
print("Account not found")
return
print("Withdrawing")
print(account)
try:
amount = BTCInput.read_float(
"Enter amount to withdraw (Current balance: {0}): ".format(
account.balance
)
)
account.withdraw(amount)
except ValueError as e:
print(e)
def view_account(self):
"""
Display a user-specified account
Returns
-------
None
"""
print("View account")
account = self.get_account()
if account is None:
print("Account not found")
return
print(account)Next we want to implement the complement to view_account which is view_account_by_name. This simply takes a user’s name and then forwards onto the appropriate message on the AccountSystem. The resulting list is then converted to a string representation using map and displayed to the user
def view_accounts_for_name(self):
"""
Find and display accounts for a user prompted name
Names are converted to lower case and stripped of
leading and trailing whitespace
Returns
-------
None
"""
print("View account holders accounts")
accounts = self.__accounts.find_users_accounts(
BTCInput.read_text("Enter account holder: ")
)
print("\n".join(map(str, accounts)))Our last major implementation detail is then to implement managing a matured savings account. Recall from the original exercise that a long-term savings account that has matured can either be reinvested or transferred to another account. To implement this structure as follows,
- The user is prompted for an account number
- We verify that account number is valid and a long-term account
- We then validate that the account has matured
- The user is then prompted if they want to reinvest
- If yes, the account is reinvested and the process stops
- Else we continue
- The user is then prompted for the account to transfer to
- We validate that the account exists, and the account holder matches the long term account
- We then attempt to close out the account
The implementation is given below,
def manage_matured_long_term_savings(self):
"""
Close out or reinvest a user specified matured long-term account
Returns
-------
None
"""
account_number = BTCInput.read_text("Enter account number: ").upper().strip()
account = self.__accounts.get_account(account_number)
if account is None:
print("Account not found")
return
if not isinstance(account, Account.LongTermSavingsAccount):
print("Account is not a long term savings account")
return
if not account.has_matured():
print("Account cannot be managed: has not matured")
return
reinvest = BTCInput.read_int_ranged(
"Reinvest this account? (1 - yes, 0 - no): ", 0, 1
)
if reinvest:
account.manage_account()
return
holder = account.account_holder
other_accounts = set(self.__accounts.find_users_accounts(holder)).difference(
{account}
)
if len(other_accounts) == 0:
print("Account cannot be closed: No accounts to transfer to")
return
print("\n".join(map(str, other_accounts)))
account_number = (
BTCInput.read_text("Enter transfer account number: ").upper().strip()
)
transfer_account = self.__accounts.get_account(account_number)
if transfer_account is None:
print("Account not found")
return
if transfer_account.account_holder != holder:
print("Could not transfer, account holder does not match")
return
try:
account.manage_account(transfer_account)
print(
"Funds in account {0} transferred to account {1}".format(
account.account_number, transfer_account.account_number
)
)
except ValueError as e:
print("Failed to close account:", e)Our last step is to implement the application of interest rates. This is done to all accounts, typically at some specified point, e.g. the first of each month. However, the natural place for this to be implemented is on the AccountSystem class,
class AccountSystem:
"""
Represents the account management system of a bank
"""
...
def apply_interest(self):
"""
Applies interest to all accounts in the system
Returns
-------
None
"""
for account in self.__account_dictionary.values():
account.apply_interest()We then just need to forward to this method from BankApplication, as can be seen already in the main_menu function
def main_menu(self):
...
elif command == 7:
self.__accounts.apply_interest()Running the program is the same as for our Fashion Shop Application
ui = BankAccountApplication("accounts.pkl")
ui.main_menu()Design Review
Now that we’ve completed the program it’s worth reviewing the program. Overall the design is one that we’re pretty happy with but there could be some improvements. The most glaring is that account numbers must be unique, yet our program doesn’t enforce this. There are a number of solutions. The ideal way to handle this would be that when we try to add an account if self.__account.add_new_account(account) returns a KeyError would be to regenerate a new number. However this number is private and can’t be changed once an object is created. We don’t want to change this, so we would have to create an entire new object which is fine, but the way the code is organised means that it’s not clear when we get to adding the account, what type it is. Solutions include,
Moving the call to
add_new_accountto each individual account type path- This means we can catch the
KeyErrorregenerate a new number, and then rebuild the appropriate account type - Has the downside that we replicate the code for adding the account for every account type
- This means we can catch the
Implement a method on
AccountSystemthat tells us if a generated account number is valid.- This means we can check at the time of number generation
- Repeat until we get a valid one
- This has the downside that if we had a concurrent system, we could potentially be told that the account number is free, then have another process beat us to using that number
- Plus we already have the
KeyErrorto tell us if the number is free- In Python you generally prefer to ask for forgiveness rather than permission
- i.e. use exceptions over checking then setting
- In Python you generally prefer to ask for forgiveness rather than permission
Handle the Error, report it to the user and leave it up to them how they want to resolve
- Simplest implementation
- Probably not the best user experience (since user might have to enter the same details all over again)
None of the solutions above are strictly the best they are all just options to consider. For this small program we’ve left the bug in as a demonstration.
There are a couple other design considerations. One is about cohesion, we have logic for defining the interest applied to accounts separate from the Account class stored on arguably the UI class BankAccountApplication. This is fine for this small system but perhaps suggests a lack of cohesion. If we were to change out our UI class to a GUI that GUI would then have to implement the same business logic. One solution is to move those details to the Account class hierarchy. Here each subclass might have to define a property base_interest_rate which defines the default interest rate for an account. On the other hand if we decide that an Account should have an interest rate, but has no business knowing how that interest rate is set, we may have to implement this behaviour either in the AccountSystem or in another class that purely handles the business logic around interest rates and propagates that through to the accounts. For the scale of this system, we probably don’t need that extra layer (remember the simpler the solution the easier, we can always refactor later)
The last question is more on of a philosophical design choice. This program implements features that are very client focused such as creating accounts, depositing and withdrawing (and arguably managing a long-term account), and some that a more targeted towards an internal user (applying interest, the ability to modify any account and see anybody’s account). This is fine for a simple toy program like the one we’re building. But if we were to scale this up we would probably want to split out the client-facing functions from the internal user facing components. Both would still talk to the same underlying data model though.
Design With Classes
- We can show the final class diagram and interactions of our program below
- (We’ve hidden some of the subclasses and
objectfor simplicity)
---
title: Complete Fashion Shop Class Diagram
---
classDiagram
class StockItem {
str stock_ref
str item_name
str colour
number price
int stock_level
}
class Dress {
str pattern
int size
}
class Pants {
int length
str pattern
int waist
}
class FashionShop {
dictionary __stock_dictionary
}
class FashionShopApplication {
FashionShop __shop
}
StockItem <|-- Dress
StockItem <|-- Pants
FashionShop "1" o-- "0..n" StockItem
FashionShopApplication "1" *-- "1" FashionShop
- Class diagrams more broadly discuss associations
- One form of association is inheritance which we’ve seen before is represented by a open arrow
- Another is aggregation represented by an open diamond-headed arrow
- We say that
FashionShopaggregatesStockItembecause it is a container - We can additionally specify multiplicities
- i.e. how many items are represented in a relationship
- We do this by adding number on either end of the arrow
- Above we have \(1\) next to the
FashionShopand \(0 \ldots n\) next toStockItem - This says that one
FashionShopaggregates \(0\) to an arbitrary finite number ofStockItems
- We say that
- A similar relationship is composition
- Where as inheritance is a is-a relationship
- Composition can be thought of as a has-a relationship
- Again we can specify multiplicities
- Here we indicate that there is one
FashionShopApplicationwhich contains oneFashionShop
- These class diagrams help express the structure and relationship of a system
- Good way to describe how the elements of your program fit together
Python Sets
- Sets are collections of values like tuples and lists
- They are mutable like lists
- Unlike lists each value in a set must be unique
Make Something Happen: Investigating Sets
Work through the following steps in the python interpreter to understand sets
A set can be created by explicitly using the set initializer
set1 = set()
set1set()
This creates an empty set.
We can add to set like with a list, but we use add rather than append
set1.add(1)
set1{1}
Sets can only hold one instance of a given value, what happens if we try to add the same element twice?
set1.add(1)
set1{1}
As we can see the set contents have not changed, there is only one 1 in the set. However the other thing to observe is that no error was thrown, the second add fails silently
We can add multiple values as long as they are distinct
set1.add(2)
set1{1, 2}
Like with lists and dictionaries there is a quick set declaration syntax. We simply provide a comma-separated list enclosed in curly braces
set2 = {2,3,4,5}
set2{2, 3, 4, 5}
This is similar but distinct to the dictionary case where the curly brace list is comma separated key:value pairs
For those familiar with set theory, sets provide the standard suite of set operations.
difference is called on one set, and takes another set as an argument. It returns a new set containing the elements in the original set that are not in the argument set, e.g.
set1.difference(set2){1}
and
set2.difference(set1){3, 4, 5}
Union returns a set containing the elements that are in either of the sets
set1.union(set2){1, 2, 3, 4, 5}
intersection returns a set containing the elements that are in both of the sets
set1.intersection(set2){2}
There are also a number of methods for comparing the contents of a set. isdisjoint returns True if the two sets have no common elements
set1.isdisjoint(set2)False
issubset takes the form seta.issubset(setb) and returns True if seta is a subset of setb. \(A\) being a subset of \(B\) means all the elements of \(A\) are in \(B\)
set3 = {2, 3}
set3.issubset(set2)True
The opposite method is issuperset which returns true if the set the method is called on is a superset of the argument. \(A\) is a superset of \(B\) if \(A\) contains every element of \(B\)
set3.issuperset(set2)False
- How are sets useful?
- Let us remove duplicates in a collection
set("Hello World"){' ', 'H', 'W', 'd', 'e', 'l', 'o', 'r'}
- Python’s sets are unordered
- They can be sorted using the function
sorted
print(sorted("Hello World"))
print(sorted(set("Hello World")))[' ', 'H', 'W', 'd', 'e', 'l', 'l', 'l', 'o', 'o', 'r']
[' ', 'H', 'W', 'd', 'e', 'l', 'o', 'r']
- We might also use them to manage a collection of items
- e.g. a players inventory
pocket = {"axe", "apple", "herbs", "flashlight"}Sets allow for easy membership checks
especially when we want to look at multiple members
apple_potion = {"apple", "herbs"} if apple_potion.issubset(pocket): print("Making an apple potion")Making an apple potionIn the above example we check that the player’s inventory has the ingredients for an apple potion
- This is done by checking the ingredients are a subset of the inventory
We can then make the apple potion using set operations
- Use set difference to remove the ingredients
- Then add an apple potion
pocket = pocket - apple_potion pocket.add("apple potion") pocket{'apple potion', 'axe', 'flashlight'}The subtraction operator on sets works like the set difference operation we’ve seen before
Sets versus Class Hierarchies
- It’s quite common for customers to provide feedback on the usability of their product
- For example our client might prefer the tag-based interface more generally over a rigid class structure
Your client would like to make changes to how the program functions. She enjoys using tags to identify stock elements. She finds having to specify a specific item type (e.g. pants, jeans, hat etc.) a painful process. She would prefer to index all stock using tags. Dresses would have the
dresstag, etc. Together you propose the following mock-up
Enter stock reference: D001 Enter price: 120 Enter tags (separated by commas): dress, colour:red, location:shop window, pattern:swirly, size:12, evening, long
The client finds searching by tags easy to work with
Tags are more flexible and remind her more of how she would organise stock by hand
Tags give the flexibility to add new items or change how items are described without needing to recompose the class hierarchy
The only additional request the client has is to allow the ability to edit the tags on a stock item
- Can add or remove tags
- Can correct edits
The downside is that any tags that are not entered correctly will result in failed searches
- The class hierarchy enforces that the specified attributes for each
StockItemsubclass exist
- The class hierarchy enforces that the specified attributes for each
A tags only implementation is given by in the TagOnlyFashionShop folder
The
StockItemimplementation becomes much simplerFirst since there are no longer subclasses, we no longer define it as an abstract class
We update the docstrings and
__init__method- This involves removing all the attributes that do not describe the stock id, price or stock level
- We also increment the version number
class StockItem: """ Represents a single inventory item Attributes ---------- stock_ref : str reference id of the stock item tags : set[str] set of tags describing the stock item Class Attributes ---------------- show_instrumentation : bool Indicates if instrumentation should be printed max_stock_add : int maximum amount of stock that can be added to an item's stock level at a time min_price : int | float minimum price of any stock item max_price : int | float maximum price of any stock item """ show_instrumentation = True max_stock_add = 10 min_price = 0.5 max_price = 500 def __init__(self, stock_ref, price, tags): """ Creates a `StockItem` instance Parameters ---------- stock_ref : str stock reference id price : int | float stock price tags : set[str] set of tags describing the stock item """ if StockItem.show_instrumentation: print("**StockItem __init__ called") self.stock_ref = stock_ref self.__price = price self.tags = tags self.__stock_level = 0 self.__StockItem_version = 4Since we no longer have multiple subclasses we can remove the
item_namepropertyThen we have to update the
__str__methoddef __str__(self): if StockItem.show_instrumentation: print("**StockItem __str__ called") template = """Stock Reference: {0} Price: {1} Stock level: {2} Tags: {3}""" return template.format(self.stock_ref, self.price, self.stock_level, self.tags)All our actual core functions can stay the same, but we just need to adjust the
check_versionWe could make
check_versionconvert all the previous attributes to tags, but this has a problem- All the subclasses that are actually instantiated no longer exist!
- So really the customer will likely have to remake these objects
- In our implementation, even though a
StockItemshould not be created directly, we can provide a warning message that the user should manually update the current object
def check_version(self): """ Checks the version of a `StockItem` instance and upgrades it if required Returns ------- None """ if StockItem.show_instrumentation: print("**StockItem check_version called") if self.__StockItem_version != 4: print("Stock item uses old data model, please recreate this item")
Data Migration is Painful
In moving from the class hierarchy to a tags based implementation, we’ve encountered one common problem. Data Migration. Before we’ve used simple versioning on classes to update them when we change their implementation. But here we have a bigger scale problem. What do we do when we change the application architecture? We can’t just update the subclasses because they no longer exist. For our small toy problem manually recreating objects probably works fine.
However if we were working on a larger project we would have to create a migration plan. This might be a simple script that converts the old data to the new data schema, or for larger projects this might be a longer term project where we slowly phase in the new system and phase the old system out.
- The
FashionShopclass needs no updates - It only ever sees objects as
StockItems and manages the collection - We do need to make minor updates to
FashionShopApplication- This is just adjusting how we create new items to reflect that we don’t have a class hierarchy
def create_new_stock_item(self): """ Create a new stock item and add it to the system Prompts the user for the necessary descriptors and any optional tags then creates a corresponding stock item and adds it to the store Returns ------- None """ # now we have a valid item so get the common attributes stock_ref = BTCInput.read_text("Enter Stock reference: ") price = BTCInput.read_float_ranged( "Enter price: ", min_value=StockItem.StockItem.min_price, max_value=StockItem.StockItem.max_price, ) tags = FashionShopApplication.tag_set_from_text( BTCInput.read_text("Enter tags (separated by commas): ") ) self.__shop.store_new_stock_item(StockItem.StockItem(stock_ref, price, tags)) - We’ve also updated the docstring
Comments can easily go stale
One of the reasons that people argue against using comments is that like code itself, they need to be maintained. As we’ve seen above, whenever we modify the code we have to ensure that the comments still correctly describe the behaviour. Since the comments we’re talking about are the docstrings it’s important to have these, and even more important they are up to date. Especially if we want to release documentation for our API.
However, you should be considered in how you document your code. A comment is a maintenance overhead, and an incorrect comment can result in a lot of frustration if it confuses someone looking at the code base
Disadvantages of using Classes
- Class hierarchies allow you to implement strict business or application logic
- All objects created must obey the specified interfaces
- For example we enforce that a dress has a size, pattern and colour
- Using open-ended tags means that a required tag (say size) might be missing
- No obligation to specify in the size embedded in the code
- Classes also allow for polymorphism
- Can make a dress behave differently from a hat for example
- We saw this with the
__str__method - Each subclass defined it’s own logic for conversion to a string
- tags don’t provide an easy way to have an item-type dependent presentation
What’s important to the programmer may not be important to the customer
Often when writing programs for other clients it is easy to get fixated on developing a piece of logic that the customer may not actually like. Equally as a programmer, you may not fully understand or appreciate the nature of the businesses logic.
In the case of the Fashion Shop application we created a complex hierarchy based on the assumption that the it was important to store all the details for different categories of stock. The class hierarchy enforces that all objects are fully described.
However, from the client’s perspective as long as the item is properly referenced, priced and it’s stock levels tracked, the other information is just a useful bonus. She finds it much more useful to be able to add and modify tags or organise stock as needed.
Properly understanding the business needs of a client and what their important use cases are is a crucial part of project management. One of the techniques for solving this is called domain-driven design which strives to make sure that software accurately models the domain it is applied to
Code Analysis: Design Decisions
Look at the following scenarios and decide if a class-based or tags based implementation makes sense
You’re creating banking software for a local bank to manage their accounts. The bank offers credit and checking accounts. Should we use a class hierarchy?
- Yes
- Accounts are likely to have a rigid set of attributes that all need to be specified and validated
- Some elements are likely common
- e.g. the account holder details
- Different account types might specify different attributes
- e.g. credit account requires a credit limit
- We can thus use an abstract
Accountclass, and then subclass this to provide specific implementations - Means we can also use method overloading and polymorphism
- e.g. Different accounts will process funds withdrawals in different ways
- You can see an example in our model bank system class structure
You’ve been approached to help a local gallery track their artwork. The gallery holds pictures, sculptures, and manuscripts. Should we use a class hierarchy?
- Likely not
- There is likely not a lot of common functionality between the objects
- The items are also likely to be flexibly described rather than have a rigid data model
- The gallery may evolve and change how they categorise
- e.g. They may switch from categorising by type to categorising by artist etc.
- Sets and tags makes sense here
- In theory you could just reuse the tags-only fashion shop application (maybe renaming some items so they contextually make sense)
Summary
- Inheritance can be used to manage a large number of related items
- Inheritance lets (sub)classes be based on other (super)classes
- Subclasses inherit the attributes and methods of their parent
- Subclasses can redefine attributes and methods to override the behaviour of the parent
- Structuring a program as individual cohesive components make it easy to develop, test and modify parts of a program
- Components can be implemented as classes that provide specific methods
- e.g.
StockItemwhich managed a stocked inventory item- Had a
add_stockto adjust its stock level
- Had a
- Different components communicate purely through each others methods
- e.g.
FashionShopstoresStockItemobjects via a dictionary- Could instead use a database implementation
- No need to rewrite the other classes as long as the method interface is the same
- Components can be developed and tested independently and cooperatively once their interfaces are defined
- Python provides a
setcollection- A set is a collection of unique values
- Mutable like a list
- An example use for sets is specifying unique, dynamic tags on an object
- The
setimplementation supports all standard set operations
- It’s important to understand client requirements when developing a program
Questions and Answers
Do I have to use a class hierarchy if I want to store many related items?
- No
- They are useful when you want to use polymorphism
- i.e. treat them all as one common object
- But have each specific type behave differently
What turns an object into a software component?
- A component is cohesive
- It can perform all the functions of it’s domain without needing another object
- e.g.
StockItem’s methods only rely on it’s internal state and some passed in basic parameters- No need to reach out to another external object instance
FashionShoporFashionShopApplicationmay call methods onStockItemobjects- These are internally encapsulated in the object
- So all good, these are still cohesive
- We don’t want to be calling out to external objects
- Poor cohesion would have methods like
add_stockandsell_stockoutside in a separate class
- Considering how easy it would be to swap the class for one with a similar interface is a good test of how cohesive a class is
- If the class is cohesive this should be easy
- For example if we wanted to swap
FashionShopfrom a dictionary implementation to a database implementation with the same interface
- When using component based design, it’s a good idea to ensure you define your components before you start implementing a design
- Need to ensure that you understand correctly what data is needed where and how components will communicate
- Components can then be implemented individually
Do I have to use object-oriented design to make my programs?
- No
- Python uses duck-typing, which means if we try to call a method on an object, it will be called so long as it’s defined
- Thus we can implement polymorphism without a rigid class hierarchy
- The downside is without a clear class hierarchy it can be hard to propagate method or design changes between related implementations
- Remember that typically the largest time cost of code is maintaining and understanding existing code
Why is the relationship between a subclass and a superclass so confusing?
- Recall that the sub and super names derive from set theory
- So while a super class may have less functionality than a sub class that is because every subclass is an instance of a superclass
- A subclass can be thought of as a more specialised implementation of a superclass
What exactly happens when a method in a class is overridden?
- When the method is called in python, python first looks for the method on the object class
- If it exists, it’s used, else it looks in the next superclass
- This continues until the base class for all python instances
objectis found - If the object is still not found an error will be found, for example with the
intobject below we try to call the fictitious methodfoo
x = 10 x.foo()--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[81], line 2 1 x = 10 ----> 2 x.foo() AttributeError: 'int' object has no attribute 'foo'
- Overriding methods can slow a program down as this method resolution takes time
- This is typically not a bottleneck in python programs
What things in my class should be made static?
- Static items are always present
- They are associated with the class, not a specific instance
- static data attributes are good for storing class wide values
- For example variables associated with validating data such as a max and min value
- We don’t want to store a copy of these for every object instance
- So we instead store it as a class attribute
- static methods are for methods that don’t need an instance of the class
- Validation or object creation methods are a common use case
- Keeps code associated with the class packaged with the class
- For example
loadonFashionShop, there’s noFashionShopinstance to load into yet
When do I use abstraction?
- Used when thinking about an object
- Abstraction helps reducing noise when considering a system
- e.g. rather than considering a range of different customers we just consider a Customer
- We can then define the basic operations we need on a customer
- Later we might then specialise for different customers
- e.g. rather than considering a range of different customers we just consider a Customer