Object-Oriented Programming Part-1

Indices and tables

Introduction

Object-oriented programming is a programming paradigm where the different types of data that move through your program, are modeled as free-standing, fully-functional, independent objects. This modeling is accomplish using entities called classes, which are defined by the programmer. Each class represents a blueprint for the object and becomes a distinct data-type in Python (just like str and int).

Classes have the following key principles:

  • Encapsulation

    • The data, and the functions that can operate on the data, are contained together as part of the object.
  • Inheritance

    • Classes can be based on, and extend, the behavior of previously defined classes.
    • The class being created (called the child, or derived, class) is said to inherit it’s behavior from its parent (or base) classes.
    • Python allows you to inherit from multiple parent classes at once (although this course will only cover single inheritance).
  • Composition and Aggregation

    • Classes can contain (i.e. be composed of, or be an aggregation of) objects of other data types in order to expand their behavior.

    Note

    Composition and aggregation are similar.

    Composition is a strong association, such as a car having an engine. Items in a composition are not usually found on their own and don’t typically survive the destruction of the object containing them (e.g. an engine is not usually running when not in a car).

    Aggregation is a loose association, such as a car having passengers that come and go. Items in an aggregation may be found on their own and typically survive the destruction of the containing object (e.g. passengers may still choose to ride in a car even if the car doesn’t work).

  • Polymorphism

    • Each object that implements an interface or method of its parent class, responds in its own unique way when that interface or method is used.
    • An object of a given class can be used as though it were an object of any of its class’s base classes
  • Relationships

    • A child class is often considered to have an “is-a” relationship with any parent classes it is derived from.
    • A child class is often considered to have a “has-a” relationship with any classes it as composed of or aggregated with.

Note

This class does not seek to train you on how to create good object-oriented programs. That is a huge topic. The goal of this class is to introduce to you the facilities available in Python for object-oriented programming.

Classes in Python are somewhat unique in that after definition, they can be further modified at run-time.

To illustrate the various object-oriented features of Python, we will build a Battery class (which models a battery), several batteries that make use of the base class, and a BatteryPack class (which models a sinking submarine, are you skimming) from the ground up.

We will use the following terminology:

  • class - The definition or blue print for creating an object.
  • class-object - The object that results from Python evaluating the class definition. It is the class definition as an object in Python. A class-object has no instance specific data.
  • class-instance - A unique object created based on the class blueprint, which can contain instance specific data.
  • object - Short-hand for class-instance, or, the ultimate base class object.

Defining a Class

Defining a class in Python is done using the class keyword as follows:

class NewClassName([parent_class[, parent_classes]*]):
   suite

A class definition creates its own namespace into which class variables and functions, which are shared by all instances of the class, are added into.

The root of the object hierarchy in Python3 is the object class. By default, if no parent classes are listed in the class definition, the new class will inherit from object. This ensures all classes have at least a minimal amount of standardized behavior.

Our Battery class, which only inherits from object, starts off as follows:

class Battery():
    """A base class from which other batteries are derived."""

Class Attributes

Any names defined inside the class definition are considered attributes of the class. There are 2 types of attributes possible:

  • Data attributes
  • function attributes

All attributes are public. There are no private attributes. However:

  • Use a leading _ as a convention to indicate a private attribute not for general use.
  • Use a leading __ to invoke name mangling to obscure its existence.

All attributes are virtual (and can be overridden by a child class), which is an important part of polymorphism.

Data Attributes

The type of data attribute (variable) being created has some dependence on where in the class definition the name is defined:

  • Class variables (static variables in other languages), which are created inside the class definition, but outside any function scope. They can be seen by all class instances. For example:
my_mod_var = 100

class MyClass():
   my_cls_var = 200

   def my_fn(self):
      my_local_var = 300
      self.my_inst_var = 400
  • Local variables, which are created inside a function, but, not attached to the object. They can only be seen by that function. For example:
my_mod_var = 100

class MyClass():
   my_cls_var = 200

   def my_fn(self):
      my_local_var = 300
      self.my_inst_var = 400
  • Instance variables, which are created inside an instance function and are attached to the object using the self variable. They can only be seen by that single class instance. For example:
my_mod_var = 100

class MyClass():
   my_cls_var = 200

   def my_fn(self):
      my_local_var = 300
      self.my_inst_var = 400

Tip

Data attributes come into existence when assigned to for the first time. That means, new variables can be created during run-time execution.

Function Attributes

Any function defined within a class, is considered a function attribute. All function attributes that are part of the class definition, are available via the class or an instance of the class.

