Python For Beginners

Table of contents

Github Repo for all of the related and in-detail code

Introduction:

Python is a high-level, general-purpose programming language that emphasizes code readability and versatility. The design philosophy of Python enhances readability by using significant indentation and English keywords. It supports multiple programming paradigms, including structured (particularly procedural), object-oriented, and functional programming. Python is dynamically typed and garbage-collected, meaning it handles memory management automatically, freeing up the programmer to focus on other aspects of the project

Python is known for its simplicity and easy-to-understand syntax. It's often described as a "batteries included" language due to its comprehensive standard library, which provides a wide variety of functionality out of the box. This, combined with the language's simplicity, makes Python a good choice for beginners looking to learn programming.

Python is also a versatile language, making it suitable for a wide range of applications. It is commonly used for web development, data science, machine learning, artificial intelligence, scientific computing, and much more Its versatility is further enhanced by a large collection of frameworks and libraries, which can significantly reduce development time

Python's popularity stems from its simplicity, readability, and versatility. Its syntax mimics natural language, making it easier to read and understand. As such, Python is beginner-friendly and is often the first choice for newcomers to programming. Python is an open-source language, meaning it's free to use, and it has a large and active community that contributes to its development and provides support to other programmers.

Here's a simple example of Python's simplicity. If we want to display the message "Hello, World!" in Python, the code would be:

print("Hello, World!")

This simplicity makes Python a great choice for beginners. In addition to being beginner-friendly, Python's simplicity and versatility make it a powerful tool for experienced programmers and developers. As a result, Python consistently ranks among the most popular programming languages in various surveys.

In summary, Python's simplicity, readability, versatility, and strong community support make it a popular choice for both beginners and experienced developers. Whether you're just starting in programming or you're an experienced developer looking to expand your skill set, Python is a valuable language to learn.

Installing Python

To install Python on your computer, you can follow these steps:

  1. Check for Pre-existing Python Installation: Python comes pre-installed on many systems. To check if it's already installed on your system, open your command prompt (Windows) or terminal (macOS or Linux) and type python --version. If Python is installed, this command will display the version number. If it's not, you'll need to install it.

  2. Download Python: Navigate to the official Python website's downloads page. Select the version that is appropriate for your operating system (Windows, macOS, or Linux). If you're using Windows, you also have the option of installing Python from the Microsoft Store, which is an easy way to get up and running without any fuss, or you can simply go to the official website of python Download Python

  3. Install Python: Run the installer file that you downloaded. During the installation, you may need to check the option "Add Python to PATH" to ensure that Python is accessible from any command prompt or terminal window. You can generally accept the default settings during the installation.

  4. Verify Installation: After the installation is complete, you can verify that Python was installed correctly by re-opening your command prompt or terminal and running the python --version command again. This time, it should display the version number of the Python you installed.

Remember, Python is a versatile language with a large standard library. However, there might be additional libraries you'll want to use for specific projects. To install these additional libraries, Python uses a package manager called pip. This should be included with your Python installation.

Here's a simple example of how to use pip to install a popular library called requests:

pip install requests

This command will download and install the requests library, which you can then use in your Python programs to make HTTP requests.

In conclusion, installing Python is generally a straightforward process. Once installed, Python provides a versatile programming environment for beginners and experienced developers alike.

Execution of python program

The execution of a Python program involves a multi-step process that begins when you run a Python script. Here's a simplified overview of how a Python program gets executed:

  1. Processing the Script: The Python interpreter processes the statements of your script sequentially. This means it reads and interprets your code line by line from top to bottom.

  2. Compilation to Bytecode: The source code is then compiled to an intermediate format known as bytecode. Bytecode is a low-level platform-independent representation of your code, and its purpose is to optimize code execution. This step is bypassed the next time the interpreter runs your code if the bytecode is already available.

  3. Execution: Finally, the bytecode is sent off for execution. At this point, the Python Virtual Machine (PVM) comes into play. The PVM is the runtime engine of Python and is responsible for executing the instructions of your bytecode one by one. It's not an isolated component but a part of the Python system you've installed on your machine.

This entire process is known as the Python Execution Model. It's important to note that this description corresponds to the core implementation of the language, that is, CPython. Other Python implementations may have slightly different execution models.

Additionally, Python provides several ways to execute external programs or system commands from within a Python script. The os.system(), subprocess.run), and subprocess.Popen() functions are commonly used for this purpose. For example, to execute a shell command like 'echo Hello, World!', you could use:

import os
os.system("echo Hello, World!")

Or using the subprocess module:

import subprocess
subprocess.run(["echo", "Hello, World!"], shell=True)

These functions allow your Python script to interact with other programs and the operating system, providing a lot of flexibility in what you can do with Python.

Variables

In Python, variables are like containers that hold values. They're essential for storing, manipulating, and referencing data throughout a program. Variables in Python are created the moment you assign a value to them. You can think of them as labels attached to values, making it easy to keep track of different pieces of information in your program. For instance, if you were writing a program to keep track of your expenses, you might use variables to store the amount of money you spent on each purchase. This would be much easier than remembering all the different values yourself.

Here's an example of how to create a variable in Python:

name = "Your name"

In this example, name is the variable, and "Your name" is the value assigned to it. You can then use this variable in your code, like so:

print(name)  # This will output: Your name

It's important to note that Python is dynamically typed, meaning you don't have to declare the type of a variable when you create it. The Python interpreter infers the type based on the value you assign to the variable. This makes Python flexible and easy to use, especially for beginners.

Python variables work by holding references to objects. In Python, everything is an object, even the most basic data types like integers and strings. This means that when you create a variable and assign a value to it, Python stores that value in memory and the variable name is a reference to that memory location. When you access the variable, Python retrieves the value from the memory location.

Variables in Python can hold different types of data, including numbers, strings, lists, and dictionaries. The value of a variable can be changed during program execution. Here's an example:

# Assigning a value to the variable
message = "Hello, World!"
print(message)  # This will output: Hello, World!

# Changing the value of the variable
message = "Hello, Python!"
print(message)  # This will output: Hello, Python!

In this example, we first assign the string "Hello, World!" to the variable message, and then change the value to "Hello, Python!".

In conclusion, variables in Python are fundamental for storing and manipulating data in a program. They're easy to create and use, making Python an accessible language for beginners. By understanding how to create and use variables, you can write more efficient and effective Python programs.

Datatypes

Python has several built-in data types that allow you to store, manipulate, and organize data in your programs. Here's an overview of some of the most commonly used data types:

Integers: These are whole numbers, without a decimal point. They can be positive or negative. For example, 5, -3, and 0 are integers. Python's integer type is int.

x = 10
print(type(x))  # This will output: <class 'int'>

Floats: These are real numbers that contain a decimal point. For example, 3.14, -0.01, and 1.0 are floats. Python's float type is float.

y = 3.14
print(type(y))  # This will output: <class 'float'>

