# Python Functions Advanced Level Last Part

In this blog post will learn about Pure Functions, Error Handling, Function Composition, Generator Functions, and Namedtuples and Data Structures.

## Pure Function

A pure function is like a magic machine. When you give it something, it always gives you back the same thing in return, no matter how many times you use it. It doesn't change anything outside itself, and it doesn't have any hidden surprises. It's predictable and doesn't mess with other things in your world.

### Examples of Pure Functions:

Addition Function: Imagine a function that adds two numbers together.
1. ```pythondef add(a, b):
result = a + b
return result```
You can use this function with any numbers, and it will always give you the same result. For example, add(3, 5) will always be 8.

Multiplication Function: Here's another pure function that multiplies two numbers:
2. ```pythondef multiply(x, y):
result = x * y
return result```
It doesn't matter when or where you use it; if you put in the same numbers, you'll get the same result. For instance, multiply(4, 2) will always be 8.

String Length Function: This function tells you how long a word or sentence is.
```pythondef get_length(text):
length = len(text)
return length```
If you give it the same word, like "apple," it will always say it's 5 characters long.

### What Makes Them Pure?

Pure functions have two key characteristics:

Input-Output Consistency: They always produce the same output for the same input. If you give them a and b, they'll return the same result every time you call them with a and b.

No Side Effects: They don't change anything else outside the function. They don't modify variables outside the function, interact with databases, or mess with the global state. They're like sealed boxes that only operate on what you give them.

In a nutshell, pure functions are reliable, and predictable, and don't cause unexpected surprises. They're a fundamental concept in programming because they make your code easier to understand and reason about.

## Python Functions Error Handling

Example 1: Handling Division by Zero

```pythondef divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError as e:
return "Oops! You can't divide by zero."

# Let's test the divide function
result = divide(10, 2)
print(result)  # Output: 5.0

result = divide(10, 0)
print(result)  # Output: Oops! You can't divide by zero.```

In this example, we have a divide function that attempts to divide two numbers. If a ZeroDivisionError occurs (like when trying to divide by zero), the code inside the except block runs, and it returns a friendly error message instead of crashing the program.

Example 2: Handling Input Errors
```pythondef get_positive_integer():
try:
num = int(input("Enter a positive integer: "))
if num < 1:
raise ValueError("Please enter a positive integer.")
return num
except ValueError as e:
return str(e)

# Let's test the get_positive_integer function
num = get_positive_integer()
print(f"You entered: {num}")```

Here, the get_positive_integer function asks the user for input. If the input isn't a positive integer, it raises a ValueError with a custom error message. The except block catches this error and returns the error message. If a valid positive integer is entered, it's returned.

Example 3: Handling File Errors
```pythondef read_file(filename):
try:
with open(filename, 'r') as file:
return content
except FileNotFoundError as e:
return f"Oops! The file '{filename}' was not found."
except IOError as e:
return f"Oops! There was an error reading the file."

# Let's test the read_file function
print(content)  # Output: Content of the 'sample.txt' file if it exists, or an error message if not found or read error occurs.```

In this example, the read_file function tries to open and read a file. If the file doesn't exist (FileNotFoundError) or there's an issue reading it (IOError), it catches the error and returns an appropriate error message.

Error handling allows your program to continue running even if unexpected issues arise, providing a more user-friendly experience and preventing crashes.

## Python Function Composition

Function composition in Python is like stacking Lego blocks to create more complex structures. It involves taking the result of one function and using it as the input for another function. This can be a powerful way to build more advanced operations by combining simpler ones.

Example 1: Basic Function Composition
```python# Two simple functions
return x + 1

def double(x):
return x * 2

# Compose them to create a new function
composed_function = lambda x: double(add_one(x))

# Let's use the composed function
result = composed_function(3)
print(result)  # Output: 8 (3 + 1 = 4, and then 4 * 2 = 8)```

In this example, we have two simple functions, add_one and double. We compose them into a new function, composed_function, which first adds one to the input and then doubles the result.

Example 2: Using Python's compose Function (with 'functools')

Python's functools module provides a compose function for easier function composition.

```pythonfrom functools import reduce

return x + y

def square(x):
return x * x

def cube(x):
return x * x * x

# Compose functions using reduce
composed_function = reduce(lambda f, g: lambda x: f(g(x)), [cube, square, add])

# Let's use the composed function
result = composed_function(2)
print(result)  # Output: 64 (2 + 2 = 4, 4 * 4 = 16, 16 * 16 * 16 = 64)```

In this example, we define three functions, add, square, and cube. We then compose them using the reduce function from functools to create a new function that computes the cube of the square of the sum of two numbers.

Function composition allows you to build complex operations by chaining together simpler functions. It can make your code more modular and easier to understand, as you break down complex tasks into smaller, reusable parts.

## Python Generator Functions

Generator functions in Python are like magical factories that produce values one at a time, and they can save memory when working with large datasets. They're different from regular functions because they use yield instead of return, and they don't generate all values at once. Instead, they generate values on-the-fly as you request them.

