<jupyter_start><jupyter_text>02b - Python basics - Classes and ObjectsClasses and objects are both expressions connected to object oriented programming paradigm which is based on the concept of "objects", which can contain data and code: data in the form of fields (often known as **attributes** or properties), and code, in the form of procedures (often known as **methods**).So what is class and what is object:* **Class** is something like a template or prescription for how the object should look like.* **Object** is instance of class (it is product of the class prescription)So lets with basic example of definition of class with one method:<jupyter_code>class MyClass:
    my_atribute = 5
        
    def my_method(self):
        print('Method of a class')<jupyter_output><empty_output><jupyter_text>As you can see the class definition doesn't run any code inside the class as it is only a template for future objects. It is similar to the definition of function (it also doesn't run enything by itself). To call that method we need to first create an **instance** of that class, the **object**. We can create an object by "making a call on the class" (add ```()``` behind the name) and assigning it to some variable. Once we have an object we can access **atributes** and **methods** of that object with dot ```.``` notation. See example.<jupyter_code>class MyClass:
    my_atribute = 5
        
    def my_method(self):
        print('Method of a class')

        
object_of_my_class = MyClass()  # creating an instance of class MyClass

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')  # get value of atribute an print it

object_of_my_class.my_method()  # calling object method<jupyter_output>The value of my_atribute is 5
Method of a class<jupyter_text>With dot notation you can also change values of atributes inside the object<jupyter_code>class MyClass:
    my_atribute = 5

    
object_of_my_class = MyClass()

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')

object_of_my_class.my_atribute = 10  # assign new value to the atribute

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')  # get value of atribute an print it<jupyter_output>The value of my_atribute is 5
The value of my_atribute is 10<jupyter_text>```self``` - accessing object methods and atributes from insideIn case want to change some atributes or access method from inside of the object we need to get that object so we can put the ```.``` after it. From outside we use the name of the object. From inside we use keyword ```self```. Inside the object, ```self``` represent the same as the object name outside. ```self``` must be also defined at the header (as a first parameter) of every method from where we want to access the object atributes and methods. Lets make new method which will be multiplying the atribute by given number.<jupyter_code>class MyClass:
    my_atribute = 5
    
    def multiply(self, num): # self in the header of method allows us to access the atribute
        self.my_atribute = num * self.my_atribute  # usage ofself to access the atribute
        

object_of_my_class = MyClass()

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')

object_of_my_class.multiply(2)  # calling the method from the outside

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')<jupyter_output>The value of my_atribute is 5
The value of my_atribute is 10<jupyter_text>Contructor - ```__init__```Constructor is one of so called magic methods (more on that later) which is used to "construct" (create) objects. It is the "thing" that happens when you put ```()``` after the name of the class. This magic behind just calls the constructor method which initialize the object and returns that object so we can assign it to variable. In Python we define constructor as ```__init__``` method inside the body of class. It is always there even if we don't define it ourselfs (default implementation is used in background). The main reason to have constructor is to be able to set basic values for our object when we are creating it so we don't need to update it imidietly after. So lets update example from above with an constructor:<jupyter_code>class MyClass:
    my_atribute = 5
    
    def __init__(self, value_for_my_atribute):  # constructor definition, expectation of one parametr to be passed
        self.my_atribute = value_for_my_atribute  # assignment of passed argument into the atribute
        
    def multiply(self, num):
        self.my_atribute = num * self.my_atribute

        
object_of_my_class = MyClass(3)

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')

object_of_my_class.multiply(2)

print(f'The value of my_atribute is {object_of_my_class.my_atribute}')<jupyter_output>The value of my_atribute is 3
The value of my_atribute is 6<jupyter_text>InheritanceInheritance in oop allows us to "pass" methods and atributes of the parent class to the child class. It means that we can define one base class and then make its children classes with the same base properties. * **Parent** class is the class being inherited from* **Child** class is the class that inherits from another classWe can specify from which class we want to inherit within the definition of child class. It is done by adding the name of parent class inside of ```()``` after the name of child class ( ```class ChildClass(ParentClass):``` ).See the example<jupyter_code># Parent class
class ParentClass:
    my_atribute = 5
        
    def my_method(self):
        print('Method of a class')

        
# Child class, empty implementation
class ChildClass(ParentClass):
    pass


x = ParentClass()
x.my_method()

x = ChildClass()
x.my_method()<jupyter_output>Method of a class
Method of a class<jupyter_text>Now we have child class that does exactly the same as it's parent. But we can do more than that. We can add new stuff to the child class thus make it more specific child of it's parent. Let's make an example of parent Person class and enhanced Student child class. Person will have constructor which will allow us to set it's name. Student will be inherited from person but he will have added atribute to represent his current courses and also he gets the method which will print out the list of his courses.<jupyter_code>class Person():
    
    def __init__(self, name):
          self.name = name
            
