<jupyter_start><jupyter_text>Object Oriented Programming**Objectives:*** Define and understand classes and objects.* Understand encapsulation and how classes support information hiding and implementation independence.* Understand inheritance and how it promotes software reuse.* Understand polymorphism and how it enables code generalisation.* Exclude: method overloading and multiple inheritance  0. RecapWe have used many Python's built-in classes, e.g. `int`, `str`, `float`, `list`, `tuple` etc.**Question:**How to find out the class of an object, e.g. `a = 'abc'`?* The built-in method `type()` returns type (class) of an object. * It actually make use of the `__class__` attribute of the object.<jupyter_code># properties are variables
# methods are functions - define behaviour of a class

a = 'abc'
print(type(a))
print(a.__class__)<jupyter_output><class 'str'>
<class 'str'><jupyter_text>**Question:**What are the attributes in the object `a = 'abc'` ?* Make use of help function provided by Jupyter Notebook.* Or use built-in `dir()` method.<jupyter_code>#a.*?
dir(a)<jupyter_output><empty_output><jupyter_text>**Question:**How to check whether an object belongs to a particular class?* The `isinstance()` method can be used to test whether an object belongs to a class.* All classes have an attribute `__name__` which returns string representation of class name.<jupyter_code>print(isinstance(a, str)) # check whether a belongs to class str
print(type(a) is str) # type(a) gives us the class of the variable
print(a.__class__ is str)
print(type(a).__name__) # type(a).__name__ gives us the string attribute of the name of the class<jupyter_output>True
True
True
str<jupyter_text>In Python, everything is an object. * This includes classes (types).* The `id()` method can be used to get unique ID of an object.**Question:**What is the ID of the `str` class, and ID of a str object `a = 'abc'`?<jupyter_code>print(id(str))
a = 'abc'
print(id(a))
print(id(type(a)))
b = 'bcd'
print(id(type(b)))<jupyter_output>4429723000
4432267952
4429723000
4429723000<jupyter_text>1. Class BasicsClasses are blueprints/template for objects. They define the **structure** and **behavior** of objects. * Python is highly object-oriented. * But it does not force you to use it until you need to do so. Creating a new object is called `instantiation`. An **object** of a class is also called an **instance** of that class.* Multiple objects can be created from same class.  Class DefinitionClasses are defined using the `class` keyword followed by CamelCase name.* Class instances are created by calling the class as if it is a function.<jupyter_code>class Vehicle:
    pass

v = Vehicle()
isinstance(v, Vehicle)
#dir(Vehicle)
Vehicle.*?<jupyter_output><empty_output><jupyter_text>When you print an instance, Python shows its class and its memory location.<jupyter_code>print(Vehicle)
print(v)<jupyter_output><class '__main__.Vehicle'>
<__main__.Vehicle object at 0x10a7c1b50><jupyter_text>Instance AttributesYou can assign values to an object using dot notation. These values are called `attributes` of the instance. * In Python, you do NOT need to declare a variable before using it. Similarly, you do NOT need to declare an attribute for an object before you use it either.* Assigning value to an non-existence attribute will create that attribute.* But using a non-existing attribute directly will cause an `AttributeError` Exception. **Exercise:*** Create an object `v` of class `Vehicle`* Check whether `v` has an attribute `color` using either `hasattr()` or `dir()` method* Assign value `blue` to its attribute `color`* Check existence of `color` attribute again<jupyter_code>v = Vehicle()
v1 = Vehicle()
v2 = Vehicle()

print(hasattr(v, 'color'))
v.color = 'blue'
print(hasattr(v, 'color'))

v1.wheels = 4
v2.name = '<NAME>'

print(v1.wheels)
print(v2.name)<jupyter_output>False
True
4
Ah Boy<jupyter_text>**Question:**What happens if you try to print out an non-existence attribute `horsepower`?* It causes `AttributeError` because `horsepower` attribute does not exist<jupyter_code>print(v.horsepower)<jupyter_output><empty_output><jupyter_text>Initializer Method \_\_init\_\_()Python class has an initializer method, `__init__()`, which will be automatically called to initialize the newly created object.* `__init__()` is a **dunder** method which generally are used by Python compiler.* Its definition is similar to function definition except that its first argument is `self`.* It can take in additional arguments. Instance AttributesIts common to initialize **Instance Attributes** in the initializer method `__init__()`. Keyword `self`To access any instance method or instance attribute in the class, you need to prefix it with `self.`.<jupyter_code>class Vehicle:
#     def __init__(self):
#         self.horsepower = 1600
#         self.color = 'black'
#         self.wheels = 4
    
    def __init__(self, horsepower, color = 'black', wheels=4):
        self.horsepower = horsepower
        self.color = color
        self.wheels = wheels
       
