A08 Functions

Funcion Definition

# Anatomy of a Python function
# ------------------------------------------------------------------------------
# A function definition begins with the ``def`` keyword followed by its name and
# parameters. The body can perform operations using those parameters and return
# a value. Well-documented functions include a docstring that briefly states
# their purpose.

def function_name(parameter1, parameter2):
    """ Docstring: description of the function """

    # Code to be executed when the function is called
    result = parameter1 + parameter2
    print(result)

    # Return statement (optional)
    return result

Positional Arguments

# Using positional arguments
# ------------------------------------------------------------------------------
# Each value is matched to a parameter based on where it appears, so the order
# of the provided arguments matters. Positional parameters correspond directly
# to the order defined in the function signature. Mixing up the order can lead
# to incorrect results or errors.

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


# Calling the greet() function with positional arguments in the correct order
greet("Alice", 25)
# Output: Hello, Alice! You are 25 years old.

# Calling the greet() function with positional arguments incorrectly
greet(30, "Bob")
# Output: Hello, 30! You are Bob years old.

Named Arguments

# Calling functions with keyword arguments
# ------------------------------------------------------------------------------
# When a function call includes parameter names, the order of those arguments no
# longer matters. Keyword arguments make the call site clearer and allow some
# parameters to be skipped if they have defaults. They also pair well with
# functions that accept many optional settings.

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


# Calling the greet() function with named arguments
greet(name="Alice", age=25)

Default Arguments

# Default arguments in functions
# ------------------------------------------------------------------------------
# Functions may define default values for parameters so callers can omit those
# arguments. This simplifies the call site and allows optional behavior.
# Remember that default expressions are evaluated when the function is defined.

def greet(name='Nemo', age=42):
    print("Hello, {0}! You are {1} years old.".format(name, age))


greet()
# Output: Hello, Nemo! You are 42 years old.

Dynamic Arguments Python3

# Mixing positional arguments with keyword-only arguments
# ------------------------------------------------------------------------------
# Python 3 lets you combine regular positional parameters with ``*args`` and
# keyword-only parameters that have default values. The `*` separator defines
# that the positional parameters until a key-value pair is encountered.

def variable_number_of_arguments(a, *args, b=1, **kwargs):
    print(f"a: {a}")
    print(f"b: {b}")
    print(f"args: {args}")
    print(f"kwargs: {kwargs}")


variable_number_of_arguments(1, 2, 3, c=4)

Dynamic Arguments Python2

# Handling a variable number of arguments in Python 2
# ------------------------------------------------------------------------------
# This code captures extra positional arguments with ``*args`` and extra
# keyword arguments with ``**kwargs`` when keyword-only parameters are
# unavailable. In Python 3 you can declare keyword-only parameters using the
# ``*`` separator instead of relying on ``**kwargs``. See
# ``func_variable_arguments_python3.py`` for comparison.

def variable_number_of_arguments(a, b, *args, **kwargs):
    print("a: {a}".format(a=a))
    print("b: {b}".format(b=b))
    print("args: {args}".format(args=args))
    print("kwargs: {kwargs}".format(kwargs=kwargs))


variable_number_of_arguments(1, 2, 3, c=4)

Positional Only Arguments

# Positional-only arguments
# ------------------------------------------------------------------------------
# Some parameters can be declared positional-only so they cannot be passed by
# name. This keeps the API minimal and prevents accidental clashes with keyword
# arguments. The syntax uses a ``/`` in the parameter list to mark the end of
# positional-only parameters.

# The arguments a and b are positional-only
def positional_only_arguments(a, b, /):
    return a + b


# The argument a is positional-only, b is positional or keyword
def one_positional_only_argument(a, /, b):
    return a + b

Keyword Only Arguments

# Keyword-only arguments
# ------------------------------------------------------------------------------
# Keyword-only parameters must be specified by name in the call. This avoids
# ambiguity and makes the purpose of each argument clear. It is particularly
# helpful when a function accepts many optional parameters.

# The arguments a and b are keyword-only
def keyword_only_arguments(*, a, b):
    return a + b


# The argument a is positional or keyword, b is keyword-only
def one_keyword_only_argument(a, *, b):
    return a + b


# The argument a is positional only, b is keyword-only
def separate_arguments(a, /, *, b):
    return a + b

Unpacking Arguments

# Argument unpacking with `*args`
# ------------------------------------------------------------------------------
# When calling a function, the star operator can expand an iterable into
# positional arguments. This allows you to store the arguments in a list or
# other iterable and pass them all at once.

