Skip to content

Latest commit

 

History

History
1106 lines (748 loc) · 33.7 KB

14.Functional-programming.md

File metadata and controls

1106 lines (748 loc) · 33.7 KB

Lesson 14: Functional Programming

"Here should be the same quote with veins as in Lesson 10"

Content

1. Revision of Functions

Let's revisit the key concepts covered in Lesson 10 about functions in Python:

  1. Introduction to Functions: Functions are self-contained blocks of code designed to perform a specific task. They help make your code modular, manageable, and reusable, enhancing readability and simplifying debugging.

  2. Parameters vs Arguments:

    • Parameters are the variables listed in the function's definition.
    • Arguments are the actual values passed to the function when it is called.
  3. Positional vs Key Arguments:

    • Positional Arguments must be passed in the order the parameters were defined.
    • Keyword Arguments are named when passed, allowing them to be in any order.
  4. Scopes:

    • Local Variables can only be accessed within their function.
    • Global Variables can be accessed anywhere in the code.
  5. Return Statements:

    • The return statement exits a function, optionally passing back a value to the caller. If no expression is specified, None is returned.
  6. Optional Parameters:

    • Functions can have optional parameters with default values, providing default behavior and making them flexible in the number of arguments they accept.
  7. Args and Kwargs:

    • *args allows a function to accept any number of positional arguments.
    • **kwargs allows a function to accept any number of keyword arguments.

Always try to split your logic into functions and modules!

2. Programming Principles(KIS, DRY, YAGNI)

2.1 KISS (Keep It Simple, Stupid)

The KISS principle advocates for simplicity in your code. It's about finding the simplest solution to a problem without unnecessary complexity.

In functional programming, this often means choosing straightforward, readable solutions over clever, convoluted ones.

Best Practices

  • Write clear and concise functions that do one thing and do it well.
  • Avoid unnecessary abstraction layers that can make the code harder to understand.
  • Use clear and descriptive names for functions and variables to make your code self-documenting.

Example

Avoid doing this, it's a bad solution to put everything into one function, also it violates Single Responsibility Principle which you will get familiar with during the lesson about SOLID.

def compute_statistics(numbers):
    total = sum(numbers)
    length = len(numbers)
    mean = (total / length) if length > 0 else float('nan')
    variance = sum((x - mean) ** 2 for x in numbers) / length if length > 0 else float('nan')
    return {"total": total, "length": length, "mean": mean, "variance": variance}

Example

Do this instead!

def compute_mean(numbers):
    return sum(numbers) / len(numbers) if numbers else float('nan')

def compute_variance(numbers, mean):
    return sum((x - mean) ** 2 for x in numbers) / len(numbers) if numbers else float('nan')

def compute_statistics(numbers):
    total = sum(numbers)
    length = len(numbers)
    mean = compute_mean(numbers)
    variance = compute_variance(numbers, mean)
    return {"total": total, "length": length, "mean": mean, "variance": variance}

Output:

{"total": 15, "length": 5, "mean": 3.0, "variance": 2.0}

Explanation

The bad code example crams too much logic into a single function, making it hard to read and maintain.

The good code example applies the KISS principle by breaking down the complex function into smaller, more manageable pieces which makes it easier to understand, test, and maintain.

