Object-Oriented Programming Part-4 ================================== .. toctree:: :maxdepth: 1 Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` 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 :py:class:`list` class, inheriting the :py:class:`list` class' sequence management logic. 2 - Through composition, by creating a new ``BatteryPack`` class with a :py:class:`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. .. _section_heading-Defining_Modelling_A_Battery_Pack: Modelling a Battery Pack ------------------------ After inheriting from the :py:class:`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! .. _section_heading-The_Python_Data_Model: 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. .. _section_heading-Defining_The_BatteryPack_Class: Defining the BatteryPack Class ------------------------------ Our class definition will use single inheritance and inherit from list, as follows: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 1-5 :emphasize-lines: 4 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. .. _section_heading-Defining_Initializing_The_Battery_Pack_Class: Initializing the BatteryPack Class ---------------------------------- The :py:func:`__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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 11-23 :emphasize-lines: 3 Classes like the :py:class:`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 :py:class:`list`, it could have been used directly, but we needed to override it to create our instance data attributes. However, the :py:class:`list` still needs to be initialized!! The solution is to call the :py:class:`list` class' ``__init__`` directly to allow it to complete its initialization. Python allows child classes to call code in their parent class using the :py:func:`super` function. In the ``BatteryPack`` case, we use :py:func:`super` to call the init code of the :py:class:`list` class, and initialize the list to the pack length with :py:data:`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. .. _section_heading-BatteryPack_Properties: 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 25-40 :emphasize-lines: 4, 9, 14 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 42-45 :emphasize-lines: 2 In this case, we used Python's, :py:func:`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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 47-56 :emphasize-lines: 2 .. note:: We conveniently used :py:func:`sum`, because it understands what a :py:class:`list` is (and a ``BatteryPack`` is-a List). Also, recall that instance methods to handle addition were defined on the Battery class in a previous topic. So all the ground work has already been laid! .. _section_heading-Basic_Object_Customization: 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 58-68 :emphasize-lines: 3 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 70-75 :emphasize-lines: 1 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: 1) The items being compared are both ``BatteryPacks``, and, 2) Both battery packs use cells with the same form factor, and, 3) Both battery packs hold the same number of cells 4) Both battery packs have the same number of cells loaded 5) The cells in each spot have the same serial number This is handled using the special method ``__eq__()`` as follows: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 77-102 :emphasize-lines: 1 .. _section_heading-Container_Emulation: 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 :py:class:`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 :py:class:`list` class. This is therefore accomplished by overriding ``__contains__()`` as follows: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 104-117 :emphasize-lines: 3 To use the item access operator, ``[]``, Python requires the presence of the following methods (the interface if you will): * ``__getitem__()`` * ``__setitem__()`` * ``__delitem__()`` The :py:class:`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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 119-137 :emphasize-lines: 1 .. 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 :py:data:`None`. The ``__delitem__()`` method is overridden to accomplish this as follows: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 139-144 :emphasize-lines: 1 .. _section_heading-List_Emulation: List Emulation -------------- The :py:class:`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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 146-161 :emphasize-lines: 5 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 171-181 :emphasize-lines: 1 .. 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 183-192 :emphasize-lines: 1 .. 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: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 194-199 :emphasize-lines: 1 .. _section_heading-Unimplementing_Methods: Unimplementing Methods ---------------------- There are several methods of the :py:class:`list`` class that don't make sense for a ``BatteryPack`` object. These are outlined below: +-----------------+----------------------------------------------+ | | Method | | Reason | +=================+==============================================+ | | append() | | BatteryPack is fixed length. | | | extend() | | | +-----------------+----------------------------------------------+ | | __add__() | | BatteryPack objects can't connect together | | | __iadd__() | | (you could probably argue otherwise) | | | __radd__() | | | +-----------------+----------------------------------------------+ | | __mul__() | | No known understanding of multiplying two | | | __imul__() | | power sources together. | | | __rmul__() | | | +-----------------+----------------------------------------------+ To deal with this, we raise a :py:exc:`NotImplementedError` exception if an attempt is made to use them, as follows: .. literalinclude:: BatteryPropsPkg/BatteryPack.py :linenos: :lines: 203-243 :emphasize-lines: 3, 8, 13, 18, 23, 28, 33, 38 .. _section_heading-BatteryPack_Class_Diagram: BatteryPack Class Diagram ------------------------- After defining all the variables and methods above, :numref:`figure-batterypack_class_diagram` represents the class diagram of the BatteryPack class that has been created: .. _figure-batterypack_class_diagram: .. figure:: BatteryPropsPkg/batterypack_class_diagram.png :scale: 75 % :align: center BatteryPack Class Diagram 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 :py:class:`list` objects! .. _section_heading-Playing_With_BatteryPacks: 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) '' >>> # >>> # 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 .. _python_data_model: https://docs.python.org/3.5/reference/datamodel.html#data-model .. _basic_object_customization: https://docs.python.org/3.5/reference/datamodel.html#basic-customization .. _emulating_container_types: https://docs.python.org/3.5/reference/datamodel.html#emulating-container-types