class Student(Person):
    
    current_courses = ['Python', 'Mathematics']  # set default courses for every student
        
    def print_courses(self): # method for printing out student courses
        print(f'Student {self.name}\'s courses:')
        for course in self.current_courses:
            print(course)
  

student = Student('Alice')  
student.print_courses()<jupyter_output>Student Alice's courses:
Python
Mathematics<jupyter_text>Parent constructor - ```super().__init__()```When we want to create an constructor for our child class we need to integrate parent's consturctor into it. Without it our child class wouldn't have properties initialized in parent's constructor so it would be impossible to access them.Simply you cant think about it as we need to create instance of parent class first (with parent's constructor) and then add some child class specific stuff to it (with child constructor). To call parent's constructor we use ```super()``` and than append ```__init__()``` to it and in general we put this statement at the top of child constructor body. You also need to pass parent class related input parameters to the parent's constructor. See the following example<jupyter_code>class Person():
    
    def __init__(self, name):
          self.name = name
    
    
class Student(Person):
    
    current_courses = []
    
    def __init__(self, name, courses):
        super().__init__(name)  # calling constructor of parent class Person, passing name parameter to it (inherited property)
        self.current_courses = courses  # setting the atribute current_courses (child class specific property)
        
    def print_courses(self):
        print(f'Student {self.name}\'s courses:')
        for course in self.current_courses:
            print(course)
           
        
# thanks to Student specific constructor we can set courses for every Student instance during it's initialization
student = Student('Alice', ['Python', 'Mathematics'])
student.print_courses()

print()

# Person class doesn't have atributes or methods of Student so we are not able to use them on Parent's instances
person = Person('Bob', ['Python', 'Mathematics'])
person.print_courses()<jupyter_output>Student Alice's courses:
Python
Mathematics<jupyter_text>You can see that while Student can work with its courses, Person cannot but both classes has access to person's name. In general child classes have atributes and methods of the parent classes but parent classes don't have properties of their children.  PolymorphismPolymorphism is an object-oriented programming concept that refers to the ability of a variable, function or object to take on multiple forms. It allows programmers to work more in general approach. We will focus on dynamic polymorphism of inherited objects. In this case polymorphism allows child classes to use methods with same name but different implementation than parent or other possible children. Let's start with simple example of inheritance (no polymorphism here yet)<jupyter_code>class Person():
    
    def __init__(self, name):
          self.name = name
            
    def personal_introduction(self):
        print(f'My name is {self.name}. I am just a generic person')
         
            
class Teacher(Person):
    pass
    
    
class Student(Person):
    pass


person = Person('Alice')
person.personal_introduction()

teacher = Teacher('Bob')
teacher.personal_introduction()

student = Student('Carlos')
student.personal_introduction()<jupyter_output>My name is Alice. I am just a generic person
My name is Bob. I am just a generic person
My name is Carlos. I am just a generic person<jupyter_text>As we expected, children inherited parent's method and the output for all of them was same (except their name). But we can the some changes for the children and create their own implementation of method ```personal_introduction()```. When we call these methods on their instances, we can see that each object used it's own method.<jupyter_code>class Person():
    
    def __init__(self, name):
          self.name = name
            
    def personal_introduction(self):
        print(f'My name is {self.name}. I am just generic person')
           
            
class Teacher(Person):
    def personal_introduction(self):
        print(f'My name is {self.name}. I am teacher')
    
    
class Student(Person):
    def personal_introduction(self):
        print(f'My name is {self.name}. I am student')

        
person = Person('Alice')
person.personal_introduction()

teacher = Teacher('Bob')
teacher.personal_introduction()

student = Student('Carlos')
student.personal_introduction()<jupyter_output>My name is Alice. I am just generic person
My name is Bob. I am teacher
My name is Carlos. I am student<jupyter_text>And now, why is it good for? Thanks to the fact that all children classes inherits from their parents, we know that every one of these children will have an implementation of ```personal_introduction()``` method. Some of them will use the parent's implementation and some of them will have their own, but in the end all of them will be able to call that method. For practical usage it means we can put all Person like objects to a container (school register, seat distribution in a bus, etc.), iterate over them and call that method on each one of them. Thanks to polymorphism we will get some kind of expected result on every call.<jupyter_code>class Person():
    
    def __init__(self, name):
          self.name = name
            
    def personal_introduction(self):
        print(f'My name is {self.name}. I am just generic person')
         
            
class Teacher(Person):
    def personal_introduction(self):
        print(f'My name is {self.name}. I am teacher')
    
    
class Student(Person):
    def personal_introduction(self):
        print(f'My name is {self.name}. I am student')
        
        
