Day 03
Generators
Generator functions are functions that allow us to return multiple times using the yield keyword. This allows us to generate many values over time from a single function. What makes generators so powerful is that unlike other forms of iteration, the values are not all computed upfront so we can suspend our state using the yield keyword and come back to to the function later to continue on. This makes generators a great choice for things like calculating large data sets.
Using the next function
Given a generator, you can obtain the next value by calling a special function called next and passing in the generator. Here's an example:
def use_next():
for x in range(10):
yield x
gen = use_next()
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
In the example above, you can't call next infinitely many times: eventually you'll get a StopIteration error (since x inside of use_next only has finitely many values).
However, if you iterate through a generator using something like a for loop, the loop will catch the error so that it doesn't break your program:
We can also do generator comprehension just like list comprehension by wrapping our comprehension in () to write generator functions with more ease. Here's how use_next would look as a generator comprehension:
Iterators
To make something an iterable (i.e. something you can iterate over) we call the iter function on it. For example, if we wanted strings to be iterators, we could do:
l = [x for x in range(10)]
op = iter(l)
print(next(op)) # 0
print(next(op)) # 1
print(next(op)) # 2
print(next(op)) # 3
print(next(op)) # 4
print(next(op)) # 5
print(next(op)) # 6
print(next(op)) # 7
print(next(op)) # 8
print(next(op)) # 9
print(next(op)) # Iteration Error
Enumerate
Sometimes when you iterate through an array, you want access not only to the elements, but also their indices. enumerate exposes both to you. It works by returning a tuple with the (index, value) at each iteration.
list = ["first","second","third"]
# How do we get the indices at each iteration? Enumerate!
for idx, value in enumerate(list):
print(f"index is {idx} and value is {value}")
# index is 0 and value is first
# index is 1 and value is second
# index is 2 and value is third
Enums
Enums (Enumerations) are sets of symbolic names bound to unique, constant values.
all and any
These are both built in functions that all us to check for a boolean matching in an iterable.
all - returns true if all elements are truthy
Closures
A closure in Python refers to a function object that has access to variables in its lexical scope, even when the function is called outside of its scope.
def outer_function(text):
def inner_function():
print(text)
return inner_function
my_closure = outer_function('Hello')
my_closure()
In other words, Closures are nothing but a function can be assigned to a variable, By then that variable becomes the function. i.e Higher Order Functions
Decorators
Decorators are functions that "decorate," or enhance, other functions. In order to see what this means, let's first review how we can pass functions to other functions. Remember, everything in Python is an object and objects are first class!
def shout():
return "WHOA!"
def whisper():
return "Shhh"
def perform_action(func):
print("Something is happening...")
return func()
We can write the behavior of a decorator like this:
def decorate_me():
print("decorate me...")
# decorate_me()
# decorate me...
def new_decorator(func):
def wrap_func():
print("Code before calling func!")
func()
print("Code after calling func!")
return wrap_func
dd = new_decorator(decorate_me)
dd()
# Code before calling func!
# decorate me...
# Code after calling func!
Think about i don't want to call the name of the function other than original name, how to deal with it ?
def decorate_me():
print("decorate me...")
# decorate_me()
# decorate me...
def new_decorator(func):
def wrap_func():
print("Code before calling func!")
func()
print("Code after calling func!")
return wrap_func
decorate_me = new_decorator(decorate_me)
decorate_me()
# Code before calling func!
# decorate me...
# Code after calling func!
Let's now use the decorator syntax to do the same thing! When you use a decorator, the function being used to decorate is prefixed with an @symbol. The function you're decorating is then defined below. Take a look:
def new_decorator(func):
def wrap_func():
print("Code before calling func!")
func()
print("Code after calling func!")
return wrap_func
@new_decorator
def decorate_me():
print("decorate me...")
decorate_me()
# Code before calling func!
# decorate me...
# Code after calling func!
Note how the code inside of the new_decorator function decorates, or enhances, the code inside of the decorate_me function.
Let's revisit the first example but refactor it to use decorator syntax.
def perform_action(func):
def wrap_func():
print("Something is happening...")
return func()
return wrap_func
@perform_action
def shout():
return "WHOA!"
@perform_action
def whisper():
return "Shhh"
This code will work just fine, but if we examine the __name__ or __doc__ attribute for our function it will not be correct!
We can manually fix this, or we can use the wraps decorator from the functools module.
from functools import wraps
def perform_action(func):
''' decorator function '''
@wraps(func)
def wrap_func():
''' Wrapper function '''
print("Something is happening...")
return func()
return wrap_func
@perform_action
def shout():
''' People Shouts '''
return "WHOA!"
@perform_action
def whisper():
''' People Whispers '''
return "Shhh"
Let's extend the previous example by passing arguments to the decorated functions and also alter the data within the decorator function. This is a common scenario where the decorator needs to accept arguments and potentially modify them or perform additional actions based on those arguments.
from functools import wraps
def perform_action(func):
''' Decorator function '''
@wraps(func)
def wrap_func(*args, **kwargs):
''' Wrapper function '''
# Altering or processing arguments if necessary
# Whatever the arguments passed converting to upper case
new_args = [arg.upper() for arg in args]
# Calling the function with new arguments that are altered
result = func(*new_args, **kwargs)
print("Something is happening...")
# Altering the result if necessary
return result + '!!!'
return wrap_func
@perform_action
def shout(message):
''' People Shouts '''
return f"WHOA, {message}"
@perform_action
def whisper(message):
''' People Whispers '''
return f"Shhh, {message}"
# Testing the decorated functions
print(shout("hello"))
print(whisper("be quiet"))
# Something is happening...
# WHOA, HELLO!!!
# Something is happening...
# Shhh, BE QUIET!!!
Map, Filter and Reduce
Map Function
The map function applies a given function to each item of an iterable (like a list or tuple) and returns an iterator. It's often used for transforming data. Here's the syntax:
Example: Suppose we want to square each number in a list.
Filter Function
The filter function constructs an iterator from elements of an iterable for which a function returns true. Essentially, it filters out the elements of an iterable that don't satisfy a certain condition.
Example: Filtering out even numbers from a list.
Reduce Function
The reduce function, which is part of the functools module, applies a rolling computation to sequential pairs of values in an iterable. The function you pass to reduce must accept two arguments, and reduce applies this function cumulatively to the items of the iterable, from left to right, so as to reduce the iterable to a single value.
Example: Use reduce to compute the sum of numbers in a list.
This example adds up all numbers in the list. reduce starts by applying the add function to the first two elements (1 and 2), then applies it to the result (3) and the next element (3), and so on, until the list is reduced to a single value.
Key Points
- map is used for applying a transformation function to an iterable.
- filter is used to select elements of an iterable that meet a certain condition.
- reduce is used for cumulatively applying a binary function to the items of an iterable to reduce them to a single value.
Sorting Techniques in Python
Sorting is a fundamental operation in computer science and Python offers a variety of ways to sort data. Let's explore the different sorting techniques available in Python.
Using the sorted() Function
The sorted() function returns a new sorted list from the elements of any iterable.
Syntax,
Parameters,
- iterable: The sequence to sort (list, tuple, string, etc.).
- key: A function that serves as a key for the sort comparison.
- reverse: If True, the list elements are sorted as if each comparison were reversed.
Sorting with a Custom Key
The key parameter allows customization of the sort order.
Example,
Sorting can be complex when dealing with lists of dictionaries, tuples, or objects.
Reverse Sorting
You can reverse the sorting order with reverse=True.
Example,
Understanding __name__ Significance
In Python, __name__ is a special built-in variable which plays a crucial role, especially when you are writing and importing modules. Understanding its significance and use cases is essential for advanced Python programming.
What is __name__ ?
When a Python script runs, the interpreter assigns values to certain special variables. __name__ is one of these special variables. Its value depends on how the containing script is being executed.
-
When the script is the main program: If the script is being run as the main program, the interpreter sets
__name__to__main__ -
When the script is imported as a module: If the script is being imported as a module into another script, the interpreter sets
__name__to the name of the script/module.
Why is __name__ Important ?
The primary use of __name__ is to determine whether a script is being run standalone or being imported elsewhere. This allows a script to change its behavior based on its context of use. It’s especially useful for running tests, executing initialization code, and providing modules with an entry point.
Example,
Let’s consider two files: main_script.py and imported_module.py.
file imported_module.py
file main_script.py
Advanced Use Cases of __name__
Testing Code: When writing modules, you can place your test code under this if __name__ == "__main__": check. This allows you to test the module as a standalone script but avoids running tests when the module is imported.
Making Modules Executable: Sometimes you want a module to be able to act as either a reusable module or as a standalone script. __name__ allows you to create a module that can do something useful when run on its own, like run a test suite or a demonstration.
Program Entry Point: In larger Python applications, particularly web applications, the if __name__ == "__main__": check is used to control the execution of the application. For example, in a Flask web application, this line is used to start the development server.
Exceptional Handling with Python
Exception handling in Python is a robust mechanism to handle runtime errors. Understanding different types of errors and how to manage them is essential for writing robust and fault-tolerant Python programs.
What is Exception Handling ?
Exception handling allows a programmer to respond to unexpected situations that can arise during the execution of a program. In Python, exceptions are special objects representing errors.
Basic Exception Handling: try, except, else, and finally
tryblock: Code that might cause an exception is placed inside a try block.exceptblock: If an error occurs within the try block, the flow jumps to the except block.elseblock (optional): Executed if no exceptions occur within the try block.finallyblock (optional): Always executed, regardless of whether an exception occurred.
Types of Errors
Errors in Python can be broadly categorized into two types:
-
Syntax Errors: Errors detected by Python as it parses the code. For example, missing colons, incorrect indentation, etc. These cannot be handled by
try/exceptblocks as they occur before the code is executed. -
Exceptions: Errors detected during execution. Python has numerous built-in exceptions (like
ValueError,TypeError,IndexError,KeyError, etc.) and also allows creation of custom exceptions.
Common Built-in Exceptions
- ZeroDivisionError: Occurs when dividing by zero.
- IndexError: Occurs when accessing an index out of range in a sequence.
- KeyError: Occurs when a dictionary key is not found.
- ValueError: Occurs when a function receives an argument of the correct type but an inappropriate value.
- TypeError: Occurs when an operation is performed on an object of an inappropriate type.
- FileNotFoundError: Occurs when a file or directory is requested but doesn't exist.
- ImportError: Occurs when an import statement fails.
Custom Exceptions
Python allows defining custom exceptions by subclassing from built-in exceptions.
Exception handling Best Practices
- Specificity: Catch specific exceptions instead of using a blanket except: clause. This avoids masking other bugs.
- Logging: Log detailed information about the exception.
- Clean Resources: Use finally or context managers to ensure resources are released even if an error occurs.
- Raising Exceptions: Sometimes it's appropriate to catch an exception but re-raise it for upstream handling.
Datetime Module
The datetime module in Python is essential for dealing with dates and times. It offers various classes for manipulating dates, times, and time intervals. Understanding these classes and their methods allows you to perform complex date and time calculations with ease.
Key Classes in the datetime Module
- datetime: A combination of a date and a time.
- date: Represents a date, independent of time.
- time: Represents a time, independent of a date.
- timedelta: Represents the difference between two dates or times.
Basic Operations
Getting Current Date and Time
Creating Specific Date and Time
Date Operations
Time Operations
Time operations are usually performed by combining time objects with datetime objects or using timedelta objects for calculations.
Imagine we want to schedule an event that starts at a particular time today and lasts for a specific duration. We'll use datetime to get today's date, time to set the event's start time, and timedelta to calculate the event's end time.
Extracting Information from datetime
timedelta: Working with Time Differences
The timedelta class is used for calculating differences in dates and also for date manipulations in Python.
Formatting Dates and Times
You can format dates and times using strftime (string format time) method.
Parsing Dates from Strings
To convert strings to datetime objects, use strptime (string parse time).
Timezones in datetime
Handling timezones is a bit more complex. Python's built-in support for timezones is limited, so it's common to use third-party libraries like pytz for comprehensive timezone support.
To Install pytz
Example,