Python is a popular programming language created by Guido van Rossum and released in 1991. It is a platform-independent, high-level, object-oriented language. Python requires less code than other programming languages to solve the same problems. It is widely used in machine learning, deep learning, data science, and artificial intelligence domains. This programming language has straightforward syntax.

What will you learn?

  1. Classes and Objects
  2. Destroying objects (Garbage collection)
  3. Class inheritance
  4. Overridden Method
  5. Overloading operators
  6. Data hiding
  7. Regular Expression
  8. Generators
  9. Generator Comprehension
  10. Multiple function arguments
  11. Sets
  12. Serialization
  13. Partial Function
  14. Closures
  15. Decorators
  16. Multithreading Concept

If you are a complete beginner in Python, I recommend you visit this link first:

Python course for beginners in an easy way

In this course, we will cover some advanced courses in Python Programming Language.

Classes and Objects:

A class is a blueprint for creating objects. We can create a class using the keyword "class".

Syntax:

class class_name

An object is simply a collection of data (variables) and methods (function). To create an object of a class, we can use the same class name for which the object of a class is created.

class Employee:
    'Common base class for all employees'
    empCount = 0

    def __init__(self, name, salary): 
        self.name = name
        self.salary = salary
        Employee.empCount += 1

    def displayEmployee(self):
        print ("Name : ", self.name,  ", Salary: ", self.salary)


#Create first object
emp1 = Employee("Ishwar", 7000)

#Create second object
emp2 = Employee("Amit", 8000)

emp1.displayEmployee()
emp2.displayEmployee()

print ("Total Employee = ", Employee.empCount)
Output:
Name :  Ishwar , Salary:  7000
Name :  Amit , Salary:  8000
Total Employee =  2

__init__ is a reserved method in Python classes. It is similar to the constructor. When an object is created, this method is invoked. Constructors are used to initialize or assign value to the data member of the class when an object of a class is created. 

Self represents an instance of a class. By using the self keyword, we can access the attribute and method of a class. In simple terms, self refers to a current object.

print ("Employee.__doc__:", Employee.__doc__)
print ("Employee.__name__:", Employee.__name__)
print ("Employee.__module__:", Employee.__module__)
print ("Employee.__bases__:", Employee.__bases__)
print ("Employee.__dict__:", Employee.__dict__)
Employee.__doc__: Common base class for all employees
Employee.__name__: Employee
Employee.__module__: __main__
Employee.__bases__: (<class 'object'>,)
Employee.__dict__: {'__module__': '__main__', '__doc__': 'Common base class for all employees', 'empCount': 2, '__init__': <function Employee.__init__ at 0x000001D6EBED6678>, 'displayEmployee': <function Employee.displayEmployee at 0x000001D6EBED63A8>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>}

Destroying objects (Garbage Collection):

The destructor __del__ prints the class name of an instance that is about to be deleted or destroyed.

class my_class:
    def __init__( self, x=0, y=0):
        self.x = x
        self.y = y
    def __del__(self): 
        class_name = self.__class__.__name__
        print (class_name, "destroyed")

pt1 = my_class()
pt2 = pt1
pt3 = pt1
print (id(pt1), id(pt2), id(pt3)) # prints the ids of the obejcts
del pt1
del pt2
del pt3
2429250819592 2429250819592 2429250819592
my_class destroyed

Class inheritance:

Inheritance allows us to define a class that inherits all methods and properties from another class. In other words, the inheritance method allows a child or derived class to inherit all methods and properties from the parent or base class.

Syntax:

Class parent_class_name:

// methods and attributes

Class derived_class_name(parent_class_name):

// other additional methods and attributes

class Parent:        # define parent class
    parentAttr = 500
    def __init__(self):
        print ("Calling parent constructor")

    def parentMethod(self):
        print ('Calling parent method')

    def setAttr(self, attr):
        Parent.parentAttr = attr

    def getAttr(self):
        print ("Parent attribute :", Parent.parentAttr)

class Child(Parent): # define child class 
    def __init__(self):
        print ("Calling child constructor")
    def childMethod(self):
        print ('Calling child method')

c = Child()          # instance of child
c.childMethod()      # child calls its method
c.parentMethod()     # calls parent's method
c.setAttr(200)       # again call parent's method
c.getAttr()          # again call parent's method
Calling child constructor
Calling child method
Calling parent method
Parent attribute : 200

