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 = 400Tip
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 inmethod.__func__
)- A reference to the class (cls) or instance (self) that the function should operate on (in this case
inst
is stored inmethod__.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 thecls
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 theself
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 returnFalse
orTrue
.
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.
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.
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:
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.