v = Vehicle(100)
v.wheels = 6
v.horsepower = 1000
print(v.horsepower, v.color, v.wheels)
def add_all(x, *y):
    print(x)
    print(y)
    
add_all(100, 1, 2, 3, 4)
def add_all_keys(x, **y):
    print(x)
    print(y)
    
add_all_keys(100, a = 10, b = 20, c = 30)
class Vehicle:
    def __init__(self, **args):
        print(args)
        for k,v in args.items():
            print(k, v)
            setattr(self, k, v)

v = Vehicle(a = 10, b = 20)
print(v.a)
print(v.b)<jupyter_output>{'a': 10, 'b': 20}
a 10
b 20
10
20<jupyter_text>Instance Methods**Methods** are functions defined within a class.**Instance Methods** are functions can be called on objects. * It defines the **behavior** of objects of the class.* Methods are called using `instance.method()`. **Argument `self`*** The `self` attribute must be the first input parameter for all instance methods.* The `self` attribute is refer to current object of the class, i.e. the instance calling the method.     * This is similar to the `this` in C or Java.* When a instance method is called, `self` argument is omitted.<jupyter_code>class Vehicle:
    def __init__(self, horsepower, color = 'black'):
        self.horsepower = horsepower
        self.color = color
        
    def engine_power_hp(self):
        return self.horsepower
    
    def engine_power_kw(self):
        return self.horsepower * 0.745699872
    
v = Vehicle(100, 'Blue')
'{} hp = {} kw'.format(v.engine_power_hp(), v.engine_power_kw())<jupyter_output><empty_output><jupyter_text>Naming Conventions A class may contain following attributes:* instance variables* class variables* constructor* instance methods* static methods* class methodsPython has a recommend convention for naming of classes and their attributes. It is good to follow these convention for readability of your code.https://visualgit.readthedocs.io/en/latest/pages/naming_convention.html  DocstringSimiliar to modules and functions. You can add docstring to class and its methods.* Docstring is enclosed by triple-quotes* It must be the 1st statement in the class* Docstrings can be accesses by `__doc__` attribute* It is used by the `help()` function<jupyter_code>def add(x, y):
    '''add x and y'''
    pass

help(add)
class Vehicle:
    '''Class Vehicle
        attributes: horsepower, color
        methods: engine_power_hp(), engine_power_kw()
    '''
    
    def __init__(self, horsepower, color = 'black'):
        self.horsepower = horsepower
        self.color = color
        
    def engine_power_hp(self):
        '''Return engine power in horsepower'''
        return self.horsepower
    
    def engine_power_kw(self):
        '''Return engine power in Kilowatt'''
        return self.horsepower * 0.745699872
    
v = Vehicle(100, 'Blue')
'{} hp = {} kw'.format(v.engine_power_hp(), v.engine_power_kw())

print(Vehicle.__doc__)
print(Vehicle.engine_power_kw.__doc__)
print(Vehicle)<jupyter_output>Class Vehicle
        attributes: horsepower, color
        methods: engine_power_hp(), engine_power_kw()
    
Return engine power in Kilo Watt
<class '__main__.Vehicle'><jupyter_text>2. Convert to Object to StringPython provides 2 methods `str()` and `repr()` to convert an object to string.<jupyter_code>s = list(range(5))
print(s)
print(str(s))
print(repr(s))
s = 'abc\tdef'
print(s) #same as print(str(s))
print(repr(s))<jupyter_output>abc	def
'abc\tdef'<jupyter_text>Difference between str() and repr()The `str()` function is meant to return representations of values which are fairly human-readable, while `repr()` is meant to generate representations which can be read by the interpreter.<jupyter_code>import datetime
now = datetime.datetime.now()
print(str(now))
print(repr(now))<jupyter_output>2020-03-11 11:03:25.960835
datetime.datetime(2020, 3, 11, 11, 3, 25, 960835)<jupyter_text>The `repr()` method returns a printable representational string of the given object, which would yield an object with the same value when passed to eval().<jupyter_code>s = repr(now)
print(s)