Example 1: Simple Generator Function
```pythondef count_up_to(limit):
current = 1
while current <= limit:
yield current
current += 1

# Let's use the generator
counter = count_up_to(5)

for num in counter:
print(num)```

In this example, the count_up_to generator function produces numbers from 1 up to a specified limit. When you call count_up_to(5), it doesn't compute all the numbers at once. Instead, it generates and yields them one by one as you loop through them.

The output will be:
```1
2
3
4
5```

Notice that memory usage remains low because it only generates values when needed.

Example 2: Infinite Generator
You can also create generators that go on forever:
```pythondef infinite_counter():
current = 1
while True:
yield current
current += 1

# Let's use the generator (careful, it's infinite)
counter = infinite_counter()

for num in counter:
print(num)
if num >= 5:
break```

Here, the infinite_counter generator keeps producing numbers endlessly. You can use it to generate values until you decide to stop. In this case, we stop after printing numbers 1 to 5.

Example 3: Generator Expressions
You can also create generators using generator expressions, which are similar to list comprehensions but use parentheses instead of square brackets:
```pythonevens = (x for x in range(1, 11) if x % 2 == 0)

for num in evens:
print(num) ```

This generator expression generates even numbers from 1 to 10. It only produces values when you loop through them, saving memory.

The output will be:
```2
4
6
8
10```

Generator functions are particularly useful when dealing with large datasets because they allow you to process data one piece at a time without loading everything into memory at once.

## Python Function Namedtuples and Data Structures

Namedtuples in Python are like custom-made data structures that combine the simplicity of tuples with the readability of dictionaries. They allow you to define your own data structure with named fields, making your code more self-explanatory.

Example 1: Creating a Namedtuple

```pythonfrom collections import namedtuple

# Define a namedtuple type called 'Person' with two fields: 'name' and 'age'
Person = namedtuple('Person', ['name', 'age'])

# Create instances of Person
person1 = Person(name='Alice', age=30)
person2 = Person(name='Bob', age=25)

# Accessing fields
print(person1.name)  # Output: Alice
print(person2.age)   # Output: 25```

In this example, we define a Person namedtuple with two fields: 'name' and 'age'. We create two instances of Person and then access their fields using dot notation.

Example 2: Namedtuples for Data Storage

```pythonfrom collections import namedtuple

# Define a namedtuple type called 'Product' with fields: 'name', 'price', and 'quantity'
Product = namedtuple('Product', ['name', 'price', 'quantity'])

# Create a list of products
products = [
Product('Laptop', 1000, 5),
Product('Phone', 500, 10),
Product('Tablet', 300, 15)
]

# Calculate the total value of products
total_value = sum(product.price * product.quantity for product in products)
print(f"Total value of products: \${total_value}")```

In this example, we use a Product namedtuple to create a list of products, each with a name, price, and quantity. We then calculate the total value of these products by multiplying the price and quantity fields of each product.

Output:
```python```Total value of products: \$22500
``````

This output is obtained by summing the total value of each product, where:
The laptop's total value is 1000 * 5 = \$5000
The phone's total value is 500 * 10 = \$5000
The tablet's total value is 300 * 15 = \$4500

Adding all these values together gives a total value of \$5000 + \$5000 + \$4500 = \$22500.

Namedtuples are handy when you want to create custom data structures that are more readable than plain tuples and more memory-efficient than dictionaries. They're particularly useful for representing structured data with well-defined fields.

## Python Quizzes: Python Functions. Test Your Memory

Quiz 1: Pure Functions
What is a pure function in programming?
A) A function that can modify variables outside itself.
B) A function that generates random output for the same input.
C) A function that produces the same output for the same input and has no side effects.
D) A function that doesn't return any value.

Quiz 2: Pure Functions
Which of the following characteristics describes a pure function?
A) It can produce different output for the same input.
B) It doesn't modify anything outside the function.
C) It performs database operations.
D) It uses global variables extensively.

Quiz 3: Error Handling
What is the purpose of error handling in Python?
A) To make the code run faster.
B) To make the code more complicated.
C) To catch and handle unexpected issues gracefully.
D) To generate errors intentionally.

Quiz 4: Error Handling
In Python, which keyword is used to catch and handle exceptions?
A) try
B) catch
C) except
D) error

Quiz 5: Function Composition
What does function composition in Python involve?
A) Combining multiple functions into a single function.
B) Using only one function for all tasks.
C) Splitting a function into smaller functions.
D) Ignoring functions entirely.

Quiz 6: Function Composition
In function composition, what keyword is commonly used to combine functions?
A) join
B) and
C) compose
D) yield

Quiz 7: Generator Functions
What is a generator function in Python?
A) A function that generates random numbers.
B) A function that generates values on-the-fly as they are needed.
C) A function that generates all values at once and stores them in memory.
D) A function that generates functions as output.

Quiz 8: Generator Functions
Which keyword is used in a generator function to yield values?
A) return
B) yield
C) generate
D) output

Quiz 9: Namedtuples
What is a namedtuple in Python?
A) A tuple that doesn't allow any changes after creation.
B) A dictionary that stores data.
C) A custom data structure with named fields.
D) A list of names for variables.

