# functools

**functools** is a Python module that provides higher-order functions and operations on callable objects. These functions can be used to create new functions from existing ones, or to modify the behavior of existing functions.

**1. Higher-Order Functions**

Higher-order functions are functions that take other functions as arguments, or that return functions as their result. Higher-order functions are often used to create new functions that combine the functionality of multiple existing functions.

For example, the `functools.partial` function can be used to create a new function that is already partially applied to some arguments. This can be useful for creating functions that are more specific to a particular task.

```python
def greet(name):
    print(f"Hello, {name}!")

greet_alice = functools.partial(greet, "Alice")

greet_alice()  # prints "Hello, Alice!"
```

**2. Operations on Callable Objects**

functools also provides several operations that can be performed on callable objects. These operations can be used to modify the behavior of existing functions, or to create new functions from scratch.

For example, the `functools.wraps` function can be used to create a new function that has the same name, documentation, and signature as an existing function. This can be useful for creating wrapper functions that add additional functionality to an existing function.

```python
def counter(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper

@counter
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")  # prints "Hello, Alice!"
print(greet.count)  # prints 1
```

**Real-World Applications**

Higher-order functions and operations on callable objects can be used in a variety of real-world applications, including:

* **Creating new functions:** Higher-order functions can be used to create new functions from existing ones. This can be useful for creating functions that are more specific to a particular task, or that combine the functionality of multiple existing functions.
* **Modifying the behavior of existing functions:** Operations on callable objects can be used to modify the behavior of existing functions. This can be useful for adding additional functionality to an existing function, or for creating wrapper functions that can be used to track the performance of an existing function.
* **Creating decorators:** Decorators are functions that can be used to modify the behavior of other functions. Decorators are typically used to add additional functionality to a function, such as logging or error handling.

**Code Implementations and Examples**

The following code implementations and examples demonstrate how higher-order functions and operations on callable objects can be used in real-world applications:

* **Creating a function that is already partially applied to some arguments:**

```python
def greet(name):
    print(f"Hello, {name}!")

greet_alice = functools.partial(greet, "Alice")

greet_alice()  # prints "Hello, Alice!"
```

* **Creating a wrapper function that adds additional functionality to an existing function:**

```python
def counter(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper

@counter
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")  # prints "Hello, Alice!"
print(greet.count)  # prints 1
```

* **Creating a decorator that logs the performance of an existing function:**

```python
import functools
import time

def timing(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"Function {func.__name__} took {end - start} seconds to run")
        return result
    return wrapper

@timing
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")  # prints "Hello, Alice!" and logs the performance of the greet function
```

***

### functools Module

The `functools` module in Python provides functions for working with higher-order functions, which are functions that operate on or return other functions.

#### cache()

The `cache()` function is a simple and lightweight function cache. It takes a function as its argument and returns a wrapper function that caches the results of the original function.

**Simplified Explanation:**

Imagine you have a function that calculates the factorial of a number. This function is computationally expensive, so it's slow to run. You can use `cache()` to speed up subsequent calls to this function by storing the results in a dictionary. When the cached function is called again with the same input, it will instantly return the cached result without performing the calculation.

**Code Example:**

```python
import functools

def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n-1)

cached_factorial = functools.cache(factorial)

# Calculate the factorial of 10
result = cached_factorial(10)
print(result)  # Output: 3628800
```

**Potential Applications:**

* Caching database queries to reduce server load
* Optimizing performance of computationally expensive functions
* Improving the efficiency of machine learning algorithms

### Wrapping Up

The `functools` module provides useful functions for working with higher-order functions. The `cache()` function is a particularly handy tool for speeding up function execution by caching the results of expensive calculations.

***

**Simplified Explanation of Functools.cache**

**Cache:** A cache is like a special box that stores results of computations. It's like a shortcut to avoid doing long calculations again.

**Functools.cache:**

* It's a decorator (@cache) you can put before a function to enable caching.
* The function's results are stored in the cache based on the input arguments.
* This means that if you call the function with the same arguments again, it will simply return the cached result instead of doing the computation all over again.

**Example:**

```python
@functools.cache
def factorial(n):
    return n * factorial(n-1) if n else 1
```

This is a function to calculate the factorial of a number. Without the cache, we would have to calculate every factorial value from 1 to n. With the cache, we only need to calculate the factorial of the last number in the sequence.

**How it Works:**

* When you first call factorial(10), it computes the value and stores it in the cache.
* When you call factorial(5) again, it finds the cached value and returns it instantly.
* When you call factorial(12), it calculates only the factorial of 11 and 12, and stores the results in the cache. The other factorials (1 to 10) are already cached.

**Real-World Applications:**

* Caching can be useful for functions that perform heavy computations or take a long time to run.
* It can improve the performance of web applications by reducing the time it takes to generate responses.
* It can be used in scientific simulations or data analysis where expensive computations are common.

**Complete Code Example:**

```python
import functools

@functools.cache
def slow_function(num):
    # Imagine this does some complex calculation
    return num ** 2

def main():
    print(slow_function(10))  # Prints 100
    print(slow_function(10))  # Prints 100 (cached result)

if __name__ == "__main__":
    main()
```

***

**What is a decorator?**

A decorator is a Python function that wraps another function. It allows you to modify the behavior of the decorated function. Decorators are often used to add extra functionality to existing functions without modifying their source code.

**What is a cached property?**

A cached property is a property whose value is computed once and then stored in a cache. This is useful for expensive computations that you only need to perform once.

**How to create a cached property using the `cached_property` decorator**

To create a cached property, you can use the `cached_property` decorator from the `functools` module. The decorator takes a function as its argument, and it transforms it into a property getter method.

The following code shows how to create a cached property for a `DataSet` class:

```python
from functools import cached_property

class DataSet:

    def __init__(self, sequence_of_numbers):
        self._data = tuple(sequence_of_numbers)

    @cached_property
    def stdev(self):
        return statistics.stdev(self._data)
```

The `stdev` property will be computed once when it is first accessed, and its value will be stored in the `_stdev` attribute of the `DataSet` instance. Subsequent accesses to the `stdev` property will return the cached value without recomputing it.

**Potential applications of cached properties**

Cached properties can be used in a variety of applications, such as:

* Caching expensive computations
* Caching data that is unlikely to change
* Caching data that is frequently accessed

**Real-world example**

The following code shows how to use a cached property to cache the results of a factorial calculation:

```python
from functools import cached_property

def factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

class FactorialCache:

    def __init__(self):
        self._cache = {}

    @cached_property
    def factorial(self, n):
        if n not in self._cache:
            self._cache[n] = factorial(n)
        return self._cache[n]

cache = FactorialCache()

print(cache.factorial(5))  # 120
print(cache.factorial(10))  # 3628800
```

In this example, the `FactorialCache` class uses a cached property to store the results of factorial calculations. This prevents the `factorial` function from being called multiple times for the same value of `n`.

***

**Simplified Explanation of @cached\_property Decorator**

**What is a cached property?**

Imagine you have a function called `get_name()`. This function takes a long time to run. Instead of running it every time you need the name, you can cache the result in a property. This means that the first time you call `get_name()`, the function will run and the result will be stored in a special variable called `_name`. The next time you call `get_name()`, it will simply return the value stored in `_name` instead of running the function again.

**How does @cached\_property work?**

The `@cached_property` decorator is a function that takes another function as an argument. It wraps the argument function in a way that automatically caches the result.

