<jupyter_start><jupyter_text>Object Oriented Programming  ClassIn this section, we will know how to define a class in Python.     We will define a class which has the following methods and attirbutes:- class: `Employee`    - attributes:         1. `name`        2. `salary`    - methods:        1. `set_name`        2. `increase_salary`        3. `set_salary`        In python, classes are defined by `class` keyword and following indented block of code.The attributes in Python classes are defined insided methods using `=` operator.             To refer to an attributes such as `name` inside the class, we use `self.name`. The `self` binds the variable `name` to instance of class.<jupyter_code>class Employee:
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, new_salary):
        self.salary = new_salary
        
    def increase_salary(self, amount):
        self.salary += amount<jupyter_output><empty_output><jupyter_text>To create objects (instances) from class, we use the `ClassName()`. In the Employee example, we write this to create an instance of `Employee` class:```Pythonemployee_1 = Employee()```The `employee_1` is an **object** or an **instance** of `Employee` **class**.<jupyter_code>employee_1 = Employee()
employee_1.set_name("<NAME>")
employee_1.set_salary(50000)
print(employee_1.name)
print(employee_1.salary)
employee_1.increase_salary(50000)
print(employee_1.salary)

# employee_2 = Employee()
employee_1 = Employee()
employee_1.set_name("<NAME>")
# employee_1.set_salary(50000)
print(employee_1.name)
print(employee_1.salary)<jupyter_output><NAME><jupyter_text>class ConstructorClass constructor is a method which will be called everytime a new object is created. In python, the special `__init__` method is the constructor.The `__init__` method is a good place to define the attributes of the class.<jupyter_code>class Employee:
    def __init__(self, name="", salary=0):
        self.name = name
        if salary >= 0:
            self.salary = salary
        else:
#             raise ValueError("The salary could not be a negative number")
            self.salary = 0
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, new_salary):
        self.salary = new_salary
        
    def increase_salary(self, amount):
        self.salary += amount
employee_1 = Employee(name="<NAME>", salary=50000)
print(employee_1.name)
print(employee_1.salary)
employee_1 = Employee(name="<NAME>", salary=-1)
print(employee_1.salary)<jupyter_output>0<jupyter_text>Class level attributes and instance level attributesClass level data is shared among all instances of the class and even it exists without creating an object.        In python, class level attributes are defined insided class, and outsided of any mehtods.```Pythonclass Employee:    MAX_SALARY = 1000000   maximum salary for an employee    MIN_SALARY = 27000   minimun salary for an employee```The `MAX_SALARY` and `MIN_SALARY` attributes are class level attributes.To refer to class level attributes such as `MAX_SALARY` inside the class, we could NOT use. `self.MAX_SALARY` and instead of that we must use ClassName.CLASS_LEVEL_ATTRIBUTE such as `Employee.MAX_SALARY`.<jupyter_code>class Employee:
    
    MAX_SALARY = 1000000  # class level attribute for maximum salary
    MIN_SALARY = 27000  # class level attribute for minimum salary
    
    def __init__(self, name="", salary=0):
        self.name = name
        self.set_salary(salary)
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, salary):
        if salary <= Employee.MAX_SALARY and salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            raise ValueError(
                f"Employe salary must be between "
                f"({Employee.MIN_SALARY}, {Employee.MAX_SALARY})"
            )
        
        
    def increase_salary(self, amount):
        self.salary += amount
        