When an object is created and you reference a function attribute (like inst.fn), Python performs a little magic. Python creates a method object, which contains two things:

  • A reference to the function attribute defined in the class, that is being referenced (in this case fn is stored in method.__func__)
  • A reference to the class (cls) or instance (self) that the function should operate on (in this case inst is stored in method__.self__)

Note

This means that instances don’t get their own copy of the function. They all refer to the function defined on the class. This saves memory by not duplicating the code for each function, in each instance.

If the method object is subsequently called (using ()), Python inserts the reference to the class (cls) or instance (self) as the first argument in the argument list provided by the call, and calls the function attribute with the updated argument list.

Most of the time referencing and calling are combined explicitly (like inst.fn()) so these steps all happen sequentially without interruption.

Why is this important? Because it underpins the handling of a big difference between the various types of functions you can define within a class and knowing this will hopefully help you understand the differences.

There are 3 types of function attributes you can create in a class:

  • Static methods, which are like regular functions and have no knowledge of the class or instance they are operating on. This type of method is created using the @staticmethod decorator. For example:

       class MyClass():
    
          @staticmethod
          def my_static_fn(some_arg):
             pass
    

    Tip

    Static methods can’t access class or instance variables. They are best used for simple utility functions.

  • Class methods, which take a cls argument as the first argument. Python will populate the cls argument with a reference to the class that the function is called on. This type of method is created using the @classmethod decorator. For example:

       class MyClass():
    
          @classmethod
          def my_class_fn(cls, some_arg):
             pass
    

    Tip

    The advantage of a class method over a static method is that by providing a reference to the class, the function can reference the class variables, and it can alter its behavior based on whether it is being called on the base class or one of the derived classes.

  • Instance methods, which take a self argument as the first argument. Python will populate the self argument with a reference to the instance that the function is called on. This type of method is created is the default inside a class and requires no decorator. For example:

       class MyClass():
    
          def my_inst_fn(self, some_arg):
             pass
    

    Tip

    Accessing attributes (data or function) from inside an instance function first looks up the attribute in the instance. If not found in the instance, the class is checked. You can force checking only the class using the self.__class__.attribute syntax.

An example of declaring the different function types and trying to retrieve a class variable, is shown below:

class TestCls():
    cls_var = 4

    @staticmethod
    def static_method():
        # Will fail because static methods
        # can't see class or instance variables
        print(cls_var)

    @classmethod
    def class_method(cls):
        print(cls.cls_var)

    def instance_method(self):
        # Two ways to get class variables from an instance
        # 1) Check instance first, then class
        print(self.cls_var)
        # 2) Force to only check the class
        print(self.__class__.cls_var)

When the various methods are called, they produce the following results:

>>> #
>>> # Invoke the static method
>>> #
>>> TestCls.static_method()
NameError: name 'cls_var' is not defined
>>> #
>>> # Invoke the class method
>>> #
>>> TestCls.class_method()
4
>>> #
>>> # Invoke the instance method
>>> #
>>> inst = TestCls()
>>> inst.instance_method()
4
4

Looking back at our Battery class, it has the following class attributes to describe a typical battery, which are available to all class instances and derived classes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Battery():
    """A base class from which other batteries are derived."""

    # Class variables which are meant to be overridden
    # by battery types derived from this class.

    # The number of batteries created
    num_created = 0
    # The size/shape of the battery. For example: AA, AAA, C
    form_factor = "Unknown"
    # The chemical composition of the cell that creates
    # the electrical charge. For example: Alkaline, NiCd, NiMH, LiPo
    chemistry = "Unknown"
    # The cell voltage when considered fully discharged
    discharged_voltage = 0
    # The cell voltage when considered fully charged
    charged_voltage = 0

    @classmethod
    def get_num_created(cls):
        """Returns the count of Battery objects created"""
        return cls.num_created

The instance attributes will be fleshed out below.

Creating an Object

Objects are created using function notation as follows:

name = ClassToInstantiate([arg[, args]* ])

Optionally, one or more args can be passed to the class-instance (the object) to aid in initializing it.

When an object is created, Python looks for the __init__ method and executes it to initialize the instance data for the class.

The first argument to __init__ is used to pass a reference to the newly created object. By convention, this argument is called self.

Python appends the arguments from the class instantiation call, to the call to __init__, after the self parameter. This data can optionally be attached to the instance by creating instance variables using the self argument.

Note

It is the arguments defined in definition of the __init__ function that specify the parameters required by the class instantiation call.