Quiz 10: Namedtuples
How do you access the fields of a namedtuple in Python?
A) Using square brackets ([]).
B) Using dot notation.
C) Using a for loop.
D) Using parentheses.

Quiz 11: Namedtuples
Which module should you import to use namedtuples in Python?
A) tuple
B) namedtuple
C) collections
D) struct

Quiz 12: Namedtuples
What advantage do namedtuples offer over regular tuples?
A) Namedtuples are mutable.
B) Namedtuples can have an unlimited number of elements.
C) Namedtuples are more memory-efficient.
D) Namedtuples have named fields for better readability.

Quiz 13: Generator Functions
What is the benefit of using generator functions when working with large datasets?
A) Generator functions are faster than regular functions.
B) Generator functions can generate all values at once.
C) Generator functions save memory by producing values one at a time.
D) Generator functions require less code to implement.

Quiz 14: Function Composition
How does function composition help in code organization?
A) It makes code longer and harder to understand.
B) It combines all functions into a single, monolithic function.
C) It breaks down complex tasks into smaller, reusable functions.
D) It eliminates the need for functions altogether.

Quiz 15: Error Handling
What is the primary purpose of handling errors in programming?
A) To make the code run faster.
B) To catch and handle unexpected issues gracefully.
C) To generate errors intentionally.
D) To create complicated and unreadable code.

Quiz 1: Pure Functions
What is a pure function in programming?
A) A function that can modify variables outside itself.
B) A function that generates random output for the same input.
C) A function that produces the same output for the same input and has no side effects.
D) A function that doesn't return any value.
Correct Answer: C) A function that produces the same output for the same input and has no side effects.

Quiz 2: Pure Functions
Which of the following characteristics describes a pure function?
A) It can produce different output for the same input.
B) It doesn't modify anything outside the function.
C) It performs database operations.
D) It uses global variables extensively.
Correct Answer: B) It doesn't modify anything outside the function.

Quiz 3: Error Handling
What is the purpose of error handling in Python?
A) To make the code run faster.
B) To make the code more complicated.
C) To catch and handle unexpected issues gracefully.
D) To generate errors intentionally.
Correct Answer: C) To catch and handle unexpected issues gracefully.

Quiz 4: Error Handling
In Python, which keyword is used to catch and handle exceptions?
A) try
B) catch
C) except
D) error
Correct Answer: C) except

Quiz 5: Function Composition
What does function composition in Python involve?
A) Combining multiple functions into a single function.
B) Using only one function for all tasks.
C) Splitting a function into smaller functions.
D) Ignoring functions entirely.
Correct Answer: A) Combining multiple functions into a single function.

Quiz 6: Function Composition
In function composition, what keyword is commonly used to combine functions?
A) join
B) and
C) compose
D) yield
Correct Answer: C) compose

Quiz 7: Generator Functions
What is a generator function in Python?
A) A function that generates random numbers.
B) A function that generates values on-the-fly as they are needed.
C) A function that generates all values at once and stores them in memory.
D) A function that generates functions as output.
Correct Answer: B) A function that generates values on-the-fly as they are needed.

Quiz 8: Generator Functions
Which keyword is used in a generator function to yield values?
A) return
B) yield
C) generate
D) output
Correct Answer: B) yield

Quiz 9: Namedtuples
What is a namedtuple in Python?
A) A tuple that doesn't allow any changes after creation.
B) A dictionary that stores data.
C) A custom data structure with named fields.
D) A list of names for variables.
Correct Answer: C) A custom data structure with named fields.

Quiz 10: Namedtuples
How do you access the fields of a namedtuple in Python?
A) Using square brackets ([]).
B) Using dot notation.
C) Using a for loop.
D) Using parentheses.
Correct Answer: B) Using dot notation.

Quiz 11: Namedtuples
Which module should you import to use namedtuples in Python?
A) tuple
B) namedtuple
C) collections
D) struct
Correct Answer: C) collections

Quiz 12: Namedtuples
What advantage do namedtuples offer over regular tuples?
A) Namedtuples are mutable.
B) Namedtuples can have an unlimited number of elements.
C) Namedtuples are more memory-efficient.
D) Namedtuples have named fields for better readability.
Correct Answer: D) Namedtuples have named fields for better readability.

Quiz 13: Generator Functions
What is the benefit of using generator functions when working with large datasets?
A) Generator functions are faster than regular functions.
B) Generator functions can generate all values at once.
C) Generator functions save memory by producing values one at a time.
D) Generator functions require less code to implement.
Correct Answer: C) Generator functions save memory by producing values one at a time.

Quiz 14: Function Composition
How does function composition help in code organization?
A) It makes code longer and harder to understand.
B) It combines all functions into a single, monolithic function.
C) It breaks down complex tasks into smaller, reusable functions.
D) It eliminates the need for functions altogether.
Correct Answer: C) It breaks down complex tasks into smaller, reusable functions.

Quiz 15: Error Handling
What is the primary purpose of handling errors in programming?
A) To make the code run faster.
B) To catch and handle unexpected issues gracefully.
C) To generate errors intentionally.
D) To create complicated and unreadable code.
Correct Answer: B) To catch and handle unexpected issues gracefully.