<jupyter_start><jupyter_text>3.1.1.2 Every class is like a recipe which can be used when you want to create a useful object (this is where the name of the approach comes from). You may produce as many objects as you need to solve your problem. Every object has a set of traits (they are called properties or attributes - we'll use both words synonymously) and is able to perform a set of activities (which are called methods). Objects are incarnations of ideas expressed in classes. They are like treadmill instances.  3.1.1.3 Classes are structured in a tree root strucutre. The top being the most general, possessing shared traits. Subclasses possess all superclasses' traits plus some more specific implementation.  3.1.1.5  What is an object? An object is an incarnation of the requirements, traits, and qualities assigned to a specific class. Inheritance An object created from a given class level inherits (possesses) all the traits of its class and that classe's parent classes   3.1.1.6 Object components:  Name: optional as classes can instantiate anonymous objects. Properties: set of attributes either unique to object (such as unique identifiers/counters, called instance attributes) or shared with the parent class(called class attributes) Methods: functions defined in the parent class that can be used by the object  3.1.1.7 Defining a class Similar to defining functions but using the "class" keyword instead of "def" Best practice to naming classes is to follow PEP 8, in the class naming section (https://www.python.org/dev/peps/pep-0008/class-names) "Class names should normally use the CapWords convention." -PEP8 Instantiating a class Similar to calling a function Naming convention same as for function. PEP8 (https://www.python.org/dev/peps/pep-0008/method-names-and-instance-variables) "Use the function naming rules: lowercase with words separated by underscores as necessary to improve readability" -PEP8<jupyter_code># Example - Defining class
class ThisIsAClass:
    pass

# Example - Insatantiating a class
first_class = ThisIsAClass()

#Sanity Check
print(type(ThisIsAClass))
print(type(first_class))<jupyter_output><class 'type'>
<class '__main__.ThisIsAClass'><jupyter_text>3.2 From procedural to object oriented, the stack example The procedural approach: you define functions and call them Shortcomming: Imagine the stack gets deleted/modified incorrectly or you need more than one stack<jupyter_code>stack = []


def push(val):
    stack.append(val)


def pop():
    val = stack[-1]
    del stack[-1]
    return val


push(3)
push(2)
push(1)

print(pop())
print(pop())
print(pop())<jupyter_output>1
2
3<jupyter_text>Filling the gap with OOP:* Encapsulation: protect attributes from being accessed/modified* Reproduce many instances of the same behavior without re-implementing the same code* Extending the "traits" of the object (in this example the stack) by implenting sub-classes and using inheritance  The OOP approach:* A constructor is a function within the class that takes care of instantiation of class objects (instances)    * It has a strict syntax         * Name should always be "\__init__"        * Requires at least one parameter "self" which points to the new instance        * "self" is a convention, any other word can be used but that is not recommended    * Only gets called when new instances are created    * It can "house" attributes (variables such as list, strings, etc)        * Need to be preceded my "self." so that python knows that they belong to the specific instance* Encapsulation works by preceding an atribute by a "dunder" which is a double underscore    * Trying to access such attributes will cause an AttributeError<jupyter_code>class Stack:
    def __init__(self):
        # Contains function (either in-built/inherited as methods/loaded as packages)
        print("New instance, created")
        import math
        
        # Contains attributes
        self.pi = math.pi
        self.stack_list = []

        # Contains private attributes
        self.__private="None of you business"
   


new_stack= Stack()
print(new_stack.pi)
print(len(new_stack.stack_list))
print(new_stack.__private)<jupyter_output>New instance, created
3.141592653589793
0<jupyter_text>Defining Class methods:* Defined just like fucntions because well, they are fucntions* Naming convention:    * No preceding preceding "dunder" and no trailing single underscpre<jupyter_code>class Stack:
    def __init__(self):
        self.__stack_list = []


    def push(self, val):
        self.__stack_list.append(val)


    def pop(self):
        val = self.__stack_list[-1]
        del self.__stack_list[-1]
        return val


stack_object = Stack()

stack_object.push(3)
stack_object.push(2)
stack_object.push(1)

print(stack_object.pop())
print(stack_object.pop())
print(stack_object.pop())<jupyter_output>1
2
3<jupyter_text>Instantiating multiple instances of the same classe* Notice how the second instance is using the return value of 'stack_object_1.pop()' as a parameter<jupyter_code>stack_object_1 = Stack()
stack_object_2 = Stack()

stack_object_1.push(3)
stack_object_2.push(stack_object_1.pop())
print(stack_object_2.pop())<jupyter_output>3<jupyter_text>Defining sub-classes* We want a stack that can evaluate the sum of all the elements currently in the stack* The subclass will inherite the methods and attributes of its superclass. This is done by defining the new class in the following manner    * The subclass definition will have the superclass name as a parameter    * The subclass constructor will call for the execution of the superclass constructor        * class SubClass(SuperClass):         def __init__(self):         SuperClass.__init()<jupyter_code>class AddingStack(Stack):
    def __init__(self):
        Stack.__init__(self)
        self.__sum = 0<jupyter_output><empty_output><jupyter_text>Redefinig methods: Changing the way a method works without changing its name* In the below example the push metho gets redenfined    * It invokes the self.__sum from its own constructor    * It invokes the Stack.push from the Stack superclass<jupyter_code>class AddingStack(Stack):
    def __init__(self):
        Stack.__init__(self)
        self.__sum = 0


    def push(self, val):
        self.__sum += val
        Stack.push(self, val)<jupyter_output><empty_output><jupyter_text>Accessing a private object* In the below example we defnine a method get_sum' to retrieve the '__sum' attribute * In subsequent reading we will discuss setters and getters<jupyter_code>class Stack:
    def __init__(self):
        self.__stack_list = []

    def push(self, val):
        self.__stack_list.append(val)

    def pop(self):
        val = self.__stack_list[-1]
        del self.__stack_list[-1]
        return val


class AddingStack(Stack):
    def __init__(self):
        Stack.__init__(self)
        self.__sum = 0

    def get_sum(self):
        return self.__sum

    def push(self, val):
        self.__sum += val
        Stack.push(self, val)

    def pop(self):
        val = Stack.pop(self)
        self.__sum -= val
        return val


stack_object = AddingStack()

for i in range(5):
    stack_object.push(i)
print(stack_object.get_sum())

for i in range(5):
    print(stack_object.pop())<jupyter_output>10
4
3
2
1
0<jupyter_text>3.3 Properties 3.3.1.1 Instance Properties* Different objects of the same class may possess different sets of properties* There is a way of checking if a specific object own a property* Each object own its own set of properties that don't interfere in one anothe* The \__dict__ method returns a dictionnary of all objects' properties<jupyter_code>class ExampleClass:
    def __init__(self, val = 1):
        self.first = val

    def set_second(self, val):
        self.second = val


example_object_1 = ExampleClass()
example_object_2 = ExampleClass(2)

example_object_2.set_second(3)

example_object_3 = ExampleClass(4)
example_object_3.third = 5

print(example_object_1.__dict__)
print(example_object_2.__dict__)
print(example_object_3.__dict__)<jupyter_output>{'first': 1}
{'first': 2, 'second': 3}
{'first': 4, 'third': 5}<jupyter_text>3.3.1.2 Private properties and "mangling"* When a property gets tagged as private (two leading underscores) it gets referenced differently in the \__dict__ of the object.* In order to invoke this property with no issues it need to be called using a single underscore followed by the classname followed by the property<jupyter_code>class ExampleClass:
    def __init__(self, val = 1):
        self.__first = val

    def set_second(self, val = 2):
        self.__second = val


example_object_1 = ExampleClass()
example_object_2 = ExampleClass(2)

example_object_2.set_second(3)

example_object_3 = ExampleClass(4)
example_object_3.__third = 5


print(example_object_1.__dict__)
print(example_object_2.__dict__)
print(example_object_3.__dict__)

print(example_object_1._ExampleClass__first)<jupyter_output>{'_ExampleClass__first': 1}
{'_ExampleClass__first': 2, '_ExampleClass__second': 3}
{'_ExampleClass__first': 4, '__third': 5}
1<jupyter_text>3.3.1.3 Class properties* Exists in just one copy and is stored outside any object.* They can be accessed through any of the objects just like any object attribute. or through the class. * The don't appeat in an object's \__dict__* The property value is the same for all objects of the class<jupyter_code>class ExampleClass:
    counter = 0
    def __init__(self, val = 1):
        self.__first = val
        ExampleClass.counter += 1


example_object_1 = ExampleClass()
example_object_2 = ExampleClass(2)
example_object_3 = ExampleClass(4)

print('Access from objects')
print(example_object_1.__dict__, example_object_1.counter)
print(example_object_2.__dict__, example_object_2.counter)
print(example_object_3.__dict__, example_object_3.counter)

print('\nAccess from class ')
print(ExampleClass.counter)<jupyter_output>Access from objects
{'_ExampleClass__first': 1} 3
{'_ExampleClass__first': 2} 3
{'_ExampleClass__first': 4} 3

Access from class 
3<jupyter_text>3.3.1.4 Private class properties and 'mangling'* Same behavior as with instance properties<jupyter_code>class ExampleClass:
    __counter = 0
    def __init__(self, val = 1):
        self.__first = val
        ExampleClass.__counter += 1


example_object_1 = ExampleClass()
example_object_2 = ExampleClass(2)
example_object_3 = ExampleClass(4)

print(example_object_1.__dict__, example_object_1._ExampleClass__counter)
print(example_object_2.__dict__, example_object_2._ExampleClass__counter)
print(example_object_3.__dict__, example_object_3._ExampleClass__counter)<jupyter_output>{'_ExampleClass__first': 1} 3
{'_ExampleClass__first': 2} 3
{'_ExampleClass__first': 4} 3<jupyter_text>3.3.1.7 Checking the existence of an attribute* The hasattr() function can be used to verify that. It is called using two parameters: The object name and a string containing the attribute name.* Returns a True/False* This replaces the need for try except blocks* hasattr() also works on classes<jupyter_code>class ExampleClass:
    attr=1
    def __init__(self, val):
        if val % 2 != 0:
            self.a = 1
        else:
            self.b = 1


example_object = ExampleClass(1)
print(example_object.a)

if hasattr(example_object, 'b'):
    print(example_object.b)

print(hasattr(ExampleClass,'attr'))<jupyter_output>1
True
{'fraction_non_alphanumeric': 0.07910001757778168, 'fraction_numerical': 0.01748989277553173, 'mean_word_length': 4.36239396795476, 'pattern_counts': {'":': 0, '<': 44, '<?xml version=': 0, '>': 44, 'https://': 2, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 10, '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': '13', 'dir': 'jupyter-structured-clean-dedup', 'id': '323255', 'license': 'MIT', 'n_tokens_mistral': 3414, 'n_tokens_neox': 3275, 'n_words': 1203, 'path': 'week5.ipynb', 'repo_name': 'karimitani1995/PCAP', 'size': '11378'}