**Code Example:**

```python
class Person:
    def __init__(self, name):
        self.name = name

    @cached_property
    def get_name(self):
        # This function takes a long time to run
        return f"Hello, my name is {self.name}!"
```

**How to use @cached\_property:**

To use the `@cached_property` decorator, simply add it before the function you want to cache.

**Benefits of using @cached\_property:**

* **Improved performance:** By caching the result, you can avoid running the function multiple times, which can significantly improve performance.
* **Simplified code:** You don't have to manually manage the caching process yourself.

**Potential Race Conditions:**

The `@cached_property` decorator does not prevent race conditions in multi-threaded applications. This means that it is possible for the getter function to run more than once on the same instance, with the latest run setting the cached value. If this is a concern, you can implement synchronization inside the decorated getter function or around the cached property access.

**Real-World Applications:**

* Caching database queries to reduce the number of times you hit the database.
* Caching the results of expensive computations, such as image processing or machine learning models.
* Caching user preferences so that you don't have to load them from the database every time.

***

**Cached Property**

Imagine you have a function that takes a lot of time to run each time you call it. To avoid running the function multiple times, you can use the `cached_property` decorator. This decorator saves the result of the function call the first time you call it and returns the saved result for all subsequent calls.

**For example:**

```python
class MyClass:
  def get_data(self):
    # This function takes a long time to run
    return [1, 2, 3]

  @cached_property
  def data(self):
    return self.get_data()

my_object = MyClass()
print(my_object.data)  # This will run the get_data() function and save the result
print(my_object.data)  # This will return the saved result, without running get_data() again
```

**Real-world application:**

This is useful for functions that are called frequently and take a long time to compute, such as loading data from a database or performing a complex calculation. By caching the result, you can significantly improve the performance of your code.

**LRU Cache**

An LRU (Least Recently Used) cache is a data structure that stores a limited number of items. When the cache is full and a new item is added, the least recently used item is removed.

**For example:**

```python
from functools import lru_cache

@lru_cache(maxsize=10)
def my_function(x):
  # This function takes a lot of time to run
  return x * x

my_function(1)
my_function(2)
my_function(3)

print(my_function.cache_info())  # Prints the cache size and number of hits/misses
```

**Real-world application:**

LRU caches are useful for speeding up functions that access frequently used data. For example, you can use an LRU cache to store the most recent results of a database query or the results of a complex computation.

**CMP to Key**

The `cmp_to_key` function is used to convert an old-style comparison function into a key function that can be used with functions that accept key functions (such as `sorted`, `min`, `max`).

**For example:**

```python
def compare_names(a, b):
  return a[1] < b[1]  # Comparison function that compares names

key_function = cmp_to_key(compare_names)

people = [('Alice', 20), ('Bob', 30), ('Charlie', 15)]
sorted(people, key=key_function)  # Sorts people by name using the comparison function
```

**Real-world application:**

This is useful for sorting objects based on a custom comparison function. For example, you could use this to sort a list of files based on their size or to sort a list of people based on their age.

***

**Comparison Functions**