now2 = eval(s)
print(now2)
print(now == now2) #now is the same object as eval(repr(now))<jupyter_output>datetime.datetime(2020, 3, 11, 11, 3, 25, 960835)
2020-03-11 11:03:25.960835
True<jupyter_text>The `str()` method returns the "informal" or nicely printable representation of a given object, which is suitable to present information to end-user. By default, the `print()` method uses `str()` to convert object to string.In the `format()` method, you can use converstion flag`!s` and `!r` to call `str()` and `repr()` methods respectively.  Implement `__str__()` and `__repr__()` for Custom ObjectBy default, our `Vehicle` class inherits `__str__()` and `__repr__()` methods from `Object` class, which print class name and memory location of the object.<jupyter_code>class Vehicle:
    def __init__(self, plate):
        self.plate = plate

v1 = Vehicle('A1234')

print(str(v1))
print(repr(v1))<jupyter_output><__main__.Vehicle object at 0x10a7f8f10>
<__main__.Vehicle object at 0x10a7f8f10><jupyter_text>Internally, `repr()` method calls `__repr__()` method of the given object, and `str()` method calls `__str__()` method of given object.**Exercise:**For our `Vehicle` class to support `str()` and `repr()` methods, we can implement `__repr__()` and `__str__()` methods in the class. For example, * the `str()` will print out `"Vehicle: A1234"`* the `repr()` will print out `"Vehicle('A1234')"`<jupyter_code>changefun = lambda x: '{:.2f}'.format(x) #same as lambda x:'%.2f' % x
changefun(1.234)
class Vehicle:
    def __init__(self, plate):
        self.plate = plate
        
    def __str__(self):
        return 'Vehicle: {}'.format(self.plate)
    
    def __repr__(self):
        return "Vehicle('{}')".format(self.plate) #for quotes to be printed, the outer quote used must be different from that used inside.

v1 = Vehicle('A1234')

print(str(v1))
print(repr(v1))

v2 = eval(repr(v1))
print(v2)<jupyter_output>Vehicle: A1234
Vehicle('A1234')
Vehicle: A1234<jupyter_text>3. Class AttributesClass Attributes are attributes which belong to class instead of a particular object.* It can be accessed through either class or instance. Example 1Create a `Time` class contains 3 instance attributes, `hour`, `minute` and `second`. * Implement its `__str__()` method which return time in "hh:mm:ss" format.Sample Output```10:20:30```<jupyter_code>class Customer:
    next_serial = 1
    
    def __init__(self):
#         self.serial = type(self).next_serial
#         type(self).next_serial += 1

#         self.serial = Customer.next_serial
#         Customer.next_serial += 1

        cls = type(self)
        self.serial = cls.next_serial
        cls.next_serial += 1
        
# Test
s1 = Customer()
s2 = Customer()

print(s1.serial)
print(s2.serial)
print(Customer.next_serial, s1.next_serial, s2.next_serial)
class Student:
    level = 'A'
    
    def __init__(self, name, num):
#         self.serial = type(self).next_serial
#         type(self).next_serial += 1

#         self.serial = Customer.next_serial
#         Customer.next_serial += 1

        cls = type(self)
        self.name = name
        self.stdid = cls.level + num
        
    def __str__(self):
        return '{} ({})'.format(self.name, self.stdid)
        
        
# Test
s1 = Student('mark', '1234')

Student.level = 'B'
s2 = Student('alan', '2345')

print(s1)
print(s2)<jupyter_output>mark (A1234)
alan (B2345)<jupyter_text>Exampe 1 (cotinued)To support validation of initial values, we defined 2 class attributes MAX_HOUR and MAX_MIN_SEC, which has value 24 and 60 respectively.* These class attributes are used during input validation.<jupyter_code>class Time:
    
    MAX_HOUR = 24
    MAX_MIN_SEC = 60
    
    def __init__(self, hour = 0, minute = 0, second = 0):
        if hour < 0 or hour >= Time.MAX_HOUR:
            raise ValueError('Hour must be between 0 and 23')
            
        if minute < 0 or minute >= Time.MAX_MIN_SEC or second < 0 or second >= Time.MAX_MIN_SEC:
            raise ValueError('Minute/Second must be between 0 and 59')
        
        self.hour = hour
        self.minute = minute
        self.second = second
        
    def __str__(self):
        return "Time created is {}:{}:{}".format(self.hour, self.minute, self.second)
    
try:
    t = Time(1, 23, 75)
    
except Exception as e:
    print(e)

