Object-oriented programming (OOP)

There are generally two approached to organise code:

  1. Procedural programming: using a function that accomplishes a task or returns a result.

  2. Object-oriented programming (OOP): using properties and behaviours.

Object-oriented programming (OOP)

It uses objects as a way to structure the code. You can define these objects in terms of properties and behaviours. It is perfectly fine to follow the procedural way of programming in Python, but it may not be wise because Python was designed to be in object-oriented programming language and can offer so much more.

OOP has more feature, including design pattern. Design patterns require object-oriented programming. Therefore, if you want to use design pattern in your code, then you have not a choice but to use OOP.

What Makes Python OOP?

The answer is very straight forward. Python supports:

  • Class

  • Attribute

  • Method

  • Constructor: In Python, the init() method is called the constructor and is always called when an object is created.

  • Inheritance

These are also building blocks of design patterns.

What is an Object?

  • An entity or 'thing' in your Python code or programme, often a noun.

  • Example of an object could be a person (single person).

Properties:

  • Name

  • Age

  • Gender

  • Address etc......

Behaviours:

  • Shopping

  • Swimming

  • Walking

  • Talking etc........

How are Objects Created?

You can create Objects in Python using Classes. You can create many and unique objects from a class. The process to create an object from a class is known as instantiation.

An example of instantiation is the built-in types like int, str etc. Let's create a variable by assigning a value/string to it.

city_name = 'Krakow'

The variable city_name with a value of Krakow. The variable city_name is a reference to an object. Krakow has object type str because the built-in class str was instantiated in order to create it.

You can create many objects from a single class, and you can instantiate the class to create as many as unique objects you want. They will all have the same type, but they can store different values for their individual properties.

Classes and Instances

This is focused on the basics of creating and instantiating simple classes. But why should we even use classes?

It allows to logically group data and functions that are easy to reuse and add complexity to it. When I say attributes and methods, I mean data and functions. A method is a function that is associated with a class.

Let's create a class and take the course as an example. You are students attending Introduction to DS with Python course. Each student has specific attributes and methods.

For example; each student has a name, email address and fee to pay as well as actions that you can perform. So it is nice to have a class as a blueprint to create each student so that you do not have to add new students manually from scratch.

Let's create student class by simply calling class keyword

class Students:
    pass

You will get an error if you leave the class empty. You can use pass keyword so the code runs successfully and you can add methods to it later.

The Student class is basically a blueprint for creating INSTANCES and each unique student that we create using our Student class will be an instance of that class.

student1 = Student()
student2 = Student()

These two instances are unique instances of the Student class and occupy different locations. You can check their memory address as well.

print(student1)
print(student2)

# or

print(hex(id(student1)))
print(hex(id(student2)))

Instance Variables/Attributes

Instance variables contain data that is unique to each instance. You can manually create instance variables for each student similar to above.

student1.name = 'Pawel'
student1.surname = 'Pawlowski'
student1.email = 'Pawel.Pawlowski@gmail.com'
student1.fee = 4000

# You can add second students
student2.name = 'Marta'
student2.surname = 'Pawlowska'
student2.email = 'Marta.Pawlowska@gmail.com'
student2.fee = 4400

As you see, each instances have attributes that are unique to them. Let's print surname of student1 and student2.

print(student1.surname)
print(student2.surname)

This is a very tiring process to create for each student all the attributes manually. It is good to set all this information for each student automatically.

The more the codes, the more it is likely to make mistakes.

In order to create all the information automatically. You are going to use a special init method.

class Students:
    
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'

When you create a method within a class, they receive the instance as the first argument automatically. It is customary that we call the instance self. You can call it whatever you like similar to args and kwargs.

After self, you can add the parameters (arguments) you want. In this case, student name, surname, fee. I know we had email as well, but we are creating an email from name and surname, so we can automate it.

Now, you can pass your values into the class when you are instantiating it.

student3 = Student('Zmnako', 'Awrahman', 0, 'Paid')

print(student3.name)
print(student3.surname)
print(student3.email)
print(student3.fee)
print(student3.fee_status)

As you can see, the init method run automatically when you instantiated the class. student3 will be passed in as self and the attributes are set.

The parameters, such as name, surname, email and fee, are attributes of the Student class.

Instance Methods

Let's add some action that each student can perform. In order to get this ability, you have to add some methods to the class.

Let's say, you want to get the full name of a student and fee status. You can either do this outside of the class or inside the class.

Outside the class you can do it as follow:

print('{} {} {}'.format(student3.name, 
                        student3.surname, 
                        student3.fee_status))

But it is recommended to create a method within your class that allows you to put this ability in one place and reuse it everywhere else that you need it.

The method within the class takes the instance (self) as first parameters. The instance is the only parameters that you need to get a student's full name. You can take the logic outside of the class as a return inside the method. You have to use self with each attribute inside the class instead of the instantiated object from the class student3.

class Student:
    
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)

Now, you can use the method full_name with the Student class