Strings: These are sequences of characters. Strings can be created using single quotes ('), double quotes ("), or triple quotes (''' or """). For example, 'Hello', "World", and '''Hello, World!''' are strings. Python's string type is str.

message = "Hello, World!"
print(type(message))  # This will output: <class 'str'>

Lists: These are ordered collections of items, which can be of any type and can be a mix of different types. Lists are created using square brackets ([]). For example, [1, 2, 3], ['apple', 'banana', 'cherry'], and [1, 'apple', 3.14] are lists. Python's list type is list.

fruits = ['apple', 'banana', 'cherry']
print(type(fruits))  # This will output: <class 'list'>

Tuples: These are similar to lists, but are immutable, meaning their values cannot be changed once they are created. Tuples are created using parentheses (()). For example, (1, 2, 3) and ('apple', 'banana', 'cherry') are tuples. Python's tuple type is tuple.

coordinates = (10.0, 20.0)
print(type(coordinates))  # This will output: <class 'tuple'>

Dictionaries: These are collections of key-value pairs. Each key is unique and the values can be of any type. Dictionaries are created using curly braces ({}). For example, {'name': 'Atharv', 'age': 20} is a dictionary. Python's dictionary type is dict.

person = {'name': 'Atharv', 'age': 20}
print(type(person))  # This will output: <class 'dict'>

Understanding these data types is fundamental to programming in Python, as they form the basis for storing and manipulating data in your programs.

Naming Conventions

Python has its own set of rules and conventions for naming different entities in your code such as variables, functions, classes, and more. Adherence to these conventions makes your code more readable and maintainable. These conventions are outlined in the Python Enhancement Proposal (PEP) 8, which is the official style guide for Python code. Here are some of the key naming conventions:

Variables and Functions

  • Variable and function names should be lowercase, with words separated by underscores to improve readability. This is also known as snake_case. For instance, student_name, calculate_sum.
def calculate_sum(a, b):
    return a + b

student_name = "Atharv Sankpal"

Classes

  • Class names should use the PascalCase convention, also known as UpperCamelCase. This means that the name starts with an uppercase letter and has no underscores between words. Each word in the name should also start with an uppercase letter. For example, ShoppingCart.
class ShoppingCart:
    def __init__(self, items=[]):
        self.items = items

Constants

  • Constants are usually defined on a module level and written in all capital letters with underscores separating words. For example, MAX_OVERFLOW.
MAX_OVERFLOW = 100

Private Instance Variables

  • If the instance variable is intended to be used internally within a class, it can be named with a single underscore prefix, like _my_variable.

Avoiding Name Clashes

  • To avoid name clashes with Python's built-in names, a single trailing underscore can be used, like class_.

Strongly Private Instance Variables

  • If an instance variable should not be accessed directly, it can be named with a double underscore prefix, like __my_variable. This will mangle the attribute name and make it harder to access unintentionally.

Magic Methods

  • Python has several special methods or attributes that have a double underscore prefix and suffix, like __init__, __len__, etc. These are known as magic methods and are used to implement certain behaviors in classes.

It's important to note that these are conventions, not hard and fast rules. Python does not enforce these naming conventions, but following them can make your code more readable and Pythonic.

Indentation in python

Indentation in Python is not just a stylistic choice, but a fundamental part of the language's syntax. Unlike many other programming languages that use braces or other markers to define blocks of code (like loops or if-else statements), Python uses indentation to denote these blocks.

A block of code is a group of statements that are meant to be executed together. In Python, consistent indentation is used to define the beginning and end of these blocks. For example, in an if statement, all the code that is indented under the if line belongs to that if block:

if 5 > 2:
    print("Five is greater than two!")

In this example, the print statement is part of the if block because it's indented under the if line.

If you don't indent your Python code correctly, you'll encounter IndentationError and your code won't run. This is because the Python interpreter uses indentation to determine how statements are grouped together.

Apart from being a syntax requirement, proper indentation provides several benefits:

  1. Readability: Indentation improves the readability of your code by making the structure of the code visually clear. This makes it easier for others (and yourself) to understand your code.

  2. Reduced Errors: By enforcing consistent indentation, Python reduces the likelihood of errors that can occur due to misplaced or mismatched block delimiters (like braces) that are used in other languages.

  3. Efficiency: Python's use of indentation simplifies the coding process. You don't have to worry about placing and matching block delimiters, and you can focus on the logic of your code.

  4. Code Maintenance: Well-indented code is easier to maintain and update. When the structure of the code is clear, it's easier to make changes without breaking the code.

  5. Collaboration: Consistent use of indentation makes it easier for teams to work together. When everyone uses the same coding style, it's easier to understand and work with each other's code.

In conclusion, indentation is a critical aspect of Python programming. It's essential for defining the structure of your code, and it provides several benefits in terms of readability, error reduction, and code maintenance. Whether you're a beginner or an experienced Python programmer, understanding and applying proper indentation is key to writing effective Python code.

Basic Operations

To perform basic arithmetic operations in Python, you can use the standard arithmetic operators:

  • Addition: +

  • Subtraction: -

  • Multiplication: *

  • Division: /

  • Modulo (remainder): %

  • Exponentiation: **

Here are some examples:

# Addition
result = 5 + 3
print(result)  # Output: 8

# Subtraction
result = 10 - 2
print(result)  # Output: 8

# Multiplication
result = 4 * 5
print(result)  # Output: 20

# Division
result = 15 / 3
print(result)  # Output: 5.0

# Modulo
result = 17 % 4
print(result)  # Output: 1

# Exponentiation
result = 2 ** 3
print(result)  # Output: 8

String Manipulation

To perform string manipulations in Python, you can use various built-in methods and operators. Here are some common operations:

  • Concatenation: You can concatenate two or more strings using the + operator. For example:
greeting = "Hello"
name = "Sanmesh"
message = greeting + " " + name
print(message)  # Output: Hello Sanmesh
  • String Length: You can get the length of a string using the len() function. For example:
text = "Hello, world!"
length = len(text)
print(length)  # Output: 13
  • Accessing Characters: You can access individual characters in a string using indexing. Python uses zero-based indexing, so the first character is at index 0. For example:
text = "Hello"
first_char = text[0]
last_char = text[-1]
print(first_char)  # Output: H
print(last_char)  # Output: o
  • Slicing: You can extract a substring from a string using slicing. Slicing allows you to specify a range of indices to extract a portion of the string. For example:
text = "Hello, world!"
substring = text[7:12]
print(substring)  # Output: world
  • String Methods: Python provides several built-in string methods for common manipulations, such as converting case, replacing characters, splitting, and joining strings. Here are a few examples:
text = "Hello, world!"

# Convert to uppercase
uppercase_text = text.upper()
print(uppercase_text)  # Output: HELLO, WORLD!

# Replace characters
replaced_text = text.replace("world", "Python")
print(replaced_text)  # Output: Hello, Python!

# Split into a list of words
words = text.split(", ")
print(words)  # Output: ['Hello', 'world!']

# Join a list of words into a single string
joined_text = "-".join(words)
print(joined_text)  # Output: Hello-world!

str.format()

Certainly! In Python, the str.format() method is a powerful way to create and format strings. It allows you to embed variables and expressions inside strings while controlling the formatting of those values. This method provides more flexibility and readability compared to traditional string concatenation. Let's dive into the details of str.format().

Basic Usage:

The str.format() method is called on a string and uses curly braces {} as placeholders for values that you want to insert into the string. You can use the placeholders within the string and then provide the values to be inserted using the format() method.

Here's the basic syntax:

formatted_string = "Hello, {}!".format(value)

In this example, {} is a placeholder where the value will be inserted.

Positional Arguments:

You can use positional arguments to specify the values to be inserted into the placeholders. The values are provided as arguments to the format() method, and they are inserted into the placeholders in the order they appear.

name = "Prajwal"
greeting = "Hello, {}!".format(name)

print(greeting)  # Output: "Hello, Prajwal!"

In this case, the name variable is inserted into the {} placeholder.

Named Arguments:

You can also use named arguments to specify which values should go into which placeholders. This allows for more clarity, especially when dealing with multiple placeholders.

first_name = "Prathamesh"
last_name = "Hegade"

full_name = "My name is {first} {last}.".format(first=first_name, last=last_name)

print(full_name)  # Output: "My name is Prathamesh Hegade."

Here, the placeholders {first} and {last} are filled with the values specified using named arguments.

Value Formatting:

You can apply various formatting options to the inserted values using format specifiers. Format specifiers start with a colon : and can include options like precision, width, alignment, and data type formatting.

price = 19.99

formatted_price = "The price is: ${:.2f}".format(price)

print(formatted_price)  # Output: "The price is: $19.99"

In this example, :.2f is a format specifier that formats the price variable as a floating-point number with 2 decimal places.

Accessing Variables by Index:

You can also access variables by index within the str.format() method, allowing you to reuse values multiple times within the string.

item = "apple"
quantity = 5

message = "I bought {1} {0}s today and ate {1} of them.".format(item, quantity)

print(message)  # Output: "I bought 5 apples today and ate 5 of them."

Here, {0} refers to the first argument (item) and {1} refers to the second argument (quantity).

F-strings (Python 3.6+):

Starting from Python 3.6, a more concise and readable way to format strings is by using f-strings. F-strings allow you to embed expressions directly inside string literals by prefixing the string with the letter 'f'. The expressions inside curly braces are evaluated at runtime.

name = "Sanket"
greeting = f"Hello, {name}!"

print(greeting)  # Output: "Hello, Sanket!"

F-strings provide a more intuitive and less error-prone way to format strings.

Summary:

The str.format() method in Python is a versatile and powerful tool for string formatting. It allows you to create complex strings with placeholders and format the inserted values as needed. Whether you're working with positional or named arguments, applying format specifiers, or using f-strings, string formatting in Python offers flexibility and readability for your code.

TypeCasting

Typecasting, also known as type conversion, is the process of converting a value from one data type to another in a programming language. In Python, you can perform typecasting using various built-in functions or constructors for specific data types. Here's an overview of common typecasting methods in Python:

1. Implicit Typecasting (Type Coercion):

In Python, some data type conversions happen automatically during operations. This is known as implicit typecasting or type coercion. For example, when you perform arithmetic operations with different data types, Python will implicitly convert them to a common data type:

num_int = 5
num_float = 2.5

result = num_int + num_float  # Implicitly converts num_int to a float
print(result)  # Output: 7.5

2. Explicit Typecasting:

Explicit typecasting, also called explicit conversion, requires you to specify the desired data type explicitly. Python provides built-in functions and constructors for this purpose:

a. int(), float(), str(), bool():

These functions allow you to explicitly convert values to integers, floating-point numbers, strings, or Boolean values, respectively:

# Explicitly converting to integer
num_str = "10"
num_int = int(num_str)

# Explicitly converting to float
num_str = "3.14"
num_float = float(num_str)

# Explicitly converting to string
num_int = 42
num_str = str(num_int)

# Explicitly converting to Boolean
zero = 0
non_zero = 42
bool_zero = bool(zero)         # False
bool_non_zero = bool(non_zero)  # True

b. list(), tuple(), set():

You can convert other iterable data types, like strings or lists, to lists, tuples, or sets using these constructors:

# Convert a string to a list of characters
text = "Hello"
char_list = list(text)  # ['H', 'e', 'l', 'l', 'o']

# Convert a list to a tuple
my_list = [1, 2, 3]
my_tuple = tuple(my_list)  # (1, 2, 3)

# Convert a list to a set
my_list = [1, 2, 2, 3]
my_set = set(my_list)  # {1, 2, 3}

c. dict():

You can create a dictionary from a list of key-value pairs using the dict() constructor:

pairs = [("a", 1), ("b", 2), ("c", 3)]
my_dict = dict(pairs)  # {'a': 1, 'b': 2, 'c': 3}

User Input

Taking user input in Python can be done using the built-in input() function. This function allows your program to pause and wait for the user to enter data through the keyboard. Here's a detailed explanation of how to use input():

  1. Basic Input:

    To take user input, simply call the input() function. It will display a prompt (if provided) and wait for the user to enter text, followed by pressing the "Enter" key.

     user_input = input("Enter your name: ")
     print("Hello, " + user_input)
    

    In this example, the program prompts the user to enter their name, stores the input in the user_input variable, and then prints a greeting.

  2. Type Conversion:

    By default, input() returns user input as a string. If you expect a different data type, like an integer or a floating-point number, you need to convert the input using type casting.

     age = int(input("Enter your age: "))  # Convert input to an integer
    

    If the user enters non-integer text, this code will raise a ValueError exception. To handle such cases, consider using exception handling.

  3. Handling User Input:

    It's a good practice to handle user input carefully. You may want to validate and sanitize the input to ensure it meets your program's requirements. For instance, you can check if the input is within a certain range or conforms to a specific format.

     while True:
         try:
             age = int(input("Enter your age: "))
             if age < 0:
                 print("Age must be a positive number.")
             else:
                 break  # Exit the loop if input is valid
         except ValueError:
             print("Invalid input. Please enter a valid number.")
    

    In this example, the program keeps asking for input until the user enters a valid positive integer.

  4. Using Default Values:

    You can provide a default value to the input() function, which will be displayed as a prompt to the user. If the user presses "Enter" without typing anything, the default value will be used.

     name = input("Enter your name [Guest]: ") or "Guest"
    

    If the user enters their name, that input is stored in the name variable. If they press "Enter" without entering anything, "Guest" will be used as the default.

  5. Security Considerations:

    Be cautious when using input() for sensitive information like passwords. User input is often echoed back to the screen, which can expose sensitive data. In such cases, it's better to use specialized libraries like getpass for secure input.

     import getpass
     password = getpass.getpass("Enter your password: ")
    

    This will hide the password input while the user types it.

Remember that input() is primarily for simple text-based input. If you need more advanced input handling, graphical user interfaces (GUIs) or web forms may be more appropriate for your application.

Control Flow

Conditional statements, often referred to as "if-else statements," are a fundamental concept in programming that allow you to control the flow of your code based on certain conditions. These conditions are evaluated as either true or false, and the program takes different actions depending on the outcome. In Python, conditional statements are implemented using the if, elif (short for "else if"), and else keywords. Let's delve into the details of how conditional statements work in Python.

Basic Syntax:

The basic syntax of an if statement in Python is as follows:

if condition:
    # Code to be executed if the condition is true

The condition in the if statement is an expression that results in a boolean value (True or False). If the condition is True, the indented block of code following the if statement will be executed. If the condition is False, that block of code is skipped.

Example:

x = 10

if x > 5:
    print("x is greater than 5")

In this example, since the condition x > 5 is true (because x is 10, which is indeed greater than 5), the statement inside the if block is executed, and "x is greater than 5" will be printed.

The else Statement:

Sometimes, you may want to execute a different block of code when the condition is False. This is where the else statement comes into play. The else statement is used to define a block of code that should be executed when the condition in the if statement is False.

Here's the syntax:

if condition:
    # Code to be executed if the condition is true
else:
    # Code to be executed if the condition is false

Example:

x = 3

if x > 5:
    print("x is greater than 5")
else:
    print("x is not greater than 5")

In this case, since x is 3, the condition x > 5 is False, so the code in the else block is executed, and "x is not greater than 5" is printed.

The elif Statement:

Sometimes, you need to check multiple conditions and take different actions based on which condition is true. You can achieve this using the elif statement (short for "else if"). You can have multiple elif statements following an if statement.

Here's the syntax:

if condition1:
    # Code to be executed if condition1 is true
elif condition2:
    # Code to be executed if condition2 is true
elif condition3:
    # Code to be executed if condition3 is true
# ...
else:
    # Code to be executed if none of the conditions are true

The conditions are evaluated in order, and the first one that is True triggers the execution of the corresponding block of code. If none of the conditions are True, the code in the else block is executed.

Example:

x = 7

if x < 5:
    print("x is less than 5")
elif x < 10:
    print("x is less than 10 but not less than 5")
else:
    print("x is 10 or greater")

In this example, because x is 7, the first condition x < 5 is False, but the second condition x < 10 is True. Therefore, "x is less than 10 but not less than 5" will be printed.

Nested Conditional Statements:

You can also nest conditional statements inside each other to create more complex logic. This involves placing one if statement inside another. Here's an example:

x = 5
y = 10

if x == 5:
    if y == 10:
        print("x is 5 and y is 10")
    else:
        print("x is 5, but y is not 10")
else:
    print("x is not 5")

In this nested example, the inner if statement is only evaluated if the outer if condition is True. This allows you to create more intricate branching in your code.

Logical Operators:

To create more complex conditions, you can use logical operators like and, or, and not to combine or negate conditions.

  • and: Returns True if both conditions are True.

  • or: Returns True if at least one of the conditions is True.

  • not: Negates the condition.

Here's an example using logical operators:

x = 7

if x > 5 and x < 10:
    print("x is greater than 5 and less than 10")

In this case, both conditions (x > 5 and x < 10) must be True for the print statement to execute.

Summary:

Conditional statements (if-else statements) are essential for controlling the flow of your Python code based on different conditions. You can use if for the primary condition, elif for additional conditions, and else to handle the case when none of the conditions are met. Logical operators can be used to create more complex conditions, and you can nest conditional statements to handle intricate branching logic in your programs.

Loops

In Python, loops are control structures that allow you to repeatedly execute a block of code as long as a certain condition is met or to iterate over a sequence of data, such as a list, tuple, string, or range. Loops are essential for performing repetitive tasks and processing collections of data. Python provides two primary types of loops: for loops and while loops.

for Loops:

A for loop is used for iterating over a sequence (that can be a list, tuple, string, dictionary, etc.) or other iterable objects. It allows you to execute a block of code for each item in the sequence.

The basic syntax of a for loop is as follows:

for variable in iterable:
    # Code to be executed for each item in the iterable

Here's an example of a for loop that iterates over a list of numbers:

numbers = [1, 2, 3, 4, 5]

for num in numbers:
    print(num)

In this example, the loop iterates through each item in the numbers list and prints it.

range() Function:

A common use of for loops is with the range() function to generate a sequence of numbers. The range() function creates a sequence of numbers from a starting point (inclusive) to an ending point (exclusive) with a specified step size.

for i in range(1, 6):
    print(i)

This loop will print the numbers 1 through 5.

while Loops:

A while loop is used for repeatedly executing a block of code as long as a certain condition is true. It is useful when you don't know in advance how many times the loop will need to run.

The basic syntax of a while loop is as follows:

while condition:
    # Code to be executed while the condition is true

Here's an example of a while loop that counts from 1 to 5:

count = 1

while count <= 5:
    print(count)
    count += 1

In this example, the loop continues executing as long as the condition count <= 5 is true.

Loop Control Statements:

break Statement:

The break statement is used to exit a loop prematurely, even if the loop condition is still true. It is often used when a specific condition is met, and you want to terminate the loop.

numbers = [1, 2, 3, 4, 5]

for num in numbers:
    if num == 3:
        break
    print(num)

In this example, the loop will terminate when num becomes equal to 3.

continue Statement:

The continue statement is used to skip the current iteration of a loop and move to the next iteration. It is useful when you want to skip certain items or conditions within a loop.

numbers = [1, 2, 3, 4, 5]

for num in numbers:
    if num == 3:
        continue
    print(num)

In this example, when num is equal to 3, the continue statement is encountered, and the loop proceeds to the next iteration, skipping the print statement.

Looping Through Data Structures:

Loops are particularly powerful when it comes to working with data structures like lists and dictionaries. You can iterate through the elements of these data structures to perform various operations.

Iterating Through Lists:

fruits = ["apple", "banana", "cherry"]

for fruit in fruits:
    print(fruit)

This loop will print each fruit in the fruits list.

Iterating Through Dictionaries:

person = {"name": "Atharv", "age": 20, "city": "Kolhapur"}

for key, value in person.items():
    print(f"{key}: {value}")

This loop will print each key-value pair in the person dictionary.

Summary:

Loops are fundamental to programming and are essential for iterating over data, performing repetitive tasks, and controlling program flow. for loops are typically used when you know the number of iterations in advance, while while loops are useful when you need to loop until a specific condition is met. Loop control statements like break and continue provide additional control over the loop's behavior. As a beginner, practicing loops is crucial for mastering the basics of Python programming and building more complex programs.

Function

In Python, functions are reusable blocks of code that perform a specific task. They are essential for organizing and modularizing your code, making it more readable, maintainable, and easier to debug. Functions help you avoid code duplication by allowing you to encapsulate a piece of functionality and call it whenever needed. Let's dive into the details of defining and using functions in Python:

Defining a Function:

You define a function in Python using the def keyword, followed by the function name and a pair of parentheses (). Optionally, you can include one or more parameters (input values) inside the parentheses. The function's code block is indented and follows the colon : at the end of the function declaration.

Here's the basic syntax:

def function_name(parameter1, parameter2, ...):
    # Function code
    # ...
    return result  # Optional return statement
  • function_name: This is the name of the function. Choose a descriptive name that reflects the function's purpose. Function names in Python should follow naming conventions (e.g., lowercase with words separated by underscores, like calculate_average).

  • parameters: These are optional. Parameters are placeholders for the values you want to pass into the function. They are like variables that store the values you pass when you call the function.

  • return: This statement is optional. It allows the function to return a result to the caller. If omitted, the function returns None.

Example Function Without Parameters:

def greet():
    print("Hello, world!")

# Call the function
greet()

In this example, greet is a simple function that prints "Hello, world!" when called.

Example Function with Parameters and a Return Value:

def add_numbers(a, b):
    result = a + b
    return result

# Call the function
sum_result = add_numbers(3, 4)
print("The sum is:", sum_result)

In this example, add_numbers is a function that takes two parameters, a and b, adds them together, and returns the result. When calling the function with add_numbers(3, 4), it returns 7, which is stored in the sum_result variable and then printed.

Function Documentation (Docstrings):

It's good practice to document your functions using docstrings. A docstring is a multi-line string that provides a brief description of the function's purpose, its parameters, and what it returns. You can access a function's docstring using the help() function or by using the .__doc__ attribute.

def calculate_average(numbers):
    """
    Calculate the average of a list of numbers.

    Parameters:
    - numbers (list): A list of numeric values.

    Returns:
    - float: The average of the numbers.
    """
    if not numbers:
        return 0  # Avoid division by zero
    total = sum(numbers)
    return total / len(numbers)

# Access the docstring
help(calculate_average)

Calling a Function:

To call a function, you simply use its name followed by parentheses, passing any required arguments (values) inside the parentheses.

result = calculate_average([10, 20, 30])
print("The average is:", result)

In this example, we call the calculate_average function with a list of numbers [10, 20, 30], and the result is printed.

Default Argument Values:

You can provide default values for function parameters, which allows you to call the function without providing those arguments. This is useful when you want to make certain arguments optional.

def greet(name="Guest"):
    print(f"Hello, {name}!")

greet()          # Prints "Hello, Guest!"
greet("Shiva")   # Prints "Hello, Shiva!"

In this example, the name parameter has a default value of "Guest." If you don't provide a name when calling greet(), it uses the default value.

Function Scope:

Variables defined inside a function are local to that function by default. They are only accessible within the function's code block. Variables defined outside of functions are called global variables and can be accessed both inside and outside functions.

global_variable = 10

def my_function():
    local_variable = 5
    print("Local variable:", local_variable)
    print("Global variable:", global_variable)

my_function()
print("Global variable outside function:", global_variable)

# This will result in an error
# print("Local variable outside function:", local_variable)

Returning Values from Functions:

Functions can return values using the return statement. You can return one or multiple values from a function, separated by commas.

def add_and_subtract(a, b):
    addition = a + b
    subtraction = a - b
    return addition, subtraction

result1, result2 = add_and_subtract(10, 5)
print("Addition:", result1)
print("Subtraction:", result2)

In this example, add_and_subtract returns two values, which are unpacked into result1 and result2.

Recursion

Recursion is a programming technique where a function calls itself to solve a problem or perform a task. Recursive functions break down complex problems into simpler, more manageable subproblems. Each recursive call processes a smaller part of the problem until a base case is reached, at which point the function returns results, and the recursive calls "unwind" or return to the original call, combining the results to solve the overall problem.

Here's a detailed explanation of recursion:

Components of a Recursive Function:

  1. Base Case(s): These are one or more conditions that determine when the recursion should stop. Base cases provide the exit point for the recursive function, preventing infinite recursion.

  2. Recursive Case(s): These are one or more instances where the function calls itself with modified inputs. Recursive cases break the problem down into smaller subproblems that are closer to the base case.

  3. Input Transformation: In each recursive call, the function typically modifies its input or state to make progress toward the base case. This transformation ensures that the problem becomes simpler with each recursive call.

The Recursive Process:

  1. Call Stack: When a function is called, a new frame (called an activation record or stack frame) is pushed onto the call stack, storing local variables and the return address.

  2. Recursive Calls: The function calls itself, creating a new frame on the stack for each call. This continues until a base case is reached.

  3. Base Case Hit: When the base case is satisfied, the function starts returning values.

  4. Unwinding the Stack: As the function returns, the call stack "unwinds," and each frame is popped off the stack. Return values are combined or processed as frames are removed.

Example - Factorial Calculation:

A classic example of recursion is calculating the factorial of a number. The factorial of a non-negative integer n (denoted as n!) is the product of all positive integers from 1 to n. For example, 5! = 5 * 4 * 3 * 2 * 1 = 120.

Here's a recursive Python function to calculate the factorial of a number:

def factorial(n):
    # Base case: if n is 0 or 1, return 1
    if n == 0 or n == 1:
        return 1
    # Recursive case: n! = n * (n-1)!
    else:
        return n * factorial(n - 1)

# Example usage
result = factorial(5)  # Calculates 5!
print(result)  # Output: 120

In this example, the factorial function calls itself with a smaller value (n-1) until it reaches the base case (n == 0 or n == 1). At that point, the recursive calls start returning, and the results are multiplied together to calculate the final factorial value.

Advantages of Recursion:

  1. Simplicity: Recursive solutions can be simpler and more elegant than their iterative counterparts, especially for problems with a recursive structure.

  2. Readability: Recursion can make code more readable when the problem naturally involves self-similar subproblems.

Disadvantages of Recursion:

  1. Stack Overhead: Each recursive call consumes memory on the call stack, which can lead to a stack overflow error for deep recursion.

  2. Performance: Recursive solutions can be less efficient than iterative ones, particularly for problems that don't naturally involve recursion.

  3. Debugging Complexity: Debugging recursive code can be challenging due to the call stack's nested nature.

Recursion is a powerful and essential concept in programming, particularly in solving problems that exhibit recursive patterns, such as tree traversal, divide-and-conquer algorithms, and more. When used appropriately, it can lead to elegant and efficient solutions.

Summary:

Functions are a fundamental concept in Python and programming in general. They allow you to encapsulate code, make it reusable, and improve code organization. Key points to remember when defining and using functions in Python:

  • Use the def keyword to define a function.

  • Functions can have parameters (inputs) and return values (outputs).

  • Document your functions using docstrings.

  • Call functions by their name and provide arguments as needed.

  • Variables defined inside functions are local by default, while variables defined outside functions are global.

By understanding and using functions effectively, you can write more structured and maintainable Python code.

Keyword arguments

Keyword arguments in Python functions allow you to pass arguments to a function by specifying the parameter names along with their values. This provides clarity and flexibility in function calls, especially when functions have multiple parameters with default values. Keyword arguments are particularly useful when you want to pass arguments out of order or only specify some of the arguments while relying on default values for the others.

Here's how you define a function that accepts keyword arguments:

def greet(name, age):
    print(f"Hello, {name}! You are {age} years old.")

In this example, the greet function accepts two parameters: name and age. To call this function using keyword arguments, you use the parameter names as keywords:

greet(name="Om", age=21)
greet(age=21, name="Abhijeet")

Key points to remember about keyword arguments:

  1. Order Independence: When using keyword arguments, you can pass the arguments in any order, as long as you specify the parameter names:

     greet(age=35, name="Yashraj")
    
  2. Partial Argument Specification: You can specify some arguments as keyword arguments and leave others as positional arguments:

     greet("Anurag", age=40)
    
  3. Default Values: Keyword arguments are often used in functions with default parameter values:

     def greet(name="Guest", age=0):
         print(f"Hello, {name}! You are {age} years old.")
    
     greet()  # Using default values
     greet(name="Aniket")  # Overriding one default value
    
  4. Mixing Positional and Keyword Arguments: When calling a function, positional arguments must appear before keyword arguments:

     greet("Hemant", age=69)  # Valid
     greet(name="Paras", 22)  # Invalid, positional argument follows keyword argument
    
  5. Overriding Default Values: If you provide a value for a keyword argument, it will override any default value specified in the function definition:

     greet("Gandhar", age=55)  # Override age, use default name
    
  6. Parameter Names Must Match: The keyword argument names you use in the function call must match the parameter names in the function definition:

     greet(nickname="Vijay", years=27)  # Invalid, parameter names don't match
    

Keyword arguments enhance the readability and maintainability of code, especially in functions with many parameters or when it's important to make the purpose of each argument explicit in the function call. They also provide flexibility when dealing with functions that have optional parameters with default values.

*args in functions

In Python, *args is a special syntax used to pass a variable number of non-keyword (positional) arguments to a function. It allows you to define functions that can accept an arbitrary number of arguments without specifying them individually. The *args parameter collects these arguments into a tuple within the function, making it easy to work with them. Let's go through an example to illustrate how *args works:

def add_numbers(*args):
    result = 0
    for num in args:
        result += num
    return result

# Call the function with different numbers of arguments
sum1 = add_numbers(1, 2, 3)
sum2 = add_numbers(10, 20, 30, 40, 50)

print("Sum 1:", sum1)
print("Sum 2:", sum2)

In this example, we have defined a function called add_numbers that takes *args as its parameter. Inside the function, we initialize result to 0 and then use a for loop to iterate through the elements in the args tuple, adding each element to the result variable.

Now, let's break down how the function works with different calls:

  1. add_numbers(1, 2, 3):

    • The function is called with three arguments: 1, 2, and 3.

    • These arguments are collected into a tuple args inside the function: args = (1, 2, 3).

    • The loop iterates through args, adding each element to result, resulting in result = 1 + 2 + 3, which is 6.

    • The function returns 6, and it is stored in the sum1 variable.

  2. add_numbers(10, 20, 30, 40, 50):

    • The function is called with five arguments: 10, 20, 30, 40, and 50.

    • These arguments are collected into a tuple args inside the function: args = (10, 20, 30, 40, 50).

    • The loop iterates through args, adding each element to result, resulting in result = 10 + 20 + 30 + 40 + 50, which is 150.

    • The function returns 150, and it is stored in the sum2 variable.

As you can see, the *args syntax allows the add_numbers function to work with a varying number of arguments, making it flexible and useful for scenarios where you want to perform operations on multiple values without specifying them individually. You can pass any number of positional arguments to the function, and they will be collected into the args tuple for further processing within the function.

**kwargs in functions

In Python, **kwargs is a special syntax used to pass a variable number of keyword arguments to a function. It allows you to define functions that can accept an arbitrary number of keyword arguments without specifying them individually. The **kwargs parameter collects these arguments into a dictionary within the function, making it easy to work with them. Let's go through an example using your display_info function:

def display_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

# Call the function with different keyword arguments
display_info(name="Atharv", age=20, city="Kolhapur")
display_info(product="Laptop", price=55000, brand="HP", in_stock=True)

In this example, we have defined a function called display_info that takes **kwargs as its parameter. Inside the function, we use a for loop to iterate through the items in the kwargs dictionary, which contains the keyword arguments.

Now, let's break down how the function works with different calls:

  1. display_info(name="Atharv", age=20, city="Kolhapur"):

    • The function is called with three keyword arguments: name, age, and city.

    • These keyword arguments are collected into a dictionary kwargs inside the function: kwargs = {"name": "Atharv", "age": 20, "city": "Kolhapur"}.

    • The loop iterates through kwargs, printing each key-value pair, resulting in the following output:

        name: Atharv
        age: 20
        city: Kolhapur
      
  2. display_info(product="Laptop", price=55000, brand="HP", in_stock=True):

    • The function is called with four keyword arguments: product, price, brand, and in_stock.

    • These keyword arguments are collected into a dictionary kwargs inside the function: kwargs = {"product": "Laptop", "price": 55000, "brand": "HP", "in_stock": True}.

    • The loop iterates through kwargs, printing each key-value pair, resulting in the following output:

        product: Laptop
        price: 55000
        brand: HP
        in_stock: True
      

As you can see, the **kwargs syntax allows the display_info function to work with a varying number of keyword arguments, making it flexible and useful for scenarios where you want to display information provided as keyword-value pairs. You can pass any number of keyword arguments to the function, and they will be collected into the kwargs dictionary for further processing within the function.

Exception Handling

Exception handling is a critical aspect of programming in Python and many other programming languages. It allows you to gracefully handle and recover from unexpected errors or exceptions that may occur during the execution of your code. Exception handling ensures that your program does not crash abruptly when an error occurs, making your code more robust and user-friendly.

In Python, exceptions are raised when an error occurs during program execution. Exceptions can be raised by the Python interpreter or explicitly by your code. Python provides a structured way to handle exceptions using the try, except, else, and finally blocks.

Basic Exception Handling with try and except:

The basic syntax of exception handling in Python involves the use of the try and except blocks:

try:
    # Code that may raise an exception
except ExceptionType as e:
    # Code to handle the exception
  • try: This block contains the code that might raise an exception.

  • except: If an exception of type ExceptionType is raised in the try block, the code inside the except block will be executed to handle the exception.

Here's an example:

try:
    x = 10 / 0  # This will raise a ZeroDivisionError
except ZeroDivisionError as e:
    print("Error:", e)

In this example, a ZeroDivisionError exception is raised when trying to divide by zero. The code inside the except block is executed, printing an error message.

Handling Multiple Exceptions:

You can handle multiple exceptions by specifying multiple except blocks, each with a different exception type.

try:
    value = int("abc")  # This will raise a ValueError
except ValueError as ve:
    print("ValueError:", ve)
except ZeroDivisionError as ze:
    print("ZeroDivisionError:", ze)

In this example, we handle both ValueError and ZeroDivisionError separately.

Handling All Exceptions:

You can also use a generic except block without specifying a particular exception type. However, this is generally discouraged because it can catch unexpected errors that you might not be aware of.

try:
    result = 10 / 0  # This will raise a ZeroDivisionError
except Exception as e:
    print("An error occurred:", e)

The else Block:

You can include an else block to execute code when no exceptions are raised within the try block.

try:
    x = 10 / 2
except ZeroDivisionError as e:
    print("Error:", e)
else:
    print("No exceptions were raised. Result:", x)

In this example, since no exceptions were raised, the code inside the else block is executed.

The finally Block:

The finally block is used to specify code that must be executed regardless of whether an exception was raised or not. It's often used for cleanup operations like closing files or releasing resources.

try:
    file = open("example.txt", "r")
    content = file.read()
except FileNotFoundError:
    print("File not found.")
else:
    print("File contents:", content)
finally:
    if file:
        file.close()

In this example, the finally block ensures that the file is closed regardless of whether an exception was raised or not.

Raising Exceptions:

You can explicitly raise exceptions using the raise statement. This is useful when you want to signal an error condition in your code.

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero.")
    return a / b

try:
    result = divide(10, 0)
except ZeroDivisionError as e:
    print("Error:", e)

In this example, the divide function raises a ZeroDivisionError if the second argument is zero.

Custom Exceptions:

You can create your own custom exception classes by inheriting from the built-in Exception class or one of its subclasses. This allows you to define your own error types for your specific application.

class MyCustomError(Exception):
    pass

try:
    raise MyCustomError("This is a custom error.")
except MyCustomError as e:
    print("Custom Error:", e)

In this example, we define a custom exception class MyCustomError and then raise an instance of it.

Exception Chaining (Python 3.3+):

Python 3.3 introduced exception chaining, allowing you to capture and re-raise an exception while preserving the original exception's traceback.

try:
    x = 10 / 0
except ZeroDivisionError as e1:
    raise ValueError("An error occurred") from e1

In this example, a ValueError exception is raised with the original ZeroDivisionError as its cause.

Handling Exceptions in a Function:

When writing functions, it's often a good practice to handle exceptions within the function and provide meaningful error messages or return values.

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        return "Division by zero is not allowed."
    return result

This function handles the ZeroDivisionError exception and returns a meaningful message instead of crashing the program.

Summary:

Exception handling in Python is crucial for dealing with unexpected errors and ensuring that your code remains robust and user-friendly. It allows you to gracefully handle errors, provide meaningful error messages, and perform cleanup operations. By using try, except, else, and finally blocks, along with raising custom exceptions, you can create code that is more resilient to errors and easier to debug.

File Handling

File handling in Python is a fundamental operation that allows you to read from and write to files on your computer. Python provides built-in functions and methods for file input and output, making it easy to work with text and binary files. Here's an overview of file handling in Python:

Opening and Closing Files:

Before you can read from or write to a file, you need to open it using the open() function. The open() function takes two arguments: the file's path and the mode in which you want to open it (read, write, append, etc.). After you're done with a file, it's essential to close it using the close() method to free up system resources and ensure that changes are saved.

# Opening a file for reading
file = open("example.txt", "r")

# Reading the file
content = file.read()
print(content)

# Closing the file
file.close()

Reading from Files:

There are several methods for reading from files:

  • read(): Reads the entire file's content as a single string.

  • readline(): Reads one line from the file at a time.

  • readlines(): Reads all lines from the file and returns them as a list of strings.

Here's an example using readlines():

file = open("example.txt", "r")
lines = file.readlines()
file.close()

for line in lines:
    print(line.strip())  # Strips newline characters

Writing to Files:

To write to a file, you need to open it in write mode ("w"). Be careful when opening a file in write mode, as it will overwrite the existing content if the file already exists. To append content to an existing file, open it in append mode ("a").

# Writing to a file
file = open("output.txt", "w")
file.write("Hello, world!")
file.close()

# Appending to a file
file = open("output.txt", "a")
file.write("\nAppending more text.")
file.close()

Using with Statements (Context Managers):

A better way to handle files is by using with statements, which act as context managers. They automatically close the file when you're done with it, even if an exception occurs.

with open("example.txt", "r") as file:
    content = file.read()
    # Work with the file content

# The file is automatically closed outside the 'with' block

Binary File Handling:

In addition to text files, Python can handle binary files. When working with binary files, open the file in binary mode ("rb" for reading, "wb" for writing, "ab" for appending, etc.).

# Reading a binary file
with open("image.png", "rb") as binary_file:
    binary_data = binary_file.read()
    # Process binary data

# Writing to a binary file
with open("output.bin", "wb") as binary_file:
    binary_file.write(b"\x48\x65\x6c\x6c\x6f")  # Binary data

Exception Handling for File Operations:

File operations, like opening and closing files, can raise exceptions. It's essential to handle exceptions to ensure that your code behaves correctly, especially when dealing with file I/O.

try:
    with open("example.txt", "r") as file:
        content = file.read()
except FileNotFoundError:
    print("File not found.")
except IOError as e:
    print("An error occurred:", e)

Copy in file handling

In file handling, "copy" refers to the process of duplicating the content of one file and creating a new file with that copied content. Python provides several methods to copy the content of a file, allowing you to duplicate text or binary data from one file to another. Here, we'll explore two common approaches for copying files in Python:

1. Copying Text Files:

To copy the content of one text file to another, you can open both files and read from the source file while writing to the destination file. Here's a basic example:

# Open the source file in read mode
with open("source.txt", "r") as source_file:
    # Read the content of the source file
    content = source_file.read()

# Open the destination file in write mode
with open("destination.txt", "w") as destination_file:
    # Write the content to the destination file
    destination_file.write(content)

In this example, we first open the source file ("source.txt") in read mode and read its content. Then, we open the destination file ("destination.txt") in write mode and write the content to it.

2. Copying Binary Files:

To copy binary files, such as images or executables, you can use binary mode ("rb" for reading and "wb" for writing). Here's an example of copying a binary file:

# Open the source binary file in read mode
with open("source.bin", "rb") as source_file:
    # Read binary data from the source file
    binary_data = source_file.read()

# Open the destination binary file in write mode
with open("destination.bin", "wb") as destination_file:
    # Write the binary data to the destination file
    destination_file.write(binary_data)

In this example, we open the source binary file ("source.bin") in read mode and read its binary data. Then, we open the destination binary file ("destination.bin") in write mode and write the binary data to it.

Using shutil for File Copying:

Python's shutil module provides a convenient way to copy files and directories. You can use the shutil.copy() function to copy a file. It handles various file operations and ensures that file attributes and permissions are preserved.

import shutil

# Copy a file from source to destination
shutil.copy("source.txt", "destination.txt")

The shutil.copy() function is a high-level approach that simplifies file copying tasks and is often recommended when working with files.

Handling File Existence:

When copying files, you may need to handle cases where the destination file already exists. Depending on your requirements, you can either overwrite the existing file or raise an exception if it exists. Python's shutil module provides functions like shutil.copy() and shutil.copy2() that offer options for handling file existence.

import shutil

# Copy a file, overwriting the destination if it already exists
shutil.copy("source.txt", "destination.txt")

# Copy a file, preserving metadata, and raising an error if the destination exists
shutil.copy2("source.txt", "destination.txt")

File and Folder removal

In Python, you can delete files and folders using various methods and modules. Two commonly used modules for this purpose are os and shutil. Here's how you can delete files and folders using these modules:

Deleting Files:

To delete a file in Python, you can use the os.remove() function or os.unlink() function (both functions are essentially the same).

import os

# Specify the file path
file_path = "file_to_delete.txt"

try:
    # Attempt to delete the file
    os.remove(file_path)
    print(f"{file_path} has been deleted.")
except FileNotFoundError:
    print(f"{file_path} not found.")
except Exception as e:
    print(f"An error occurred while deleting {file_path}: {e}")

In this example, we try to delete a file named "file_to_delete.txt" using os.remove(). If the file exists, it will be deleted, and if it doesn't exist, a FileNotFoundError will be caught.

Deleting Folders (Directories):

To delete a folder (directory) in Python, you can use the os.rmdir() function to delete an empty directory or the shutil.rmtree() function to delete a directory and its contents (including subdirectories and files).

Deleting an Empty Directory:

import os

# Specify the empty directory path
directory_path = "empty_directory_to_delete"

try:
    # Attempt to delete the empty directory
    os.rmdir(directory_path)
    print(f"{directory_path} has been deleted.")
except FileNotFoundError:
    print(f"{directory_path} not found.")
except Exception as e:
    print(f"An error occurred while deleting {directory_path}: {e}")

In this example, we try to delete an empty directory named "empty_directory_to_delete" using os.rmdir().

Deleting a Directory and Its Contents:

import shutil

# Specify the directory path to delete
directory_path = "directory_to_delete"

try:
    # Attempt to delete the directory and its contents
    shutil.rmtree(directory_path)
    print(f"{directory_path} has been deleted, along with its contents.")
except FileNotFoundError:
    print(f"{directory_path} not found.")
except Exception as e:
    print(f"An error occurred while deleting {directory_path}: {e}")

In this example, we use shutil.rmtree() to delete a directory named "directory_to_delete" and all its contents. This is a more comprehensive way to remove directories, as it handles subdirectories and files within the specified directory.

Remember to be cautious when deleting files and folders, especially when using shutil.rmtree(), as it irreversibly removes data. Ensure that you have appropriate backups and confirm that you want to delete the data before executing the deletion operation in your code.

Modules

In Python, a module is a file containing Python code. The code can include variables, functions, and classes. Modules are used to organize and structure Python code into reusable components. They facilitate code management, promote code reusability, and enhance maintainability. In this detailed explanation of Python modules, we will cover various aspects:

Creating and Importing Modules:

To create a module, you simply write Python code in a .py file. For example, if you create a file named my_module.py with the following content:

# my_module.py

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

person = {
    "name": "Atharv",
    "age": 20
}

You can import and use this module in another Python script:

import my_module

message = my_module.greet(my_module.person["name"])
print(message)

Here, we import the my_module module using the import statement and use its functions and variables.

Module Search Path:

Python has a predefined search path to locate modules. When you import a module, Python searches for it in the following order:

  1. The current directory.

  2. Directories defined in the PYTHONPATH environment variable.

  3. Standard library directories.

  4. Third-party library directories.

  5. The sys.path list, which can be modified at runtime.

You can view the list of directories in the module search path by importing the sys module and printing sys.path.

Module Aliases:

You can give a module a shorter alias when importing it to make the code more concise:

import my_module as mm

message = mm.greet(mm.person["name"])
print(message)

Here, we import my_module as mm and use the alias to access its elements.

Importing Specific Elements:

Instead of importing the whole module, you can import specific functions, variables, or classes from a module:

from my_module import greet

message = greet("kaytr nav")
print(message)

This approach can make your code more readable and avoid potential naming conflicts.

Built-in Modules:

Python comes with a vast standard library that includes numerous built-in modules for various purposes. These modules provide functions and classes to perform a wide range of tasks, from working with file systems (os and shutil) to handling dates and times (datetime) to performing mathematical operations (math).

You can import and use these modules just like any other Python module.

Creating Your Own Packages:

Packages are a way to organize related modules into directories. A package is a directory containing an __init__.py file (which can be empty) and one or more module files. You can create packages to structure your code logically and hierarchically.

For example, you can create a package called my_package with the following structure:

my_package/
    __init__.py
    module1.py
    module2.py

You can then import modules from the package like this:

from my_package import module1

result = module1.some_function()

The if __name__ == "__main__" Block:

When a module is imported, all its code is executed, including function and variable definitions. To differentiate between a module being run as the main program and being imported as a module, you can use the if __name__ == "__main__": block. The code inside this block only runs when the module is executed as the main program.

# my_module.py

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

if __name__ == "__main__":
    print(greet("nav athvana"))

Distributing Your Modules and Packages:

You can share your Python modules and packages with others by packaging them and distributing them through the Python Package Index (PyPI). Tools like setuptools and pip facilitates the packaging and distribution process.

Random Module in python

The random module in Python is a built-in module that provides functions for generating random numbers and performing random operations. It's a powerful tool for tasks such as generating random data, shuffling sequences, or simulating random events. Here are some of the key functions and features of the random module:

Importing the random Module:

You can import the random module using the following import statement:

import random

Once imported, you can use the functions and methods provided by the module.

Generating Random Numbers:

  1. random.random(): This function returns a random floating-point number in the range [0.0, 1.0).

     random_number = random.random()
    
  2. random.uniform(a, b): This function returns a random floating-point number in the range [a, b).

     random_number = random.uniform(1, 10)
    
  3. random.randint(a, b): This function returns a random integer in the range [a, b], including both a and b.

     random_integer = random.randint(1, 6)
    

Generating Random Sequences:

  1. random.choice(seq): This function returns a random element from the given sequence (list, tuple, or string).

     fruits = ["apple", "banana", "cherry"]
     random_fruit = random.choice(fruits)
    
  2. random.shuffle(seq): This function shuffles the elements of a sequence in-place.

     numbers = [1, 2, 3, 4, 5]
     random.shuffle(numbers)
    
  3. random.sample(seq, k): This function returns a list of k unique random elements from the given sequence without replacement.

     deck = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
     random_hand = random.sample(deck, 5)
    

Setting the Random Seed:

You can use the random.seed(seed) function to set the seed for the random number generator. Setting the seed ensures that you get reproducible results when using random functions.

random.seed(42)
random_number = random.random()  # This will always produce the same result

Random Integer Ranges:

If you need to generate random integers within a specific range, you can use the following functions:

  • random.randrange(start, stop[, step]): This function returns a random integer chosen from the specified range [start, stop) with an optional step.

      random_number = random.randrange(1, 11)  # Random integer between 1 and 10 (inclusive)
    

Random Choices with Weights:

The random.choices(seq, weights=None, k=1) function allows you to make random selections from a sequence with specified weights. This is useful for creating weighted random choices.

colors = ["red", "green", "blue"]
weights = [3, 1, 2]  # Weights for the corresponding colors

random_color = random.choices(colors, weights=weights, k=1)

Summary:

The random module in Python provides a wide range of functions for generating random numbers and performing random operations. It's a versatile tool that is commonly used in tasks involving simulations, games, data sampling, and more. Understanding how to use the functions in this module allows you to introduce randomness and variability into your Python programs.

Object Oriented Programming

Object-oriented programming (OOP) is a programming paradigm that uses objects and classes to structure and organize code. Python is a versatile and powerful language for implementing OOP concepts. Here, we'll provide a detailed explanation of OOP in Python, covering key concepts such as classes, objects, inheritance, encapsulation, and polymorphism.

Classes and Objects:

Classes are the blueprints or templates for creating objects. A class defines attributes (data) and methods (functions) that represent the properties and behaviors of objects. In Python, you define a class using the class keyword.

Objects are instances of classes. They are created based on the class definition and represent real-world entities in your code. You create objects by calling the class as if it were a function.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        print(f"{self.name} says Woof!")

# Creating objects (instances) of the Dog class
dog1 = Dog("tula pahije te ghe", 3)
dog2 = Dog("hith pan", 5)

# Accessing attributes and calling methods of objects
print(dog1.name)  # Output: "tula pahije te ghe"
dog2.bark()       # Output: "higt pan says Woof!"

Constructors and self:

The __init__ method is a special method called a constructor. It initializes the object's attributes when the object is created. The self parameter refers to the object itself and is used to access its attributes and methods within the class.

Class Variables and Instance Variables:

  • Class Variables: These are shared among all instances of a class and are defined within the class but outside any method using the class keyword. Class variables are accessed using the class name.

  • Instance Variables: These are specific to each instance and are created and accessed using self within methods.

class Circle:
    pi = 3.14159265  # Class variable

    def __init__(self, radius):
        self.radius = radius  # Instance variable

    def area(self):
        return self.pi * self.radius * self.radius

circle1 = Circle(5)
circle2 = Circle(7)

print(circle1.area())  # Output: 78.53981625
print(circle2.area())  # Output: 153.93804025

Inheritance:

Inheritance is a fundamental OOP concept that allows you to create a new class (subclass or derived class) that inherits properties and methods from an existing class (base class or parent class). Python supports single inheritance, meaning a subclass can inherit from one parent class.

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

dog = Dog("kutra")
cat = Cat("manjar")

print(dog.speak())  # Output: "kutra says Woof!"
print(cat.speak())  # Output: "manjar says Meow!"

Encapsulation:

Encapsulation is the principle of hiding the internal details of an object and providing a public interface to interact with it. In Python, you can achieve encapsulation by using private and protected attributes and methods.

  • Private attributes/methods: Start with a double underscore (e.g., __private_var).

  • Protected attributes/methods: Start with a single underscore (e.g., _protected_var).

class Student:
    def __init__(self, name, roll_number):
        self.name = name
        self._roll_number = roll_number  # Protected attribute

    def get_roll_number(self):
        return self._roll_number  # Protected method

student = Student("Atharv",123456+)
print(student.name)               # Accessing public attribute
print(student.get_roll_number())  # Accessing protected method

Polymorphism:

Polymorphism allows objects of different classes to be treated as objects of a common superclass. It promotes code reusability and flexibility. Python supports polymorphism through method overriding.

class Shape:
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159265 * self.radius * self.radius

class Rectangle(Shape):
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

shapes = [Circle(5), Rectangle(4, 6)]

for shape in shapes:
    print(f"Area: {shape.area()}")

In this example, both Circle and Rectangle are subclasses of Shape, and they override the area method.

Types of Inheritance

Inheritance is a fundamental concept in object-oriented programming (OOP) that allows you to create new classes (subclasses or derived classes) based on existing classes (base classes or parent classes). Different types of inheritance describe how classes inherit and reuse properties and behaviors from their parent classes. In Python, you can implement various types of inheritance:

1. Single Inheritance:

Single inheritance is the simplest form of inheritance, where a subclass inherits from a single-parent class. In other words, a class can have only one immediate superclass.

class Animal:
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

In this example, Dog and Cat are subclasses of Animal, demonstrating single inheritance.

2. Multiple Inheritance:

Multiple inheritance allows a class to inherit from more than one parent class. In Python, you can achieve multiple inheritance by specifying multiple parent classes in the class definition.

class Bird:
    def fly(self):
        return "Flying"

class Fish:
    def swim(self):
        return "Swimming"

class FlyingFish(Bird, Fish):
    pass

In this example, FlyingFish inherits from both Bird and Fish, enabling instances of FlyingFish to use methods from both parent classes.

3. Multilevel Inheritance:

In multilevel inheritance, a class inherits from another class, which, in turn, inherits from another class. This creates a chain of inheritance.

class Grandparent:
    def method1(self):
        pass

class Parent(Grandparent):
    def method2(self):
        pass

class Child(Parent):
    def method3(self):
        pass

Here, Child inherits from Parent, which, in turn, inherits from Grandparent, creating a multilevel inheritance relationship.

4. Hierarchical Inheritance:

In hierarchical inheritance, multiple subclasses inherit from a single parent class. Each subclass can have its own methods and attributes while sharing common features from the parent class.

class Shape:
    def area(self):
        pass

class Circle(Shape):
    def area(self):
        return 3.14159265 * self.radius * self.radius

class Rectangle(Shape):
    def area(self):
        return self.length * self.width

Both Circle and Rectangle inherit from the common Shape class, demonstrating hierarchical inheritance.

5. Hybrid Inheritance:

Hybrid inheritance is a combination of two or more types of inheritance. It often involves multiple and multilevel inheritance. Complex class hierarchies can be formed using hybrid inheritance.

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

In this example, D inherits from both B and C, demonstrating a hybrid inheritance scenario.

6. Multiple Inheritance with Method Resolution Order (MRO):

In Python, when a class inherits from multiple parent classes, it follows a specific method resolution order (MRO) to determine which method to call when there are name conflicts. You can check the MRO using the mro() method or the super() function.

class A:
    def method(self):
        return "A"

class B:
    def method(self):
        return "B"

class C(A, B):
    pass

c = C()
print(c.method())  # Output: "A" (follows MRO, so it uses A's method)

In this example, C inherits from both A and B, and the MRO dictates that it uses A's method.

Method Chaining

Before we dive into the concepts of method chaining it is important to know that a method can return a object which is responsible for calling the function itself

In object-oriented programming, a method can return an object that is responsible for calling the function itself by returning a reference to itself. This is commonly seen in the context of the "Builder" design pattern. The Builder pattern is used to construct a complex object step by step, allowing for a more readable and maintainable code.

Here's an example in Python without using method chaining:

class PizzaBuilder:
    def __init__(self):
        self.size = None
        self.toppings = []

    def set_size(self, size):
        self.size = size
        return self  # Return the builder object itself

    def add_topping(self, topping):
        self.toppings.append(topping)
        return self  # Return the builder object itself

    def build(self):
        return Pizza(self.size, self.toppings)

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings

    def __str__(self):
        return f"A {self.size}-inch pizza with {', '.join(self.toppings)} toppings."

# Usage
pizza_builder = PizzaBuilder()
pizza = pizza_builder.set_size(12).add_topping("Pepperoni").add_topping("Mushrooms").build()

print(pizza)

In this example, we have a PizzaBuilder class responsible for building a Pizza object. The set_size and add_topping methods return the PizzaBuilder object itself (return self), allowing you to chain method calls if desired. The build method creates and returns a Pizza object based on the configured parameters.

By returning self in each method, you can chain method calls on the same object, which provides a more fluent and expressive way of constructing objects.

Now we are familiar with the method returning the object we can discuss about method chaining

Method chaining in Python is a programming style where multiple method calls are invoked sequentially on the same object. This style is prevalent in Object-Oriented Programming (OOP) languages where methods are created inside classes and are invoked or called by objects of the class.

The advantage of method chaining is that it reduces the need for intermediate variables and makes the code more readable. In method chaining, each method performs an action on the same object and then returns the object to the next call, thus removing the need for assigning variables at each intermediate step. It also enhances the readability of the code since methods are invoked sequentially.

For example, consider a class CalculatorFunctions with methods sum(), difference(), product(), and quotient(). Instead of calling these methods one after the other, we can chain them and call them in a single line as shown below:

class CalculatorFunctions(): 
   def sum(self): 
      print("Sum called") 
      return self 
   def difference(self): 
      print("Difference called") 
      return self 
   def product(self): 
      print("Product called") 
      return self 
   def quotient(self): 
      print("Quotient called")
      return self

if __name__ == "__main__": 
   calculator = CalculatorFunctions() 
   # Chaining all methods of CalculatorFunctions 
   calculator.sum().difference().product().quotient()

Here, each method is performing an action (printing a message in this case) and returning the same object (using return self), which allows the next method in the chain to be called on the same object.

Method chaining is not limited to user-defined classes and methods. It can also be used with built-in methods and functions in Python. For instance, consider the following example where string and list methods are chained together:

days_of_week = "Monday Tuesday Wednesday Thursday Friday Saturday,Sunday"
weekend_days = days_of_week.split()[-1].split(',')
print(weekend_days)  # Output: ['Saturday', 'Sunday']

In the above code, the method split() is called on the string days_of_week to split it into a list of days. Then, the last element of the list (which is "Saturday,Sunday") is accessed using [-1] and split again using split(',') to get the individual weekend days.

Super

The super() function in Python is a built-in function that is used to call a method from a parent class, also known as a superclass. This function is most commonly used in the context of inheritance, which is a fundamental concept in object-oriented programming (OOP). Inheritance allows a class (known as a subclass) to inherit attributes and methods from another class (known as a superclass or parent class).

In Python, the super() function provides a way to call methods from the superclass. This can be particularly useful when the subclass has a method with the same name as a method in the superclass (a situation known as method overriding). In such cases, you can use super() to call the method from the superclass, and thus avoid having to rewrite the method in the subclass.

The syntax of super() is as follows:

super().method_name(args)

Here, method_name is the name of the method that you want to call from the superclass, and args are any arguments that the method takes.

Let's consider an example. Suppose we have a superclass Person and a subclass Student. The Person class has a method show_name(), and the Student class has a method with the same name. If we create an instance of the Student class and call show_name(), the method from the Student class will be executed. However, if we want to call the show_name() method from the Person class, we can use super() as follows:

class Person:
    def show_name(self):
        print("This is the Person's show_name method")

class Student(Person):
    def show_name(self):
        print("This is the Student's show_name method")
        super().show_name()

student = Student()
student.show_name()

In this code, super().show_name() inside the Student class's show_name() method calls the show_name() method from the Person class.

The super() function can also be used with two parameters: the first is the subclass, and the second parameter is an object that is an instance of that subclass. This can be useful in more complex scenarios, such as multiple inheritance or when you need to call a method from a specific superclass.

super(subclass, instance).method(args)

Here, subclass is the subclass that super() is being called from, instance is an instance of that subclass, method is the method from the superclass that you want to call, and args are any arguments that the method takes.

For example, consider the following code:

class Rectangle:
    def area(self, length, width):
        return length * width

class Square(Rectangle):
    def area(self, length):
        return super(Square, self).area(length, length)

In the Square class's area() method, super(Square, self).area(length, length) is used to call the area() method from the Rectangle class.

One of the main advantages of using super() is that it enhances code reusability and modularity. You don't need to rewrite the entire function in the subclass and it doesn't require you to specify the parent class name to access its methods. This can be particularly beneficial in larger codebases or when working with complex class hierarchies.

However, it's important to note that the super() function returns a temporary object of the superclass, which allows access to its methods. This means that you can only use super() to call methods that exist in the superclass. If the superclass does not have a method with the specified name, calling super().method_name(args) will raise an AttributeError.

Abstract

In Python, an abstract class serves as a blueprint for other classes, allowing the definition of methods that must be created within any child classes built from the abstract class. A class that contains one or more abstract methods is known as an abstract class. An abstract method is a method that has a declaration but does not have an implementation. In the process of designing large functional units, abstract classes are used. When there's a need to provide a common interface for different implementations of a component, abstract classes are also used.

Python supports abstract classes through its Abstract Base Class (ABC) module. This module provides a way to define abstract classes and enforce their interface on their subclasses. An abstract class in Python is a class designed to be inherited by other classes. It cannot be instantiated on its own and its primary purpose is to provide a template for other classes to build upon.

Here is an example of an abstract class in Python:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

r = Rectangle(5, 6)
print(r.area())    # Output: 30

In this example, Shape is an abstract base class that defines an abstract method area. Rectangle is a concrete class that inherits from Shape and implements the area method.

Abstract classes in Python are classes that cannot be instantiated and are meant to be inherited by other classes. They are useful for defining common methods and properties that should be implemented by their concrete subclasses. One way to implement abstract classes in Python is to use abstract base classes (ABCs) from the abc module. An ABC is a special type of class that cannot be instantiated directly, but can be subclassed. ABCs define one or more abstract methods, which are methods that must be implemented by any concrete subclass.

Abstract classes give us a standard way of developing our code even if multiple developers are working on a project. They are useful because they provide a common interface for all the classes that inherit from them. This allows you to create code that is more flexible and easier to maintain.

It's important to note that abstract classes cannot be instantiated. This means that you can't create an object from an abstract class. Abstract classes are only meant to be used as base classes for other classes. The subclasses that inherit from an abstract class must provide an implementation for all the abstract methods defined in the abstract class.

One of the main advantages of using abstract classes is that they can be used to enforce a certain interface contract on classes, without specifying their implementation details. This can be particularly beneficial when working with plugins or when working in a large team or with a large codebase where keeping all classes in your mind is difficult or not possible.

In conclusion, abstract classes in Python provide a way to define a template for other classes to build on. They cannot be instantiated on their own and they provide a way to ensure that subclasses implement specific methods. The abc module provides a way to define abstract base classes in Python.

Passing object as argument

At its core, passing objects to methods means invoking a method on an object, where the method performs some actions on the object itself. This allows for dynamic and modular interactions with objects, making your code more readable and maintainable.

Let's dive into an illustrative example. We'll define two classes, A and B, and a global function, global_function, that accepts an object and an optional color parameter. The function sets the color attribute of the object, creating it if it doesn't exist. This simple example showcases the concept effectively:

class A:
    color = None

class B:
    pass

def global_function(object_of_any_class, color="White"):
    # If the object does not have a color attribute,
    # this function will create a color attribute for the passed object
    object_of_any_class.color = color

a1 = A()
global_function(a1, "black")
print(a1.color)  # Output: "black"

b1 = B()
global_function(b1, "red")
print(b1.color)  # Output: "red"

b2 = B()
global_function(b2)
print(b2.color)  # Output: "White"

In this example:

  • We define two classes, A and B, with different attributes.

  • The global_function method sets or modifies the color attribute of the passed object, providing a default value of "White" if not specified.

  • We create instances of both classes and use global_function to modify their color attributes.

Duck Typing

Duck typing is a concept in programming languages, particularly in dynamically typed languages like Python, that focuses on the behavior of objects rather than their specific types or classes. The term "duck typing" comes from the saying, "If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck." In Python, this means that the type or class of an object is determined by its behavior rather than its explicit declaration.

In other words, when you use duck typing in Python, you don't check the type of an object explicitly; instead, you rely on whether the object supports the operations or methods you intend to perform on it. If it does, Python allows you to perform those operations, regardless of the object's specific type.

Here's an example to illustrate duck typing in Python:

def add(a, b):
    return a + b

result = add(5, 10)  # Adding two integers
print(result)  # Output: 15

result = add("Hello, ", "world!")  # Concatenating two strings
print(result)  # Output: Hello, world!

In this example, the add function doesn't specify the types of a and b. It simply performs the + operation on them. Python allows this because it checks whether the objects provided as arguments support the + operation, and if they do, it performs the operation. This is duck typing in action – Python "looks at" the behavior of the objects, not their explicit types.

However, if you pass objects that don't support the + operation, you'll get a runtime error (e.g., TypeError), indicating that the operation is not supported on those objects. Duck typing assumes that if an object behaves like it can be used in a particular way, it can be used that way, and it's up to the programmer to ensure that the behavior is consistent with their expectations.

Duck typing can make Python code more flexible and allow for more concise and generic functions. It encourages a focus on what an object can do rather than what it is, which can lead to more versatile and reusable code.

Advanced Functional Programming

Walrus Operator

The walrus operator := is used for assignment within larger expressions. It allows you to both assign a value to a variable and use that value in an expression simultaneously. This can lead to more concise and readable code, especially in scenarios where you want to use the result of an expression and also keep track of that result.

In your example, you have two while loops that take user input and continue executing until the user enters 0. Let's break down both loops to understand how the walrus operator is used:

  1. Traditional while loop with separate assignment:
while True:
    choice = int(input("Enter the choice --> "))
    if choice == 0:
        break
    print("do something")

In this loop, you first take user input and assign it to the choice variable using the = assignment operator. Then, you check if choice is equal to 0, and if it is, you break out of the loop.

  1. while loop using the walrus operator:
while choice := int(input("Enter the choice --> ")) != 0:
    print("do something")

In this loop, you use the walrus operator := to simultaneously assign the result of int(input("Enter the choice --> ")) to the choice variable and check if the value is not equal to 0. Here's how it works step by step:

  • The user is prompted to enter a choice.

  • The int(input("Enter the choice --> ")) expression takes user input, converts it to an integer, and assigns the result to choice while checking if it's not equal to 0.

  • If the user enters a value other than 0, the loop continues executing, and "do something" is printed.

  • If the user enters 0, the loop terminates because the condition choice := int(input("Enter the choice --> ")) != 0 evaluates to False.

The second loop using the walrus operator achieves the same functionality as the first loop but in a more concise and inline manner. It assigns the user's input to choice and checks if it's not equal to 0 all in one line, making the code more compact and readable.

Higher order functions

Higher-order functions are a fundamental concept in functional programming and are supported in Python as well. A higher-order function is a function that can take one or more functions as arguments and/or return a function as its result. In Python, functions can be treated just like any other data type such as integers or strings.

  1. Passing Functions as Arguments:

    Higher-order functions can take other functions as arguments. This allows you to abstract behavior and make your code more reusable. For example:

     def apply(func, x):
         return func(x)
    
     def square(x):
         return x ** 2
    
     def double(x):
         return x * 2
    
     result1 = apply(square, 5)  # Pass the square function as an argument
     result2 = apply(double, 5)  # Pass the double function as an argument
    
     print(result1)  # Output: 25
     print(result2)  # Output: 10
    
  2. Returning Functions:

    Higher-order functions can also return functions as their result. This allows you to create functions dynamically based on some conditions or parameters. For example:

     def create_multiplier(factor):
         def multiplier(x):
             return x * factor
         return multiplier
    
     double = create_multiplier(2)
     triple = create_multiplier(3)
    
     result1 = double(5)  # Returns 5 * 2 = 10
     result2 = triple(5)  # Returns 5 * 3 = 15
    

Lambda function

A lambda function, also known as an anonymous function or a lambda expression, is a concise way to define small, unnamed functions in Python. Lambda functions are typically used for simple operations and can be defined in a single line. They are created using the lambda keyword, followed by a list of arguments, a colon (:), and an expression that is evaluated and returned as the function's result.

The syntax of lambda functions is:

# variable_name `lambda` arguments: return_statment

Now let's take an example for understanding

def multiply(a, b):
    return a * b

This is a regular function named multiply that takes two arguments, a and b, and returns their product.

Now, let's define the same functionality using a lambda function:

multiply_lambda = lambda a, b: a * b

Here, we've created a lambda function assigned to the variable multiply_lambda. It takes two arguments, a and b, separated by a comma, and returns their product, which is expressed as a * b. This lambda function is equivalent in functionality to the multiply function.

You can call this lambda function just like any other function:

print(multiply_lambda(2, 3))

This will print the result of multiplying 2 and 3, which is 6.

check_age = lambda age: True if age > 17 else False

Here, we've defined a lambda function named check_age that takes a single argument, age. It checks if age is greater than 17 and returns True if it is, otherwise, it returns False. This lambda function is essentially a concise way of writing a conditional statement.

You can call this lambda function with different values of age:

print(check_age(10))  # This will print False
print(check_age(19))  # This will print True

In this way, lambda functions are useful for creating simple, one-line functions without the need to define a full function using the def keyword.

Inbuilt functions

Sorted Function in Python

The sorted() function is a built-in Python function that takes an iterable and returns a new sorted list from the elements in the iterable. It can sort different types of iterables such as lists, tuples, and dictionaries.

Syntax

The syntax of the sorted() function is as follows:

sorted(iterable, key=None, reverse=False)
  • iterable: This is the sequence (list, tuple, string) or collection (dictionary, set, frozenset) or any other iterator that needs to be sorted.

  • key (optional): This is a function that serves as a key or a basis of sort comparison.

  • reverse (optional): If set to True, then the iterable would be sorted in reverse (descending) order.

Basic Usage

Here is an example of using the sorted() function to sort a list of numbers in ascending order:

list_1 = [1, 5, 3, 62, 6, 2, 27]
sorted_list = sorted(list_1)
print(sorted_list)  # Output: [1, 2, 3, 5, 6, 27, 62]

The sorted() function can also be used on tuples. However, it's important to note that it returns a new sorted list, not a tuple. To get a sorted tuple, you can convert the sorted list to a tuple as shown below:

tuple_1 = (5, 2, 9, 7)
sorted_tuple = sorted(tuple_1)
print(sorted_tuple)  # Output: [2, 5, 7, 9]

Sorting With a Key Function

The key parameter of the sorted() function can be used to specify a function of one argument that is used to extract a comparison key from each input element. The function is applied to each item on the iterable.

Here's an example of sorting a list of tuples based on the first element of each tuple:

list_2 = [(1, 4, 2), (2, 5, 7), (7, 2, 9), (9, 6, 1), (6, 1, 6)]
list_2.sort()
for i in list_2:
    print(i)

You can also use a lambda function as the key function. Here's an example of sorting a list of lists based on the second item in each list:

list_3 = [[1, 2, 3], [2, 1, 6], [4, 3, 2], [6, -1, 4]]
list_3.sort(key=lambda x: x[1])
for i in list_3:
    print(i)

In this case, the lambda function lambda x: x[1] is used to extract the second item from each list as the key for sorting.

Sorting in Descending Order

By default, the sorted() function sorts in ascending order. If you want to sort in descending order, you can set the reverse parameter to True:

list_1 = [1, 5, 3, 62, 6, 2, 27]
sorted_list = sorted(list_1, reverse=True)
print(sorted_list)  # Output: [62, 27, 6, 5, 3, 2, 1]

Sorting Strings

The sorted() function can also be used to sort strings. When applied to a string, it treats the string as an iterable of characters and returns a new list that contains the sorted characters:

str_1 = "hello"
sorted_str = sorted(str_1)
print(sorted_str)  # Output: ['e', 'h', 'l', 'l', 'o']

However, it's important to note that the sorted() function returns a list, not a string. To get a sorted string, you can join the sorted characters back into a string as shown below:

str_1 = "hello"
sorted_str = ''.join(sorted(str_1))
print(sorted_str)  # Output: "ehllo"