print(t)
print(t.hour, t.minute, t.second)<jupyter_output>Minute/Second must be between 0 and 59
Time created is 1:23:45
1 23 45<jupyter_text>Example 2We can use class attributes to keep a rolling value which is shared among all instances. For example, we would like to keep track of number of Customers and assign each customer a unique serial number.Sample Output```1233```  Instance Attribute vs Class AttributeInstance attributes belong to a particular instance.* Modifying instance attribute of an instance does not affect other instances.Class attributes are shared among all instances. * They can be accessed not only through class but also through an instance.   Modify a Class AttributeModification to class attribute can only be done with the notation `ClassName.AttributeName`. Otherwise, a new instance variable will be created.<jupyter_code>class A:
    cls_attr = 'original'
    
x = A()
print(x.cls_attr)

A.cls_attr = "modified"
print(x.cls_attr)
print(x.cls_attr is A.cls_attr)<jupyter_output>original
modified
True<jupyter_text>Question:In above example, what if you modify `A.cls_attr = "my value"` to `x.cls_attr = "my value"`?  Mutable vs Immutable (Confusing)Modification to attribute of immutable type will create a new object.<jupyter_code>x = 10

def myfun():
    print('from global, x = {}'.format(x))
    #x = 20
    print(x, id(x))

myfun()
print(x, id(x))
class A:
    cls_attr = 'original'
    
x = A()
print(x.cls_attr)

x.cls_attr = "modified"
print(x.cls_attr)
print(x.cls_attr is A.cls_attr)<jupyter_output>original
modified
False<jupyter_text>4. Static Methods and Class Methods Static MethodsIn Python, all instance methods have `self` as their first argument. Static methods in Python are similar to instance methods, the difference being that a static method is bound to a class rather than the objects for that class.* A static method is a method which does not has `self` as its first argument. * It can be called without an object of that class. * This also means that static methods cannot modify the state of an object as they are not bound to it. Static method are declared using `@staticmethod` decorator. * The `@staticmethod` decorator is optional. But static method without `@staticmethod` decorator cannot be called from its instance.For example, we add a static method in `Time` class to check if a hour is AM or PM.<jupyter_code>class Time:
    
    MAX_HOUR = 24
    MAX_MIN_SEC = 60
    
    def __init__(self, hour = 0, minute = 0, second = 0):
        if hour < 0 or hour >= Time.MAX_HOUR:
            raise ValueError('Hour must be between 0 and 23')
            
        if minute < 0 or minute >= Time.MAX_MIN_SEC or second < 0 or second >= Time.MAX_MIN_SEC:
            raise ValueError('Minute/Second must be between 0 and 59')
        
        self.hour = hour
        self.minute = minute
        self.second = second
        
    @staticmethod
    def get_am_pm(hour):
        if hour < 12:
            return 'AM'
        else:
            return 'PM'
        
    def __str__(self):
        return "Time created is {}:{}:{}".format(self.hour, self.minute, self.second)
    
try:
    t = Time(13, 23, 55)
except Exception as e:
    print(e)

print(t)
print(t.hour, t.minute, t.second)

print(Time.get_am_pm(t.hour))<jupyter_output>Time created is 13:23:55
13 23 55
PM<jupyter_text>Class MethodsClass methods are much like **static method**. They are methods that are bound to a class rather than its object.The difference between a static method and a class method is:* Static method knows nothing about the class and just deals with the parameters.* Class method works with the class since its parameter is always the class itself.To create a class method, use `@classmethod` decorator. Example:To buy a car in Singapore, buyer needs to purchase a Certificate of Entitlement or COE. COEs were divided into several categories.* Cat A: Cars with engine capacity at 1600cc & below, and the engine power should not exceed 97 kilowatts (kW)* Cat B: Cars with engine capacity above 1600cc, or the engine power output exceeds 97 kW Implement a `Car` class with following specifications.* It has 2 instance attributes `engine_capacity` and `category`* Its initializer accepts 1 input to initialize `engine_capacity`* It has a class method `get_category()` which returns category A or B based on parameter engine_capacity value* It also defines a class attribute `MAX_CAT_A_CC` with value 1600.<jupyter_code>class Car:
    def category(self, MAX):
        if self.engine_capacity <= MAX:
            return 'A'
        else:
            return 'B'
class Car:
    MAX_CC = 1600
    
    @classmethod
    def category(cls, engine_capacity):
        if engine_capacity <= cls.MAX_CC:
            return 'A'
        else:
            return 'B'
class Car:
    
    def static_category(self, MAX):
        if self.engine_capacity <= MAX:
            return 'A'
        else:
            return 'B'
        
    def __init__(self, engine_capacity):
        self.engine_capacity = engine_capacity
        #use static method
        self.category = self.static_category(1600)
        