For example, the Battery class is initialized 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
class Battery():
    """A base class from which other batteries are derived."""

    # Class variables which are meant to be overridden
    # by battery types derived from this class.

    # The number of batteries created
    num_created = 0
    # The size/shape of the battery. For example: AA, AAA, C
    form_factor = "Unknown"
    # The chemical composition of the cell that creates
    # the electrical charge. For example: Alkaline, NiCd, NiMH, LiPo
    chemistry = "Unknown"
    # The cell voltage when considered fully discharged
    discharged_voltage = 0
    # The cell voltage when considered fully charged
    charged_voltage = 0

    @classmethod
    def get_num_created(cls):
        """Returns the count of Battery objects created"""
        return cls.num_created

    def __init__(self, serial_number, percent_charged=0):
        """Initializes new class instances"""
        self.serial_number = serial_number
        self.percent_charged = percent_charged
        self.__class__.num_created += 1

Fundamental Special Methods

Custom classes integrate better with the Python world if they define as many of the fundamental special methods as possible:

__str__(self)

Called by str(object) and the built-in functions format() and print() to compute the “informal” or nicely printable string representation of an object. The return value must be a string object.

__repr__(self)

Called by the repr() built-in function to compute the “official” string representation of an object. If possible, this should look like a valid Python expression that could be used to recreate an object with the same value.

__bool__(self)

Called to implement truth value testing and the built-in operation bool(); should return False or True.

We can easily define these for our Battery class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    # Fundamental Special Methods
    def __str__(self):
        """Prints the string representation of the battery."""
        return '{}v (Charge:{}% Size:{} Type:{} SN:"{}")'.format(self.get_voltage(),
                                                                 self.percent_charged,
                                                                 self.form_factor,
                                                                 self.chemistry,
                                                                 self.serial_number)

    def __repr__(self):
        """Create an eval'able representation of the object."""
        return '{}("{}", {})'.format(self.__class__.__name__,
                                     self.serial_number,
                                     self.percent_charged)

    def __bool__(self):
        """Return True if battery has any charge. Otherwise False"""
        return self.percent_charged() > 0

Instance Methods

The functionality of the data-type is typically modelled primarily with instance methods. Below are the instance methods of the Battery class, which perform operations you might commonly want to do on a battery.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    # Methods to get/set characteristics of the battery
    def get_form_factor(self):
        """Gets the battery's form factor"""
        return self.form_factor

    def get_chemistry(self):
        """Gets the battery's chemistry"""
        return self.chemistry

    def get_serial_number(self):
        """Gets the battery's serial number"""
        return self.serial_number

    def get_percent_charged(self):
        """Gets the battery's change level as a percentage of full charge"""
        return self.percent_charged

    def set_percent_charged(self, percent_charged):
        """Sets the battery's charge level as a percentage of full charge"""
        self.percent_charged = percent_charged

    def get_voltage(self):
        """Converts current charge level to a voltage"""
        return None

Comparison Special Methods

Defining the functions shown in Table 17 will tell Python how to handle objects of your class in comparison operations.

Table 17 Table of Comparison Special Methods
op Special Method
< __lt__(self, other)
<= __le__(self, other)
== __eq__(self, other)
!= __ne__(self, other)
> __gt__(self, other)
>= __ge__(self, other)

The comparison special methods should return True or False as the result of the comparison.

Note

It is not necessary to define __ne__ if you define __eq__. Python will just invert the result of calling __eq__.

These methods are easily defined for the Battery class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    # Comparison special methods
    def __eq__(self, other):
        """Test if two batteries are equal in voltage."""
        return self.get_voltage() == other.get_voltage()

    def __le__(self, other):
        """Test if self has a lower voltage than other."""
        return self.get_voltage() <= other.get_voltage()

    def __lt__(self, other):
        """Test if self has a lower voltage than other."""
        return self.get_voltage() < other.get_voltage()

    def __ge__(self, other):
        """Test if self has a higher voltage than other."""
        return self.get_voltage() >= other.get_voltage()

    def __gt__(self, other):
        """Test if self has a higher voltage than other."""
        return self.get_voltage() > other.get_voltage()

Numeric Special Methods

You can emulate numeric types by defining numeric special methods on your class. An example of these special methods is shown in Table 18. For the full set see here.

