"Here should be the same quote with veins as in Lesson 10"
- 1. Revision of Functions
- 2. Programming Principles(KIS, DRY, YAGNI)
- 3. Function pointers
- 4. Higher Order Functions
- 5. Closures
- 6. Decorators
- 7. Lambda Functions
- 8. Function Composition
- 9. Currying
- 10. Recursion
- 11. Function Memoization
- 12. Quiz
- 13. Homework
Let's revisit the key concepts covered in Lesson 10 about functions in Python
:
-
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.
-
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.
-
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.
-
Scopes:
- Local Variables can only be accessed within their function.
- Global Variables can be accessed anywhere in the code.
-
Return Statements:
- The
return
statement exits a function, optionally passing back a value to the caller. If no expression is specified,None
is returned.
- The
-
Optional Parameters:
- Functions can have optional parameters with default values, providing default behavior and making them flexible in the number of arguments they accept.
-
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!
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.
- 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.
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}
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}
{"total": 15, "length": 5, "mean": 3.0, "variance": 2.0}
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.
The DRY principle is about reducing repetition in your code. It encourages you to abstract common patterns into reusable functions or components.
- 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.
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']}")
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
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.
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.
- 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.
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)
85.0
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)
85.0
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!
# 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()
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
-
KISS (Keep It Simple, Stupid): Functions are simple and focused.
calculate_item_total
calculates the total for a single item, andapply_discount
applies a discount to a total amount. -
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
andapply_discount
), ensuring that each calculation is defined only once. -
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!
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.
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.
def addition(a, b):
return a + b
print(addition)
print(type(addition))
func = addition
print(func(1, 2))
<function addition at 0x7fdec2d8d800>
<class 'function'>
140594728458240
3
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.
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()
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.
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.
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.
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))
[1, 4, 9, 16, 25]
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.
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))
[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!
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.
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.
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()
Hi
Bye
-
When we call
outer_function('Hi')
, the local variablemessage
is set to'Hi'
. Theouter_function
then returns theinner_function
. -
The returned
inner_function
retains access to themessage
variable ofouter_function
. This retained access to themessage
variable is a closure. -
Even though
outer_function
has finished executing, the message variable is not garbage collected. This is because theinner_function
closure retains a reference to it. Whenhi_func()
is called, it still has access to themessage
variable of theouter_function
, allowing it to print'Hi'
. -
Each call to
outer_function
creates a new closure. So,hi_func
andbye_func
are independent of each other. Each closure retains its own unique environment.hi_func
retains the environment wheremessage
was'Hi'
, andbye_func
retains the environment wheremessage
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.
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.
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")
- The Decorator Function: This is a function that takes another function as an argument. This function will usually define an inner function.
- 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.
- 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.
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.
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")
Hello, Sasha!
KeyError: 'Oops, you are not a superuser'
I LOVE DECORATORS! Hopefully you too now!
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.
To define a lambda function, use the lambda
keyword followed by a comma-separated list of arguments, a colon :
, and then the expression.
# 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.
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))
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
[2, 4, 6, 8, 10]
Create --> Execute --> Say Goodbye!
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 |
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.
def f(x):
return x + 4
def g(x):
return 2 * x ** 2 + 3
print(f(g(5)))
57
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.
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))
20
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
).
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:
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
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.
Recursion is a programming method where a function calls itself to complete a job or solve an issue.
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=' ')
0 1 1 2 3 5 8 13 21 34
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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) []
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
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.
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
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!
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
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")
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!