Overridden Method:

Overriding is the Property of Class or an ability of OOP (Object Oriented Programming) to change the implementation of a method provided by one of its base classes.

class Parent:        # define parent class or base class
    def myMethod(self):
        print ('Calling parent method')

class Child(Parent): # define child class or derived class
    def myMethod(self): # same method defined in child class also
        print ('Calling child method')

c = Child()          # instance of child
c.myMethod()         # child calls overridden method

Output:

Calling child method

Overloading operators:

Overloading refers to a function's or operator's capacity to work differently based on the parameters provided to the function.

The below program shows how to overload the binary + operator using the OperatorMagic Method.

class my_class:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __str__(self):
        return 'my_class (%d, %d)' % (self.a, self.b)
   
    def __add__(self,other):
        return my_class(self.a + other.a, self.b + other.b)

v1 = my_class(16,15)
v2 = my_class(2,-2)
print (v1 + v2)

Output:

my_class (18,13)

Data hiding:

Data hiding is a method of making the data private so that it will not be accessible to the other class members. That is, an object attribute may not be visible outside the class definition. We must name attributes with a double underscore prefix, and those attributes are then hidden from outsiders.

class CounterClass:
    __secretCount = 0
  
    def count(self):
        self.__secretCount += 1
        print (self.__secretCount)

counter = CounterClass()
counter.count() # count=1
counter.count() # count=2
print(counter.__secretCount) # we are not able to print secretcount as we have apply hidden property

Output:

To print private attributes:

print (counter._CounterClass__secretCount)

Output:

2

Regular Expression:

A Regular Expression or RegEx is a special sequence of characters that uses a search pattern to find a particular character, string, or set of strings.

To import RegEx:

import re

Match function:

import re

line = "Cats are smarter than dogs"
substr1 = "cats"
substr2 = "Smarter"

matchObj1 = re.match( substr1, line, re.I) #syntax: re.match(pattern, string, flags=0)
#re.I performs case-insensitive matching
matchObj2 = re.match( substr2, line, re.I)

print(matchObj1)
print(matchObj2)

Output:

<re.Match object; span=(0, 4), match='Cats'>
None

None means the particular string is not found. In our program, 'Smarter' is there but the match function only checks at the beginning of the string. When it encounters space, further text in the line doesn't get checked.

Search function:

import re

line = "Cats are smarter than dogs"
substr1 = "cats"
substr2 = "Smarter"

searchObj1 = re.search( substr1, line, re.I) #syntax: re.match(pattern, string, flags=0)
#re.I performs case-insensitive matching
searchObj2 = re.search( substr2, line, re.I)

print(searchObj1)
print(searchObj2)

Output:

<re.Match object; span=(0, 4), match='Cats'>
<re.Match object; span=(9, 16), match='smarter'>

The search function checks over all the words in the string. So, it successfully finds 'cats' and 'Smarter'.

Search and replace:

import re

phone = "20020-943-257 # This is Phone Number"

# Delete Python-style comments
num = re.sub(r'#.*$', "", phone)
print ("Phone Num : ", num)

# Remove anything other than digits
num = re.sub(r'\D', "", phone)    #\D is anything other than digits
print ("Phone Num : ", num)

Output:

Phone Num :  20020-943-257 
Phone Num :  20020943257

Generators:

Generator functions allow us to declare a function that behaves like an iterator, i.e. It is applicable to 'for' loops. When iterators are instantiated, they do not compute the value of each item. They only compute it when we ask for it. For this, we use the 'yield' keyword. It requires less memory i.e. no memory is wastage and thus increases performance with generators.

def nums(n):
    for i in range(1,n+1):
        yield i
for number in nums(10):
    print(number)

Output:

1

2

3

4

5

6

7

8

9

10

def even_generator(n):
    for num in range(2,n+1,2):
        yield num
for num in even_generator(10):
    print (num)

Output:

2

4

6

8

10

import random
def lottery():
    # returns 10 numbers between 1 and 500
    for i in range(10):
        yield random.randint(1, 500)

    # returns a 11th number between 1 and 90
    yield random.randint(1,90)

for random_number in lottery():
       print("User having serial number %d is assigned 10 kitta IPO" %(random_number))

Output:

User having serial number 401 is assigned 10 kitta IPO
User having serial number 188 is assigned 10 kitta IPO
User having serial number 463 is assigned 10 kitta IPO
User having serial number 116 is assigned 10 kitta IPO
User having serial number 223 is assigned 10 kitta IPO
User having serial number 438 is assigned 10 kitta IPO
User having serial number 460 is assigned 10 kitta IPO
User having serial number 366 is assigned 10 kitta IPO
User having serial number 57 is assigned 10 kitta IPO
User having serial number 467 is assigned 10 kitta IPO
User having serial number 65 is assigned 10 kitta IPO

Generator Comprehension:

Comprehension provides a short and concise (brief but comprehensive) way to create a new sequence using a previously defined sequence. They are generally one-line code.

square = (i**2 for i in range(1,11)) 
#In python 3, range function is a generator
for num in square:
    print (num)

Multiple function arguments:

If the number of arguments to be passed in a function is not known, we can use the * symbol which indicates a variable number of arguments to a function.

def my_func(first, second, third, *other):
    print("First: %s" %(first))
    print("Second: %s" %(second))
    print("Third: %s" %(third))
    print("And all the rest... %s" %(list(other)))
my_func(1,2,3,4,5)

Output:

First: 1
Second: 2
Third: 3
And all the rest... [4, 5]

Another we have keyword arguments (kwargs), symbolized by **.

def my_func(first, second, third, **options):
    if options.get("action") == "sum":
        print(f"Sum= {first + second + third}")
    if options.get("number") == "first":
        return first
result = my_func(1, 2, 3, action = "sum", number = "first")
print("Result: %d" %(result))

Output:

The sum is: 6
Result: 1

Sets:

A set is an unordered collection of data types that is iterable, mutable and contains unique elements.

We use curly bracket to represent sets just like a dictionary. The difference is that a dictionary has key:value pair whereas a set has only keys.

print(set("my name is Ishwar and Ishwar is my name".split()))
Output:
{'my', 'Ishwar', 'is', 'name', 'and'}

You can clearly see, the duplicate element gets removed.

a = set(["Dhoni", "Virat", "Rohit"])
print(a)
b = set(["Virat", "De villers"])
print(b)

Output:

{'Virat', 'Dhoni', 'Rohit'}
{'De villers', 'Virat'}
# To find out which members attended both events
print(a.intersection(b))
print(b.intersection(a))

Output:

{'Virat'}
{'Virat'}
# To find out which members attended only one of the events
print(a.symmetric_difference(b))
print(b.symmetric_difference(a))
{'De villers', 'Dhoni', 'Rohit'}
{'De villers', 'Dhoni', 'Rohit'}
# To find out which members attended only one event and not the other
print(a.difference(b))
print(b.difference(a))
{'Dhoni', 'Rohit'}
{'De villers'}
# To receive a list of all participants
a.union(b)
{'De villers', 'Dhoni', 'Rohit', 'Virat'}

Serialization:

Serialization is the process of converting a structured object into a sequence of bytes which can be stored in a file system or database or sent through the network. An item is serialized when it is converted into a format that can be stored in order to de-serialize it later and rebuild the original object from the serialized format.

json.dump() method can be used for writing JSON files.

json.dumps() method can be used to convert a Python object into a JSON string.

import json
#json --> javascript object notation
data = {
    "channel": {
        "name": "Ishwar Gautam",
        "rating": 10,
        "Location": "Nepal",
        "Content": "Technology related"
    }
}
# Serializing json and writing json file
with open( "data_file.json" , "w" ) as myJsonFile:
    json.dump( data , myJsonFile, indent=4) # create json file
# indent=4 added space of 4 so that a file doesn't display output in same line
json_str = json.dumps(data, indent=4) # Get json string
print(json_str)

When you execute this code, the datafile.json file will be created in the same directory which looks like this:

And also your JSON string gets printed in the terminal which is the same as the JSON file.

Next, the Python pickle module is an object-oriented way to store objects directly in a special storage format.

import pickle
print(pickle.dumps([1, 2, 3, "a", "b", "c"])) # generate pickled string
b'\x80\x03]q\x00(K\x01K\x02K\x03X\x01\x00\x00\x00aq\x01X\x01\x00\x00\x00bq\x02X\x01\x00\x00\x00cq\x03e.'
# To print a actual string
print(pickle.loads(pickled_string))
[1, 2, 3, 'a', 'b', 'c']

