# Classes In Python

`Class` is a very powerful feature of object-oriented programming. In Python, we first write a `class` to mimic any real word scenario and then create `object` based on this `class`. In this way, when we create individual `object` from the `class`, each `object` is automatically equipped with the general behavior of a `class`; however, we can give each `object` whatever unique features we desire.

> Making an `object` from a `class` is called ***instantiation***, and we work with ***instances*** of a class.

## 1. CREATING AND USING A CLASS

Let’s write our first class, `Restaurant` with *two parameters* ( `restaurant_name` and `cuisine_type`) and *three methods* ( `__init__()`, `describe_restaurant` and `open_restaurant`)

Using this `class`, we can write any `instance` with the access to these methods:

### 1.1. Creating a Class

```python
class Restaurant():
    """ A class to store two attributes of a restaurant and have two methods (behaviors)"""
    def __init__(self, restaurant_name, cuisine_type):
        self.restaurant_name = restaurant_name
        self.cuisine_type = cuisine_type

    def describe_restaurant(self):
        print(f"{self.restaurant_name.title()} serves {self.cuisine_type.title()} food.")

    def open_restaurant(self):
        print(f"{self.restaurant_name.title()} is open now.")
```

* **Method:** a `function` that is part of a `class` is called ***method***
* **Special Method:** `__init__()` is a special method and it is required. Python runs this automatically whenever we create a new `object` based on the `class`.
* **Parameters of Special Method:** the `__init__()` method have three parameters: `self`, `restaurant_name` and `cuisine_type`. The `self` parameter is required in the method definition, and it must come first, before the other parameters
* **Attributes:** any variable prefixed with *self* is called an ***attribute***. Attributes are available to every method in the `class`, and we can also access these variables(attributes) through any `instance` created from the `class`.
* **Other Defined Methods:** the `Restaurant` class has two other methods: `describe_restaurant` and `open_restaurant` Because these methods don’t need additional information (parameters), we just define them to have one parameter, `self`.

### 1.2. Making instances of Class

```python
# making instance,restaurant from the class,Restaurant
restaurant = Restaurant('arabian nights','middle eastern')

# printing attributes
print(restaurant.restaurant_name)
print(restaurant.cuisine_type)

# calling methods of the class
restaurant.describe_restaurant()
restaurant.open_restaurant()
```

```
arabian nights
middle eastern
Arabian Nights serves Middle Eastern food.
Arabian Nights is open now.
```

#### a. Making instance

```python
restaurant = Restaurant('arabian nights','middle eastern')
```

* this line tells Python to create an instance(object), `restaurant` whose `restaurant_name` value is `arabian nights` and `cuisine_type` is `middle eastern` . When Python reads this line, it calls the `__init__()` method in `Restaurant` class with the arguments `arabian nights` and `middle eastern`
* the `__init__()` method creates an `instance` representing this particular restaurant and sets the `restaurant_name` and `cuisine_type` using the values provided
* you can create as many instances from a class as you need.

#### b. Accessing Attributes

```python
print(restaurant.restaurant_name)
print(restaurant.cuisine_type)
```

To access these attributes value directly we use dot notation — `restaurant.restaurant_name` and `restaurant.cuisine_type` The syntax is essentially `instance_name.parameter_name`

#### c. Calling Methods

```python
restaurant.describe_restaurant()
restaurant.open_restaurant()
```

We can also use dot notation to call any method defined in `Restaurant` — `restaurant.describe_restaurant()` and `restaurant.open_restaurant()`

#### d. Output

```
arabian nights
middle eastern
Arabian Nights serves Middle Eastern food.
Arabian Nights is open now.
```

## 2. WORKING WITH CLASSES AND INSTANCES

In this section, we will learn how to set a default value for attributes and, three ways to modify attributes value. We will use the previous example of class `Restaurant` in this section.

### 2.1. Setting a Default Value for an Attribute

> Every `attribute` in a `class` needs an initial value, even if that value is 0 or an empty string. We can specify this initial value in the body of the `__init__()` method; \*if we do set a default value for an attribute, we don’t have to include a parameter for that attribute in method definition \*

Let’s add an attribute called `numbers_served` and set its default value to `0`

```python
class Restaurant():
    def __init__(self, restaurant_name, cuisine_type):
        self.restaurant_name = restaurant_name
        self.cuisine_type = cuisine_type
        self.numbers_served = 0
```

As we have specified the default value of `self.numbers_served`, we don’t need to mention the parameter `numbers_served` in the `__init__()`

We are going to create an instance and print the (default) attribute value for `numbers_served`

```python
restaurant = Restaurant('atlas kitchen', 'chinese')
print(f"{restaurant.restaurant_name.title()} has served {restaurant.numbers_served} customers") 
```

```
Atlas Kitchen has served 0 customers
```