class MasterStudent(Student):
    def personal_introduction(self):
        print(f'My name is {self.name}. I am master student')
        
        
class Stranger(Person):  
    # stranger doesn't override parent's method with own implementation so he will use implementation of Person (parent class)
    pass

        
# Teacher, Student, MasterStudent overrides parent method with own implementation    
teacher = Teacher('Alice')
student1 = Student('Bob')
student2 = MasterStudent('Carlos')
# Stranger uses parent's implementation, no override
stranger = Stranger('Derek')  
school_register = [teacher, student1, student2, stranger]

for person in school_register:
    person.personal_introduction()<jupyter_output>My name is Alice. I am teacher
My name is Bob. I am student
My name is Carlos. I am master student
My name is Derek. I am just generic person<jupyter_text>The main advantage of polymorphism is that it allows us to think and program more in the general rather than do it in the specific. As a real life examples we can take the school register of all students, teachers and other associates. Both teachers and students have access to KOS system but each of them have different privileges and constraints. Teacher can fill in grades for students and students may sign to exam terms. Other example could be a system for vehicle parking slots where the general Vehicle is inherited into Car, Bus, Truck and so on. As a voluntary home exercise think about other possible situations where polymorphism might be beneficial and how would you implemented it.  Magic methods Magic methods are special methods of classes which offers some advanced functionality. They have two prefix and two suffix underscores ```__``` so you can easily recognize them. You also already know one of them and it is ```__init__```. Other examples might be ```__repr__```, ```__add__```, ```__len__```, etc. These methods allow us to change a behavior of our objects in some specific cases. For example as we know method ```__init__``` is called when we create new instance of class. It is called automatically and it has default implementation, but thanks to this magic method we can alter the default behaviour and define our own. We will try other three methods listed here in simple examples. Magic method ```__repr__```Method ```__repr__``` serve is called when we use function ```print()``` on any object. It's default implementation shows us the string with location of our object in memory. It is fine but it isn't exatly user friendly representation of the object.<jupyter_code>class Example:
    pass


example = Example()
print(example)<jupyter_output><__main__.Example object at 0x000002C81A53BD30><jupyter_text>If we want to modify the print representation of our object we just define ```__repr__``` method.<jupyter_code>class Example:

    def __repr__(self):
        return 'I am instance of class Example'
    
    
example = Example()
print(example)<jupyter_output>I am instance of class Example<jupyter_text>Magic method ```__add__()```Method ```__add__``` is used for overloading of operator ```+```. This is reason why we can add two string together with. This method is called once the operator ```+``` is used on the object. Let's make an example with our own implementation of complex number.<jupyter_code>class MyComplex():
    
    def __init__(self, re, im):
        self.re = re
        self.im = im

c1 = MyComplex(1, 2)
c2 = MyComplex(3, 4)

print(c1+c2)<jupyter_output><empty_output><jupyter_text>```__add__``` doesn't have default implementation for user created object so we need to define it.<jupyter_code>class MyComplex():
    
    def __init__(self, re, im):
        self.re = re
        self.im = im
    
    def __add__(self, other):
        return (self.re + other.re) + (self.im + other.im)*1j
        

c1 = MyComplex(1, 2)
c2 = MyComplex(3, 4)

print(c1 + c2)  # notation with operator
print(c1.__add__(c2))  # notation with method<jupyter_output>(4+6j)
(4+6j)<jupyter_text>Magic method ```__len__()```Method ```__len__()``` is called when we use function ```len()``` on object. For example if we use it on list, it tells us number of elements in the ```list``` and if we use it on ```String```, it returns number of chars in the string. We will create our specific implementation. We will have object containing atribute ```list``` of strings. And we want the result of ```len()``` to be sum of chars in all string in the list.<jupyter_code>class MyStringList():
    
    def __init__(self, lst):
        self.string_list = lst
        
    def __len__(self):
        char_sum = 0
        for elem in self.string_list:
            char_sum += len(elem)
        return char_sum
    

lst = ['Hello', ' ', 'world', '!']    
my_string_list = MyStringList(lst)
print(len(my_string_list))<jupyter_output>12
{'fraction_non_alphanumeric': 0.07370506321738624, 'fraction_numerical': 0.003787216687059372, 'mean_word_length': 3.970750072400811, 'pattern_counts': {'":': 0, '<': 52, '<?xml version=': 0, '>': 52, 'https://': 0, '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': '16', 'dir': 'jupyter-structured-clean-dedup', 'id': '475885', 'license': 'MIT', 'n_tokens_mistral': 4712, 'n_tokens_neox': 4548, 'n_words': 2183, 'path': 'courses/E375004/python_basics_2/basics_02b.ipynb', 'repo_name': 'CVUT-FS-12110/Python-for-scientific-computation-and-control', 'size': '17163'}