2.2 DRY (Don't Repeat Yourself)

The DRY principle is about reducing repetition in your code. It encourages you to abstract common patterns into reusable functions or components.

Best Practices

  • Identify patterns and commonalities in your code and abstract them into separate functions.
  • Use higher-order functions to create more generalized and reusable code components.

Example

Bad approach, you repeat the same operation several times!

def print_user_details(user):
    print(f"Name: {user['name']}")
    print(f"Age: {user['age']}")
    print(f"Email: {user['email']}")
    print(f"Name: {user['name']}")
    print(f"Membership: {user['membership']}")

Example

Use for loop, simple as it is!

def print_user_details(user):
    for key in user:
        print(f"{key}: {user[key]}")

user_details = {
    "Name": "John Doe", 
    "Age": "30", 
    "Email": "[email protected]", 
    "Membership": "Premium"
    }

print_user_details(user_details)                    
Name: John Doe
Age: 30
Email: [email protected]
Membership: Premium

Explanation

The bad code example unnecessarily repeats the logic for printing user details. The good code example eliminates repetition by using a loop, adhering to the DRY principle.

Moreover, we have made our code more dynamical, in case new keys are added.

2.3 YAGNI (You Aren't Gonna Need It)

YAGNI is a reminder to avoid adding unnecessary complexity or functionality to your code.

It suggests that you should not implement something until it is necessary. In practice, this means focusing on what you need right now, not what you might need in the future.

Best Practices

  • Focus on the requirements at hand and implement only the functionalities that are immediately needed.
  • Keep your functions and modules focused and lean. The fewer responsibilities they have, the easier they are to manage, test, and reuse.

Example

Will we have a future_promotion_plan?

def calculate_discount(price, customer_age, customer_membership, future_promotion_plan):
    discount = 0
    if customer_age > 65:
        discount += 0.10
    if customer_membership == "Premium":
        discount += 0.15
    if future_promotion_plan:
        discount += get_potential_discount()
    return price * (1 - discount)

Output:

85.0

Example

Now we have much cleaner solution comparing to what we have seen before.

def calculate_discount(price, customer_age, customer_membership):
    discount = 0
    if customer_age > 65:
        discount += 0.10
    if customer_membership == "Premium":
        discount += 0.15
    return price * (1 - discount)

Output:

85.0

Explanation

The bad code example includes functionality (future_promotion_plan) that isn't required for the current requirements, violating the YAGNI principle.

The good code example removes this unnecessary complexity, focusing on the current needs and keeping the implementation simple and straightforward. This results in cleaner, more maintainable code.

Let's put it altogether!

Example

# Applying KISS, DRY, and YAGNI principles in a restaurant order system

def calculate_item_total(price, quantity):
    return price * quantity

def apply_discount(total, discount_rate):
    return total * (1 - discount_rate) if discount_rate else total

def print_receipt(order_items, discount_rate=None):
    grand_total = 0
    print("Receipt:")
    for item, details in order_items.items():
        item_total = calculate_item_total(details['price'], details['quantity'])
        grand_total += item_total
        print(f"{item}: ${item_total:.2f} ({details['quantity']} @ ${details['price']:.2f})")

    if discount_rate:
        print(f"Subtotal: ${grand_total:.2f}")
        grand_total = apply_discount(grand_total, discount_rate)
        print(f"Discount: {discount_rate * 100}%")

    print(f"Grand Total: ${grand_total:.2f}")

def main():
    order_items = {
        'Burger': {'price': 8.50, 'quantity': 2},
        'Fries': {'price': 3.00, 'quantity': 1},
        'Soda': {'price': 1.50, 'quantity': 2}
    }
    discount_rate = 0.1  # 10% discount

    print_receipt(order_items, discount_rate)

main()

Output

Receipt:
Burger: $17.00 (2 @ $8.50)
Fries: $3.00 (1 @ $3.00)
Soda: $3.00 (2 @ $1.50)
Subtotal: $23.00
Discount: 10%
Grand Total: $20.70
  1. KISS (Keep It Simple, Stupid): Functions are simple and focused. calculate_item_total calculates the total for a single item, and apply_discount applies a discount to a total amount.

  2. DRY (Don't Repeat Yourself): The print_receipt function iterates through the items to compute and display the totals, avoiding repetition. The calculations are delegated to specific functions (calculate_item_total and apply_discount), ensuring that each calculation is defined only once.

  3. YAGNI (You Aren't Gonna Need It): The system is straightforward, providing only what is necessary for the task at hand. There's no over-engineering or anticipation of future, speculative requirements.

Try modifying some functions you have already created in previous applications following these principles, and you will see, how easier it is to maintain your code!

3. Function pointers

Pointer is a piece of code that refernces or points to a memory location that stores a value or an object.

In Python, unlike in low level programming languages, such as C there are no pointers for regular objects and values. However, there is such functionality with regards to functions. Which is often extremely helpful for your code.

3.1 Syntax

In order to create function's pointer all you have to do is assign your function to another variable without paranticies () at the end. You will store a reference (A.K.A pointer) inside this variable.

Example

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

print(addition)
print(type(addition))
func = addition
print(func(1, 2))

Output

<function addition at 0x7fdec2d8d800>
<class 'function'>
140594728458240
3

Explanation

If you check the type of addition using type(addition), Python tells you that addition is of type function, which confirms that addition is indeed a function object.

And as you see, we can call the addition function through func variable!

Function pointers have many useful applications, which allow you to improve your code's quality.

Example

For instance, you could assign function pointers to values of a dict, thereby creating a kind of reusable if statement, this kind of dictionary is called handler.

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

def subtraction(a, b):
    return a -b

func_handler = {
    "add": addition,
    "substract": subtraction
}

def main():
    num1 = int(input("1st number:"))
    num2 = int(input("2nd number:"))
    choice = input("choose arithhmetical operation:")
    print("output:", func_handler[choice](a=num1, b=num2))

main()
main()

Output

1st number:4
2nd number:3
choose arithhmetical operation:add
output:7
1st number:4
2nd number:3
choose arithhmetical operation:substract
output:1

The technique presented in the example above might seem useless from the first glance, because we can simply write this, right?

if choice == "add":
    print(add(num1, num2))
elif choice == "substract":
    print(substract(num1, num2))

Of course, however it is much more convinient to use handlers in this case, because with many inputs your if statement would grow exponentially. And you'll end up having endless if statements.

Handlers is one of the most favourite patterns I have ever used. Think about where it can be applied within your applications.

4. Higher Order Functions

Higher-order functions are functions that accept other functions as their parameters or return functions as their results.

Note: Pay attention to parentheses () when using higher-order functions, as they distinguish between referencing the function and calling it.

4.1 map()

map() Function: This is a built-in function that applies a specified function to each item of an iterable (such as a list) and returns a map object (an iterable). The beauty of map() is that it allows for function application without explicitly writing a loop.

Example

def square(x):
    """Returns the square of a number."""
    return x * x

numbers = [1, 2, 3, 4, 5]
squared = map(square, numbers)

print(list(squared))

Output

[1, 4, 9, 16, 25]

4.2 filter()

Similar to map(), filter() takes a function and an iterable, but it constructs an iterator from those elements of the iterable for which the function returns true. In other words, filter() forms a list of elements for which a function returns true.

Example

def is_even(x):
    """Returns True if x is even, False otherwise."""
    return x % 2 == 0

numbers = [1, 2, 3, 4, 5]
even_numbers = filter(is_even, numbers)

print(list(even_numbers))

Output

[2, 4]

NOTE: In both cases we converted outputs of map()/filter() to list, as they return custom objects, we need to convert results into iterables.

I wouldn't recommend you to refuse using for loops, but definitely it worth updating some parts of your applications with map() and filter(). Again, try it yourself!

5. Closures

Closures provide a way for a function to capture and "remember" the environment in which it was created, even when it's called outside that environment.

IMPORTANT: Please, read attentively and try to understand how they work. We will need closures to create custom decorators for your functions later on.

5.1 How it works?

A closure occurs when a function has access to a local variable from an enclosing scope that has finished its execution.

When functions in Python refer to variables in their enclosing scope, the values of those variables are captured and retained throughout the lifetime of the function object.

Even if the outer function has returned, the inner function still has access to those captured variables. This behavior forms a closure.

Example

There are lots of practical use cases where closures may be useful, but I want to highlight the point, that it is possible to retain state with them (variables from the outer function) without using global variables or object properties.

def outer_function(msg):
    message = msg  # A local variable within the outer_function

    def inner_function():
        # The inner_function has access to the 'message' variable of the outer_function.
        print(message)

    return inner_function  # The outer_function returns the inner_function

# Create a closure
hi_func = outer_function('Hi')
bye_func = outer_function('Bye')

# Call the closures
hi_func()
bye_func()

Output

Hi
Bye

Explanation

  1. When we call outer_function('Hi'), the local variable message is set to 'Hi'. The outer_function then returns the inner_function.

  2. The returned inner_function retains access to the message variable of outer_function. This retained access to the message variable is a closure.

  3. Even though outer_function has finished executing, the message variable is not garbage collected. This is because the inner_function closure retains a reference to it. When hi_func() is called, it still has access to the message variable of the outer_function, allowing it to print 'Hi'.

  4. Each call to outer_function creates a new closure. So, hi_func and bye_func are independent of each other. Each closure retains its own unique environment. hi_func retains the environment where message was 'Hi', and bye_func retains the environment where message was 'Bye'.

For now, the best will be to stop at this point, think about closures and try to experiment with them, before moving to decorators. Try to realise the concept of closures and create one with your hands.

6. Decorators

Decorators provide the ability to alter the functionality of a function or method.

When you decorate a function with a decorator, you're essentially replacing that function with a new function that typically calls the original function and does something extra.

Decorators use the @ symbol and are placed on the line before the function definition.

Example

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Do something before")
        result = func(*args, **kwargs)
        print("Do something after")

        return result
    return wrapper

@my_decorator
def say_hello(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

say_hello("Alice", greeting="Hi")

Explanation

  1. The Decorator Function: This is a function that takes another function as an argument. This function will usually define an inner function.
  2. The Inner Function: This function is defined inside the decorator function and is where you put the code that you want to execute before and/or after the original function runs. This function will call the original function at some point, and return the result of that call.
  3. Returning the Inner Function: The decorator function will return the inner function. This way, when you use the decorator, you're replacing the original function with the inner function.

Output

Do something before
Hello, Alice!
Do something after

As you can see in the example above in decorator functions we can even modify the arguments or return values which allows for extreme flexibility of the code.

Generally we use decorators in order to enable code reusability (which can be aplied to ANY function or method, without direct altering of behaviour, without changing the code of these functions).

As well, you can add an additional validation or measure the time of execution of your functions.

Example

def superuser(func):
    allowed_users = ["Yehor", "Sasha"]
    def wrapper(*args, **kwargs):
        if kwargs["name"] not in allowed_users:
            raise KeyError("Oops, you are not a superuser")
        result = func(*args, **kwargs)
        return result
    return wrapper


def validate_strings(func):
    def wrapper(*args, **kwargs):
        for value in kwargs.values():
            if not isinstance(value, str):
                raise TypeError("All arguments must be strings")
        return func(*args, **kwargs)
    return wrapper

# Yes! You can apply several decorators to the function
# The resolution of nested decorators will be from the top to the bottom
@validate_strings
@superuser
def say_hello(name, greeting="Hello"):
    print(f"{greeting}, {name}!")


say_hello(name="Sasha")
say_hello(name="Dima")

Note, that you can decorate a function indirectly, not using @ as a syntax sugar.

decorator = superuser(say_hello)
decorator(name="Yehor", greeting="Morning")

Output

Hello, Sasha!
KeyError: 'Oops, you are not a superuser'

I LOVE DECORATORS! Hopefully you too now!

7. Lambda Functions

Lambda function in Python is a small anonymus function, meaning that it doesn't have a name. It can take any number of arguments but it can only have one expression.

7.1 Syntax

To define a lambda function, use the lambda keyword followed by a comma-separated list of arguments, a colon :, and then the expression.

Example

# Lambda function that multiplies its input by 5
l1 = lambda a: a * 5
print(l1(2))  # Output: 10

# Lambda function that sums three arguments
l2 = lambda a, b, c: sum([a, b, c])
print(l2(2, 3, 4))  # Output: 9

The simplicity of lambda functions is demonstrated here, showing how they directly return the result of a single expression.

Generally, lambdas ideal for encapsulating small bits of functionality that you do not need to reuse elsewhere. As well, the best usage will be passing them into map() or filter() functions.

Example

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

squared_numbers = map(lambda x: x * x, numbers)
print(list(squared_numbers))

even_numbers = filter(lambda x: x % 2 == 0, numbers)
print(list(even_numbers))

Output

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
[2, 4, 6, 8, 10]

Create --> Execute --> Say Goodbye!

7.2 Lambda vs Def

I am not a big fan of lambdas, though, should admit that I have nothing against them! I guess, the best in this situation would be refering to this table while making a decision what should your code do.

Feature lambda def
Syntax lambda args: expression def name(args): body
Expressions Allowed Single Multiple
Reusability Single-use, typically Designed for reuse
Use Case Small, one-liner More complex logic
Named No Yes

8. Function Composition

Function composition is the process of combining two or more functions to produce a new function. Composing functions together is like snapping together a series of pipes for our data to flow through.

Example

def f(x):
    return x + 4

def g(x):
    return 2 * x ** 2 + 3


print(f(g(5)))

Output

57

Explanation

g() is being executed -> returns a value and passes it to the f() function.

The best approach to compose functions in a more functional programming style is to use higher-order functions along with lambda functions.

Example

def compose(f, g):
    return lambda x: f(g(x))

def add_five(x):
    return x + 5

def multiply_three(x):
    return x * 3

composed_function = compose(add_five, multiply_three)

print(composed_function(5))

Output

20

Explanation

In the example provided, the function compose is a higher-order function because it takes two functions, f and g, as its arguments. It returns a new function that represents the composition of f and g. In other words, it creates a new function where g is applied first to any input, and then f is applied to the result of g.

The final output is 20, which is the result of first multiplying 5 by 3 (getting 15), and then adding 5 to the result (getting 20).

9. Currying

Function currying is a functional programming concept where a function, instead of taking multiple arguments at once, takes the first argument and returns a new function until all arguments have been provided.

Then, the final result is returned. It's a way of translating a function that takes multiple arguments into a sequence of functions that each take a single argument.

Let's look at an example to understand how currying works:

Example

Default approach of calling the function

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

result = add(1, 2, 3)  # Call the function with all arguments at once
print(result)  # Output: 6

Example

Using currying

def add(a):
    return lambda b: (
        lambda c: a + b + c
    )

add_one = add(1)  # Returns a function that takes the second argument
add_two = add_one(2)  # Returns a function that takes the third argument
result = add_two(3)  # Finally computes the result

print(result)  # Output: 6

Curried functions allows you to delay computation. You can pass around intermediate functions (with some arguments fixed) and only complete the computation later, when all arguments are available.

If you are familiar with databases, consider it as a sort of transaction in DB. It might be useful in some cases when you need to make several operations at one time.

10. Recursion

Recursion is a programming method where a function calls itself to complete a job or solve an issue.

Example

def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)


for i in range(10):
    print(fibonacci(i), end=' ')

Output

0 1 1 2 3 5 8 13 21 34

Explanation

In this example fibonacci() is calling itself n times. That is useful when you need to run repetitive task which implies the same algorithm several times to get the final result eventually.

You have to understand that such operations are extremely resource consuming and try to avoid recursion in your apps. But in case it's impossible or unavoidable, you may refer to such mechanism to solve your issue.

11. Function Memoization

Memorization is a technique for storing the outcomes of earlier function calls to expedite subsequent computations.

Repeated function calls with the same inputs allow us to save the prior values rather than doing pointless calculations again.

Calculations are significantly accelerated as a consequence. One example of how you might want to implement momoization in your code is decorators.

Example

Let's begin by creating the functions themeseleves. For the better demonstation of effectiveness of memoization technique we can use recursive fibbonacci sequence calculator function which usually requires a lot of computational power.

def memoize(func):
    cache = {}

    def wrapper(*args):
        if args in cache:
            return cache[args]
        else:
            result = func(*args)
            cache[args] = result
            return result

    return wrapper


@memoize
def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

Basically, we cache the result in memory which eventually will lead to better performance once we decide to refer to the same function again.

Example

from datetime import datetime

# non memoized function time
start_time = datetime.now()
print(fibonacci(499))
non_memoized_function_time = datetime.now() - start_time
print(f"execution of non memoized function took {non_memoized_function_time}")

# memoized function time
start_time = datetime.now()
print(fibonacci(499))
memoized_function_time = datetime.now() - start_time
print(f"execution of memoized function took {memoized_function_time}")

# Difference
print(f"non memoized function took {non_memoized_function_time - memoized_function_time} more time")

IMPORTANT: Pass a small value to fibonacci function, I have created a random output as calculation for 499 fibonacci numbers will take ages and why this is happening will be explained in a lesson about algorithms.

Output

86168291600238450732788312165664788095941068326060883324529903470149056115823592713458328176574447204501
execution of non memoized function took 0:00:00.000573
86168291600238450732788312165664788095941068326060883324529903470149056115823592713458328176574447204501
execution of memoized function took 0:00:00.000015
non memoized function took 0:00:00.000558 more time

As you can see in the output above, there is a huge time difference between two times thanks to memoization.

Note: Do not get confused by the fact that we called same function twice - the first time we called the function its result hasn't been recorded yet, therefore it is the same as calling unmemoized function.

12. Quiz

Question 1:

What is a higher-order function in Python?

A) A function that operates at a higher security level.
B) A function that accepts other functions as arguments or returns a function as a result.
C) A function that can only be executed as an administrator.
D) A function that performs higher mathematical calculations.