Partial function:

In partial functions, we can fix a specific number of arguments in a function and create a new function.

from functools import partial

def multiply(x,y):
        return x * y

# create a new partial function
p = partial(multiply,2)
print(p(4))
print(p(8))
print(p(10))

8

16

20

# A normal function 
def f(z, y, x, t): 
    return 1000*z + 100*y + 10*x + t 
  
# A partial function that calls f (above function) with  a as 3, b as 1 and c as 4. 
g = partial(f, 3, 1, 4) 
  
# Calling g() 
print(g(5)) # here, we pass the value of x

3145

Closures:

A closure is a nested function that has access to a free variable from an enclosing function. Closures offer some kind of data protection (data hiding).

def outer_func(message): 
    message = message 
  
    def inner_func(): 
        print(message) 
  
    return inner_func # Note we are returning function without parenthesis 
  
if __name__ == '__main__': 
    myfunc = outer_func('Test message!') 
    myfunc() 
Test message!

Even though the execution of the "outer_func()" was completed, the message was rather preserved (in myfunc at the last line of code; when calling myfunc, the message is preserved). Thus, closures are a Python mechanism for attaching data to code even after the other original functions have finished.

Decorators:

Decorators allow programmers to change how a function or class behaves. It enables us to wrap another function in order to boost the behavior of the wrapped function without affecting it permanently.

def decorator_func(func): # decorator function take function as parameter
    def inner_func(a,b):
        print("I am going to divide",a,"and",b)
        if b == 0:
            print("cannot divide these numbers")
            return
        return func(a,b) # this func is divide function in this case
    return inner_func

@decorator_func  # this line pass divide function to decorator function
def divide(a,b):
    return a/b

print(divide(10,2))
print("--------------------------------")
print(divide(10,0))
I am going to divide 10 and 2
5.0
--------------------------------
I am going to divide 10 and 0
cannot divide these numbers
None

Note: divide() function gets executed from the decorator function. When we call the divide() function, this function doesn't get executed. Instead, this divide() function gets passed to the decorator_func() as a parameter.

Multithreading in Python | Calculate square and cube at the same time with Multithreading

Multithreading is a way to execute many threads at the same time. A thread is a tiny program that can be scheduled for execution. That means multithreading is a process of executing several parts of a program simultaneously or one after another without the completion of the previous one. 

In Python programming language, we have to import the 'threading' library to achieve this task. Another system library i.e., 'time' is used to sleep one part of the program so that we can execute another part.


Let us see an example:

Suppose we have two functions: one function returns the square of a number whereas another function returns the cube of a number. Let's see how these two functions execute simultaneously with threading.

1: Import threading and time

import threading
import time

2: Define two functions (one for finding a square and another for a cube)

def find_square(num):
    for i in range(num):
        time.sleep(2)
        print(f"Square: {i*i} ")
        print(time.ctime())
def find_cube(num):
    for i in range(num):
        time.sleep(3)
        print(f"Cube: {i**3} ")
        print(time.ctime())

3: Create threads t1 and t2

# create thread
t1 = threading.Thread(target=find_square,args=(5,))
t2 = threading.Thread(target=find_cube,args=(5,))

As our function takes one parameter, we have to pass one value inside args as shown above.

4: Start the thread

t1.start()
t2.start()

# join method waits for threads to terminate
t1.join()
t2.join()

print("Thread successfully executed!!!")

Square: 0 

Wed Oct 20 19:18:58 2021 

Cube: 0 

Wed Oct 20 19:18:59 2021

Square: 1 

Wed Oct 20 19:19:00 2021

Cube: 1 Square: 4 

Wed Oct 20 19:19:02 2021 

Wed Oct 20 19:19:02 2021 

Square: 9 

Wed Oct 20 19:19:04 2021 

Cube: 8 

Wed Oct 20 19:19:05 2021 

Square: 16 

Wed Oct 20 19:19:06 2021 

Cube: 27 

Wed Oct 20 19:19:08 2021 

Cube: 64 

Wed Oct 20 19:19:11 2021 

Thread successfully executed!!!

You can clearly see, the function is executed one after another as we have used the time function. Sometimes, it also executes at the same time as highlighted above.

This is how multithreading is created in Python Programming language. I hope this is clear to you.