Object-Oriented Programming Part-4¶
Indices and tables¶
Introduction¶
A battery is frequently combined with other batteries in a battery pack in-order to increase the voltage and/or the total charge that can be supplied into a load. A Battery pack can be modeled in Python three ways:
1 - Through inheritance, by deriving a BatteryPack
class from the built-in list
class, inheriting the list
class’ sequence management logic.
2 - Through composition, by creating a new BatteryPack
class with a list
object as a data attribute and defining methods that allow the user to read/write the list.
3 - From scratch, creating all the required functionality as custom code.
Option 3 is the most onerous. Likely not a good choice given how close a BatteryPack
resembles a list.
Option 2 is a completely valid method, but, not as great a learning opportunity for this topic!
Option 1 will allow for the exploration of more aspects of Python object oriented programming than the other options will. As a result, this is the route that will be taken in this topic.
Modelling a Battery Pack¶
After inheriting from the list
class, we will specialize it around the following characteristics:
The pack (list) will be of fixed length corresponding to the number of cells it holds (the pack size).
Each battery pack can contain a creation-time configurable number of cells. The number of cells is an instance attribute since each battery pack can be different.
The cells in a battery pack are all the same form factor. The cell form factor is an instance attribute since each battery pack can hold cells of a different form factor.
Note
Much like a real battery pack, the chemistry of the battery is unimportant. Only the size matters. Our battery pack class should support this notion.
Each battery pack can have a unique serial number, which makes the serial number an instance attribute.
Batteries of the correct form factor can be added and removed from a specific battery pack. Instance methods are required to handle adding/removing the proper form factor of battery (and ensuring it is a battery and not a carrot) from a battery pack.
It is useful to be able to query the number of cells loaded in a specific pack. An instance method is required to accomplish this.
It is useful to be able to query the total voltage of the specific pack after all batteries are loaded. An instance method is required to accomplish this.
It is useful to be able to see if a battery object is part of a given pack, and if so, where in the pack it resides. Instance methods can be used to accomplish this.
It is useful to be able to determine if two battery pack objects have the same contents. An instance method can be used to accomplish this.
It is programmatically convenient to be able to use the item access operator,
[]
, to set, get and delete cells from a specific battery pack.It might be useful to sort the batteries in a specific battery pack, by serial number, which requires an instance method.
Printing a specific battery pack object should produce a natural result, such as you would find if you were looking at it (i.e. you could determine which type of batteries were loaded, their serial numbers etc).
Let’s get started!
The Python Data Model¶
Python lays out the methods (the interface) required on an object for it to be considered to be a particular style of object. For example, a numerical object, a container etc. The definitions are contained in the Python Data Model found here.
The data model has a wealth of information. It is not a quick (or trivial) read, but, it is worthwhile to get to know it. You will find it useful as a reference so you should read (skim) it at least once.
Defining the BatteryPack Class¶
Our class definition will use single inheritance and inherit from list, as follows:
1 2 3 4 5 | from Battery import Battery
class BatteryPack(list):
"""A class that represents a pack of batteries in series."""
|
Now, we have access to the sequence management logic of the list class for free! We can reimplement select methods to deal with the unique characteristics of our battery pack class.
Note
The Battery class was imported as it will be needed later in the class code.
Initializing the BatteryPack Class¶
The __init__()
function allows us to customize each BatteryPack
object that is created. In this case, we define some private data attributes that characterize the cell form factor and the number of cells that comprise the battery pack. Additionally, we record the battery packs serial number:
1 2 3 4 5 6 7 8 9 10 11 12 13 | # ----------------------------------------------------------------------------------
# Methods that handle basic object customization
def __init__(self, num_cells, cell_form_factor, serial_number):
"""Initialize the BatteryPack instance"""
# num_cells (int) giving the number of cells that make up the pack
# cell_form_factor (str) indicating the size of each cell in the pack.
# serial_number (str) gives the pack's unique serial number.
self._cell_form_factor = cell_form_factor
self._num_cells = num_cells
self._serial_number = serial_number
# Call the super-class initializer. Start the list off
# with None in each battery slot.
super().__init__([None] * self._num_cells)
|
Classes like the list
often have their own initialization code that needs to run when an object of their type is created. Since the BatteryPack
class inherited the init code from list
, it could have been used directly, but we needed to override it to create our instance data attributes. However, the list
still needs to be initialized!!
The solution is to call the list
class’ __init__
directly to allow it to complete its initialization. Python allows child classes to call code in their parent class using the super()
function.
In the BatteryPack
case, we use super()
to call the init code of the list
class, and initialize the list to the pack length with None
values (i.e. empty) in each slot.
Note
It is not always required to call the corresponding method in the super-class. Re-call for the Battery class, we did not need to call the super-class __init__
, as the one defined in the Battery class was sufficient for our needs.
BatteryPack Properties¶
The private instance data attributes should be protected from accidental modification. Thus, they are accessed using trivial read-only property accessors, as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # ----------------------------------------------------------------------------------
# Properties of the battery pack object
@property
def cell_form_factor(self):
"""Gets the form factor of the cells that can be stored."""
return self._cell_form_factor
@property
def num_cells(self):
"""Gets the number of cells that make up the pack."""
return self._num_cells
@property
def serial_number(self):
"""Gets the battery's serial number"""
return self._serial_number
|
Determining how many cells have been loaded, the fill level, is not a private instance data attribute. Instead, it can be computed on-the-fly by examining how many list entries are objects of type Battery. This is conveniently done using a list comprehension (the map/filter/reduce idiom) as follows:
1 2 3 4 | @property
def fill_level(self):
"""Returns the number of cells actually loaded in the pack"""
return len([cell for cell in self if isinstance(cell, Battery)])
|
In this case, we used Python’s, isinstance()
, function to check if an object is an instance of the given class (or an instance of one of its sub-classes).
Finally, the voltage of a pack, also not a private instance data attribute, can be computed on-the-fly. In real life, only battery packs that have all the cells loaded, will generate a voltage. We can model this behavior by only summing the contents of the list if the fill level is high enough. This is done as follows:
1 2 3 4 5 6 7 8 9 10 | @property
def voltage(self):
"""Returns the voltage of the battery pack considering all cells."""
if self.fill_level < self.num_cells:
# Not all slots have a battery. Pack
# is incomplete and can't generate a
# voltage.
return 0
else:
return sum(self)
|
Basic Object Customization¶
The Python Data Model defines several functions, that when implemented/overridden, take part in basic object customization. They are listed here. Those methods relevant to the BatteryPack will be overridden to customize it.
It was stated earlier that our battery pack model needs to output something natural when a BatteryPack
object is printed. The following code handles printing details of the BatteryPack
and each Battery contained:
1 2 3 4 5 6 7 8 9 10 11 | # ----------------------------------------------------------------------------------
# Additioanl methods that handle basic object customization
def __str__(self):
"""Prints the string representation of each battery in the pack"""
retval = "Cell Size:{} Num Cells:{} Filled:{} SN:{}\n".format(self.cell_form_factor,
self.num_cells,
self.fill_level,
self.serial_number)
for idx in range(0, self.num_cells):
retval += " {} : {}\n".format(idx, str(self[idx]))
return retval
|
It is not possible to create a representational view of a BatteryPack
object. Instead, per Python convention, the object type and address in memory, inside angle brackets <>
, is returned:
1 2 3 4 5 6 | def __repr__(self):
"""Create an eval'able representation of the pack"""
# In this case it is not possible to create something
# that can be passed to the eval method, so, just return
# identifying information.
return "<object BatteryPack at {:#x}>".format(id(self))
|
Finally, our model of a battery pack requires that it be possible to compare two battery packs for equality. Two battery packs are considered equal if:
- The items being compared are both
BatteryPacks
, and,- Both battery packs use cells with the same form factor, and,
- Both battery packs hold the same number of cells
- Both battery packs have the same number of cells loaded
- The cells in each spot have the same serial number
This is handled using the special method __eq__()
as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | def __eq__(self, other):
"""Determines whether two BatteryPack objects contain the same cells."""
# Reimplement to check that all cells in one pack have the
# same serial numbers, in the same slot, as in the other pack.
if not isinstance(other, BatteryPack):
# Attempt to compare with non-BatteryPack object
return NotImplemented
elif self.cell_form_factor != other.cell_form_factor:
# Packs hold cells of different sizes
return False
elif self.num_cells != other.num_cells:
# Packs are of different lengths
return False
elif self.fill_level != other.fill_level:
# Packs are same length but different number of
# cell are loaded
return False
else:
# Compare individual cells
for idx in self:
if self[idx].serial_number != other[idx].serial_number:
# Found cells in the same slot with different SN
return False
else:
# All loaded slots have cells of the same SN
return True
|
Container Emulation¶
The Python Data Model defines several functions, that when implemented/overridden, allow a data type to emulate a container. They are listed here. Those methods relevant to the BatteryPack will be overridden to customize it.
Testing whether a BatteryPack
object contains a specific Battery
object using the in
operator is handled by the __contains__()
method. The list
class has already defined this method. However, for the BatteryPack
class, the batteries are compared by serial number and only Battery
objects are accepted for comparison. These specifics are not handled by the version in the list
class. This is therefore accomplished by overriding __contains__()
as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # ----------------------------------------------------------------------------------
# Emulate container types
def __contains__(self, item):
"""Tests whether item is in the battery pack based on its serial number."""
if not (isinstance(item, Battery)):
# Can't check for non-battery type in pack
raise ValueError("Can only check for items of type Battery.")
for cell in self:
if cell is not None and (cell.serial_number == item.serial_number):
# Found a cell with the same serial number in the pack
return True
else:
# Not cell with the same serial number found
return False
|
To use the item access operator, []
, Python requires the presence of the following methods (the interface if you will):
__getitem__()
__setitem__()
__delitem__()
The list
version of __getitem__()
is sufficient for our needs as it just returns the cell at the given index.
However, the BatteryPack
is fixed length and it only accepts Battery
objects. This requires extra validation and handling logic not present in the base class version of __setitem__()
. It therefore needs to be overridden as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | def __setitem__(self, key, value):
"""Inserts a battery into the pack at the given index."""
# Slicing not supported and only Battery objects can be inserted.
if isinstance(key, slice) or not (isinstance(value, Battery)):
raise ValueError("Can only set single items of type Battery.")
elif self[key] is not None:
# Battery slot is occupied already
raise IndexError("Attempt to add battery to a slot that has a battery.")
elif value.form_factor != self.cell_form_factor:
# Item is the wrong form factor
raise ValueError("Cell needs to be of size {}".format(self.cell_form_factor))
elif value in self:
# Attempt to add same battery more than once
raise ValueError("Cell is already present in the pack")
else:
# Good to go! Use __setitem__() machinery from base class
# to actually insert the item and update the list.
# WARN: Can't use self[key] = value or it will be recursive!
super().__setitem__(key, value)
|
Note
Once the validation is complete, actually adding the item to the pack is turned over to the base class __setitem__
logic so that it is what actually handles interacting with and manipulating the stored sequence!
Similarly, deleting a Battery
from the pack is not allowed to alter the total length of the pack. Instead, the Battery
object must be replaced with None
. The __delitem__()
method is overridden to accomplish this as follows:
1 2 3 4 5 6 | def __delitem__(self, key):
"""Removes the battery at the given index from the pack."""
# NOTE: Unlike the regular delitem method, which shrinks the collection,
# BatteryPack is fixed length, so, 'None' is inserted instead
# in order to maintain the length.
super().__setitem__(key, None)
|
List Emulation¶
The list
class defines some extra methods that need to be adjusted to deal with Battery
objects.
The index()
method needs to be altered to only search for Battery
objects and compare them by serial number. It is accomplished as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # ----------------------------------------------------------------------------------
# Emulate the list class but specialize it to handle only items
# derived from Battery, and of the same form factor as what the
# pack supports.
def index(self, item, start=0, stop=-1):
"""Find item in the pack by SN, on or after start, and before stop."""
if not (isinstance(item, Battery)):
raise ValueError("Can only search for items of type Battery.")
# Convert the start/stop indices to always be positive
xstart = start if start >= 0 else self.num_cells + start
xstop = stop if stop >= 0 else self.num_cells + stop + 1
for idx in range(xstart, xstop):
if self[idx] is not None and (self[idx].serial_number == item.serial_number):
return idx
else:
raise ValueError("Battery is not in pack")
|
The remove
method needs to be altered to search for Battery
objects and only remove them from the pack if the serial number matches that Battery
object supplied. This is accomplished as follows:
1 2 3 4 5 6 7 8 9 10 11 | def remove(self, item):
"""Removes the given battery from the pack."""
# Note: Unlike the original remove(), the battery pack length
# must be kept fixed.
if not (isinstance(item, Battery)):
raise ValueError("Can only remove items of type Battery.")
# Get the index of the battery in the pack
idx = self.index(item)
# Use __delitem__() as it already handles removing items
# without altering the length of the pack.
self.__delitem__(idx)
|
Note
Since the index()
and __delitem__()
methods was customized to work with Battery
objects, the calls involving Battery
objects work seamlessly!
The count
method needs to be altered to deal with the fact that a Battery
object with a given serial number, can only be added at most, one time. This is accomplished as follows:
1 2 3 4 5 6 7 8 9 10 | def count(self, item):
"""The total number of occurrences of the battery in the pack."""
# Find the number of times item occurs in the pack (can be at most 1
# because you can't add the same cell more than once).
if not (isinstance(item, Battery)):
raise ValueError("Can only count items of type Battery.")
elif item in self:
return 1
else:
return 0
|
Note
Since the __contains__()
method was customized to work with Battery
objects, the in
operator works seamlessly!
Finally, the sort
method needs to be altered to deal with the fact that the sort order is determined by the Battery
objects serial number, not its value. This is easily accomplished by changing the default sort key, as follows:
1 2 3 4 5 6 | def sort(self, *, key=lambda x: x.serial_number, reverse=False):
"""Sorts the list, by default based on the cell serial number"""
# Check that all cells are occupied or can't do a sort
# Have the super-class machinery do the sort
super().sort(key=key, reverse=reverse)
|
Unimplementing Methods¶
There are several methods of the list`
class that don’t make sense for a BatteryPack
object. These are outlined below:
Method
|
Reason
|
---|---|
append()
extend()
|
BatteryPack is fixed length.
|
__add__()
__iadd__()
__radd__()
|
BatteryPack objects can’t connect together
(you could probably argue otherwise)
|
__mul__()
__imul__()
__rmul__()
|
No known understanding of multiplying two
power sources together.
|
To deal with this, we raise a NotImplementedError
exception if an attempt is made to use them, as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | # ----------------------------------------------------------------------------------
# Unimplement methods that don't make sense for BatteryPack objects.
def append(self, item):
"""Appends item to the end of the pack."""
# Unsupported because the BatteryPack is fixed length.
raise NotImplementedError("append() is not supported by the BatteryPack class.")
def extend(self, other):
"""Not implemented for BatteryPack objects"""
# Unsupported because the BatteryPack is fixed length.
raise NotImplementedError("extend() is not supported by the BatteryPack class.")
def __add__(self, other):
"""Not implemented for BatteryPack objects"""
# Adding BatteryPacks together is not supported for BatteryPack objects
raise NotImplementedError("__add__() is not supported by the BatteryPack class.")
def __iadd__(self, other):
"""Not implemented for BatteryPack objects"""
# Adding BatteryPacks together is not supported for BatteryPack objects
raise NotImplementedError("__iadd__() is not supported by the BatteryPack class.")
def __radd__(self, other):
"""Not implemented for BatteryPack objects"""
# Adding BatteryPacks together is not supported for BatteryPack objects
raise NotImplementedError("__radd__() is not supported by the BatteryPack class.")
def __mul__(self, other):
"""Not implemented for BatteryPack objects"""
# Repetition not supported for BatteryPack objects.
raise NotImplementedError("__mul__() is not supported by the BatteryPack class.")
def __imul__(self, other):
"""Not implemented for BatteryPack objects"""
# Repetition not supported for BatteryPack objects.
raise NotImplementedError("__imul__() is not supported by the BatteryPack class.")
def __rmul__(self, other):
"""Not implemented for BatteryPack objects"""
# Repetition not supported for BatteryPack objects.
raise NotImplementedError("__rmul__() is not supported by the BatteryPack class.")
|
BatteryPack Class Diagram¶
After defining all the variables and methods above, Fig. 51 represents the class diagram of the BatteryPack class that has been created:
For the most part, the customizations consisted of the following types of changes:
- Adding additional instance properties to model a battery pack.
- Re-implementing instance methods to validate inputs to ensure only
Battery
objects were passed. - Un-implementing instance methods that don’t make sense for
BatteryPack
objects.
In all cases, all sequence manipulation operations are delegated to the parent class method so we never had to concern ourselves with that. Further more, methods like __iter__()
are inherited from the base class and allow our BatteryPack
class to be used just like list
objects!
Playing with Battery Packs¶
>>> #
>>> # Import the classes we need
>>> #
>>> from BatteryPack import BatteryPack
>>> from KR6 import KR6
>>> from HR14 import HR14
>>> from LR4 import LR4
>>> #
>>> # Create a BatteryPack object
>>> #
>>> pack0 = BatteryPack(4, "AA", "XR4Ti")
>>> #
>>> # Get the representational form
>>> #
>>> repr(pack0)
'<object BatteryPack at 0x7f32fef5dbd8>'
>>> #
>>> # Print the pack to see what is returned
>>> #
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:0 SN:XR4Ti
0 : None
1 : None
2 : None
3 : None
>>> #
>>> # Explore the instance variables
>>> #
>>> pack0.num_cells
4
>>> pack0.cell_form_factor
'AA'
>>> pack0.serial_number
'XR4Ti'
>>> pack0.fill_level
0
>>> #
>>> # Fill the pack with cells
>>> #
>>> pack0[0] = KR6("ADF", 0)
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:1 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : None
2 : None
3 : None
>>> pack0[1] = LR4("MRT14", 25)
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:2 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
2 : None
3 : None
>>> pack0[2] = LR4("AJK", 50)
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:3 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
2 : 1.124v (Charge:50% Size:AA Type:Alkaline SN:"AJK")
3 : None
>>> pack0[3] = LR4("UTW", 75)
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:4 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
2 : 1.124v (Charge:50% Size:AA Type:Alkaline SN:"AJK")
3 : 1.2035v (Charge:75% Size:AA Type:Alkaline SN:"UTW")
>>> #
>>> # Check the fill level again
>>> #
>>> pack0.fill_level
4
>>> #
>>> # Try sorting the batteries in the pack
>>> #
>>> pack0.sort()
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:4 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.124v (Charge:50% Size:AA Type:Alkaline SN:"AJK")
2 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
3 : 1.2035v (Charge:75% Size:AA Type:Alkaline SN:"UTW")
>>> #
>>> # Examine some properties of individual cells in the pack
>>> #
>>> pack0[2].form_factor
'AA'
>>> pack0[2].percent_charged
25
>>> pack0[2].voltage
1.042
>>> float(pack0[2])
1.042
>>>
>>> #
>>> # Get total voltage of pack
>>> #
>>> pack0.voltage
4.2695
>>> #
>>> # Search for cells in the pack
>>> #
>>> LR4("MRT14") in pack0
True
>>> pack0.index(LR4("MRT14"))
2
>>> pack0.count(LR4("MRT14"))
1
>>> #
>>> # Remove cells from the pack
>>> #
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:4 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.124v (Charge:50% Size:AA Type:Alkaline SN:"AJK")
2 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
3 : 1.2035v (Charge:75% Size:AA Type:Alkaline SN:"UTW")
>>> del pack0[3]
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:3 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : 1.124v (Charge:50% Size:AA Type:Alkaline SN:"AJK")
2 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
3 : None
>>> pack0.remove(LR4("AJK"))
>>> print(pack0)
Cell Size:AA Num Cells:4 Filled:2 SN:XR4Ti
0 : 0.9v (Charge:0% Size:AA Type:NiCd SN:"ADF")
1 : None
2 : 1.042v (Charge:25% Size:AA Type:Alkaline SN:"MRT14")
3 : None
>>> pack0.count(LR4("AJK"))
0
>>> pack0.voltage
0
>>> #
>>> # Try comparing packs
>>> #
>>> pack1 = BatteryPack(4, "C", "QRTO4")
>>> pack1[0] = HR14("QD123", 22)
>>> pack1[1] = HR14("MKT55", 44)
>>> pack1[2] = HR14("ZRT19", 66)
>>> pack1[3] = HR14("JB999", 88)
>>> pack0 == pack1
False