Core Python: Part 6

Functions

Core Python: Part 6

Check out the previous part: Core Python: Part 5

Functions in programming save a lot of effort by eliminating the repetition of some parts of the code. It is like having a coffee machine in the kitchen that knows how to do coffee and we can use it every time instead of following the same process. There are built-in functions and user-defined functions.

Built-in Functions

Some of the great programmers already built some useful functions so that we can use them in our programs instead of writing the logic for everything. Some built-in functions are provided in a module like math, datetime. A module is a file that has a collection of related functions. We usually import the required module and using dot notation we can access the functions stored in them.

import math # creates a module object called math
math.log10(2) # log10 is a built-in function inside math module
type(42) # Output: int
print("hello") # Output: hello

User-defined Functions

It is sometimes necessary to define our functions according to the need of our program. We need to know how exactly we can construct a function. It requires us to define it by giving a name and this line is called header and defines as many statements as you need in the body of the function. We can only use them by calling the functions and some functions need arguments to be passed while some don't.

greet() and say_hello() functions are objects of type function.

# the argument passed while calling a function is assigned to parameter "name"
def greet(name): # header
    return f"Hello, {name}!" # body of the function
# function without taking arguments
def say_hello():
    print("Hello, world!")

# Call the function with an argument
result = greet("Alice")
print(result)  # Output: Hello, Alice!

# Call the function without any arguments
say_hello() # Output: Hello, world!

A stack diagram is a graphical representation to represent the function call and execution that is useful to keep track of the execution. Each function in the stack is called a frame and when some error occurs in the function call, a list of functions is given in the traceback to debug the error.

Fruitful function

These functions have a return statement that returns a value to be displayed or computed further. They are used when we want to use the returned value further in the program.

def add_numbers(a, b):
    sum_result = a + b
    return sum_result

# Call the function and store the result
result = add_numbers(5, 7)
print("Sum:", result)  # Output: Sum: 12

Void function

These functions always return a special type of None of type None. They are used for basic purposes to perform some action and cannot be used to store the result.

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

# Call the function (no result is stored)
greet("Alice")  # Output: Hello, Alice!
greet("Bob")    # Output: Hello, Bob!

# Attempt to store the result (which is None)
result = greet("Charlie")
print("Result:", result)  # Output: Result: None

Some Terms

  1. Composition is when we call one function within another.

     def square(x):
         return x ** 2
    
     def double(x):
         return x * 2
    
     # Composition: (square ∘ double)(x) = square(double(x))
     result = square(double(5))
     print(result)  # Output: 100
    
  2. Scaffolding is the code we use during the program development but are removed in the final version of production.

  3. Docstring is a string literal placed as the first statement in a module, function, class, or method definition in Python.

     def calculate_area(radius):
         """
         Calculate the area of a circle.
         """
         return 3.14159 * radius ** 2
    

Recursive function

Some functions are designed to call themselves till we obtain the final result. This type of function is used extensively in solving DSA problems. The function will have a base class that is very important to come out of the loop otherwise function keeps calling itself till the recursion depth is reached.

def factorial(n):
    if n == 0 or n == 1: # base class
        return 1
    else:
        return n * factorial(n - 1) # function calling itself

result = factorial(5)
print(result)  # Output: 120
  1. factorial(5) calls factorial(4) and multiplies the result by 5.

  2. factorial(4) calls factorial(3) and multiplies the result by 4.

  3. This process continues until factorial(1) is reached, which returns 1.

  4. The results are multiplied together as the function calls unwind: 5 * 4 * 3 * 2 * 1 = 120.

Positional arguments

They are passed to a function in the order they appear in the function's parameter list.

def greet(name, age):
    print(f"Hello, {name}! You are {age} years old.")

greet("Alice", 30)  # Positional arguments: name="Alice", age=30

Keyword arguments

They are passed with a name (keyword) followed by an equal sign and the value. They allow you to pass arguments in any order and explicitly specify which parameter each value corresponds to.

