Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Tim Peters, Zen of Python
In Chapters I-VII, we introduced the basic concepts required to make a computer perform any computation
in the sense of Turing completeness. People new to programming often think that programming is the art of
giving a computer instructions it understands and executes correctly. In reality, programming is more about
writing code that other people (who understand programming concepts) can read and even extend. In Python,
functions are a cornerstone of well-organized code, allowing you to create modular and reusable blocks.
Functions are defined using the def keyword, followed by the function name and a pair of
parentheses that can enclose parameters.
def greet(name):
# This block belongs to the function
print(f"Hello, {name}!")
greet("Elizabeth")
A function is defined by parameters and can return a value. To execute a function, you call it with the necessary arguments.
def greet(name):
print(f"Hello, {name}!")
greet("Elizabeth")
The return statement is used to exit a function and provide a value back to the caller. Unlike
print, which merely displays text, return passes data back so it can be stored in
a variable or used in further calculations.
def add(a, b):
return a + b
result = add(5, 7)
print(f"Result: {result}")
In modern Python (and especially when working with AI), it is best practice to annotate your inputs and outputs using Type Hints and describe the function's purpose with a Docstring (triple quotes). This makes your code self-documenting.
def calculate_area(radius: float) -> float:
"""
Calculates the area of a circle given its radius.
Args:
radius (float): The radius of the circle.
Returns:
float: The calculated area.
"""
return 3.14159 * (radius ** 2)
print(f"Area: {calculate_area(5.0)}")
Functions can have default arguments, letting them be called with fewer arguments than defined. You can also use keyword arguments for clarity when passing values.
def power(base, exponent=2):
return base ** exponent
print(f"Default (3^2): {power(3)}")
print(f"Positional (3^3): {power(3, 3)}")
print(f"Keyword (2^3): {power(base=2, exponent=3)}")
Sometimes you may not know in advance how many arguments will be passed to your function. Python allows you
to handle an arbitrary number of arguments using *args (for positional arguments) and **kwargs
(for keyword arguments).
def summarize_data(*args, **kwargs):
print(f"Data points: {args}")
print(f"Metadata: {kwargs}")
summarize_data(10, 20, 30, source="sensor_1", date="2024-01-01")
A variable created within a function is considered a local variable. Its scope is limited to that function, meaning you can only access and modify it there. This design ensures independence of function calls and promotes clearer, modular code.
If you need to modify a variable from an outer scope inside a function, Python provides the
global keyword:
x = 10
def modify_global_var():
global x
x = 20
print(f"Inside the function, x is {x}")
modify_global_var()
print(f"Outside the function, x is also {x}")
Although global can be helpful in certain scenarios, it is generally discouraged in large
programs because it can make your code less modular and more prone to bugs (side effects).
x = 10
# Better approach: Pass the value as an argument
def change_x(new_x):
# This 'x' is a new local variable, distinct from the global 'x'
x = new_x
print(f"Inside the function, x is {x}")
change_x(20)
print(f"Outside, global x is still: {x}")
Lambda functions, also known as anonymous functions, are a feature typical of functional programming
languages. Python, being multi-paradigm, supports these compact functions with the lambda
keyword. They are particularly useful when you need a small, short-lived function (e.g., for sorting or
filtering data).
multiply = lambda x, y: x * y
print(f"Lambda result: {multiply(5, 6)}")
# Practical usage: Sorting a list of tuples by the second element
data = [(1, 'apple'), (3, 'cherry'), (2, 'banana')]
sorted_data = sorted(data, key=lambda item: item[1])
print(f"Sorted by name: {sorted_data}")
Recall the bisection search algorithm from the previous chapter. That program only works with
integer_input and accuracy. Reusing it would mean copying and pasting the same
code, which is impractical. Larger programs stitched together from repeated code fragments quickly become
confusing, error-prone, and tough to maintain. By encapsulating the bisection logic in a function and
passing integer_number and accuracy as parameters, we make the logic modular and
reusable. In other words, we get independent, reusable subroutines that communicate through their
parameters (input) and return values (output).
def square_root(integer_number: int, accuracy: float):
if integer_number < 0:
return "There is no true square root of a negative number."
else:
minimum = 0
maximum = max(1, integer_number)
number_of_iterations = 0
midpoint = (maximum + minimum) / 2
while abs(midpoint ** 2 - integer_number) >= accuracy:
if midpoint ** 2 < integer_number:
minimum = midpoint
else:
maximum = midpoint
midpoint = (maximum + minimum) / 2
number_of_iterations += 1
return f"After {number_of_iterations} iterations, found {midpoint}."
print(square_root(100, 0.01))
This code is not entirely practical if we need a numeric result for further calculations. Returning a string is useful for displaying, but not for additional computation. It is better to return the raw number.
def square_root(integer_number: int, accuracy: float):
if integer_number < 0:
print("There is no true square root of a negative number.")
return None # Implicitly returns None
else:
minimum = 0
maximum = max(1, integer_number)
number_of_iterations = 0
midpoint = (maximum + minimum) / 2
while abs(midpoint ** 2 - integer_number) >= accuracy:
if midpoint ** 2 < integer_number:
minimum = midpoint
else:
maximum = midpoint
midpoint = (maximum + minimum) / 2
number_of_iterations += 1
return midpoint
print(f"Numeric result: {square_root(100, 0.01)}")
However, do we really need to allow adjustable accuracy for every call? Excessive options in a function interface can lead to confusion in larger projects. Often, setting a default accuracy or rounding the result is enough.
def square_root(integer_number: int, accuracy: float = 0.01):
if integer_number < 0:
print("There is no true square root of a negative number.")
return
else:
minimum = 0
maximum = max(1, integer_number)
number_of_iterations = 0
midpoint = (maximum + minimum) / 2
while abs(midpoint ** 2 - integer_number) >= accuracy:
if midpoint ** 2 < integer_number:
minimum = midpoint
else:
maximum = midpoint
midpoint = (maximum + minimum) / 2
number_of_iterations += 1
# We round the result to 2 decimal places for cleaner output
return round(midpoint, 2)
print(f"Standard Accuracy: {square_root(100)}")
print(f"User-defined accuracy: {square_root(100, 0.0001)}")
Leveraging AI can significantly enhance your ability to write accurate, efficient functions.
Example Prompt:Resulting AI-generated code:
def average_without_zeros(numbers: list[float]) -> float:
"""
Calculates the average of a list of numbers, excluding zeros.
"""
filtered_numbers = [num for num in numbers if num != 0]
if not filtered_numbers:
return 0.0
return sum(filtered_numbers) / len(filtered_numbers)
numbers = [1, 2, 0, 4, 0, 5]
print(f"Average: {average_without_zeros(numbers)}")