<jupyter_start><jupyter_text>Basic Python Class (Object-oriented Programming) Fundamental Idea of ClassA **blue print** contains some information, and we can create multiple objects or instances from a class with same properties (methods or attributes).Target: save time from repeating create instance with same content or functionBased on the concept 'object', a class may contain data as 'attribute', and also function as 'method'.Reference:1. [Python OOP Tutorial 1: Classes and Instances](https://www.youtube.com/watch?v=ZDa-Z5JzLYM) and so following vedios2. [Programming Foundations with Python - Udacity](https://cn.udacity.com/course/programming-foundations-with-python--ud036)<jupyter_code>## Create a class object

class Employee:

    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.fname = first
        self.lname = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
    
    # create method (function within class object)
    def fullname(self):
        return '{} {}'.format(self.fname, self.lname)
## Output a attribute

emp_1 = Employee('Yuet', 'Yeung', 1000)

emp_1.email
## Output the outcome of method

print (emp_1.fullname())

# same as passing an instance to the method in class 
print (Employee.fullname(emp_1))  # actually it is passing self in instance emp_1 into method<jupyter_output><NAME>
<NAME><jupyter_text>Class Variable and Instance Variable**Class variable** is global variable defined in class, which can be understood as default value. For example, customized ones like `num_of_emps`, or pre-defined like `__doc__` and `__name__`. **Instance variable** is manually defined only for particular instance, being effective on that instance.<jupyter_code>class Employee:

    # create class variable
    num_of_emps = 0
    raise_amount = 1.04
    
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.fname = first
        self.lname = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
        
        Employee.num_of_emps += 1  # sum of created employees
    
    # create method (function within class object)
    def fullname(self):
        return '{} {}'.format(self.fname, self.lname)
    
    # create a method using variable
    def apply_raise(self):
        self.pay = int(self.pay * self.raise_amount)
## Check the content of class itself and instance

print (Employee.__dict__)  # all class variables included

emp_1 = Employee('Yuet', 'Yeung', 1000)
print (emp_1.__dict__)  # only include init attributes, no variable
## Check number of employee created

print ('Variable in Employee class: ' + str(Employee.num_of_emps))  # existed in Employee class

# from above, there is no num_of_emps within emp_1
# therefore, this command requests and output the info from Employee class
print ('No variable in instance but request from Employee class: ' + str(emp_1.num_of_emps))  
## Update variable

# update variable in Employee class
Employee.raise_amount = 1.05
print ('Variable updated in Employee class: ' + str(Employee.raise_amount))

# update variable specifically for an instance
# actually add in a instance variable with same name as class variable
emp_1.raise_amount = 1.06
print ('Variable updated in emp_1 instance: ' + str(emp_1.raise_amount))
print (emp_1.__dict__)
## Try to raise payment
emp_1.apply_raise()
print ('raised payment as ' + str(emp_1.pay) + ' by rate ' + str(emp_1.raise_amount))  # follow the raise_amount in instance

# if there is no raise_amount variable in instance, command will request this variable from Employee class<jupyter_output>raised payment as 1060 by rate 1.06<jupyter_text>Regular Method / Class Method / Static Method**Regular Method** passes instance (self) as first argument, and operates with self in method.**Class Method** passes class (cls) as first argument, and operates with cls in method.**Static Method** do not operate with instance or class, and just like a function itself with logically connection with the class.<jupyter_code>class Employee:

    # create class variable
    num_of_emps = 0
    raise_amount = 1.04
    
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.fname = first
        self.lname = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
        
        Employee.num_of_emps += 1  # sum of created employees
    
    # create method (function within class object)
    # regular function
    def fullname(self):
        return '{} {}'.format(self.fname, self.lname)  # operate with instance
    
    # create a method using variable
    # regular function
    def apply_raise(self):
        self.pay = int(self.pay * self.raise_amount)  # operate with instance
        
    # create class function
    @classmethod  # decorator
    def set_raise_amt(cls, amount):
        cls.raise_amount = amount  # operate with class
        
    # create class function as alternative constructor
    @classmethod
    def from_string(cls, emp_str):
        first, last, pay = emp_str.split('-')
        return cls(first, last, pay)  # operate with class
    
    # create static function
    @staticmethod
    def is_workday(day):
        if day.weekday()==5 or day.weekday()==6:
            return False
        return True
## Update raise rate by classmethod

Employee.set_raise_amt(1.06)
print (Employee.raise_amount)

# Exactly the same result as update class variable directly: Employee.raise_amount = 1.06
## Create employee info from input with dash by classmethod

emp_2 = Employee.from_string('Wen-Huishan-2000')
print (emp_2.email)
print (emp_2.pay)
## Judge a day is work day or not by staticmethod

import datetime
my_date = datetime.date(2018, 2, 22)

Employee.is_workday(my_date)<jupyter_output><empty_output><jupyter_text>Subclasses (Inheritance)Subclass is topping upon parent class, inheriting methods from parent class like `__int__` and any customized functions.The inheritance relationship can be understood by `help(subclass_name)` function.<jupyter_code>class Employee:

    # create class variable
    num_of_emps = 0
    raise_amount = 1.04
    
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.fname = first
        self.lname = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
        
        Employee.num_of_emps += 1  # sum of created employees
    
    # create method (function within class object)
    # regular function
    def fullname(self):
        return '{} {}'.format(self.fname, self.lname)  # operate with instance
    
    # create a method using variable
    # regular function
    def apply_raise(self):
        self.pay = int(self.pay * self.raise_amount)  # operate with instance
        
    # create class function
    @classmethod  # decorator
    def set_raise_amt(cls, amount):
        cls.raise_amount = amount  # operate with class
        
    # create class function as alternative constructor
    @classmethod
    def from_string(cls, emp_str):
        first, last, pay = emp_str.split('-')
        return cls(first, last, pay)  # operate with class
    
    # create static function
    @staticmethod
    def is_workday(day):
        if day.weekday()==5 or day.weekday()==6:
            return False
        return True
## Create subclass inheriting from Employee

class Developer(Employee):
    
    # specific raise amount for developer
    raise_amount = 1.10
    
    # add in programming language as init info
    def __init__(self, first, last, pay, prog_lang):
        super().__init__(first, last, pay)  # use parent init for the first three argument
        # can also use Employee.__init__(self, first, last, pay)
        
        self.prog_lang = prog_lang  # add in additional info    
## Understand the method resultion order (chain of inheritance)

print (help(Developer))
## Create a new developer with programming language information

dev_1 = Developer('Shao', 'Guodong', 3000, 'Matlab')
dev_2 = Developer('Zhang', 'Jian', '3000', 'Matlab')

print (dev_1.email)
print (dev_2.email)
## Create another subclass inheriting from Employee

class Manager(Employee):
    
    def __init__(self, first, last, pay, employees=None):
        super().__init__(first, last, pay)
        if employees is None:
            self.employees = []
        else:
            self.employees = employees
    
    def add_emp(self, emp):
        if emp not in self.employees:
            self.employees.append(emp)
    
    def remove_emp(self, emp):
        if emp in self.employees:
            self.employees.remove(emp)
            
    def print_emp(self):
        for emp in self.employees:
            print('-->', emp.fullname())  # here emp will be an instance of Employee class from input
## Create a manager

mgr_1 = Manager('Chandra', 'Verseka', 4000, [dev_1])
mgr_1.print_emp()

# add in employee under him
mgr_1.add_emp(dev_2)
mgr_1.print_emp()
## Verify whether an object is instance of an class

isinstance(mgr_1, Employee)

isinstance(mgr_1, Developer)
## Verify whether a class is a sub-class of another class

issubclass(Developer, Employee)

issubclass(Manager, Developer)<jupyter_output><empty_output><jupyter_text>Special Method (Magic / Dunder)A method starts and ends with double underscore.<jupyter_code>class Employee:

    # create class variable
    num_of_emps = 0
    raise_amount = 1.04
    
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.fname = first
        self.lname = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
        
        Employee.num_of_emps += 1  # sum of created employees
    
    # create method (function within class object)
    # regular function
    def fullname(self):
        return '{} {}'.format(self.fname, self.lname)  # operate with instance
    
    # create special method for ambiguous print output
    # you can define anything here, but __repr__ used for development purpose in convention
    def __repr__(self):
        return "Employee('{}', '{}', '{}')".format(self.fname, self.lname, self.pay)
    
    # create special method for readable print output
    def __str__(self):
        return '{} - {}'.format(self.fullname(), self.email)

    # create special method for adding two employees' pay
    def __add__(self, other):
        return self.pay + other.pay
## Print out Employee, which will be something meaningful instead of object type

emp_1 = Employee('Yuet', 'Yeung', 1000)
print (emp_1)
print (emp_1.__str__())
print (emp_1.__repr__())
## Adding two employees' pay

emp_2 = Employee('Wen', 'Huishan', 2000)
print (emp_1 + emp_2)  # euqally
print (Employee.__add__(emp_1, emp_2))

# actually for normal add operation of int, it works also in __add__ way
print (1+2)  # equally
print (int.__add__(1,2))<jupyter_output>3000
3000
3
3<jupyter_text>Property Decorators -- Getters, Setters and DeletersUsing method as an attribute.<jupyter_code>## Parse the orignial class

class Employee:
   
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.first = first
        self.last = last
        self.pay = pay
        self.email = first + '.' + last + '@company.com'
        
    # create method (function within class object)
    # regular function
    def fullname(self):
        return '{} {}'.format(self.first, self.last)  # operate with instance
## Illustrate the necessary of decoractors

emp_1 = Employee('Yuet', 'Yeung', 1000)

emp_1.last = 'Yang'  # update self.last to 'Yang'
print (emp_1.email)  # no change on email due to already fixed at the time the instance was created
print (emp_1.fullname())  # changed due to getting from self.last while operating this regular method
## Add in decoractors

class Employee:
   
    # create attribute (data within class object)
    def __init__(self, first, last, pay):
        self.first = first
        self.last = last
        self.pay = pay

    # add in property decoractor to use method as attribute
    @property
    def email(self):
        return <EMAIL>'.format(self.first, self.last)
        
    # also change regular method to property getter
    @property
    def fullname(self):
        return '{} {}'.format(self.first, self.last)  # operate with instance
    
    # create setter of fullname
    @fullname.setter  # fullname is already set as property, so here can define its setter function
    def fullname(self, name):
        first, last = name.split(' ')
        self.first = first
        self.last = last
        
    # create deleter of fullname
    @fullname.deleter
    def fullname(self):
        self.first = None
        self.last = None
        print ('Delete Name!')
## Exactly the same as above code, and it works

emp_1 = Employee('Yuet', 'Yeung', 1000)

emp_1.last = 'Yang'  # update self.last to 'Yang'
print (emp_1.email)  # property decoractor makes method to be used as attribute
print (emp_1.fullname)  # also as property
## Use fullname setter to update the attribute

emp_1.fullname = '<NAME>'
print (emp_1.email)
print (emp_1.fullname)
## Use fullname deleter

del emp_1.fullname
print (emp_1.email)
print (emp_1.fullname)<jupyter_output>Delete Name!
<EMAIL>
None None
{'fraction_non_alphanumeric': 0.0934049537451507, 'fraction_numerical': 0.012533572068039392, 'mean_word_length': 3.21143575243481, 'pattern_counts': {'":': 0, '<': 26, '<?xml version=': 0, '>': 27, 'https://': 2, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 7, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '6', 'dir': 'jupyter-structured-clean-dedup', 'id': '363470', 'license': 'MIT', 'n_tokens_mistral': 4068, 'n_tokens_neox': 3876, 'n_words': 1373, 'path': 'Basic Python/Class_OOP.ipynb', 'repo_name': 'YestinYang/Studying-Machine-Deep-Learning', 'size': '13439'}