employee_1 = Employee("<NAME>", 28000)
employee_2 = Employee("<NAME>", 60000)
print(f"MAX_SALARY of employee 1 = {employee_1.MAX_SALARY}")
print(f"MAX_SALARY of employee 2 = {employee_2.MAX_SALARY}")
print(f"Employee MAX_SALARY = {Employee.MAX_SALARY}")
# print(f"Employee MAX_SALARY = {Employee.salary}")  # this should raise AttributeError
print(f"MAX_SALARY of employee 1 = {employee_1.MAX_SALARY}")
print(f"MAX_SALARY of employee 2 = {employee_2.MAX_SALARY}")
print(f"Employee MAX_SALARY = {Employee.MAX_SALARY}")
employee_1.MAX_SALARY = 7777777
print(f"MAX_SALARY of employee 1 = {employee_1.MAX_SALARY}")
print(f"MAX_SALARY of employee 2 = {employee_2.MAX_SALARY}")
print(f"Employee MAX_SALARY = {Employee.MAX_SALARY}")
Employee.MAX_SALARY = 7777777
print(f"MAX_SALARY of employee 1 = {employee_1.MAX_SALARY}")
print(f"MAX_SALARY of employee 2 = {employee_2.MAX_SALARY}")
print(f"Employee MAX_SALARY = {Employee.MAX_SALARY}")<jupyter_output>MAX_SALARY of employee 1 = 7777777
MAX_SALARY of employee 2 = 7777777
Employee MAX_SALARY = 7777777<jupyter_text>Alternative constructorsPython allows us to define class methods as well, using the `@classmethod` decorator and a special first argument `cls`. The main use of class methods is defining methods that return an instance of the class, but aren't using the same code as `__init__()`.<jupyter_code>class Employee:
    
    MAX_SALARY = 1000000  # class level attribute for maximum salary
    MIN_SALARY = 27000  # class level attribute for minimum salary
    
    def __init__(self, name="", salary=0):
        self.name = name
        self.set_salary(salary)
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, salary):
        if salary <= Employee.MAX_SALARY and salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            raise ValueError(
                f"Employe salary must be between "
                f"({Employee.MIN_SALARY}, {Employee.MAX_SALARY})"
            )
            
    @classmethod
    def from_dict(cls, employee_info):
        name = employee_info["name"]
        salary = int(employee_info["salary"])
        return cls(name=name, salary=salary)

        
    def increase_salary(self, amount):
        self.salary += amount
employee_info = {
    "name": "<NAME>",
    "salary": "65000",
}
employee_1 = Employee.from_dict(employee_info)
print(f"Name = {employee_1.name}")
print(f"Salary = {employee_1.salary}")<jupyter_output>Name = <NAME>
Salary = 65000<jupyter_text>InheritanceInheritance means creating a new class by extending functionalities of an existing class.      We defined `Employee` class. Now, in organizations there are persons which are __managers__ of groups. Managers are still employees but they have some more responsibilites. Therefore, in programming we define `Manager` class which is inherited from `Employee` class.    In this case, the `Employee` is called the **parent**, **base** or **super** class. The `Manager` which is inherited from `Employee` class is called **child** class or **sub-class**.<jupyter_code>class Manager(Employee):
    
    def print_role(self):
        print(f"Manager: {self.name}")
manager = Manager(name="<NAME>", salary=200000)
print(f"Manager name: {manager.name}")
print(f"Manage salary: {manager.salary}")
manager.print_role()
type(manager)
isinstance(manager, Manager)
isinstance(manager, Employee)
employee = Employee("<NAME>", 28000)
isinstance(employee, Employee)
isinstance(employee, Manager)<jupyter_output><empty_output><jupyter_text>Inheritance - customized functionalities<jupyter_code>class Employee:
    
    MAX_SALARY = 1000000  # class level attribute for maximum salary
    MIN_SALARY = 27000  # class level attribute for minimum salary
    
    def __init__(self, name="", salary=0):
        self.name = name
        self.set_salary(salary)
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, salary):
        if salary <= Employee.MAX_SALARY and salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            raise ValueError(
                f"Employe salary must be between "
                f"({Employee.MIN_SALARY}, {Employee.MAX_SALARY})"
            )
            
    @classmethod
    def from_dict(cls, employee_info):
        name = employee_info["name"]
        salary = int(employee_info["salary"])
        return cls(name=name, salary=salary)

        
    def increase_salary(self, amount):
        self.salary += amount
class Manager(Employee):
    MIN_SALARY = 50000
    
    def __init__(self, name, salary=50000, project=None):
        Employee.__init__(self, name, salary)
        self.project = project
        
    def increase_salary(self, amount, bonus=1.2):
        Employee.increase_salary(self, amount*bonus)
        
    def print_role(self):
        print(f"Manager: {self.name}")