Question 2:

What does the map() function do?

A) Maps a function to a specific module.
B) Applies a given function to each item of an iterable and returns a list of results.
C) Creates a visual map of function calls.
D) Translates a function from one programming language to another.


Question 3:

What is the purpose of function currying?

A) To add flavor to the function.
B) To secure a function against external modifications.
C) To transform a function with multiple arguments into a sequence of functions each taking a single argument.
D) To optimize a function for faster execution.


Question 4:

Which of the following is a correct example of a lambda function?

A) lambda x, y: x + y
B) def lambda(x, y): return x + y
C) lambda(x, y): x + y()
D) lambda = (x, y): x + y


Question 5:

How does memoization enhance function performance?

A) By converting the function to machine code directly.
B) By storing the results of expensive function calls and returning the cached result when the same inputs occur again.
C) By distributing function execution across multiple processors.
D) By rewriting the function in a more efficient programming language.


Question 6:

What is a closure in Python?

A) A syntax for closing a file after it's been opened.
B) A technique to terminate a loop early.
C) An inner function that remembers and has access to variables in the local scope in which it was created, even after the outer function has finished executing.
D) A special type of error that occurs when a function finishes execution.


Question 7:

In Python, what does the decorator syntax typically involve?

A) The # symbol.
B) The @ symbol before a function definition.
C) The & symbol.
D) The ! symbol.