student4 = Student('Marco', 'Lucas', 4000, 'unpaid')

# To get full name
student4.full_name()

# Output
'Marco Lucas'

if we remove the parenthesis, you can see it prints method

student4.full_name

# Output
<bound method Student.full_name of <__main__.Student object at 0x7fd1b15a4668>>

There is a very common mistake when creating a method is people forget to pass self parameter for the instance.

Class Variables

Class variables are variables that are shared among all instances of a class. While the instance variable can be unique for each instance, such as name, surname, fee etc., class variables should be the same for each instance. If you look at the class you created earlier

class Student:
    
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)

Let's say that the course will give discounts to students who register early and pay the full fee at least a month before the course start date. This discount should be the same for all instances of the Student class. The discount is a good candidate for a class variable.

Let's hardcode the class variable to the class created earlier. You can create a method within the class to apply a 10% discount to early birds.

class Student:
    
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)
        
    def apply_discount(self):
        self.fee = int(self.fee * 0.90)

Let's use the apply_discount method

student6 = Student('Piotr', 'Kyc', 4000, 'unpaid')

print(student6.fee)
# Apply discount
student6.apply_discount()
print(student6.fee)

# Output
4000
3600

This is OK, but you cannot see what is the discount amount is and it is difficult to change the discount rate for each stage of recruitment. As I mentioned, this is a hardcoded way and it is not recommended.

The discount rate is fixed and hidden in the apply_discount. If you want to change it, you have to go within the class to change it. Going from the hardcoded version to a class variable is very easy. You just go above all methods and create a variable.

class Student:

    discount_amount = 0.90
            
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)
        
    def apply_discount(self):
        self.fee = int(self.fee * discount_amount)

Running this raises a NameError message

student6 = Student('Piotr', 'Kyc', 4000, 'unpaid')

print(student6.fee)
# Apply discount
student6.apply_discount()
print(student6.fee)

# Output
NameError                                 Traceback (most recent call last)
<ipython-input-81-bc754d2afbf2> in <module>()
      3 print(student6.fee)
      4 # Apply discount
----> 5 student6.apply_discount()
      6 print(student6.fee)

<ipython-input-80-a377950cc839> in apply_discount(self)
     14 
     15     def apply_discount(self):
---> 16         self.fee = int(self.fee * discount_amount)

NameError: name 'discount_amount' is not defined

This is because the method cannot access the discount amount. You can pass the discount amount either through the class itself or an instance of the class.

Solution 1:

Access the discount amount through the class itself by using Student.discount_amount.

class Student:

    discount_amount = 0.90
            
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)
        
    def apply_discount(self):
        self.fee = int(self.fee * Student.discount_amount)

Output:

student6 = Student('Piotr', 'Kyc', 4000, 'unpaid')

print(student6.fee)
# Apply discount
student6.apply_discount()
print(student6.fee)

# Output
4000
3600

Solution 2:

Access through the instance by using self.

class Student:
    '''
    This is my class
    '''
    discount_amount = 0.10
            
    def __init__(self, name, surname, fee, fee_status):
        self.name = name
        self.surname = surname
        self.fee = fee
        self.fee_status = fee_status
        self.email = name + '.' + surname + '@gmail.com'
        
    def full_name(self):
        return '{} {}'.format(self.name, self.surname)
        
    def apply_discount(self):
        self.fee = int(self.fee * (1 - self.discount_amount))

Output:

student6 = Student('Piotr', 'Kyc', 4000, 'unpaid')

print(student6.fee)
# Apply discount
student6.apply_discount()
print(student6.fee)

# Output
4000
3600

You can access all the keys and values in a class by using __dict__.

student6.__dict__

# Output
{'email': 'Piotr.Kyc@gmail.com',
 'fee': 3600,
 'fee_status': 'unpaid',
 'name': 'Piotr',
 'surname': 'Kyc'}

As you can see, all arguments with their values are returned in a dictionary, but the discount amount is missing. But if you do the same thing for the class, you get a different result.

Student.__dict__

# Output
mappingproxy({'__dict__': <attribute '__dict__' of 'Student' objects>,
              '__doc__': None,
              '__init__': <function __main__.Student.__init__>,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'Student' objects>,
              'apply_discount': <function __main__.Student.apply_discount>,
              'discount_amount': 0.1,
              'full_name': <function __main__.Student.full_name>})

The discount_amount is the class variable that your instances see and access it when you are instantiating Student class.

How to Change the Discount Amount?

You can change the discount amount by updating it.

student7 = Student('Piotr', 'Kyc', 4000, 'unpaid')
student8 = Student('Marco', 'Lucas', 4000, 'unpaid')

student7.discount_amount = 0.2

print(Student.discount_amount)
print(student7.discount_amount)
print(student8.discount_amount)

Homework?

Can you add a Class Variable for the number of students enrolled? If the number of students reaches 10 registered students, Add the students to a waiting list.

You can read further details in this free Python Textbook on OOP (Object-Oriented Programming in Python Documentation)

Last updated