employee = Employee("<NAME>", 28000)
manager = Manager("<NAME>", 200000)
print(f"Employee : {employee.MIN_SALARY}")
print(f"Manager : {manager.MIN_SALARY}")
manager = Manager("<NAME>", 200000, "SpaceX")
print(f"Manager salary: {manager.salary}")
print(f"Manager project: {manager.project}")
employee = Employee("<NAME>", 60000)
manager = Manager("<NAME>", 60000, "SpaceX")
print(f"Employee salary before increment: {employee.salary}")
print(f"Manager salary before increment: {manager.salary}")
employee.increase_salary(10000)
manager.increase_salary(10000)
print(f"Employee salary after increment: {employee.salary}")
print(f"Manager salary after increment: {manager.salary}")<jupyter_output>Employee salary before increment: 60000
Manager salary before increment: 60000
Employee salary after increment: 70000
Manager salary after increment: 72000.0<jupyter_text>Operator OverloadingIn Python, we can use operator overloading to give new meanings for operators such as `==`, `>`, `>=`, `<`, `<=`, `!=` and etc.<jupyter_code>class Employee:
    
    MAX_SALARY = 1000000  # class level attribute for maximum salary
    MIN_SALARY = 27000  # class level attribute for minimum salary
    
    def __init__(self, name="", salary=0):
        self.name = name
        self.set_salary(salary)
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, salary):
        if salary <= Employee.MAX_SALARY and salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            raise ValueError(
                f"Employe salary must be between "
                f"({Employee.MIN_SALARY}, {Employee.MAX_SALARY})"
            )
            
    # overload == operator
    def __eq__(self, other):
        if self.name == other.name and self.salary == other.salary:
            return True
        else:
            return False
        
    # overload != opeartor
    ...
        
    # overlaod > operator
    def __gt__(self, other):
        if self.salary > other.salary:
            return True
        else:
            return False
    
    @classmethod
    def from_dict(cls, employee_info):
        name = employee_info["name"]
        salary = int(employee_info["salary"])
        return cls(name=name, salary=salary)
        
    def increase_salary(self, amount):
        self.salary += amount
employee_1 = Employee("<NAME>", 50000)
employee_2 = Employee("<NAME>", 50000)
employee_3 = Employee("<NAME>", 100000)
employee_1 == employee_2
employee_1 == employee_3
employee_1 < employee_3
employee_1 > employee_3
class c1:
    def __init__(self, name):
        self.name = name
        
    def __eq__(self, other):
        if type(self) != type(other):
            return False
        if self.name == other.name:
            return True
        else:
            return False
        
class c2:
    def __init__(self, name):
        self.name = name
obj_1 = c1("John")
obj_2 = c2("John")
obj_1 == obj_2
class Parent:
    def __init__(self, name):
        self.name = name
        
    def __eq__(self, other):
        if self.name == other.name:
            print("Parent __eq__ is exectued")
            return True
        else:
            return False
            
class Child(Parent):
    def __init__(self, name):
        self.name = name
        
    def __eq__(self, other):
        if self.name == other.name:
            print("Child __eq__ is exectued")
            return True
        else:
            return False
p = Parent("John")
c = Child("John")
p == p
c == c
p == c<jupyter_output>Child __eq__ is exectued<jupyter_text>Operator Overloading: string representationThere are two special methods in Python classes- `__str__`- `__repr__`    which can be used to achieve string representations for objects.When you use `print` function or `str` function for Python object, the `__str__` method of that object will be executed.   The goal of defining `__str__` for class is to give users of your class a user friendly information about objects which are created from that class.The `__repr__` method is used by `repr` function and also give information about object when you print the object in console **without** using `print` function.<jupyter_code>class Employee:
    
    MAX_SALARY = 1000000  # class level attribute for maximum salary
    MIN_SALARY = 27000  # class level attribute for minimum salary
    
    def __init__(self, name="", salary=0):
        self.name = name
        self.set_salary(salary)
    
    def set_name(self, new_name):
        self.name = new_name
        
    def set_salary(self, salary):
        if salary <= Employee.MAX_SALARY and salary >= Employee.MIN_SALARY:
            self.salary = salary
        else:
            raise ValueError(
                f"Employe salary must be between "
                f"({Employee.MIN_SALARY}, {Employee.MAX_SALARY})"
            )
            
    def __str__(self):
        return f"Employee name: {self.name}\nEmployee salary: {self.salary}"
    
    def __repr__(self):
        return f"Employee('{self.name}', {self.salary})"
    
    def increase_salary(self, amount):
        self.salary += amount
employee = Employee("<NAME>", 50000)
print(employee)
str(employee)
employee
Employee('<NAME>', 50000)<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10064661231374754, 'fraction_numerical': 0.02333427045262862, 'mean_word_length': 3.1496062992125986, 'pattern_counts': {'":': 2, '<': 60, '<?xml version=': 0, '>': 63, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 5, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '9', 'dir': 'jupyter-structured-clean-dedup', 'id': '352881', 'license': 'MIT', 'n_tokens_mistral': 4629, 'n_tokens_neox': 4277, 'n_words': 1242, 'path': 'Lecture-12-Object-Oriented-Programming.ipynb', 'repo_name': 'javadebadi/python-course', 'size': '14318'}