Question 8:

What is the main advantage of using recursion in programming?

A) It simplifies the code for functions where the solution involves solving smaller instances of the same problem.
B) It always speeds up the execution time.
C) It uses less memory compared to iterative solutions.
D) It can be used with any function without restrictions.

Question 9:

What will be the output of the following Python code snippet using the filter() function?

nums = [1, 2, 3, 4, 5, 6]
filtered_nums = filter(lambda x: x % 2 == 0, nums)
print(list(filtered_nums))

A) [1, 3, 5]
B) [2, 4, 6]
C) [1, 2, 3, 4, 5, 6]
D) []


Question 10:

Consider the following function that uses recursion to calculate the factorial of a number. What value is returned when calling factorial(5)?

# Ha-ha, it's a question from math :)
def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)

print(factorial(5))

A) 120
B) 24
C) 100
D) None


Question 11:

What is demonstrated by the following code snippet using function composition?

def double(x):
    return x * 2

def increment(x):
    return x + 1

def compose(f, g):
    return lambda x: f(g(x))

f = compose(double, increment)
print(f(3))

A) The code doubles the input and then increments it.
B) The code increments the input and then doubles it.
C) The code triples the input.
D) The code increments the input twice.


Question 12:

What does the following curried function calculate, and what will be the output when executed as shown?

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

