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):
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.
def add(a, b):
return a + b
result = add(5, 7)
print(result)
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(power(3))
print(power(3, 3))
print(power(base=2, exponent=3))
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.
x = 10
def change_x(new_x):
x = new_x
print(f"Inside the function, x is {x}")
change_x(20)
print(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 and prefer not to define a
standard function block.
multiply = lambda x, y: x * y
print(multiply(5, 6))
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_input
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, the bisection search algorithm found {midpoint} to be close to the square root of {integer_number} with an accuracy of {accuracy}. "
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.
def square_root(integer_number: int, accuracy: float):
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
return midpoint
print(square_root(100, 0.01))
However, do we really need to allow adjustable accuracy for every call? Usually, we just want the square root without worrying about precision details. 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
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):
filtered_numbers = [num for num in numbers if num != 0]
if not filtered_numbers:
return 0
return sum(filtered_numbers) / len(filtered_numbers)
numbers = [1, 2, 0, 4, 0, 5]
print(average_without_zeros(numbers))