c1 = Car(1000)
print(c1.category)
c2 = Car(1700)
print(c2.category)
class Car:
    
    @staticmethod
    def static_category(engine_capacity, MAX):
        if engine_capacity <= MAX:
            return 'A'
        else:
            return 'B'
        
    def __init__(self, engine_capacity):
        self.engine_capacity = engine_capacity
        #use static method
        self.category = Car.static_category(engine_capacity, 1600)
        
c1 = Car(1000)
print(c1.category)
c2 = Car(1700)
print(c2.category)<jupyter_output>A
B<jupyter_text>5. PropertiesIt is common to have **getter** and **setter** methods in the class, which manage access and modification to attributes in the class.  Example:* In `Person` class, `_name` attribute is marked as "private".* For ourside world to access and modify `_name`, the `getName()` and `setName()` methods are provided.  Property DecoratorPython provides `@property` decorator for getter and setter methods. Property makes getter and setters look like a normal attribute.  * The `@property` decorator marks the getter method* The `@attr.setter` decorator marks the setter method for attribute attr  Read-only Computed AttributeIt is common to use @property to implement a read-only computed attribute. Exercise:Construct a class `Circle` which has a "private" attribute `radius`.* Implement its initializer to initialize `radius` from input* Implement property methods of `radius`* Implement 2 read-only properties which returns area and perimeter<jupyter_code>import math

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def c_radius(self):
        return self._radius

    @c_radius.setter
    def c_radius(self, new_radius):
        if radius <= 0:
            raise ValueError("Radius must be positive")
        else:
            self._radius = new_radius

    @property
    def area(self):
        return math.pi*(self._radius)**2

    @property
    def circumference(self):
        return 2*math.pi*_radius


c  = Circle(3)
print(c.c_radius)
print(c.area)<jupyter_output>3
28.274333882308138<jupyter_text>6. InheritanceSimilar to other programming languages, Python allows class inheritance.In following code sample, both class `B` and `C` inherit from class `A`.```pythonclass A:    pass    class B(A):    pass    class C(A):    pass```The special attribute `__base__` returns its 1st base class. To get all base classes, use attribute `__bases__`.We can test whether a class is subclass of one or more classes using `issubclass()` method.  Code ReuseA sub-class can be derived from a base-class and inherit its methods and variables. This allows sharing of implementation between classes, which avoids code duplication ExerciseStudy the code in `Teacher` and `Student` classes and perform following:* Abstract common code in `Teacher` and `Student` class to a `Person` class* Modify following two classes to inherit from `Person` class```class Teacher():    def __init__(self, firstName, lastName):        self._firstName = firstName        self._lastName = lastName    @property    def fullname(self):        return self._firstName + ' ' + self._lastName        def __str__(self):        return '{}: {}'.format(type(self).__name__, self.fullname())        def work(self):        print("{} is working".format(self.fullname))    class Student():    def __init__(self, firstName, lastName):        self._firstName = firstName        self._lastName = lastName    @property    def fullname(self):        return self._firstName + ' ' + self._lastName    def __str__(self):        return '{}: {}'.format(type(self).__name__, self.fullname)    def study(self):        print("{} is studying".format(self.fullname))```<jupyter_code>class Person():
    def __init__(self, firstName, lastName):
        self._firstName = firstName
        self._lastName = lastName

    @property
    def fullname(self):
        return self._firstName + ' ' + self._lastName
    
    def __str__(self):
        return '{}:{}'.format(type(self).__name__, self.fullname)

class Teacher(Person):
    def work(self):
        print("{} is working".format(self.fullname))

t = Teacher('Shane', 'Kwek')
print(t.fullname)
print(t)<jupyter_output><NAME>
Teacher:<NAME>
{'fraction_non_alphanumeric': 0.10178877510657935, 'fraction_numerical': 0.020920318199797828, 'mean_word_length': 3.8578138343296327, 'pattern_counts': {'":': 0, '<': 99, '<?xml version=': 0, '>': 93, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '24', 'dir': 'jupyter-structured-clean-dedup', 'id': '485167', 'license': 'MIT', 'n_tokens_mistral': 7185, 'n_tokens_neox': 6724, 'n_words': 2527, 'path': 'Working Folder/Fundamentals_9_Object_Oriented_Programming.ipynb', 'repo_name': 'javkoh/Materials', 'size': '22761'}