multiply_by_3 = multiply(3)
result = multiply_by_3(5)
print(result)

A) Adds 3 to 5; output is 8
B) Multiplies 3 by 5; output is 15
C) Divides 5 by 3; output is approximately 1.67
D) Subtracts 3 from 5; output is 2


13. Homework

In this homework I will provide some snippets, which you will have to modify to consolidate what you've learnt. Additionaly, create a decorator which measures the execution time and apply to each function!

Task 1: Inventory Manager

Objective: Implement a function that handles adding and updating an inventory for a fantasy game character using higher-order functions.

Requirements:

  • Write a manage_inventory() function that can take commands such as "add" or "update" along with item details and modifies the inventory accordingly.
  • Use closures to encapsulate the inventory state within the manager.
def inventory_manager():
    inventory = {}

    def manage(command, item, quantity):
        pass
    return manage

Task 2: Message Encoder

Objective: Create a simple text encoder using function composition that applies multiple transformations to text.

Requirements:

  • Define multiple small lambda functions for different text transformations (e.g., reverse, encode characters, etc.).
  • Compose these functions to create a more complex text encoding function.
def compose(*functions):
    def composed_func(input):
        pass
    return composed_func

encoder = compose(reverse, encode)

# Encode a message
encoded_message = encoder("hello world")

Task 3: Music Composer

Objective: Implement a system that lets users compose their own music by adding notes and applying effects using higher-order functions.

Requirements:

  • Define functions for adding musical notes and applying various effects like echo or speed change.
  • Use function composition to apply multiple effects to a sequence of notes.
def add_notes(*notes):
    pass

def echo_effect(notes):
    # Be creative!
    pass

def speed_change(notes, factor):
    # Be creative!
    pass

# Create a new composition and apply effects
echoed = compose_music(echo_effect)
print(f"Echoed Composition: {echoed}")

# Apply speed change
faster = speed_change(echoed, 2)
print(f"Faster Composition: {faster}")

You can even create more decorators based on your taste and try to apply them!

Congratulations with mastering the functional programming! Looking forward to see your solutions!