Python Advanced course with notes and source code
🕒 2025-04-27 10:13:59.206333Python 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?
- Classes and Objects
- Destroying objects (Garbage collection)
- Class inheritance
- Overridden Method
- Overloading operators
- Data hiding
- Regular Expression
- Generators
- Generator Comprehension
- Multiple function arguments
- Sets
- Serialization
- Partial Function
- Closures
- Decorators
- 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.
Comments
Loading comments...
Leave a Comment