### 2.2. Modifying Attribute Values

We will use three methods to modify an attribute value:

#### a. *Modifying* an Attribute Value *Directly*

The simplest way to modify the value of an attribute is *to declare the new value in the instance of the class.* Here, we will modify the default value of attribute `numbers_served` to 100

```python
restaurant = Restaurant('atlas kitchen', 'chinese')

# declaring new value to overwrite the default value
restaurant.numbers_served = 100
print(f"{restaurant.restaurant_name.title()} has served {restaurant.numbers_served} customers") 
```

```
Atlas Kitchen has served 100 customers
```

#### b. *Modifying* an attribute’s Value *through a Method*

Instead of modifying the attribute value directly, we can pass the new value to a method (`set_numbers_served`) that handles the updating internally.

Note: In the following example, we could set the new value without using `if` statement, but that will make the code open to errors where new value can be lower than the old value of `numbers_served`

```python
---
	 def set_numbers_served(self, numbers_served):
        if numbers_served >= self.numbers_served:
            self.numbers_served = numbers_served
            print(f"{self.restaurant_name.title()} has served {self.numbers_served} customers")
        else:
            print("Numbers served can't be lower than previous value")
```

Then we call this new method:

```python
restaurant = Restaurant('atlas kitchen', 'chinese')
print(f"{restaurant.restaurant_name.title()} has served {restaurant.numbers_served} customers") 

# updating numbers_served by calling method, set_numbers_served
restaurant.set_numbers_served(10)
```

```
Atlas Kitchen has served 0 customers
Atlas Kitchen has served 10 customers
```

#### c. *Incrementing* an attribute’s Value *through a Method*

Sometimes we want to *increment* an attribute’s value by a certain amount rather than set an entirely new value. Here’s a method that allows us to pass this incremental amount and add that value to the existing value of `numbers_served`

```python
---

	def increment_numbers_served(self, increment_numbers):
        if increment_numbers >= 1:
            self.numbers_served += increment_numbers
            print(f"{self.restaurant_name.title()} has served {self.numbers_served} customers")
        else:
            print("You can't increment the value by negative number or zero")
```

Let’s make an instance and call this new method:

```python
restaurant = Restaurant('atlas kitchen', 'chinese')
print(f"{restaurant.restaurant_name.title()} has served {restaurant.numbers_served} customers") 

# incrementing numbers_served by calling method, increment_numbers_served
restaurant.increment_numbers_served(5)
restaurant.increment_numbers_served(3)
restaurant.increment_numbers_served(2) 
```

```
Atlas Kitchen has served 0 customers
Atlas Kitchen has served 5 customers
Atlas Kitchen has served 8 customers
Atlas Kitchen has served 10 customers
```

## 3. INHERITANCE

* In this section we will study the parent-child class structure. If the class we are writing (child) is a specialized version of another class (parent) that we wrote before, we can use **inheritance** structure
* The original class is called the *parent class* (superclass), and the new class is the *child class* (subclass)
* The *child class* automatically inherits all attributes and methods from its *parent class* but at the same time, can define new attributes and methods of its own
* **Rule 1:** When we create a *child class*, the *parent class* must be part of the current file
* **Rule 2:** When we create a *child class*, the *parent class* must appear before the child class in the file
* **Rule 3:** The name of the *parent class* must be included in parentheses `()` in the definition of the *child class*

### 3.1. Creating Child Class from Parent Class

Let’s create a child class, `IceCreamStand` from parent class, `Restaurant`

#### a. Creating Child Class

```python
# creating child class, IceCreamStand
class IceCreamStand(Restaurant):
    """ defining the parameters needed for the child class"""
    def __init__(self, restaurant_name, cuisine_type):
        """ Initializing the attributes from the parent class """
        super().__init__(restaurant_name, cuisine_type)
        # declaring attributes specific to child class
        self.flavors = []
    
    # defining method specific to child class
    def display_flavors(self):
        """ take the list of flavors provided by user and print it """
        print(f"{self.restaurant_name.title()} serves the ice cream in following flavors:")
        for self.flavor in self.flavors:
            print(self.flavor.title())
```

* we created the child class, `IceCreamStand` by providing the name of parent class, `Restaurant` within its parenthesis — `IceCreamStand(Restaurant):`
* The `__init__()` method takes in the required parameters from the parent class, `Restaurant`
* The `super().__init__` function is a special function that helps Python *make connections between the parent and child class*. This line tells Python to call the `__init__()` method in *parent class*, which gives *child class* all the attributes of its *parent class*
* then, we defined the attribute `self.flavors` specific to *child class*. This supposed to be `list`, so we gave it an empty list default value `[]`
* then, we defined a method, `display_flavors(self)` that is specific to child class. This method used the `self.flavors` attribute and print the value of each item in the list

#### b. Creating Instance of Child Class