def my_function(a, b, c):
    print(a, b, c)


args = [1, 2, 3]
my_function(*args)

Variable Scope

# Understanding variable scope in Python
# ------------------------------------------------------------------------------
# There are two types of variable scope in Python: global and local. If a
# local variable has the same name as a global variable, the local variable
# will take precedence within the function.
#
# If the function needs to use the global variable, it must declare it as
# global using the `global` keyword.
#
# !!! WARNING !!!
# Modifying a global variable inside a function can lead to unexpected behavior
# and should be done with caution. A good practice is to avoid the use of
# global variables altogether, unless absolutely necessary.

var = 1
print(var)


# Output: 1

# Local variable with the same name
def func_local_var():
    # Redefine the variable within the function scope
    var = 2
    print(var)


func_local_var()
print(var)


# Output: 1

def func_using_global_var():
    # Declare that we want to use the global variable
    global var
    var = 3
    print(var)


func_using_global_var()

Nested Functions

# Nested functions and their access to enclosing variables
# ------------------------------------------------------------------------------
# Inner functions can access variables from the outer function that defines
# them. This ability creates a closure which preserves the environment even
# after the outer function has finished executing. It allows the inner function
# to operate using data that would otherwise be out of scope.

def absolute_value(x):
    # Emulate the built-in abs() function, e.g. abs(-1) == 1 and abs(1) == 1

    def negative_value():
        # An inner function can access the variables of the outer function

        return -x

    def positive_value():
        # An inner function can also access the variables of the outer function

        return x

    # Use the inner functions to return the correct value
    return negative_value() if x < 0 else positive_value()


print(absolute_value(-1))  # 1
print(absolute_value(1))  # 1

Closure Functions

# Closures in Python
# ------------------------------------------------------------------------------
# A closure in Python is a function object that “remembers” values from its
# enclosing scope even when that scope has finished execution. In other
# words, a closure lets you bind variables from an outer function into an
# inner function, and keep using them later.

def greet(message):
    def inner_function(name):
        return "{} {}".format(message, name)
    return inner_function


welcome = greet("Welcome")
print(welcome('Branko'))
# Output: Welcome Branko

print(greet('Hello')('Branko'))
# Output: Hello Branko

Function Factory

# Function factories to create specialized functions
# ------------------------------------------------------------------------------
# A function factory returns a new function tailored to the argument it
# receives. It enables creation of many small functions without repeating code.
# Each generated function captures the parameters provided to the factory.

def power_of(n):
    def power(x):
        return x ** n

    return power


# Square root
sqrt = power_of(0.5)
print(sqrt(100.0))

# Square
sqr = power_of(2)
print(sqr(10.0))

Recursive Function

# Recursive functions in Python
# ------------------------------------------------------------------------------
# A recursive function repeatedly calls itself with a simpler version of the
# original problem. Each call works toward a base case that stops the recursion.
# This technique is often used for tasks that can be defined in terms of similar
# subproblems.

def factorial(n):
    # Base case
    if n == 0:
        return 1

    # Recursive case
    else:
        return n * factorial(n - 1)


test_function = factorial(5)
print(test_function)

Callback Funciton

# Using callback functions to handle events
# ------------------------------------------------------------------------------
# A callback function is passed as an argument to another function and executed
# when a particular event occurs. This technique lets the caller customize
# behavior without changing the callee. Callbacks are common in event-driven
# architectures and asynchronous code.

_listeners = {}

def on(event_name, callback):
    _listeners.setdefault(event_name, []).append(callback)


def emit(event_name, *args, **kwargs):
    for callback in _listeners.get(event_name, []):
        callback(*args, **kwargs)


def handle_data(data):
    print(f"[DATA] Received: {data!r}")


def handle_error(msg):
    print(f"[ERROR] {msg}")


on("data", handle_data)
on("error", handle_error)

emit("data", {"id": 1, "value": 42})
emit("data", {"id": 2, "value": 99})
emit("error", "Timeout occurred")

Functions Attributes

# Adding attributes to functions
# ------------------------------------------------------------------------------
# Functions in Python are can have attributes. They are accessed using the dot
# notation (e.g. `foo.name`), and can be used to store metadata about the
# function, such as its name, description, or author.

def foo():
    pass


foo.name = "MyFunc"
foo.description = "This is my function"
foo.author = "Me"

print(foo.author)
# Output: Me

print(foo.name)
# Output: MyFunc

print(foo.description)
# This is my function