Skip to content

Python Tutorial

print("Hello, World!")

Variables and Types

Python is dynamically typed:

# Variables
name = "Alice"     # str
age = 30           # int
height = 5.8       # float
is_student = True  # bool

Control Flow

If Statements

if age >= 18:
    print("Adult")
else:
    print("Minor")

Loops

for i in range(5):
    print(i)

count = 0
while count < 3:
    print(count)
    count += 1

Functions

Functions are defined using the def keyword:

def greet(name: str) -> str:
    return f"Hello, {name}!"

print(greet("Alice"))

Decorators

Decorators provide a way to modify the behavior of functions or classes without permanently modifying their source code. They are frequently used for logging, access control, timing, and caching.

def my_decorator(func):
    def wrapper():
        print("Before function execution")
        func()
        print("After function execution")
    return wrapper

@my_decorator
def say_hello():
    print("Hello, world!")

say_hello()
# Output:
# Before function execution
# Hello, world!
# After function execution

Magic Methods (Dunder Methods)

Magic methods, often called "dunder" (double underscore) methods, are special methods that define how objects of a class behave for built-in operations (like addition, comparison, or string representation).

class Book:
    def __init__(self, title, author):
        # The constructor is a magic method
        self.title = title
        self.author = author

    def __str__(self):
        # Used by print() and str()
        return f"'{self.title}' by {self.author}"

    def __eq__(self, other):
        # Used by the == operator
        if not isinstance(other, Book):
            return False
        return self.title == other.title and self.author == other.author

    def __len__(self):
        # Used by len()
        return len(self.title)

b1 = Book("1984", "George Orwell")
b2 = Book("1984", "George Orwell")

print(b1)       # Output: '1984' by George Orwell
print(b1 == b2) # Output: True
print(len(b1))  # Output: 4

Concurrency and Parallelism

Python provides several ways to execute code concurrently or in parallel: asyncio, threading (Multithreading), and multiprocessing (Multiprocessing).

Asyncio

asyncio is a library to write concurrent code using the async/await syntax. It's often a perfect fit for IO-bound and high-level structured network code. It operates on a single thread using an event loop.

import asyncio
import time

async def fetch_data(id):
    print(f"Task {id}: Fetching data...")
    await asyncio.sleep(1) # Simulating I/O operation
    print(f"Task {id}: Data fetched!")
    return {"id": id, "data": "Sample"}

async def main():
    start_time = time.time()
    # Run tasks concurrently
    results = await asyncio.gather(
        fetch_data(1),
        fetch_data(2),
        fetch_data(3)
    )
    end_time = time.time()
    print(f"Results: {results}")
    print(f"Time taken: {end_time - start_time:.2f} seconds")

# asyncio.run(main())

Multithreading

The threading module is used for running multiple threads (tasks, function calls) in a single process. Due to Python's Global Interpreter Lock (GIL), multiple threads cannot execute Python bytecodes at once. Therefore, multithreading is primarily useful for I/O-bound tasks (like reading files, network requests) rather than CPU-bound tasks.

import threading
import time

def fetch_data_sync(id):
    print(f"Thread {id}: Fetching data...")
    time.sleep(1) # Simulating I/O operation
    print(f"Thread {id}: Data fetched!")

def main_threading():
    start_time = time.time()
    threads = []

    # Create and start threads
    for i in range(1, 4):
        thread = threading.Thread(target=fetch_data_sync, args=(i,))
        threads.append(thread)
        thread.start()

    # Wait for all threads to complete
    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"Time taken: {end_time - start_time:.2f} seconds")

# main_threading()

Multiprocessing

The multiprocessing module allows the programmer to fully leverage multiple processors on a given machine. It creates separate processes, each with its own Python interpreter and memory space, completely bypassing the GIL. This is the recommended approach for CPU-bound tasks (like heavy mathematical computations).

import multiprocessing
import time

def cpu_bound_task(n):
    print(f"Process {n}: Starting heavy computation...")
    result = sum(i * i for i in range(10**7))
    print(f"Process {n}: Computation finished!")
    return result

def main_multiprocessing():
    start_time = time.time()
    processes = []

    # Create and start processes
    for i in range(1, 4):
        process = multiprocessing.Process(target=cpu_bound_task, args=(i,))
        processes.append(process)
        process.start()

    # Wait for all processes to complete
    for process in processes:
        process.join()

    end_time = time.time()
    print(f"Time taken: {end_time - start_time:.2f} seconds")

# if __name__ == '__main__':
#     main_multiprocessing()