Lecture 5 - Object-Oriented Programming¶
5.1 Overview¶
Object-oriented programming (OOP) is a programming approach to structuring programs based on the concept of objects, which can contain data and behavior in the form of attributes and methods, respectively. For instance, an object could represent a person with attributes like name, age, and address, and methods such as walking, talking, and running. Or, it could represent an email with attributes like a recipient list, subject, and body, and methods like adding attachments and sending.
In OOP, computer programs are designed by defining objects that interact with one another. In Python, the main tool for achieving OOP are Python classes. Classes are created using the class
statement. Then, from classes we can construct object instances, which are specific objects created from a particular class.
Besides OOP, other programming paradigms on which other languages are based include procedural, functional, and logic programming paradigms.
5.2 Defining a Class¶
The class
Statement¶
Let’s define a class Dog
by using the class
statement and the name of the class. It is a convention in Python to begin class names with an uppercase letter, and module and function names with a lowercase letter. This is not a requirement, but if you follow this naming convention it will be appreciated by others who are to use your codes.
[1]:
# Create a new class called Dog
class Dog:
"""Class object for a dog."""
pass
[2]:
# Create an instance object of the class Dog
sam = Dog()
print(type(sam))
<class '__main__.Dog'>
In the above code, inside the class definition we currently have just the pass
command, which is only a placeholder for the code that we intend to write afterwards and means do nothing for now.
Classes can be thought of as blueprints for creating objects. When we defined the class Dog
using the line class Dog:
, we didn’t actually create an object.
To create an object of the class Dog
, we called the class by its name and a pair of parentheses (and optionally we can pass arguments in the parentheses as we did with functions in Python). That is, we instantiated the Dog class, and sam
is now the reference to our new instance of the Dog class. Or, the action of creating instances (objects) from an existing class is known as instantiation.
The name sam
is referred to as a class instance, or instance object, or just an instance. We will use these terms interchangeably.
We can create many instances of the class by calling the class with Dog()
. For example, below we created two Dog instances sam
and frank
. Note that although they are both instances of the class Dog, they represent two distinct objects.
[3]:
sam = Dog()
frank = Dog()
sam == frank
[3]:
False
Note below that the two instances sam
and frank
have different memory addresses, shown after at
in the cell outputs. The addresses for these two instances in your computer’s memory will be different than those shown here.
[4]:
sam
[4]:
<__main__.Dog at 0x2c132164160>
[5]:
frank
[5]:
<__main__.Dog at 0x2c132164550>
In summary:
Classes serve as instance factories. They provide attributes and methods that are inherited by all the instances created from them.
Instances represent the concrete objects of a class. Their attributes consist of information that varies per specific object. Their methods describe behavior that is different for specific objects.
Class objects can have attributes and methods.
An attribute is an individual characteristic of an instance of the class. Or, attributes are variables that hold class data.
A method is an operation that is performed with the instance of the class. Or, methods are functions that provide behavior to class objects.
5.2.1 Attributes¶
As we explained, attributes allow us to attach data to class objects. Python classes can have two types of attributes: instance attributes and class attributes.
Instance Attributes¶
In Python, instance attributes are defined by using the __init__()
constructor method. The term init is abbreviated from initialize, since it is used to initialize the attributes of class instances.
In the parentheses of the __init__()
method first self
is listed, and afterwards the attributes are listed.
The syntax for creating attributes is:
def __init__(self, attribute1, attribute2, ...):
self.attribute1 = attribute1
self.attribute2 = attribute2
For example:
[6]:
class Dog:
def __init__(self, breed, name):
self.breed = breed
self.name = name
[7]:
# Create an instance from the 'Dog' class by passing breed and name, assign it to the name 'sam'
sam = Dog(breed='Labrador', name='Sam')
# Create another instance from the 'Dog' class by passing breed and name, assign it to the name 'frank'
frank = Dog(breed='Huskie', name='Frank')
# Create another instance from the 'Dog' class by passing breed and name, assign it to the name 'my_dog'
my_dog = Dog(breed='Terrier', name='Scooby')
The __init__()
method is present in almost every class, and it is used to initialize newly created class instances by passing attributes. In the above example, the attributes breed and name are the arguments to the special method __init__()
. Each attribute in a class definition begins with a reference to the class instance, which by convention is named self
, such as in self.breed = breed
.
When we created the instances of the class Dog, __init__()
initialized these objects by passing the assigned values for breed and name to the instances sam, frank, and my_dog. In the __init__()
method, the word self
is the newly created instance. Therefore, for the instance sam, the line self.breed = breed
is equivalent to stating sam.breed = 'Labrador'
. Similarly, self.name = name
is equivalent to stating sam.name = 'Sam'
. Similarly, for the instance
frank, self.breed = breed
is equivalent to stating frank.breed = 'Huskie'
and self
refers to the instance frank.
Notice again that sam
, frank
, and my_dog
are three separate instances of the Dog
class, and they have their own attributes, i.e., different breed and name.
Accessing Instance Attributes¶
The syntax for accessing an attribute of a class instance uses the dot operator.
instance.attribute
We can therefore access the attributes breed
and name
as in the next examples.
[8]:
# Access the 'breed' attribute of the class instance 'sam'
sam.breed
[8]:
'Labrador'
[9]:
# Access the 'breed' attribute of the class instance 'frank'
frank.breed
[9]:
'Huskie'
[10]:
# Access the 'name' attribute of the class instance 'my_dog'
my_dog.name
[10]:
'Scooby'
Note that we cannot access the instance attributes through the class, as in class.attribute
, since they are specific to concrete instances of the class. If we try to do that, we will get an Attribute Error.
[11]:
Dog.name
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\4053790346.py in <module>
----> 1 Dog.name
AttributeError: type object 'Dog' has no attribute 'name'
In general, it is possible to create new classes without the __init__()
construction method. This is shown below, where we used a def
statement to introduce a method called enterinfo
to the class Dog
.
[12]:
class Dog:
def enterinfo(self, breed):
self.breed = breed
[13]:
sam = Dog()
sam.enterinfo(breed='Labrador')
[14]:
sam.breed
[14]:
'Labrador'
However, in this case, we need to first create a new class instance sam
as shown above, and afterward assign the breed
attribute using the enterinfo()
method.
On the other hand, by using the __init__()
method, we can initialize the instance attributes at the same time when the new instance is created. Therefore, using __init__()
is preferred and always recommended. Without __init__()
, an empty instance is created, and we need to initialize it afterwards.
In addition, we can dynamically attach new instance attributes to existing class objects that we have already created. In the next cell, the new attribute age
is attached to the instance sam
. However, it is preferred to define instance attributes inside the class definition, since it makes the code more organized and makes it easier for others to understand or debug our code.
[15]:
sam.age = 3
print(sam.age)
3
Modifying Instance Attributes¶
We can modify the attributes of an instance by using the dot .
notation and an assignment statement, as in:
instance.attribute = new_value
[16]:
frank.name
[16]:
'Frank'
[17]:
# Modify attribute
frank.name = 'Franki'
frank.name
[17]:
'Franki'
To delete any instance attribute, use the del
keyword.
[18]:
del frank.name
[19]:
# Error, the name attribute does not exist for 'frank'
print(frank.name, frank.breed)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\2831644747.py in <module>
1 # Error, the name attribute does not exist for 'frank'
----> 2 print(frank.name, frank.breed)
AttributeError: 'Dog' object has no attribute 'name'
The above code does not delete the attribute name
for the other class instances.
[20]:
# The name attribute still exist for 'my_dog'
my_dog.name
[20]:
'Scooby'
Class Attributes¶
Class attributes in Python are also referred to as class object attributes. The class attributes are the same for all instances of the class.
For example, we could create the attribute species for the Dog class, as shown in the next cell. Regardless of their breed, name, or other attributes, all dog instances will have the attribute species = 'mammal'
. The instances of the class Dog in the next cell also have the instance attributes breed
and name
which can be unique for each class instance.
We apply this logic in the following manner.
[21]:
class Dog:
# Class attribute
species = 'mammal'
# Instance attributes
def __init__(self, breed, name):
self.breed = breed
self.name = name
[22]:
# Create an instance from the 'Dog' class by passing breed and name
sam = Dog('Labrador','Sam')
Accessing class attributes is the same as accessing instance attributes.
[23]:
# Access class attributes
sam.species
[23]:
'mammal'
[24]:
# Access instance attributes
sam.name
[24]:
'Sam'
Note that the class attribute species
is defined directly in the body of the class
definition, outside of any methods in the class. Also by convention, the class attributes are placed before the __init__()
method.
Also, we can access class attributes through the class via class.attribute
, as in the next example.
[25]:
Dog.species
[25]:
'mammal'
Modifying Class Attributes¶
We cannot modify class attributes via assignment to class instances. In the next example, we used an assignment statement frank.species = 'bird'
to modify the attribute species
of the class instance frank
to bird
.
[26]:
frank = Dog(breed='Huskie', name='Frank')
[27]:
frank.species
[27]:
'mammal'
[28]:
# Reassing the attribute 'species' to 'bird'
frank.species = 'bird'
This didn’t change the class attribute for the newly created class instance my_dog
, as shown below. Instead, the reassignment frank.species = 'bird'
created a new instance attribute species
for the class instance frank
that has the same name as the class attribute species
.
[29]:
my_dog = Dog(breed='Terrier', name='Scooby')
[30]:
# The class attribute of the new instance is still 'mammal'
my_dog.species
[30]:
'mammal'
We can change the class atribute via assignment when using the class name, as shown in the next example.
[31]:
Dog.species = 'animal'
[33]:
sam = Dog('Labrador','Sam')
[34]:
sam.species
[34]:
'animal'
In summary:
Class attributes are defined in the body of the
class
definition directly. Class attributes are common to the class. Their data is the same for all instances of the class.Instance attributes are defined inside the
__init__()
method within theclass
definition. Instance attributes belong to a concrete instance of the class. Their data is specific to that concrete instance of the class.
The __dict__
Attribute¶
Both classes and instances in Python have a special attribute called __dict__
. This attribute is a dictionary, with the keys being the attribute names and the values are the attached attribute values. For a class instance __dict__
holds the instance attributes, and for a class __dict__
holds class attributes and methods.
[35]:
my_dog.__dict__
[35]:
{'breed': 'Terrier', 'name': 'Scooby'}
[36]:
frank.__dict__
[36]:
{'breed': 'Huskie', 'name': 'Frank', 'species': 'bird'}
[37]:
Dog.__dict__
[37]:
mappingproxy({'__module__': '__main__',
'species': 'animal',
'__init__': <function __main__.Dog.__init__(self, breed, name)>,
'__dict__': <attribute '__dict__' of 'Dog' objects>,
'__weakref__': <attribute '__weakref__' of 'Dog' objects>,
'__doc__': None})
Python also allows to change the value of existing instance attributes through __dict__
, or even to add new attributes through __dict__
.
5.2.2 Methods¶
Methods are functions defined inside the body of a class. By defining it inside the class, we establish a relationship between the method and the class. Because methods are functions, they can take arguments and return values.
In a Python class, we can define three different types of methods:
Instance methods, which take the current instance
self
as their first argument.Class methods, which take the current class
cls
as their first argument.Static methods, which take neither the class nor the instance.
This section describes instance methods, as the most common type of methods in classes. Class methods and static methods are described in the Appendix section.
Instance Methods¶
Instance methods are functions defined inside the body of a class, designed to perform operations on the class objects.
Methods have access to all attributes for an instance of the class. They can access and modify the attributes through the argument self
.
We can basically think of methods as regular functions, with one major difference that the first argument of the method is always the instance object referenced through self
.
Technically, even the word self
is a convention, and any other term can be used instead of self
. However, if you use another word, that would be very unusual for other coders using your code.
Let’s see an example of creating a class for a Circle
shown below. The objects of this class have three methods: getArea
which calculates the area, getCircumference
which calculates the circumference, and SetRadius
which allows to change the attribute radius
.
[38]:
class Circle:
pi = 3.14
# Circle gets instantiated with a radius (default is 1)
def __init__(self, radius=1):
self.radius = radius
# Method for getting Area
def getArea(self):
return self.radius * self.radius * self.pi
# Method for getting Circumference
def getCircumference(self):
return self.radius * self.pi * 2
# Method for resetting Radius
def setRadius(self, new_radius):
self.radius = new_radius
Notice that within the methods getArea
and getCircumference
we used the notation self.radius
to reference the instance attribute radius
which we defined with the __init__
method inside the body of the class.
Similarly, we defined the class attribute pi = 3.14
inside the body of the class, and since we can access it as an attribute of new instances, we used the notation self.pi
to reference it within the methods getArea
and getCircumference
. As we explained in the previous section, we can also access class attributes through the class name, that is, we could have used the notation Circle.pi
within the methods getArea
and getCircumference
to get access to the class
attribute pi
.
The two methods getArea
and getCircumference
don’t take any other arguments except self
. The method setRadius
takes another argument new_radius
, and it allows to change the value of the current attribute radius
.
The methods are accessed by using the dot notation.
instance.method()
[39]:
# Let's call it
c = Circle()
print('Radius is: ', c.radius)
print('Area is: ', c.getArea())
print('Circumference is: ', c.getCircumference())
Radius is: 1
Area is: 3.14
Circumference is: 6.28
Now let’s change the radius with the method setRadius
and see how that affects the Circle
object:
[40]:
c.setRadius(3)
print('Radius is: ', c.radius)
print('Area is: ', c.getArea())
print('Circumference is: ', c.getCircumference())
Radius is: 3
Area is: 28.26
Circumference is: 18.84
Notice again in the above cell that when we call getArea
and getCircumference
methods, we don’t need to provide a value for the self
argument. Python takes care of that step, and it automatically passes the class instance to self
.
However, if we wish, we can manually provide the desired class instance when calling these methods. To do this though, we need to call the method on the class, as shown next.
[41]:
Circle.getCircumference(c)
[41]:
18.84
If we try to call the methods on the instance c
, that will raise an exception.
[42]:
c.getCircumference(c)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\521085191.py in <module>
----> 1 c.getCircumference(c)
TypeError: getCircumference() takes 1 positional argument but 2 were given
Shown next is one more example of a class Customer
with attributes name
and balance
, and methods withdraw
and deposit
.
[43]:
class Customer():
"""A customer of ABC Bank with a checking account. Customers have the
following properties:
Attributes:
name: A string representing the customer's name.
balance: A float tracking the current balance of the customer's account.
"""
def __init__(self, name, balance=0.0):
"""Return a Customer object whose name is *name* and starting
balance is *balance*."""
self.name = name
self.balance = balance
def withdraw(self, amount):
"""Return the balance remaining after withdrawing *amount*
dollars."""
if amount > self.balance:
raise RuntimeError('Amount greater than available balance.')
self.balance -= amount
return self.balance
def deposit(self, amount):
"""Return the balance remaining after depositing *amount*
dollars."""
self.balance += amount
return self.balance
[44]:
# Create a new instance
bob = Customer('Bob Smith', 1000)
[45]:
bob.withdraw(100)
[45]:
900
[46]:
bob.deposit(400)
[46]:
1300
[47]:
# Based on the exception in the 'withdraw' method
bob.withdraw(1600)
---------------------------------------------------------------------------
RuntimeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\323204834.py in <module>
1 # Based on the exception in the 'withdraw' method
----> 2 bob.withdraw(1600)
~\AppData\Local\Temp\ipykernel_32936\2930014863.py in withdraw(self, amount)
18 dollars."""
19 if amount > self.balance:
---> 20 raise RuntimeError('Amount greater than available balance.')
21 self.balance -= amount
22 return self.balance
RuntimeError: Amount greater than available balance.
Polymorphism in Classes¶
We learned about polymorphism in functions, and we saw that when functions take in different arguments, the actions depend on the type of objects. Similarly, in Python polymorphism exists with classes, where different classes can share the same method name, and these methods can perform different actions based on the object they act upon.
Let’s see an example.
[48]:
class Dog:
def __init__(self, name):
self.name = name
def speak(self):
return self.name+' says Woof!'
class Cat:
def __init__(self, name):
self.name = name
def speak(self):
return self.name+' says Meow!'
niko = Dog('Niko')
felix = Cat('Felix')
print(niko.speak())
print(felix.speak())
Niko says Woof!
Felix says Meow!
Here we have a Dog
class and a Cat
class, and each has a speak()
method. When called, each object’s speak()
method returns a result that is unique to the class of the instance. This demonstrated polymorphism, because we passed in different object types to the speak()
method, and we obtained object-specific results from the same method.
Naming Conventions in Classes¶
The recommended naming convention for Python classes is to use capitalized names, and for longer names each word is capitalized and connected without underscores. For example, examples of classes in the machine learning library Keras
include Conv2DTranspose
, CheckpointCallback
, BatchNormalization
, etc.
Another naming convention is to include a leading underscore in the names of attributes and methods (e.g., _radius
, _calculate_area()
in the class Circle
) to communicate them as non-public attributes and methods. All regular names (such as radius
and calculate_area()
) are public attributes and methods.
Public members are intended to be part of the official interface or API of the classes, while non-public members are not intended to be part of the API. This naming convention indicates that the non-public members should not be used outside their defining class. However, the naming convention does not prevent direct access. Non-public members exist only to support the internal implementation of a given class and may be removed at any time, so we should not rely on them.
5.3 Inheritance¶
For our programs to be truly object-oriented, it is required that they use inheritance hierarchy. Inheritance is the process of creating a new class by reusing the attributes and methods from an existing class. This way, we can edit only what we need to modify in the new class, and this will override the behavior of the old class.
The newly formed inheriting class is known as a subclass or child class or derived class, and the class it inherits from is known as a superclass or parent class or base class.
Important benefits of inheritance are code reuse and reduction of complexity of a program, because the child classes override or extend the functionality of parent classes.
Let’s see an example by incorporating inheritance. In this example, we have four classes: Animal
, Dog
, Cat
, and Fish
. The Animal
is the parent class (superclass), and Dog
, Cat
, and Fish
are the child classes (subclasses).
Note that when defining the child classes Dog
, Cat
, and Fish
, the parent class Animal
is listed in parentheses in the class header, i.e., class Dog(Animal)
.
[49]:
class Animal:
def __init__(self):
print("Animal created")
def whoAmI(self):
print("Animal")
def eat(self):
print("Eating")
class Dog(Animal): # The class Dog inherits the functionalities of the class Animal
def __init__(self):
Animal.__init__(self) # This can also be replaced with: super().__init__()
print("Dog created")
def whoAmI(self):
print("Dog")
def bark(self):
print("Woof!")
class Cat(Animal): # The class Cat inherits the functionalities of the class Animal
def __init__(self):
# The line Animal.__init__(self) is missing in the Cat class
print("Cat created")
def whoAmI(self):
print("Cat")
class Fish(Animal): # The class Fish inherits the functionalities of the class Animal
# attributes are not specified
def whoAmI(self):
print("Fish")
Let’s create instances of the child classes.
[50]:
d = Dog()
Animal created
Dog created
[51]:
# Note the difference in the attributes in comparison to Dog
c = Cat()
Cat created
[52]:
# Note the difference in the attributes in comparison to Cat
f = Fish()
Animal created
Parent classes typically provide generic and common functionality that we can reuse throughout multiple child classes. In this sense, the Animal
class provides properties that are common for most other animals.
Child classes inherit attributes and methods from the parent class. For instance, notice that if call the eat()
method with the class instances of Dog
and Fish
, the word Eating
is printed. Although the method eat()
is not defined in the classes Dog
and Fish
, the instances inherit the method from the parent class Animal
.
[53]:
d.eat()
Eating
[54]:
f.eat()
Eating
The child classes not only inherit attributes and methods from the parent class, but they can also modify attributes and methods existing in the parent class. This is shown by the method whoAmI()
. When this method is called, Python searches for the name first in the child class, and if it is not found, afterwards it searches in the parent class. In this case, whoAmI()
method is found in the child classes Dog
, Cat
, and Fish
.
[55]:
d.whoAmI()
Dog
[56]:
c.whoAmI()
Cat
Finally, the child class Dog
extends the functionality of the parent class by defining a new bark()
method that does not exist in the Animal
class.
[57]:
d.bark()
Woof!
Similar to inheritance in nature, only child classes inherit from the parent class, and the parent class does not inherit attributes and methods from the child classes.
One more example follows, where Person
is a parent class, and Manager
is a child class of Person
and inherits attributes and methods.
[58]:
class Person:
def __init__(self, name, job=None, pay=0):
self.name = name
self.job = job
self.pay = pay
def giveRaise(self, percent):
self.pay = int(self.pay * (1 + percent))
class Manager(Person):
def giveRaise(self, percent, bonus=.10):
self.pay = int(self.pay * (1 + percent + bonus))
[59]:
# Create a new instance of Person
bob = Person('Bob Smith', pay=50000)
bob.giveRaise(percent=0.1) # 50000 * (1+ 0.1) = 50000 * 1.1 = 55000
bob.pay
[59]:
55000
[60]:
# Create a new instance of Manager
tom = Manager('Tom Jones', 'mgr', 50000)
print(tom.name, tom.job, tom.pay)
Tom Jones mgr 50000
[61]:
# On a salary of 50,000, giveRaise for Person applied 10% raise, and giveRaise for Manager applied 10% bonus
tom.giveRaise(percent=0.1, bonus=0.1) # 50000 * (1+ 0.1 + 0.1) = 50000 * 1.2 = 60000
tom.pay
[61]:
60000
Another way to define the method giveRaise
for the Manager
child class is by using the syntax below superclass.method(self, arguments)
, as in Person.giveRaise(self, percent + bonus)
shown below. Note that this is different from the syntax above self.method(arguments)
as in self.pay = int(self.pay * (1 + percent + bonus))
. However, this coding approach using self.method(arguments)
does not rely on the superclass Person
, and if Person
is changed, the code will
not work as expected. Therefore, it is preferred to use the syntax superclass.method(self, arguments)
.
[62]:
class Manager(Person):
def giveRaise(self, percent, bonus=.10):
Person.giveRaise(self, percent + bonus)
[63]:
tom = Manager('Tom Jones', 'mgr', 50000)
# On a salary of 50,000, giveRaise for Person applied 10% raise, and giveRaise for Manager applied 10% bonus
tom.giveRaise(percent=0.1, bonus=0.1) # Person.giveRaise(.10+0.10) = Person.giveRaise(0.20) # 50000 * 1.2 = 60000
tom.pay
[63]:
60000
The super() function¶
The super()
function in Python returns a temporary object of the parent class that then allows to call methods from the parent class in child classes. This allows to define new methods in the child class with minimal code changes.
For instance, in the example below, a parent class Rectangle
is defined, and a child class Cube
is created that inherits from Rectangle
. To calculate the volume of a Cube, the child class Cube
inherited the method area()
from the class Rectangle
via super().area()
. Since the method volume()
for a cube relies on calculating the area of a single face, rather than reimplementing the area calculation, we use the function super()
to extend the area calculation. The
function super()
returns an object of the superclass, and allows to call the method volume()
directly through super().area()
.
[64]:
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width
def perimeter(self):
return 2 * self.length + 2 * self.width
class Cube(Rectangle):
def __init__(self, length, width, height):
self.length = length
self.width = width
self.height = height
def volume(self):
face_area = super().area()
return face_area * self.height
[65]:
cube1 = Cube(4, 4, 2)
cube1.volume()
[65]:
32
One more example is provided next, with a parent class Person
and child class Student
. Note that in the definition of the Student
class, we called the __init__()
function from the superclass to initialize the attributes student_name
, student_age
, and student_residence
. The call to the parent class super().__init__(student_name, student_age, student_residence)
is equivalent to calling the function as
Person.__init__(self, student_name, student_age, student_residence)
.
[66]:
class Person:
def __init__(self, name, age, residence):
self.name = name
self.age = age
self.residence = residence
def show_name(self):
print(self.name)
def show_age(self):
print(self.age)
class Student(Person):
def __init__(self, student_name, student_age, student_residence, student_id):
super().__init__(student_name, student_age, student_residence)
self.studentId = student_id
def show_id(self):
print(self.studentId)
[67]:
# Create an object of the child class
student1 = Student("Max", 22, "Moscow", "100022")
student1.show_name()
Max
[68]:
student1.show_id()
100022
Class Hierarchies¶
Using inheritance, we can build class hierarchies, also known as inheritance trees. A class hierarchy is a set of closely related classes that are connected through inheritance and arranged in a tree-like structure. The class or classes at the top of the hierarchy are the parent classes, while the classes below are derived classes or child classes.
Therefore, classes at the top of the hierarchy are generic classes with common functionality, while classes down the hierarchy are more specialized and they inherit attributes and methods from their parent classes and also have their own attributes and methods.
Let’s revisit again the example with the animals, where the following tree hierarchy will be created.
Figure source: Reference [3].
In this hierarchy, a parent class Animal
is at the top. Below this class, we have subclasses like Mammal
, Bird
, and Fish
, which inherit the attributes and methods from the class Animal
. At the bottom level, we can have classes like Dog
, Cat
, Eagle
, Penguin
, Salmon
, and Shark
. E.g., Dog
and Cat
are both mammals and animals, and they inherit from both of these superclasses and have their own attributes and methods.
[69]:
class Animal:
def __init__(self, name, sex, habitat):
self.name = name
self.sex = sex
self.habitat = habitat
class Mammal(Animal):
unique_feature = "Mammary glands"
class Bird(Animal):
unique_feature = "Feathers"
class Fish(Animal):
unique_feature = "Gills"
class Dog(Mammal):
def walk(self):
print("The dog is walking")
class Cat(Mammal):
def walk(self):
print("The cat is walking")
class Eagle(Bird):
def fly(self):
print("The eagle is flying")
class Penguin(Bird):
def swim(self):
print("The penguin is swimming")
class Salmon(Fish):
def swim(self):
print("The salmon is swimming")
class Shark(Fish):
def swim(self):
print("The shark is swimming")
[70]:
d = Dog('Fido', 'M', 'Europe')
[71]:
d.unique_feature
[71]:
'Mammary glands'
[72]:
d.walk()
The dog is walking
5.4 Special Methods¶
Python has many other built-in methods which can be used with user-defined classes. These methods are also known as special methods or magic methods. Similar to the __init__()
method, all special methods have leading and trailing double underscores (also called dunders).
For instance, the list of special methods for mathematical operators in Python involve the following.
a + b a.__add__(b)
a - b a.__sub__(b)
a * b a.__mul__(b)
a / b a.__truediv__(b)
a // b a.__floordiv__(b)
a % b a.__mod__(b)
a << b a.__lshift__(b)
a >> b a.__rshift__(b)
a & b a.__and__(b)
a | b a.__or__(b)
a ^ b a.__xor__(b)
a ** b a.__pow__(b)
-a a.__neg__()
~a a.__invert__()
abs(a) a.__abs__()
Special methods for item access in sequences involve the following.
len(x) x.__len__()
x[a] x.__getitem__(a)
x[a] = v x.__setitem__(a,v)
del x[a] x.__delitem__(a)
Other type of special methods are used for access to object attributes. These include:
x.a x.__getattr__(a)
x.a = v x.__setattr__(a,v)
del x.a x.__delattr__(a)
And there are other types of special methods that are not listed above.
The use of special methods with user-defined classes is also called operator overloading in OOP, because these methods allow the new instances of our user-defined classes to exhibit the behaviors of the applied special methods. For example, the operator +
is implemented using the special method __add__()
and it can perform addition of numbers, concatenation of strings, etc. Operator overloading in Python is an example of polymorphism in Python. Note that the term polymorphism is
more general, and it describes actions performed upon different objects in a different way based on the object, as we saw in the example from the previous section.
By implementing special methods into our user-defined classes, our classes can behave like built-in Python types.
Sequence Length with __len__()
¶
We can implement the special method __len__()
in our custom classes, which will allow us to use len()
with the instances of the class.
In the example below, we used the method __len__()
in the class Employee
, which returns the length of the attribute self.pay
.
[73]:
class Employee:
def __init__(self, name, pay):
self.name = name
self.pay = pay
def __len__(self):
return len(self.pay)
[74]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[75]:
# Length of the bob.pay object
len(bob.pay)
[75]:
4
[76]:
sue = Employee(name='Sue Jones', pay=[50000, 60000])
print(sue.name, sue.pay)
Sue Jones [50000, 60000]
[77]:
len(sue.pay)
[77]:
2
See the Appendix for additional information about special methods for classes in Python.
5.5 When to Use Classes¶
Classes allow to leverage the power of Python while writing and organizing code. The benefits of using classes include:
Reuse code and avoid repetition: we can define hierarchies of related classes, where the parent classes at the top of a hierarchy provide common functionality that we can reuse later in the child classes down the hierarchy. This allows to reuse code and reduces code duplication.
Group related data and behaviors in a single entity: classes allow to group together related attributes and methods in a single entity. This helps you better organize code using modular entities that can be reused across multiple projects.
Abstract away the implementation details of concepts and objects: classes allow to abstract away the implementation details of core concepts and objects. This helps provide the users with intuitive interfaces to process complex data and behaviors.
In conclusion, Python classes can help write more organized, structured, maintainable, reusable, flexible, and user-friendly code. appears, then go for it.
On the other hand, we should not use classes for everything in Python, since in some situations, they can overcomplicate our solutions. Sometimes, writing a couple of functions are enough for solving a problem.
For example, we don’t need to use classes when we need to:
Store only data: if there are no any methods inside the body of a class, we can use a dictionary or a named tuple instead.
Provide a single method: if a class has only one method, it would be better to use a function instead.
When a fucntionality is available through built-in types or third-party classes: in that case, we should avoid creating custom classes.
Also, there are other situations where we may not need to use classes, such as: in short and simple programs with simple logic and data structures, in performance-critical programs where classes may slow down the performance, when working in a team with a coding style that doesn’t rely on classes, etc.
Therefore, although classes provide many benefits, they don’t need to be used in every situations. Often, it is preferred to begin with a simple but working code, and if there is a need to use classes, then go for it.
Appendix: Additional OOP Info¶
The material in the Appendix is not required for quizzes and assignments.
Static Methods and Class Methods¶
In Section 5.2 above we studied instance methods, and we explained that they are applied to class instances through the use of the keyword self
.
In Python there are also static methods and class methods, that are defined inside a class and are not connected to a particular instance of that class. These methods are created with the built-in decorators @staticmethod
and @classmethod
.
The following code shows the difference in the syntax between instance method, @classmethod
and @staticmethod
.
[78]:
class MyClass:
def instance_method(self, arg1, arg2, argN):
return 'instance method called', self
@classmethod
def classmethod(cls, arg1, arg2, argN):
return 'class method called', cls
@staticmethod
def staticmethod(arg1, arg2, argN):
return 'static method called'
Static Methods with @staticmethod
¶
In Python and other programming languages, a static method is a method that does not require the creation of an instance of a class. For Python, it means that the first argument of a static method is not self
, but a regular positional or keyword argument. Also, a static method can have no arguments at all, as in the following example.
In general, static methods are used to create helper functions that have a logical connection with the class but do not have access to the attributes or methods of the class, or to the class instances.
[79]:
class Cellphone:
def __init__(self, brand, number):
self.brand = brand
self.number = number
def get_number(self):
return self.number
@staticmethod
def get_emergency_number():
return "911"
[80]:
Cellphone.get_emergency_number()
[80]:
'911'
In this example, get_number()
is a regular instance method of the class and requires the creation of an instance. The method get_emergency_number()
is a static method because it is decorated with the @staticmethod
decorator. Also note that get_emergency_number()
does not have self
as the first argument, which means that it does not require the creation of an instance of the Cellphone
class.
Again, get_emergency_number()
can just work as a standalone function, and it does not need to be defined as a static method. However, it makes sense and is intuitive to put it in the Cellphone
class because a cellphone should be able to provide the emergency number.
Here is one more example of using a static method. The method is_full_name()
just checks whether the entered name for a student consists of more than one string.
[81]:
class Student():
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@staticmethod
def is_full_name(name_str):
names = name_str.split(' ')
return len(names) > 1
[82]:
scott = Student('Scott', 'Robinson')
[83]:
# call the static method
Student.is_full_name('Scott Robinson')
[83]:
True
[84]:
# call the static method
Student.is_full_name('Scott')
[84]:
False
And one more example of using @staticmethod
follows. In order to convert the slash-dates to dash-dates, we used the function toDashDate
within the Dates
class. It is a static method because it doesn’t need to access any properties of the class Dates
through self
. It is also possible to create a function toDashDate()
outside the class, but since it works for dates, it is logical to keep it inside the Dates
class.
[85]:
class Dates:
def __init__(self, date):
self.date = date
def getDate(self):
return self.date
@staticmethod
def toDashDate(slash_date):
return slash_date.replace("/", "-")
[86]:
date1 = Dates("15/12/2016")
date1.getDate()
[86]:
'15/12/2016'
[87]:
date2 = Dates.toDashDate("15/12/2016")
date2
[87]:
'15-12-2016'
In addition, static methods are used when we don’t want subclasses of a superclass to change or override a specific implementation of a method. Because @staticmethod
is ignorant of the class it is attached to, we can use it in subclasses just as it was defined in the superclass.
In the following code, DatesWithSlashes
is derived from the superclass Dates
. We wouldn’t want the subclass DatesWithSlashes
to override the static method toDashDate()
because it only has a single use, i.e., change slash-dates to dash-dates. Therefore, we will use the static method to our advantage by overriding getDate()
method in the subclass so that it works well with the DatesWithSlashes
class.
[88]:
class Dates:
def __init__(self, date):
self.date = date
def getDate(self):
return self.date
@staticmethod
def toDashDate(date):
return date.replace("/", "-")
class DatesWithSlashes(Dates):
def getDate(self):
return Dates.toDashDate(self.date)
[89]:
date1 = Dates("15/12/2016")
date1.getDate()
[89]:
'15/12/2016'
[90]:
date2 = DatesWithSlashes("15/12/2016")
date2.getDate()
[90]:
'15-12-2016'
Class Methods with @classmethod
¶
In Python, a class method is created with the @classmethod
decorator and requires the class itself as the first argument, which is written as cls
. A class method returns an instance of the class with supplied arguments or adds other additional functionality.
[91]:
class Cellphone:
def __init__(self, brand, number):
self.brand = brand
self.number = number
def get_number(self):
return self.number
@staticmethod
def get_emergency_number():
return "911"
@classmethod
def iphone(cls, number):
print("An iPhone is created.")
return cls("Apple", number)
[92]:
# create an iPhone instance using the class method
iphone = Cellphone.iphone("1112223333")
An iPhone is created.
[93]:
# call the instance method
iphone.get_number()
[93]:
'1112223333'
[94]:
# call the static method
iphone.get_emergency_number()
[94]:
'911'
[95]:
samsung1 = Cellphone('Samsung', '123456789')
[96]:
samsung1.get_number()
[96]:
'123456789'
[97]:
# the 'iphone' method cannot modify the instance 'samsung1'
samsung1.iphone('222222222')
An iPhone is created.
[97]:
<__main__.Cellphone at 0x2c1326f49d0>
[98]:
# the brand atribute of the instance was not modified by the 'iphone' method
# class method cannot modify specific instances
samsung1.brand
[98]:
'Samsung'
[99]:
# the number atribute of the instance was not modified by the 'iphone' method
samsung1.number
[99]:
'123456789'
In this example, iphone()
is a class method since it is decorated with the @classmethod
decorator and has cls
as the first argument. It returns an instance of the Cellphone
class with the brand preset to 'Apple'
.
Class methods are often used as alternative constructors beside the __init__()
constructor method, or as factory methods in order to create instances based on different use cases.
This is shown in the following example. Here, the __init__()
constructor method takes two parameters name
and age
. The class method fromBirthYear()
takes class, name, and birthYear, and calculates the current age by subtracting it from the current year. That is, it allows to create instances based on the year of birth, instead of based on the age. The reason for it is because we don’t want the list of arguments in the __init__()
method to be lengthy and confusing. Instead, we
can use class methods to return a new instance based on different arguments.
Note again that the fromBirthYear()
method takes Person
class as the first parameter cls
, and not an instance of the class Person via self
. Also, this method returns cls(name, date.today().year - birthYear)
, which is equivalent to Person(name, date.today().year - birthYear)
.
[100]:
from datetime import date
# random Person
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def fromBirthYear(cls, name, birthYear):
return cls(name, date.today().year - birthYear)
def display(self):
print(self.name + "'s age is: " + str(self.age))
[101]:
person1 = Person('Adam', 19)
person1.display()
Adam's age is: 19
[102]:
person2 = Person.fromBirthYear('John', 1985)
person2.display()
John's age is: 38
[103]:
# class method cannot modify specific instances
person1.fromBirthYear('John', 1985)
[103]:
<__main__.Person at 0x2c1326e97c0>
[104]:
person1.name
[104]:
'Adam'
The main difference between a static method and a class method is:
Static methods can neither modify the class nor class instances, and they just handle the attributes. They are used to create helper or utility functions. Static methods have a logical connection with the class but do not have access to class or instance states.
Class methods can modify the class since its parameter is always the class itself, but they cannot modify class instances. They can be used as factory methods to create new instances based on alternative information about a class.
Abstract Classes¶
An abstract class is one that never expects to be instantiated. In the next example, we will never instantiate an Animal object, but only Dog and Cat objects will be derived from the class Animal.
Abstract classes allow to create a set of methods that must be created within any subclasses built from the abstract class. An abstract method is a method that has a declaration but does not have an implementation. An example is the speak
method in the Animal class. Abstract classes are helpful when designing large functional units and we want to provide a common interface for different implementations of a method.
An abstract class is one that is not expected to be instantiated, and contains one or more abstract methods.
Python supports abstract classes through the abc
module, which provides the infrastructure for defining abstract classes. Defining an abstract method is achieved by using the @abstractmethod
decorator.
[105]:
from abc import ABC, abstractmethod
# Abstract class
class Animal(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def speak(self): # Abstract method, it is not implemented
pass
# Subclass of Animal
class Dog(Animal):
def speak(self):
return self.name+' says Woof!'
# Subclass of Animal
class Cat(Animal):
def speak(self):
return self.name+' says Meow!'
# Create instances
fido = Dog('Fido')
isis = Cat('Isis')
# The method 'speak' has different implementations for the subclasses Dog and Cat
print(fido.speak())
print(isis.speak())
Fido says Woof!
Isis says Meow!
Note that the abstract class Animal cannot be instantiated, because it has only an abstract version of the speak
method.
[106]:
a = Animal('fido')
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\3885960488.py in <module>
----> 1 a = Animal('fido')
TypeError: Can't instantiate abstract class Animal with abstract method speak
By defining an abstract class, one can define common methods for a set of subclasses. This capability is especially useful in situations where a third-party is going to provide implementations, or it can also help when working in a large team or with a large code-base where maintaining all classes is difficult or not possible.
One more example is shown, where the abstract class Employee
has an abstract method get_salary
. The subclasses FulltimeEmployee
and HourlyEmployee
are derived, and they define different get_salary
methods for each class. The class Payroll
has methods to add an employee and print the name and salary information.
[107]:
from abc import ABC, abstractmethod
# Abstract class Employee
class Employee(ABC):
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@abstractmethod
def get_salary(self):
pass
# Subclass
class FulltimeEmployee(Employee):
def __init__(self, first_name, last_name, salary):
super().__init__(first_name, last_name)
self.salary = salary
def get_salary(self):
return self.salary
# Subclass
class HourlyEmployee(Employee):
def __init__(self, first_name, last_name, worked_hours, rate):
super().__init__(first_name, last_name)
self.worked_hours = worked_hours
self.rate = rate
def get_salary(self):
return self.worked_hours * self.rate
# A separate class Payroll
class Payroll:
def __init__(self):
self.employee_list = []
def add(self, employee):
self.employee_list.append(employee)
def display(self):
for e in self.employee_list:
print(f'{e.first_name} {e.last_name} \t ${e.get_salary()}')
[108]:
payroll = Payroll()
payroll.add(FulltimeEmployee('John', 'Doe', 6000))
payroll.add(FulltimeEmployee('Jane', 'Doe', 6500))
payroll.add(HourlyEmployee('Jenifer', 'Smith', 200, 50))
payroll.add(HourlyEmployee('David', 'Wilson', 150, 100))
payroll.add(HourlyEmployee('Kevin', 'Miller', 100, 150))
[109]:
payroll.display()
John Doe $6000
Jane Doe $6500
Jenifer Smith $10000
David Wilson $15000
Kevin Miller $15000
Additional Special Methods¶
Indexing and Slicing with __getitem__()
and __setitem__()
¶
Indexing in Python is implemented with the built-in method __getitem__()
.
[110]:
list1 = [1, 2, 3]
list1[0]
[110]:
1
[111]:
list1.__getitem__(0)
[111]:
1
We can implement the special method __getitem__()
in our classes to provide built-in indexing behaviors of Python sequences to our class instances.
In the following code, the index
argument is used to specify the elements in self.pay
.
[112]:
class Employee:
def __init__(self, name, pay):
self.name = name
self.pay = pay
def __getitem__(self, index):
return self.pay[index]
[113]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[114]:
bob[1]
[114]:
55000
[115]:
bob[-1]
[115]:
60000
Interestingly, in addition to indexing, __getitem__()
is also used for slicing expressions, as shown below. The Python slice object is used for this purpose to define the starting and stopping index (and optional index step) for extracting the elements from a sequence.
[116]:
list1 = [1, 2, 3, 4, 5]
[117]:
list1[slice(2, 4)]
[117]:
[3, 4]
[118]:
list1[slice(1, -1)]
[118]:
[2, 3, 4]
[119]:
list1[slice(3, None)]
[119]:
[4, 5]
This means that we can use the __getitem__()
method within our defined class to perform slicing, if we wanted to, and not only indexing.
[120]:
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[121]:
bob[0:2]
[121]:
[50000, 55000]
[122]:
bob[1:]
[122]:
[55000, 53000, 60000]
On the other hand, if we want to change the value of bob.pay
outside of the class, we won’t be able to do that.
[123]:
bob[0] = 25000
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\64624401.py in <module>
----> 1 bob[0] = 25000
TypeError: 'Employee' object does not support item assignment
The special method __setitem__()
allows to assign values to sequence objects. In the example below, new value can be assigned to the elements of the instance bob
with a user-entered index.
[124]:
class Employee:
def __init__(self, name, pay):
self.name = name
self.pay = pay
def __setitem__(self, index, value):
self.pay[index] = value
[125]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[126]:
bob[0] = 45000
[127]:
print(bob.name, bob.pay)
Bob Smith [45000, 55000, 53000, 60000]
Printing using __str__()
and __repr__()
¶
We know that str()
is used to convert an object to a str
object. Internally, it is implemented by the __str__()
method. Moreover, Python uses __str__()
when we call print()
to display an object.
Let’s consider again the instance of the Employee
class. If we call the instance bob
which we created before or if we try to print it, we can see a general Python output that tells us that it is an object created by the Employee
class and Python also provides its memory address.
[128]:
bob
[128]:
<__main__.Employee at 0x2c1326f8040>
[129]:
print(bob)
<__main__.Employee object at 0x000002C1326F8040>
We can implement the __str__()
method, so that, when the class instance self
is printed, we can customize the displayed output. The code below instructs the output to list the employee name and pay. Recall the string formatting methods, which we covered earlier: %s
with % (name)
formatting, {}
with .format(name)
formatting, and f strings f
with {name}
formatting.
[130]:
class Employee:
def __init__(self, name, pay):
self.name = name
self.pay = pay
def __str__(self):
return 'Employee name %s and pay %s' % (self.name, self.pay)
# return 'Employee name {0} and pay {1}'.format(self.name, self.pay)
# return f'Employee name {self.name} and pay {self.pay}'
[131]:
bob = Employee(name='Bob Smith', pay=50000)
print(bob.name, bob.pay)
Bob Smith 50000
[132]:
print(bob)
Employee name Bob Smith and pay 50000
[133]:
sue = Employee(name='Sue Jones', pay=60000)
print(sue)
Employee name Sue Jones and pay 60000
If we enter only the instance name without print
, we will still obtain the general Python output.
[134]:
sue
[134]:
<__main__.Employee at 0x2c1326f8a00>
Besides the __str__()
method, another similar method is __repr__()
which stands for representation and it is also used for overloading the print
method in Python. It provides another way to customize the printed outputs, and returns what is known as formal string representation. Similarly, the __str__()
method is known as informal string representation.
[135]:
class Employee:
def __init__(self, name, pay):
self.name = name
self.pay = pay
def __repr__(self):
return '<The name and pay for the employee are {} and {} dollars>'.format(self.name, self.pay)
[136]:
sue = Employee(name='Sue Jones', pay=60000)
print(sue)
<The name and pay for the employee are Sue Jones and 60000 dollars>
Note also in the cell below that the displayed output for sue
is the same as for print(sue)
. I.e., Python uses __repr__()
to display the object in the interactive prompt.
[137]:
sue
[137]:
<The name and pay for the employee are Sue Jones and 60000 dollars>
The method __repr__()
is more general than _str__()
and it applies to nested appearances and a few other additional cases. The __str__()
and __repr__()
methods are very useful, because when other people are using our codes, they can get a good idea of what an object is by just printing it.
References¶
Mark Lutz, “Learning Python,” 5th edition, O-Reilly, 2013. ISBN: 978-1-449-35573-9.
Pierian Data Inc., “Complete Python 3 Bootcamp,” codes available at: link.
Leodanis Pozo Ramos, Python Classes: The Power of Object-Oriented Programming, available at: link
Python - Made with ML, Goku Mohandas, codes available at: link.
Jeff Knupp, Improve Your Python: Python Classes and Object Oriented Programming, available at link.
Python Tutorial, Python Abstract Classes, available at link.
Kyle Stratis, Supercharge Your Classes with Python super(), available at link.
BACK TO TOP