# 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).&#x20;

### Properties:&#x20;

* Name&#x20;
* Age
* Gender
* Address etc......

### **Behaviours:**

* Shopping
* Swimming
* Walking
* Talking etc........

![Common example of classes](/files/-M4mjysEgV0yxpKXFKqH)

## 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.

```python
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.&#x20;

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

```python
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.

```python
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.

{% tabs %}
{% tab title="Code" %}

```python
print(student1)
print(student2)

# or

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

{% endtab %}

{% tab title="Output" %}

```python
#print(student1)
#print(student2)
<__main__.Student object at 0x7fd1b161d0b8>
<__main__.Student object at 0x7fd1b161d080>

#print(hex(id(student1)))
#print(hex(id(student2)))
0x7fd1b161d0b8
0x7fd1b161d080
```

{% endtab %}
{% endtabs %}

### 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.

```python
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`.

{% tabs %}
{% tab title="Code" %}

```python
print(student1.surname)
print(student2.surname)
```

{% endtab %}

{% tab title="Output" %}

```python
#print(student1.surname)
#print(student2.surname)

Pawlowski
Pawlowska
```

{% endtab %}
{% endtabs %}

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.&#x20;

**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`**.

```python
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](/datascience/functions.md#what-is-args) and [kwargs](/datascience/functions.md#what-is-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.

{% tabs %}
{% tab title="Code" %}

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

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

{% endtab %}

{% tab title="Output" %}

```python
#print(student3.name)
#print(student3.surname)
#print(student3.email)
#print(student3.fee)
#print(student3.fee_status)

Zmnako
Awrahman
Zmnako.Awrahman@gmail.com
0
paid
```

{% endtab %}
{% endtabs %}

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.&#x20;

The parameters, such as name, surname, email and fee, are attributes of the **Student** class.&#x20;

### 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:

```python
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`.

```python
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

```python
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

```python
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

```python
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.

```python
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

```python
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.&#x20;

```python
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

```python
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`.

```python
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:

```python
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`.

```python
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:

```python
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__`.

```python
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.

```python
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.

{% tabs %}
{% tab title="Code" %}

```python
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)
```

{% endtab %}

{% tab title="Output" %}

```python
#print(Student.discount_amount)
#print(student7.discount_amount)
#print(student8.discount_amount)

0.1
0.2
0.1
```

{% endtab %}
{% endtabs %}

### 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](https://readthedocs.org/projects/python-textbok/downloads/pdf/1.0/))


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://zmnako.gitbook.io/datascience/object-oriented-programming-oop.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