* A comparison function is a special function that takes two values (let's call them `a` and `b`) and returns a number based on how `a` compares to `b`.
* If `a` is less than `b`, the function returns a negative number (like -1).
* If `a` is equal to `b`, the function returns 0.
* If `a` is greater than `b`, the function returns a positive number (like 1).

**Key Functions**

* A key function is a special function that takes one value (`a`) and returns a different value that will be used for sorting.
* For example, if you have a list of names and you want to sort them alphabetically by the last name, you can use a key function to extract the last name from each name.
* When the sorting function is called, it will use the values returned by the key function to determine the sort order.

**Real World Examples**

**Comparison Functions**

```python
def compare_numbers(a, b):
    if a < b:
        return -1
    elif a == b:
        return 0
    else:
        return 1

my_numbers = [1, 3, 2, 4, 5]
sorted_numbers = sorted(my_numbers, key=compare_numbers)
# sorted_numbers will be [1, 2, 3, 4, 5]
```

In this example, the `compare_numbers` function is a comparison function that returns -1, 0, or 1 based on how the two numbers compare. When used with `sorted()`, the `compare_numbers` function determines the sort order of the numbers.

**Key Functions**

```python
def get_last_name(name):
    return name.split(" ")[-1]

my_names = ["John Doe", "Jane Smith", "Peter Parker"]
sorted_names = sorted(my_names, key=get_last_name)
# sorted_names will be ["John Doe", "Peter Parker", "Jane Smith"]
```

In this example, the `get_last_name` function is a key function that returns the last name from a full name. When used with `sorted()`, the `get_last_name` function determines the sort order of the names by their last names.

**Potential Applications**

* Comparison functions can be used to sort any type of data, such as numbers, strings, or objects.
* Key functions can be used to sort data based on a specific attribute or property. For example, you could use a key function to sort a list of employees by their salary or job title.

***

**What is a Decorator?**

In Python, a decorator is a function that takes another function as an argument and returns a modified version of that function. Decorators are commonly used to add extra functionality to existing functions without modifying their code.

**lru\_cache Decorator**

The `lru_cache` decorator is used to cache the results of a function call. This means that if the same function is called with the same arguments multiple times, the cached result is returned instead of recalculating it.

**Simplified Explanation of lru\_cache:**

Imagine you have a function that calculates the factorial of a number. Calculating a factorial can be a time-consuming process, especially for large numbers. By using the `lru_cache` decorator, you can speed up subsequent calls to the factorial function by storing the results in a cache. When you call the function again with the same number, it will simply return the cached result instead of recalculating it.

**Syntax:**

```python
@lru_cache(maxsize=128, typed=False)
def factorial(n):
    """Calculates the factorial of a number."""
    if n == 0:
        return 1
    return n * factorial(n - 1)
```

**Parameters:**

* **maxsize**: The maximum number of cache entries to store. If not specified, the default is 128.
* **typed**: Specifies whether the cache should be typed. If `True`, only cache entries with the same type of arguments will be used. If `False`, cache entries will be based solely on the values of the arguments.

**Real-World Example:**

```python
import time

@lru_cache(maxsize=128)
def fibonacci(n):
    """Calculates the nth Fibonacci number."""
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Calculate the first 10 Fibonacci numbers
start_time = time.time()
fibonacci(10)
end_time = time.time()

# Calculate the 11th Fibonacci number again
start_time_2 = time.time()
fibonacci(11)
end_time_2 = time.time()

print("Time taken for first 10 Fibonacci numbers:", end_time - start_time)
print("Time taken for 11th Fibonacci number:", end_time_2 - start_time_2)
```

**Output:**

```
Time taken for first 10 Fibonacci numbers: 0.00010004043579101562
Time taken for 11th Fibonacci number: 0.0
```

As you can see, the time taken to calculate the first 10 Fibonacci numbers is much higher than the time taken to calculate the 11th Fibonacci number. This is because the results of the first 10 Fibonacci numbers were cached, so the 11th Fibonacci number was simply retrieved from the cache instead of being recalculated.

**Potential Applications:**

The `lru_cache` decorator can be used in a variety of applications where the same function is called multiple times with the same arguments. Some potential applications include:

* Caching database queries to improve performance
* Caching API responses to reduce latency
* Caching expensive calculations, such as matrix multiplications or numerical simulations

***

**lru\_cache Decorator**

The `lru_cache` decorator optimizes a function to make it run faster.

**How it Works:**

Imagine you have a function that takes a long time to run. The `lru_cache` decorator remembers the results of the function for a certain number of previous inputs called "maxsize."

**Benefits:**

* **Speed Boost:** If you call the function with the same input again, the decorator will skip the long process and return the remembered result, which is much faster.

**Customizing the Decorator:**

* **maxsize:** Sets the maximum number of inputs to remember (default is 128). If set to `None`, the cache will remember all inputs.
* **typed:** If True, the decorator will store different results for inputs of different types, even if they have the same value.

**Example:**

Let's say we have a function that calculates the factorial of a number:

```python
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)
```

We can optimize this function with the `lru_cache` decorator:

```python
@lru_cache
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)
```

Now, when we call `factorial(5)` multiple times, the decorator will remember the result for `n=5` and return it instantly, making subsequent calls much faster.

**Real-World Applications:**

* Caching database queries to reduce the number of times a database is accessed.
* Storing search results to avoid re-searching the same query.
* Optimizing machine learning algorithms by remembering previous calculations.

***

**Simplified Explanation:**

The `functools` module in Python provides a way to add a cache to functions. This cache stores the results of previous function calls so that if the function is called again with the same arguments, it can return the result from the cache instead of performing the computation again.

**Topics:**

**1. Instrumentation:**

The decorated function is equipped with three additional functions:

* **`cache_parameters`**: Returns a dictionary with the values of `maxsize` and `typed`.
* **`cache_info`**: Returns a named tuple with the cache's `hits`, `misses`, `maxsize`, and `currsize`.
* **`cache_clear`**: Clears the cache.

**2. Original Function Access:**

You can still access the original, unwrapped function through the `__wrapped__` attribute. This is handy for:

* Inspecting the function.
* Bypassing the cache.
* Rewrapping the function with a different cache.

**3. Cache Behavior:**

The cache stores the function's arguments and return values. When the cache is full, it removes the oldest item to make room for the new one.

**Code Snippets:**

```python
# functools import
import functools

# Define the original function
def original_function(x, y):
    return x * y

# Create the cached version
cached_function = functools.cache(original_function)

# Example usage
print(cached_function(10, 5))  # 50 (result stored in cache)
print(cached_function(10, 5))  # 50 (retrieved from cache)
```

**Real-World Applications:**

* **Memoizing heavy computations**: Cache the results of expensive computations to avoid repeating them.
* **Caching database queries**: Store query results to reduce database load.
* **Accelerating web page loading**: Cache static content to improve performance.

**Potential Improvements:**

* **LruCache**: A specialized cache that removes the least recently used items first.
* **MaxSizeDecorator**: A simple decorator for setting the maximum cache size.
* **ClearableCache**: A cache with an explicit `clear` method.

***

**Introduction to Caching in functools**

Imagine you have a function `calculate_fibonacci(n)` that calculates the nth Fibonacci number. Each time you call this function with a specific `n`, it performs a complex calculation to find the result.

To make things faster, we can use caching to store the results of previous calls to the function. That way, when we call the function again with the same `n`, we can simply retrieve the cached result instead of performing the calculation again.

The `functools.cache` module provides a simple way to add caching to functions.

**How Caching Works in functools.cache**

When you decorate a function with `@functools.cache`, the `self` instance argument is included in the cache. This means that each instance of a class will have its own cached results.

The cache uses a Least Recently Used (LRU) algorithm to manage its size. This means that the most recently used results are kept in the cache, while older results are removed to make room for new ones.

**Benefits of Caching**

Caching can significantly improve the performance of your application by:

* Reducing the number of calculations performed
* Reducing the amount of time spent on calculations
* Improving the responsiveness of your application

**When to Use Caching**

Caching is best used for functions that:

* Are expensive to compute
* Are called frequently with the same arguments
* Do not have side effects (i.e., they do not modify the state of the program)

**Real-World Example**

Here's a real-world example of how caching can be used:

```python
from functools import cache

@cache
def calculate_fibonacci(n):
    if n < 2:
        return n
    else:
        return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)

print(calculate_fibonacci(5))
print(calculate_fibonacci(5))
```

In this example, we use `functools.cache` to cache the results of `calculate_fibonacci`. The first time we call `calculate_fibonacci(5)`, it calculates the result and stores it in the cache. When we call `calculate_fibonacci(5)` again, the cached result is retrieved instead of performing the calculation again.

This can significantly improve the performance of our application, especially if we are calling `calculate_fibonacci` with the same arguments repeatedly.

**Potential Applications**

Caching can be used in a variety of real-world applications, such as:

* Database queries
* Data processing
* Machine learning
* Web caching
* API caching

***

**LRU Cache (Least Recently Used)**

**Concept:** An LRU cache is a cache that keeps track of the most recently used items and evicts the least recently used items when the cache reaches its maximum size.

**Benefits:**

* Improves performance by quickly accessing frequently used data.
* Efficiently utilizes memory resources.

**Implementation in Python:**

```python
import functools

@functools.lru_cache(maxsize=32)
def get_pep(num):
    # Retrieve text of a Python Enhancement Proposal
    return fetch_pep(num)

# Usage:
for n in [8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991]:
    pep = get_pep(n)
    print(n, len(pep))

# Check cache statistics:
print(get_pep.cache_info())
```

In this example, the `get_pep` function is decorated with @lru\_cache(maxsize=32), which creates an LRU cache of size 32. When `get_pep` is called with a PEP number, it retrieves the PEP text from a remote source. If the PEP is already in the cache, it is returned immediately.

**Real-World Applications:**

* **Web caching:** Caching static web content (e.g., HTML, images, CSS) improves website load times.
* **Database queries:** Caching frequently executed database queries reduces database load and improves runtime performance.
* **In-memory data structures:** LRU caches can be used to store frequently accessed data that is too large to fit in memory entirely.

***

### Dynamic Programming

* **What is it?**
  * A technique to solve complex problems by breaking them down into smaller, overlapping subproblems.
  * Each subproblem is solved once and stored in a cache (memory).
  * Subsequent calls to the same subproblem retrieve the cached result, reducing computation time.

### @lru\_cache Decorator

* **What is it?**
  * A decorator from Python's `functools` module.
  * Decorates a function to automatically cache its results.
  * Improves performance for functions that are called repeatedly with the same arguments.
* **How it works:**
  1. When the decorated function is first called, it executes as usual and stores the result in a cache.
  2. For subsequent calls with the same arguments, the cached result is returned instead of recomputing.

### Code Examples

**Simple Fibonacci Calculator:**

```python
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)
```

* The `lru_cache` decorator caches the results of the `fib` function.
* `maxsize=None` specifies that the cache has unlimited size.
* The cache is automatically consulted whenever `fib` is called.

**Improved Fibonacci Calculator:**

```python
from functools import lru_cache

@lru_cache(maxsize=1000)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)
```

* Now, the cache has a maximum size of 1000.
* This ensures that memory usage is bounded, even for large `n`.

### Real-World Applications

* **Memoization:** Storing results of function calls to avoid recomputation.
* **Dynamic Programming:** Solving problems by breaking them down into smaller subproblems and caching their solutions.
* **Caching Web Pages:** Storing frequently accessed web pages to reduce server load and improve response times.
* **Game AI:** Optimization of decision-making algorithms by caching previous game states and their outcomes.

### Potential Applications

**Inventory Management:** Tracking product availability and optimizing inventory levels based on historical data.

**Transportation Optimization:** Planning efficient routes and transportation schedules by caching previous routes and travel times.

**Machine Learning:** Caching training data and model parameters to improve performance and reduce training time.

***

### total\_ordering Decorator

**Simplified Explanation:**

The `total_ordering` decorator makes it easier to create classes that can be compared using operators like `<`, `<=`, `>`, and `>=`.

**Detailed Explanation:**

To compare objects, Python uses special methods like `__lt__` (less than) and `__eq__` (equal to). You usually need to define all these methods yourself.

`total_ordering` simplifies this by requiring you to define only one of these methods (like `__lt__`). It then automatically creates all the other comparison methods based on the one you provide.

This is helpful because it ensures that your classes follow Python's comparison rules, making them compatible with other code that expects comparable objects.

**Real-World Example:**

Consider a class that represents a student:

```python
class Student:
    def __init__(self, last_name, first_name):
        self.last_name = last_name
        self.first_name = first_name
```

To make this class comparable, we can use the `total_ordering` decorator:

```python
@total_ordering
class Student:
    def __init__(self, last_name, first_name):
        self.last_name = last_name
        self.first_name = first_name

    def __eq__(self, other):
        return (self.last_name.lower(), self.first_name.lower()) == (other.last_name.lower(), other.first_name.lower())

    def __lt__(self, other):
        return (self.last_name.lower(), self.first_name.lower()) < (other.last_name.lower(), other.first_name.lower())
```

Now, we can compare students using operators like `<` and `==`:

```python
student1 = Student("Smith", "John")
student2 = Student("Jones", "Mary")

print(student1 < student2)  # True
print(student1 == student2)  # False
```

**Potential Applications:**

* Sorting lists of objects
* Comparing objects for equality in dictionaries
* Implementing custom logic for comparing objects in specific scenarios

***

**Total Ordering Decorator**

**Simplified Explanation:**

Imagine you have a list of objects, like numbers or people. You want to be able to sort this list based on some defined rules. The `total_ordering` decorator helps you create objects that can be compared and ordered in a consistent way.

**Implementation:**

```python
from functools import total_ordering

@total_ordering
class MyObject:
    def __eq__(self, other):
        # Code to determine if self (this object) is equal to other

    def __lt__(self, other):
        # Code to determine if self is less than other
```

**Real-World Use:**

* Sorting a list of students by their grades
* Ordering a list of dates from earliest to latest

**Exception Handling:**

If an object does not implement all six comparison operators (`<`, `<=`, `>`, `>=`, `!=`, `==`), the decorator will not override the existing ones. This means you can implement only the comparison methods you need for your specific scenario.

**Other Notes:**

* Using the decorator has a performance cost compared to implementing all six comparison operators yourself.
* Returning `NotImplemented` from the comparison function indicates that the comparison is not defined for certain object types.

***

### functools.partial()

**What is it?**

`partial()` is a function that creates a new function that is a partial application of another function. Partial application means that some of the arguments of the original function are already set, and the new function only needs to be called with the remaining arguments.

**How does it work?**

You call `partial()` with the original function and any arguments you want to pre-set. The new function that is returned will have the pre-set arguments bound, and any additional arguments passed in when the new function is called will be appended to the pre-set arguments.

**Why is it useful?**

Partial application can be useful in a number of situations:

* **Reducing boilerplate code**: If you have a function that you call multiple times with the same arguments, you can use partial application to create a new function with those arguments pre-set, making your code more concise and readable.
* **Creating new functions**: You can use partial application to create new functions that have a different signature than the original function. For example, you could create a new function that takes two arguments by partially applying a function that takes three arguments.
* **Freezing arguments**: Partial application can be used to freeze certain arguments of a function, so that they cannot be changed when the new function is called. This can be useful for creating functions that are always called with the same arguments.

**Example:**

The following example shows how to use `partial()` to create a new function that converts a string to an integer with a base of 2:

```python
from functools import partial

# Create a new function that converts a string to an integer with a base of 2
basetwo = partial(int, base=2)

# Call the new function to convert the string '10010' to an integer
result = basetwo('10010')

# Print the result
print(result)  # Output: 18
```

**Real-world applications:**

Partial application has a number of real-world applications, including:

* **Configuration**: Partial application can be used to configure functions with a set of default arguments. For example, you could create a function that creates a logger with a specific set of default log levels.
* **Caching**: Partial application can be used to create cached functions that store the results of previous calls. This can be useful for improving the performance of functions that are called repeatedly with the same arguments.
* **Concurrency**: Partial application can be used to create concurrent functions that can be executed in parallel. This can be useful for speeding up tasks that can be broken down into independent subtasks.

***

**What is `partialmethod`?**

`partialmethod` is a magic function in Python that allows you to create a new function that behaves like the `partial` function, but can be used as a method within a class.

**How does `partialmethod` work?**

`partialmethod` takes two arguments:

1. `func`: The function you want to create a partial method for.
2. `args` and `keywords`: These are the optional arguments that you want to pass to the partial method when it is called.

The `partialmethod` function returns a new function that behaves like the `partial` function, but when it is called as a method within a class, the first argument will be the `self` argument.

**Here is a simple example of how to use `partialmethod`:**

```python
class Myclass:
  def __init__(self, name):
    self.name = name

  def greet(self, other_name):
    return "Hello, {}! My name is {}.".format(other_name, self.name)

greet_john = partialmethod(Myclass.greet, "John")

myclass_instance = Myclass("Alice")
print(myclass_instance.greet_john())  # Output: "Hello, John! My name is Alice."
```

In this example, the `partialmethod` function is used to create a new method called `greet_john` that is bound to the `Myclass` class. The `greet_john` method takes one argument, `other_name`, and returns a greeting message that includes the `other_name` and the `name` attribute of the `Myclass` instance.

**Potential applications of `partialmethod`**

`partialmethod` can be used in a variety of real-world applications, such as:

* Creating callback functions that are bound to a specific object.
* Creating event handlers that are bound to a specific object.
* Creating factory functions that return objects that are bound to a specific object.

**Simplified explanation of `partialmethod`**

In very simple terms, `partialmethod` is like a magic function that helps you create new functions that can be used as methods within a class. These new functions can have pre-defined arguments, which makes them very convenient to use.

***

**partial**

In programming, a function is a block of code that performs a specific task. When you call a function, you can pass it arguments, which are values that the function uses to perform its task. The values returned by the function can be stored in a variable or used in an expression.

A **partial function** is a function that has some of its arguments already set. This means that when you call a partial function, you don't need to specify all of its arguments. The arguments that are already set are known as **bound arguments**, and the arguments that you still need to specify are known as **free arguments**.

**Creating and calling partial functions:** You can create a partial function using the `functools.partial()` function. The `partial()` function takes two arguments: a callable (which is a function, method, or class that can be called) and a list of bound arguments. The bound arguments are passed to the callable as positional arguments.

Here's an example of how to create a partial function:

```python
import functools

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

add_five = functools.partial(add, 5)
```

The `add_five` partial function has one bound argument (5) and one free argument. When you call `add_five`, you only need to specify the free argument. For example:

```python
result = add_five(10)
print(result)  # Output: 15
```

**Benefits of using partial functions:**

* **Code reusability:** Partial functions can be reused multiple times without having to rewrite the same code.
* **Improved code readability:** Partial functions can make your code more readable by reducing the number of arguments that you need to specify when calling a function.
* **Easier testing:** Partial functions can make it easier to test your code by allowing you to focus on a specific set of arguments.

**Applications of partial functions in the real world**

* Creating configuration objects
* Binding events to event handlers
* Creating decorators
* Customizing function behavior

***

**What is `reduce()`?**

`reduce()` is a function in Python that takes two parameters: a function and an iterable (a sequence of elements). It applies the function cumulatively to the elements of the iterable, from left to right, to produce a single value.

**Simplified Explanation:**

Imagine you have a list of numbers, like \[1, 2, 3, 4]. You want to add them all together. You could do this by hand: 1 + 2 = 3, 3 + 3 = 6, 6 + 4 = 10. `reduce()` does this for you automatically.

**Code Example:**

```python
numbers = [1, 2, 3, 4]

# Add all the numbers together using reduce()
total = reduce(lambda x, y: x + y, numbers)

print(total)  # Output: 10
```

**Optional Argument:**

`reduce()` has an optional third argument, `initial`, which is the starting value. If you don't provide `initial`, the first element of the iterable will be used as the starting value.

**Code Example with Initial Value:**

```python
numbers = [1, 2, 3, 4]

# Multiply all the numbers together, starting with 5
product = reduce(lambda x, y: x * y, numbers, 5)

print(product)  # Output: 120
```

**Real-World Applications:**

* Calculating the sum, average, or product of a list of numbers.
* Combining multiple strings into a single string.
* Flattening a nested list into a single list.

**Improved Code Example:**

Here's a more practical example of using `reduce()` to calculate the average of a list of numbers:

```python
numbers = [6, 8, 10, 12, 14]

# Calculate the average using reduce()
average = reduce(lambda a, b: (a + b) / 2, numbers)

print(average)  # Output: 10.0
```

***

### Single Dispatch

Dispatches a function based on the type of its first argument.

#### Motivation:

When working with multiple data types, we can write code that handles each type differently. With single dispatch, we can create a function that dispatches to different implementations based on the type of its first argument.

#### Usage:

```python
from functools import singledispatch

@singledispatch
def func(arg):
    print("Default implementation")

@func.register(int)
def _(arg):
    print("Integer implementation")

@func.register(str)
def _(arg):
    print("String implementation")
```

#### Example:

```python
>>> func(1)
Integer implementation
>>> func("hello")
String implementation
```

#### Applications:

* **Polymorphism:** Handle different data types with a single function.
* **Type-based dispatch:** Execute specific code based on the type of the first argument.
* **Extensibility:** Easily add new implementations for different types without modifying the original function.

#### Potential Improvements:

* Use a more descriptive function name.
* Provide a better default implementation.
* Handle multiple arguments by using `functools.singledispatchmethod` instead of `singledispatch`.

***

**Overloaded Functions:**

Imagine you have a function called "process" that can do different things depending on the type of data it receives. For example, if you give it a number, it prints the number. If you give it a list, it prints each element in the list.

**Generic Function:**

To create a function that can handle different types of data, you can use a "generic" function. This function doesn't specify what type of data it will receive, and it can be overloaded with different implementations later.

**Registering Overloads:**

To add different implementations to a generic function, you use the `register` attribute of the function. You can use it as a decorator, which is a shortcut for adding annotations to a function.

**Type Inference:**

If the function is annotated with types, the decorator will automatically guess the type of the first argument. For example, in the code you provided:

```python
@fun.register
def _(arg: int, verbose=False):
    # ...
```

The decorator automatically knows that the first argument is an integer (int).

**Real-World Example:**

Here's a complete example of an overloaded function:

```python
from functools import singledispatch

@singledispatch
def calculate(value):
    raise NotImplementedError("Unsupported type")

@calculate.register(int)
def _(value: int):
    return value + 1

@calculate.register(list)
def _(value: list):
    return [x + 1 for x in value]

# Example usage
result = calculate(10)
print(result)  # Output: 11

result = calculate([1, 2, 3])
print(result)  # Output: [2, 3, 4]
```

In this example, the `calculate` function is overloaded to handle both integers and lists. The generic implementation raises an error for unsupported types.

**Potential Applications:**

Overloaded functions are useful in situations where you need to handle different types of data with a single function. For example, you could have a function that:

* Converts a temperature from Celsius to Fahrenheit if the input is a float
* Converts a temperature from Fahrenheit to Celsius if the input is an integer
* Raises an error if the input is not a number

***

**Custom Types with Functools**

In Python, you can create custom types using the `Union` class from the `functools` module. A `Union` type allows a function to accept multiple types as input.

**Example 1: Union of Int and Float**

```python
from functools import Union

@fun.register
def add(arg: Union[int, float], verbose=False):
    if verbose:
        print("Adding numbers together!")
    return arg + arg

# Calling the function with different types
result1 = add(10)  # arg is an int
result2 = add(10.5)  # arg is a float
```

**Explanation:** The `add()` function expects a single argument of type `Union[int, float]`. This means it can accept either an integer or a floating-point number. The `verbose` parameter is optional and defaults to `False`.

**Real-World Application:** This type of union can be useful when you want to perform a calculation that can handle both integers and floats, such as calculating the average of a list of numbers that may contain both types.

**Example 2: Union of List and Set**

```python
from functools import Union

@fun.register
def iterate(arg: Union[list, set], verbose=False):
    if verbose:
        print("Iterating over the collection:")
    for item in arg:
        print(item)

# Calling the function with different types
iterate([1, 2, 3])  # arg is a list
iterate({4, 5, 6})  # arg is a set
```

**Explanation:** The `iterate()` function expects a single argument of type `Union[list, set]`. This means it can accept either a list or a set. The `verbose` parameter, again, is optional and defaults to `False`.

**Real-World Application:** This type of union can be useful when you want to perform an operation on a collection without caring about its specific type. For instance, you might want to print the elements of a collection, regardless of whether it's a list or a set.

***

**Type Annotations in Python**

Type annotations describe the expected type of data that a function takes as input and returns as output. They are not enforced by Python but provide information to developers and static analysis tools about the intended behavior of code.

**functools.register() Decorator**

The `functools.register()` decorator allows you to associate a function with a specific type or list of types. This information is used by the `functools.singledispatch()` function to determine which implementation of a function to call based on the type of the first argument.

**Example with Explicit Type Argument**

If you don't use type annotations, you can pass the expected type argument explicitly to the `functools.register()` decorator:

```python
from functools import register

# Register the function for the 'complex' type
@register(complex)
def my_function(arg, verbose=False):
    if verbose:
        print("Better than complicated.", end=" ")
    print(arg.real, arg.imag)
```

In this example, the `my_function()` is registered to work with arguments of type `complex`. When called with a complex number, it prints the real and imaginary parts of the argument.

**Real-World Application**

A common application of `functools.register()` is to implement polymorphism in Python. Polymorphism allows you to define different implementations of a function for different types of data.

For example, consider a function that calculates the area of a shape:

```python
from functools import register

# Register the function for the 'Circle' type
@register(Circle)
def area(shape):
    return math.pi * shape.radius ** 2

# Register the function for the 'Rectangle' type
@register(Rectangle)
def area(shape):
    return shape.width * shape.height
```

In this example, the `area()` function is registered to calculate the area of different shapes based on their types. When called with a `Circle` object, it uses the formula for the area of a circle. When called with a `Rectangle` object, it uses the formula for the area of a rectangle.

**Benefits of Type Annotations**

Using type annotations with `functools.register()` provides several benefits:

* **Improved Code Readability:** Type annotations make it clear what types of data a function expects and returns.
* **Enhanced Error Detection:** Static analysis tools can use type annotations to detect potential errors earlier.
* **Improved Performance:** The Python interpreter can optimize code based on type annotations.
* **Support for Duck Typing:** Type annotations allow you to use duck typing (type checking based on behavior rather than inheritance) while still providing type information.

***

**Decorators**

A decorator is a function that takes another function as an argument and returns a new function. Decorators are used to modify the behavior of the function they are applied to.

**The @fun.register(type) Decorator**

The `@fun.register(type)` decorator is used to register a function with a particular type. This allows the function to be called with arguments of that type.

For example, the following code registers the `nothing` function with the `None` type:

```python
def nothing(arg, verbose=False):
    print("Nothing.")

fun.register(type(None), nothing)
```

Now, the `nothing` function can be called with a `None` argument:

```python
nothing(None)
# Nothing.
```

**Decorator Stacking**

Decorators can be stacked to combine their effects.

For example, the following code registers the `fun_num` function with the `float` and `Decimal` types:

```python
@fun.register(float)
@fun.register(Decimal)
def fun_num(arg, verbose=False):
    if verbose:
        print("Half of your number:", end=" ")
    print(arg / 2)
```

Now, the `fun_num` function can be called with a `float` or `Decimal` argument:

```python
fun_num(3.14)
# Half of your number: 1.57
fun_num(Decimal('3.14'))
# Half of your number: 1.57
```

**Pickling**

Pickling is a process of converting an object into a byte stream so that it can be stored or transmitted.

Decorators can be pickled, which allows them to be used in conjunction with pickled functions.

**Unit Testing**

Decorators can be used to create unit tests for functions.

For example, the following code creates a unit test for the `fun_num` function:

```python
import unittest

class TestFunNum(unittest.TestCase):

    def test_float(self):
        self.assertEqual(fun_num(3.14), 1.57)

    def test_decimal(self):
        self.assertEqual(fun_num(Decimal('3.14')), 1.57)
```

**Real-World Applications**

Decorators have a wide variety of real-world applications, including:

* **Logging:** Decorators can be used to log the arguments and results of function calls.
* **Caching:** Decorators can be used to cache the results of function calls.
* **Authorization:** Decorators can be used to authorize users before they can call a function.
* **Instrumentation:** Decorators can be used to instrument functions to track their performance.

***

**Generic Functions and Dispatching**

**Simplified Explanation:**

Generic functions are like Swiss Army knives that can perform different tasks depending on the type of input you give them. When you call a generic function, it "dispatches" (chooses) the correct behavior based on the first argument's type.

**Code Snippets:**

```python
def fun(arg, verbose=False):
    if isinstance(arg, str):
        if verbose:
            return "Let me just say, " + arg
        else:
            return arg
    elif isinstance(arg, int):
        if verbose:
            return "Strength in numbers, eh? " + str(arg)
        else:
            return arg
    elif isinstance(arg, list):
        if verbose:
            output = "Enumerate this:\n"
            for i, item in enumerate(arg):
                output += f"{i} {item}\n"
            return output
        else:
            return arg
    elif arg is None:
        return "Nothing."
    else:
        return "0.615"
```

**Real-World Applications:**

* **Logging:** Generic functions can handle logging in different ways based on the type of data being logged.
* **Data Manipulation:** Generic functions can perform different transformations on data based on its type (e.g., converting strings to numbers).
* **Factory Methods:** Generic functions can create objects of different types based on a single parameter.

**Example Code:**

```python
# Logging Example
import logging

def log(message, level):
    if isinstance(level, int):
        logging.log(level, message)
    elif isinstance(level, str):
        logging.log(getattr(logging, level), message)

# Example Usage
log("Hello, world!", logging.INFO)  # Logs info message
```

**Decorator Functions**

**Simplified Explanation:**

Decorators are functions that enhance other functions. They wrap around a function and modify its behavior.

**Code Snippets:**

```python
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

@my_decorator
def my_function(arg):
    return arg + 1
```

**Real-World Applications:**

* **Time Tracking:** Decorators can measure how long a function takes to execute.
* **Caching:** Decorators can cache function results to improve performance.
* **Method Overriding:** Decorators can be used to change the behavior of existing methods.

**Example Code:**

```python
# Time Tracking Example
from time import time

def timeit(func):
    def wrapper(*args, **kwargs):
        start = time()
        result = func(*args, **kwargs)
        end = time()
        print(f"Function '{func.__name__}' took {end - start} seconds to execute.")
        return result
    return wrapper

@timeit
def long_function():
    # Some time-consuming operation
    return sum(range(1000000))

# Example Usage
result = long_function()  # Logs time taken to execute
```

**Miscellaneous Utilities**

**Simplified Explanation:**

The functools module provides a collection of helper functions for working with functions.

**Code Snippets:**

* `functools.partial(func, *args, **kwargs)`: Creates a new function that has some of its arguments pre-filled.
* `functools.reduce(func, iterable, initializer=None)`: Accumulates values in an iterable by applying a function.
* `functools.wraps(func)`: Preserves function metadata (e.g., name, docstring) when wrapping it.

**Real-World Applications:**

* **Partial Functions:** Creating functions with fixed arguments to simplify code.
* **Reducing Iterables:** Computing a single value from an iterable using a cumulative operation.
* **Metadata Preservation:** Ensuring that wrapped functions have the original function's information.

**Example Code:**

```python
# Partial Function Example
from functools import partial

increment = partial(lambda x: x + 1, 10)  # Creates a function that adds 11 to its input

# Example Usage
print(increment(5))  # Output: 16

# Reduce Example
from functools import reduce

sum_of_squares = reduce(lambda x, y: x + y**2, range(5))  # Computes the sum of squares from 0 to 4

# Example Usage
print(sum_of_squares)  # Output: 30

# Wraps Example
from functools import wraps

def my_wrapper(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Do something before calling func
        result = func(*args, **kwargs)
        # Do something after calling func
        return result
    return wrapper

@my_wrapper
def my_function():
    """My function documentation."""
    pass

# Example Usage
print(my_function.__name__)  # Output: 'my_function'
print(my_function.__doc__)  # Output: 'My function documentation.'
```

***

**Singledispatch decorator in Python's Functools Module**

**Purpose:**

* Singledispatch helps in dispatching function calls to the most specific implementation based on the type of its first argument.

**How it Works:**

* **Default Implementation for Object:**
  * The original function decorated with `@singledispatch` is registered for the base `object` type.
  * This means it will be used if no better implementation is found for a specific type.
* **Registering Implementations:**
  * You can register implementations for specific types using the `fun.register` function.
  * This decorator associates a function with a specific type or its superclass.
* **Method Resolution Order (MRO):**
  * If no registered implementation is found for a specific type, Python uses the type's MRO to find a more generic implementation.
  * For example, if there is no implementation for a specific subclass, the implementation for its parent class or any higher in the MRO will be used.

**Real-World Example:**

Consider a function that prints key-value pairs of a dictionary.

**Code:**

```
from collections.abc import Mapping
from functools import singledispatch

@singledispatch
def print_key_values(arg, verbose=False):
    for key, value in arg.items():
        print(key, "=>", value)

@print_key_values.register(Mapping)
def _(arg: Mapping, verbose=False):
    if verbose:
        print("Keys & Values")
    for key, value in arg.items():
        print(key, "=>", value)
```

**How it Works:**

* The `print_key_values` function is decorated with `@singledispatch` and registered for the `object` type.
* A more specific implementation is registered for the `Mapping` type, which is a superclass of `dict`.
* If you pass a `dict` as the first argument to `print_key_values`, the implementation registered for `Mapping` will be used.
* If you pass a non-`Mapping` object, the default implementation registered for `object` will be used.

**Potential Applications:**

* Implementing polymorphic behavior where different types of objects handle the same operation in different ways.
* Providing type-specific optimizations or error handling in functions.
* Simplifying code by avoiding type-checking and conditional statements for different types.

***

**Generic Functions and Dispatching**

**What are Generic Functions?**

Imagine you have a function that can perform different tasks depending on what type of data you give it. For example, you might have a function that can add two numbers or concatenate two strings. This is called a generic function.

**Dispatching**

When you call a generic function, Python needs to choose the correct implementation for the type of data you give it. This process is called dispatching.

**How Dispatching Works**

Python keeps a registry of all the different implementations of a generic function. When you call the function, Python checks the registry to see which implementation is registered for the type of data you're using.

**Checking the Dispatch Implementation**

To check which implementation will be used for a specific type, you can use the `dispatch()` attribute:

```python
fun.dispatch(float)  # Returns the implementation for float
```

**Accessing All Registered Implementations**

You can access all the registered implementations using the `registry` attribute:

```python
fun.registry  # Returns a dictionary of all implementations
```

**Real-World Example**

Here's an example of a generic function that can add two numbers or concatenate two strings:

```python
from functools import singledispatch

@singledispatch
def add_or_concat(a, b):
    raise NotImplementedError

@add_or_concat.register(int)
def _(a: int, b: int) -> int:
    return a + b

@add_or_concat.register(str)
def _(a: str, b: str) -> str:
    return a + b
```

You can use this function like this:

```python
# Adding two numbers
print(add_or_concat(1, 2))  # Output: 3

# Concatenating two strings
print(add_or_concat("Hello", " World"))  # Output: "Hello World"
```

**Potential Applications**

Generic functions and dispatching can be used in many real-world applications, such as:

* **Type-safe code:** By dispatching based on type, you can ensure that your code only performs operations that are valid for that type.
* **Extensibility:** You can easily add new implementations for different types without modifying the original function.
* **Polymorphism:** You can create functions that can handle multiple types of data without having to write separate functions for each type.

***

**Functools.Registry**

**What is a Registry?**

A registry is a place where you can store and access things by their types.

**Simplified Explanation with Example:**

Imagine you have a box with different toys. Each toy has its own type, like "car," "ball," or "doll." Instead of searching through the box to find a specific toy, you can keep a list of the types of toys in the box. When you want to find a specific toy, you can look in the list to see if it's there.

**Functools.Registry in Python:**

Python's functools module has a special class called Registry that helps you store and retrieve functions based on the types of their arguments.

**How to Use Registry:**

To use Registry, you first need to create it:

```python
import functools

fun = functools.Registry()
```

**Registering Functions:**

You can register functions in the registry using the `register` method:

```python
fun.register(int, fun_num)
fun.register(object, fun)
```

`fun_num` and `fun` are functions that take arguments of type `int` and `object`, respectively.

**Getting Functions from the Registry:**

Once you've registered functions, you can retrieve them using the square bracket notation:

```python
fun_int = fun[int]
fun_obj = fun[object]
```

**Real-World Applications:**

* **Type-Specific Function Dispatching:** You can use Registry to dispatch different functions based on the types of arguments.
* **Type-Based Caching:** You can cache results of functions based on the types of their arguments.

**Complete Code Example:**

```python
import functools

def fun_num(num):
    return num + 1

def fun(obj):
    return str(obj)

fun = functools.Registry()

fun.register(int, fun_num)
fun.register(object, fun)

print(fun[int](5))  # Outputs: 6
print(fun[object]("Hello"))  # Outputs: Hello
```

***

**Single Dispatch Method Decorator**

**Simplified Explanation:**

The `@singledispatchmethod` decorator in Python's `functools` module lets you create a generic method that can handle different types of input differently.

**Detailed Explanation:**

* A **generic method** is a method that can work with different types of input. For example, a method that calculates the length of a string or a list.
* **Single dispatch** means that the choice of which method to call depends on the type of the first argument (after `self`).

**Syntax:**

```python
@singledispatchmethod
def my_function(arg):
    # Default method
    pass
```

**Example:**

Consider a class called `Negator` that has a method `neg()` for negating (inverting) different types of values.

```python
class Negator:
    @singledispatchmethod
    def neg(self, arg):
        raise NotImplementedError("Cannot negate this type of argument")

    @neg.register
    def _(self, arg):
        return -arg

    @neg.register
    def _(self, arg):
        return not arg
```

In this example:

* `@singledispatchmethod` makes `neg()` a generic method.
* The first `@neg.register` decorator registers a method to handle integers, returning their negative value.
* The second `@neg.register` decorator registers a method to handle booleans, returning their negation.

**Real-World Applications:**

* **Type checking:** Check the type of an argument and perform different operations based on the type.
* **Polymorphism:** Implement behavior that varies depending on the type of object being manipulated.
* **Data validation:** Ensure that input values meet certain criteria, depending on their type.

**Improved Code Snippet:**

```python
class Negator:
    @singledispatchmethod
    def neg(self, arg):
        raise NotImplementedError("Cannot negate this type of argument")

    @neg.register(int)
    def _(self, arg):
        return -arg

    @neg.register(bool)
    def _(self, arg):
        return not arg

    @neg.register(list)
    def _(self, arg):
        return [-x for x in arg]  # Negate each element in the list

negator = Negator()
print(negator.neg(10))  # -> -10
print(negator.neg(True))  # -> False
print(negator.neg([1, 2, 3]))  # -> [-1, -2, -3]
```

This code snippet shows how to use the `@singledispatchmethod` decorator to create a generic `neg()` method that can handle integers, booleans, and lists.

***

**@singledispatchmethod**

Imagine you have a class called `Negator` that can negate different types of data, such as integers or booleans. You want to create a method `neg` that performs this operation based on the type of data it receives. Instead of writing multiple methods with the same name for each data type, you can use `@singledispatchmethod` to create a single method that dispatches the call to the appropriate implementation based on the type of its argument.

```python
class Negator:
    @singledispatchmethod
    def neg(cls, arg):
        raise NotImplementedError("Cannot negate a")

    @neg.register
    def _(cls, arg: int):
        return -arg

    @neg.register
    def _(cls, arg: bool):
        return not arg

result = Negator.neg(10)  # Result: -10
result = Negator.neg(True)  # Result: False
```

**Nesting with Other Decorators**

You can nest `@singledispatchmethod` with other decorators, such as `@classmethod`. Just ensure that `@singledispatchmethod` is the outermost decorator. This allows you to bind methods to the class instead of individual instances.

```python
class Negator:
    @singledispatchmethod
    @classmethod
    def neg(cls, arg):
        raise NotImplementedError("Cannot negate a")

    @neg.register
    @classmethod
    def _(cls, arg: int):
        return -arg

    @neg.register
    @classmethod
    def _(cls, arg: bool):
        return not arg

result = Negator.neg(10)  # Result: -10
result = Negator.neg(True)  # Result: False
```

**Potential Applications**

* Simplifying code by reducing the number of methods needed for different data types.
* Creating polymorphic methods that can handle different types of data with a single interface.
* Enabling dynamic dispatch based on argument types at runtime.

**Real-World Examples**

* Creating a function to convert values to different units based on their type (e.g., converting inches to centimeters or pounds to kilograms).
* Building a framework to handle different types of requests based on their HTTP method (e.g., GET, POST, PUT, DELETE).
* Implementing a data validation system that can handle different types of data and validate them according to their specific rules.

***

**Simplified Explanation:**

Imagine you have an old toy car (the *wrapped* function) that you want to give a new paint job (the *wrapper* function). To make the new car look like the old one, you need to copy over certain parts (the attributes) from the old car to the new car.

**update\_wrapper** is a function that helps you do this seamlessly. It takes three things:

* The *wrapper* function (the new toy car)
* The *wrapped* function (the old toy car)
* Two lists of attributes (the parts you want to copy)

**WRAPPER\_ASSIGNMENTS** is a list of attributes that are directly assigned from the *wrapped* function to the *wrapper* function. Think of them as the non-moving parts like the body and wheels.

**WRAPPER\_UPDATES** is a list of attributes that are updated in the *wrapper* function with values from the *wrapped* function. These are like the moving parts like the engine and steering wheel.

**Adding Access to the Original Function:**

To be able to still access the old toy car (the *wrapped* function) after the makeover, **update\_wrapper** automatically adds a new attribute called `__wrapped__` to the *wrapper* function. This attribute points to the *wrapped* function.

**Real-World Example:**

The most common use of **update\_wrapper** is in decorator functions. Decorators are like functions that wrap other functions to add extra functionality. Without **update\_wrapper**, the decorated function would show the details of the decorator instead of the original function.

Here's a simple example:

```python
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before calling the function")
        result = func(*args, **kwargs)
        print("After calling the function")
        return result
    update_wrapper(wrapper, func)
    return wrapper

@my_decorator
def add(a, b):
    return a + b
```

In this example, the `my_decorator` function wraps the `add` function with extra "print" statements. `update_wrapper` is used to make the `wrapper` function look like the `add` function so that the decorated function name, docstring, etc. are not lost.

**Potential Applications:**

* **Caching:** Decorators can be used to cache the results of functions to improve performance.
* **Logging:** Decorators can be used to log the execution of functions for debugging or auditing purposes.
* **Authorization:** Decorators can be used to restrict access to functions based on user permissions or other criteria.

***

**Python's `update_wrapper` Function**

**Purpose:** To enhance a wrapper function by copying attributes from the original wrapped function.

**How it Works:**

Imagine you have a function `original` and you create a wrapper function `wrapper` to wrap it. The `update_wrapper` function helps you "inject" certain attributes from `original` into `wrapper` to make it appear more like `original`.

**Attributes to Copy:**

You can specify two sets of attributes to copy:

* **assigned:** Attributes that must always be copied.
* **updated:** Attributes that are copied if they exist in both `original` and `wrapper`.

**Missing Attributes:**

If `wrapper` is missing any attributes specified in `updated`, `update_wrapper` will raise an error (except in Python 3.2 and later, where it will ignore missing attributes).

**Example:**

Let's create a wrapper function `add_5` that adds 5 to a number:

```python
def original(x):
    return x

def wrapper(x):
    return original(x) + 5

# Copy the "__name__" attribute from `original` to `wrapper`
functools.update_wrapper(wrapper, original, assigned=("__name__",))

# Check if `wrapper` has the copied attribute
print(wrapper.__name__)  # Output: 'original'
```

**Applications in Real World:**

* **Decorators:** `update_wrapper` is often used in decorators to preserve the attributes of the decorated function.
* **Method Overriding:** It can help override methods in child classes while maintaining the attributes of the overridden method.
* **Function Adaptation:** It allows you to adapt functions to work with certain frameworks or libraries that require specific attributes.

***

**Simplified Explanation of the `@wraps` Decorator Factory**

**What is a decorator?** A decorator is a special Python feature that allows you to modify the behavior of other functions without changing their original code.

**What does `@wraps` do?** `@wraps` is a decorator factory that helps you preserve the attributes (like the name and docstring) of the decorated function.

**Why is it useful?** When you use decorators to wrap a function, it's important to keep the original function's name and other metadata. This helps with debugging, introspection, and code readability.

**How does it work?** `@wraps` is equivalent to calling `update_wrapper` with specific arguments. `update_wrapper` copies the attributes of the decorated function (the second argument) to the wrapper function (the first argument). The keyword arguments `assigned` and `updated` specify which attributes to copy.

**Example:**

```python
def my_decorator(f):
    @wraps(f)
    def wrapper(*args, **kwds):
        print('Calling decorated function')
        return f(*args, **kwds)
    return wrapper

@my_decorator
def example():
    print('Called example function')
```

**Simplified Explanation:**

* `my_decorator` is a decorator factory that applies `@wraps` as a decorator.
* `@wraps(f)` is a decorator that applies `update_wrapper` to the `wrapper` function.
* `update_wrapper(wrapper, f)` copies the attributes of `f` (the decorated function) to `wrapper` (the wrapping function).
* The `assigned` and `updated` arguments specify that attributes like `__name__` and `__doc__` should be copied.
* The `example` function is decorated with `my_decorator`, which applies `@wraps`.

**Output:**

```
Calling decorated function
Called example function
```

**Real-World Applications:**

* **Keeping track of function metadata:** Preserving the name and docstring of decorated functions makes it easier to identify and debug them.
* **Introspection:** Introspection tools like `inspect.getdoc` can retrieve metadata from wrapped functions, making it easier to manipulate and analyze code.
* **Custom function loggers:** Decorators can log function calls and arguments, but keeping the original function's name is important for accurate logging.

***

**What are Partial Objects?**

Imagine you have a function like this:

```python
def add_numbers(a, b):
    return a + b
```

This function takes two arguments and returns their sum. Now, let's say you want to create a new function that always adds 5 to a given number. Instead of writing a completely new function, you can use a "partial" object to simplify the code.

**Creating Partial Objects**

To create a partial object, we use the `functools.partial` function. It takes the original function as the first argument and the pre-specified arguments as the remaining arguments.

Here's how you would create a partial object to add 5 to any number:

```python
from functools import partial

# Create a partial object that adds 5
add_five = partial(add_numbers, 5)
```

**Using Partial Objects**

Now, you can use the partial object `add_five` like a regular function. However, it automatically includes the pre-specified argument (5) in its call.

```python
# Add 5 to 10 using the partial object
result = add_five(10)

# Prints 15
print(result)
```

**Attributes of Partial Objects**

Partial objects have three read-only attributes:

* `func`: The original function being called.
* `args`: The pre-specified arguments.
* `keywords`: Any pre-specified keyword arguments.

**Real-World Applications**

Partial objects are useful in many situations, such as:

* **Creating a callback function with fixed arguments:** You can use partial objects to create a callback function with some pre-specified arguments, simplifying the logic of your code.
* **Generating a sequence of functions:** You can use partial objects to generate a sequence of functions with varying arguments, making it easy to create and call multiple similar functions.
* **Partial application:** You can use partial objects to perform partial application of functions, reducing the number of arguments required when calling them.