Table 18 Table of Numeric Special Methods
op
Special Method
int()
__int__(self)
float()
__float__(self)
+
__add__(self, other)
__radd__(self, other)
-
__sub__(self, other)
__rsub__(self, other)
*
__mul__(self, other)
__rmul__(self, other)
@
__matmul__(self, other)
__rmatmul__(self, other)
/
__truediv__(self, other)
__rtruediv__(self, other)
//
__floordiv__(self, other)
__rfloordiv__(self, other)
%
__mod__(self, other)
__rmod__(self, other)
divmod()
__divmod__(self, other)
__rdivmod__(self, other)
pow(), **
__pow__(self, other)
__rpow__(self, other
<<
__lshift__(self, other)
__rlshift__(self, other)
>>
__rshift__(self, other)
__rrshift__(self, other)
&
__and__(self, other)
__rand__(self, other)
|
__or__(self, other)
__ror__(self, other)
^
__xor__(self, other)
__rxor__(self, other)

Note

The “__r*__” versions of the methods above are called the “reflected” methods. They are automatically called by Python when your custom object is the second object in the expression and the first object in the expression can’t handle your custom object. For example:

y = 4 + my_obj

If int doesn’t know how to handle your custom object type, Python would call __radd__(my_obj, 4) in the hopes it could handle integers and allow the operation to complete.

It is desireable to obtain the voltage of the battery when the int() and float() functions are used on it. It is also intuitive to be able to add and subtract battery voltages, so, they are added to the Battery class as shown below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    # Numeric special methods
    def __int__(self):
        """Return an integer voltage value."""
        return int(self.get_voltage())

    def __float__(self):
        """Return a floating-point voltage value."""
        return float(self.get_voltage())

    def __add__(self, other):
        """Returns the total voltage of the battery plus another numeric"""
        return float(self) + float(other)

    def __radd__(self, other):
        """Returns the total voltage of the battery plus another numeric"""
        return float(self) + float(other)

    def __sub__(self, other):
        """Returns the total voltage of the battery minus another numeric"""
        return float(self) - float(other)

    def __rsub__(self, other):
        """Returns the total voltage of the battery minus another numeric"""
        return float(self) - float(other)

Note

By using the float() function, our add and subtract methods can easily handle Battery objects together with regular integer / float objects all in the same one line of code!

Functors

Objects can be made callable by defining the following function:

__call__(self [, args]*)

Called when the instance is “called” as a function.

While this may not be directly applicable to modeling a Battery, we will still define one for demonstration purposes:

1
2
3
4
5
    # Support callable instances
    def __call__(self):
        """Discharges the battery like it was shorted"""
        self.percent_charged = 0
        print("Battery discharged")

Battery Class Diagram

After defining all the variables and methods above, Fig. 49 represents the class diagram of the Battery class that has been created:

../../_images/battery_class_diagram.png

Fig. 49 Battery Class Diagram

Creating Battery Objects

We can create instances of the Battery class and explore it:

>>> #
>>> # Import the battery package
>>> #
>>> from BatteryOrigPkg.Battery import Battery
>>> #
>>> # Make the battery
>>> #
>>> base_battery1 = Battery("BB0194", 50)
>>> #
>>> # Get the eval'able form of the battery
>>> #
>>> repr(base_battery1)
'Battery("BB0194", 50)'
>>> #
>>> # Get the human readable string of the battery
>>> #
>>> print(base_battery1)
Nonev (Charge:50% Size:Unknown Type:Unknown SN:"BB0194")
>>> #
>>> # Call some of its instance methods
>>> #
>>> base_battery1.get_num_created()
1
>>> base_battery1.get_form_factor()
'Unknown'
>>> base_battery1.get_chemistry()
'Unknown'
>>> base_battery1.get_serial_number()
'BB0194'

And so forth…

Creating another instance of the Battery class increments the class instance counter:

>>> base_battery2 = Battery("JF29", 25)
>>> base_battery2.get_num_created()
2

And we can also get the instance count directly from the class:

>>> Battery.get_num_created()
2

Finally, we can quickly discharge the battery by calling an instance of it:

>>> #
>>> # Query the state of the battery
>>> #
>>> print(base_battery2)
Nonev (Charge:25% Size:Unknown Type:Unknown SN:"JF29")
>>> #
>>> # Discharge the battery
>>> #
>>> base_battery2()
Battery discharged
>>> #
>>> # Query the state of the battery again
>>> #
>>> print(base_battery2)
Nonev (Charge:0% Size:Unknown Type:Unknown SN:"JF29")

We can’t yet call the comparisons and numeric operations without generating an error as the get_voltage() method on the base class currently returns None. That will be fixed in the future.