```python
# creating instance of child class
icecreamstand_1 = IceCreamStand('dairy queen', 'ice cream')

# demo: child class inherits all methods from its parent class
icecreamstand_1.describe_restaurant()

# providing child class its specific attribute and calling its specific method
icecreamstand_1.flavors = ['vanilla', 'chocolate', 'strawberry']
icecreamstand_1.display_flavors()
```

```
Dairy Queen serves Ice Cream food.
Dairy Queen serves the ice cream in following flavors:
Vanilla
Chocolate
Strawberry
```

* we created an instance of child class (`IceCreamStand`), `icecreamstand_1` Arguments provided are `dairy queen`, and `ice cream`
* then we call a method, which is defined in the parent class to demonstrate how the inheritance works
* then we provided the attribute (`icecreamstand_1.flavors`) that is specific to child class (`IceCreamStand`) and call the method (`icecreamstand_1.display_flavors()`)that is also specific to child class

### 3.2. Overwriting Methods from the Parent Class

We can overwrite any method from the parent class when creating a child class. To do this, we **define a method in the child class with the same name as the method we want to overwrite from the parent class**. Python intelligently uses the method that we defined in the child class.

### 3.3. Instances as Attributes

When our program start becoming lengthier and messier, we can break it down into various classes, where an instance of class can become an attribute of another class. The example below will help understand this concept:

```python
# class to show the admin previledges
class Privileges():
    """ Print admin previledges"""
    def __init__(self):
        self.privileges = ['can add post', 'can delete post', 'can band user']

    def show_privileges(self):
        print("Admin has following privileges:")
        for self.privilege in self.privileges:
            print(self.privilege)

class User():
    """ To display the general information of a user """
    def __init__(self, first_name, last_name, gender, email_address):
        self.first_name = first_name
        self.last_name = last_name
        self.gender = gender
        self.email_address = email_address
        # attribute = instance of the class
        self.privileges = Privileges()

    def greet_user(self):
        print(f"Welcome to the website {self.first_name.title()} {self.last_name.title()}")

# making instance of 'User' class
admin_user = User('dexter', 'morgan', 'male', 'dexter.morgan@apd.com')

# calling method of 'User' class
admin_user.greet_user()
# calling method of 'Priviledges' class 
admin_user.privileges.show_privileges()
```

* we first defined the class (`Privileges`) that will be used as an *attribute* in other class (`User`) `Privileges` class has only one method, `show_priviledges`
* then while defining the `User` class, we give it an attribute of `privileges` that takes its value from instance of `Privileges` class

```python
self.privileges = Privileges()
```

* then we created an instance of `User`, named `admin_user` and provided the arguments

```python
admin_user = User('dexter', 'morgan', 'male', 'dexter.morgan@apd.com')
```

* finally we call the `show_privileges` method of `Privilege` class, as an attribute of `User` class

```python
admin_user.privileges.show_privileges()
```

## 4. IMPORTING CLASSES

The method to import class(es) from modules is similar to what we have covered in the Part 7: Functions in Python

### 4.1. Importing a Single Class

> In theory, we can store as many classes as we need in a single module, but it is good is each class in a module are related

#### a. Syntax to import

```python
from module_name import class_name
```

The *import* statement tells Python to open the module and *import* the class, `class_name`.

#### b. Syntax to create instance

The syntax to create `instance` is same as if the `class` is defined in the same file

```python
instance_name = class_name(argument1, argument2, ...)
```

### 4.2. Importing Multiple Classes from a Module

#### a. Syntax to import

You `import` multiple classes from a module by separating each `class` with a comma

```python
from module_name import class_name1, class_name2
```

#### b. Syntax to create instance

The syntax to create `instance` is same as if the `class` is defined in the same file

```python
instance_name = class_name1(argument1, argument2, ...)
```

### 4.3. Importing an Entire Module

We can also *import* an entire *module* and then access the `classes` you need using dot notation. Therefore, we need to use the module name whenever creating the instance

#### a. Syntax to import

```python
import module_name
```

#### b. Syntax to create instance

```python
instance_name = module_name.class_name(argument1, argument2, ...)
```

### 4.4. Importing All Classes from a Module

We can import all classes using the syntax defined in section 4.3. Then why do we need to `import` all `classes` from module using this method? Because this way, we don’t need to use the dot notation.

#### a. Syntax to import

```python
from module_name import *
```

#### b. Syntax to create instance

```python
instance_name = class_name(argument1, argument2, ...)
```

> ALERT: This method to import classes is not recommended

### 4.5. Importing a Module into a Module

One module maybe dependent on other modules, therefore, at the start of file, we can import other modules and required classes, using syntax

```python
# start of module_1 

from module_name_2 import class_name_x
from module_name_6 import class_name_y
```

The syntax to create instance is same as if the class is defined in the same file