def greet(name, age):
    print(f"Hello, {name}! You are {age} years old.")

greet(age=25, name="Bob")  # Keyword arguments: name="Bob", age=25

Default arguments

They have default values specified in the function definition. If you don't provide a value for a default argument when calling the function, the default value is used. So we have the option to send those arguments or not.

def greet(name, age=18):  # age has a default value of 18
    print(f"Hello, {name}! You are {age} years old.")

# Output: Hello, Charlie! You are 18 years old.
greet("Charlie")        # Uses default age value: age=18
# Output: Hello, David! You are 28 years old.
greet("David", 28)      # Overrides default age value: age=28

Scope

In Python, variables have different scopes that determine where they can be accessed and modified.

  1. Local Scope

    Variables defined within a function are considered to be in the local scope. They can only be accessed and modified within that specific function. Once the function execution completes, the local scope is destroyed, and the variables are no longer accessible.

     def my_function():
         x = 10  # Variable x is in the local scope of my_function
         print(x)
    
     my_function()  # Output: 10
     print(x)       # Error: NameError - x is not defined outside the function
    
  2. Enclosing (Closure) Scope

    If a function is defined within another function, the inner function has access to variables from the enclosing (outer) function's scope, in addition to its own local variables.

     def outer_function():
         y = 20  # Variable y is in the enclosing scope of inner_function
         def inner_function():
             print(y)
         inner_function()
    
     outer_function()  # Output: 20
    
  3. Global Scope

    Variables defined in the outermost scope of a Python program can be accessed from any part of the program. However, modifying global variables from within a function requires using the global keyword.

     z = 30  # Variable z is in the global scope
     x = 20  # Variable z is in the global scope
     y = 15  # Variable z is in the global scope
    
     def my_function():
         global z
         print(z) # Output: 30
         z = 40
         x = 25 # This creates a new local variable x within the function
         y = y + 1  # Error: UnboundLocalError, trying to modify local y without global declaration
    
     my_function()  # Output: 30
     print(z)       # Output: 40 - modified value inside a function
     print(x)       # Output: 20 (global variable x is not modified)
    

Important

  • When you pass an empty list as a default argument in a Python function, you need to be aware of how default arguments work and how they behave when mutable objects like lists are involved. When we call the function multiple times without providing an argument, you might expect each call to start with a fresh empty list. Instead, the default argument retains its state between function calls, leading to unexpected behavior.
def process_list(data=[]):
    data.append("new item")
    return data

result1 = process_list()
result2 = process_list()

print(result1)  # Output: ['new item', 'new item']
print(result2)  # Output: ['new item', 'new item']

This happens because the default argument is created once when the function is defined, and subsequent calls to the function share the same list object. To avoid this behavior, you should use immutable objects (like None) as default arguments and create a new mutable object (like a list) within the function if needed:

def process_list(data=None):
    if data is None:
        data = []
    data.append("new item")
    return data

result1 = process_list()
result2 = process_list()

print(result1)  # Output: ['new item']
print(result2)  # Output: ['new item']
  • Pass by value and Pass by reference

    "Pass by value" is when a copy of the actual value of the argument is passed to the function. Any changes made to the parameter within the function do not affect the original variable outside the function.

    "Pass by reference" is when a reference (memory address) to the original variable is passed to the function. Any changes made to the parameter within the function directly affect the original variable outside the function.

  • In Python, when you pass an argument to a function, you are actually passing a reference to the object, whether it's mutable (like a list) or immutable (like a string). However, how changes to the parameter affect the original variable depends on whether the object is mutable or immutable.

  •       def modify_value(a, b):
              a = 20        # a is assigned a new value (immutable)
              b.append(4)   # b is modified in place (mutable)
    
          x = 10
          y = [1, 2, 3]
    
          modify_value(x, y)
    
          print(x)  # Output: 10 (unchanged)
          print(y)  # Output: [1, 2, 3, 4] (modified)
    

Check out the next part: Core Python: Part 7