Functions: Deep Dive

More About Functions

Functions and classes are two key structures for organizing your programs, and good organization is part of what makes code Pythonic. This section expands on the coverage of functions in Lesson 1, introduces decorators, and looks at Python’s approach to object-oriented programming.

Nested Functions

Python supports nested functions, or functions inside functions, as shown in the code below:

global_variable = 5

def outer_function():
  # Define inner functions *before* using them,
  # otherwise you’ll raise an error when you call them.

  def inner_function():
    print("Executing inner function...")

    # Inner functions have access to the outer function’s variables
    # as well as global variables
    print(f"value of global_variable: {global_variable}")
    print(f"value of outer_function_local_variable: \
      {outer_function_local_variable}")

    print("Finished executing inner function.")

  print("Executing outer function...\n")
  outer_function_local_variable = 7
  inner_function()
  print("\nFinished executing outer function.")

outer_function()

Note that inner_function() is available only within outer_function().

Passing Functions as Arguments

In Python, everything is an object — functions included — meaning you can pass them as arguments to other functions. The code below defines two functions that take a number and perform a math operation on it, plus_5() and squared(), and calculate(), which takes a function and a number and applies that function to the number:

def plus_5(number):
  return number + 5

def squared(number):
  return number ** 2

def calculate(function, number):
  return(function(number))

print(calculate(plus_5, 5))   # 10
print(calculate(squared, 5))  # 25

Defining Anonymous Functions

Python uses the keyword lambda to define anonymous functions — small unnamed functions typically used as arguments passed to functions.

# Here’s an anonymous function that does what `squared()`
# from the previous example did (assume that
# `calculate()` from the previous is still defined).
print(calculate(lambda number: number ** 2, 5))  # 25

Python has a built-in sorted() function that takes an optional key argument — a function that defines the sort order — and returns a new sorted collection. The example below shows how lambda functions can be used in this case:

players_and_scores = [
  {"name": "Beatrice", "score": 62},
  {"name": "Anoop", "score": 75},
  {"name": "Dave", "score": 38},
  {"name": "Carol", "score": 41}
]

sorted_by_name = sorted(players_and_scores, key=lambda player_and_score:
  player_and_score["name"])
print(f"By name:\n {sorted_by_name}")

sorted_by_score = sorted(players_and_scores, key=lambda player_and_score:
  player_and_score["score"])
print(f"By score:\n {sorted_by_score}")

Functions That Take a Variable Number of Arguments

So far, this module has covered only functions that take a fixed number of arguments, also known as fixed-arity functions. Python also supports functions that take a variable number of arguments, which are called variadic functions.

Consider this function:

def show_arguments(*args, **kwargs):
  print(f"args:\n {args}\n")
  print(f"kwargs:\n {kwargs}")

Note the parameters for show_arguments():

  • *args: The * means “capture all the positional arguments” (the arguments passed to this function based on order) as a tuple and assign them to the parameter name that follows. args is a parameter name often used after * by convention, but it’s not a keyword.
  • *kwargs: The ** means “capture all the keyword arguments” (the arguments passed to this function in parameter_name=value format) as a dictionary and assign them to the parameter name that follows. kwargs is a parameter name often used after ** by convention, but it’s not a keyword.

Try making these calls to show_arguments():

# Calling show_arguments() with all positional arguments
show_arguments("pizza", 1, [2, 3], True)
# args:
#  ('pizza', 1, [2, 3], True)
# kwargs:
#  {}

# Calling show_arguments() with all keyword arguments
show_arguments(os="Android", languages=["Java", "Kotlin"], years_in_service=16)
# args:
#  ()
# kwargs:
#  {'os': 'Android', 'languages': ['Java', 'Kotlin'], 'years_in_service': 16}

# Calling show_arguments() with a mix
show_arguments(9, "test", prompt="Hi", threshold=0.8)
# args:
#  (9, 'test')
# kwargs:
#  {'prompt': 'Hi', 'threshold': 0.8}

You can use the * and ** symbols when you want a function to require a specific set of parameters followed by a variable set of parameters:

def calculate_bill(user_name, user_status, *args, **kwargs):
  print(f"user_name: {user_name}")
  print(f"user_status: {user_status}")
  print(f"args: {args}")
  print(f"kwargs: {kwargs}")

calculate_bill("Alice", "active", 29.99, 49.99, tax_exempt=False, tax_rate=0.08)
# user_name: Alice
# user_status: active
# args: (29.99, 49.99)
# kwargs: {'tax_exempt': False, 'tax_rate': 0.08}

Decorators

Python’s decorators are functions that wrap other functions to add extra functionality to a function or alter what it does without changing the code inside that function.

Simple Decorators

Suppose you have these two functions:

def hello():
  return "Hello!"

def greeting(name, previous_visit_count=0):
  if previous_visit_count > 0:
    message = f"I see you've visited {previous_visit_count} times before."
  else:
    message = "I see this is your first visit."
  return f"Welcome, {name}! {message}"

Now, suppose you want to enhance the result returned by greeting() and possibly the results returned by other similar functions as well. The enhancement involves converting all characters in the result to uppercase and surrounding it with a heart emoji on either side. You can do this by defining a decorator function called enhance():

def enhance(func):
  """
  This decorator makes the output
  of a function that returns a string
  a little more fancy.
  """

  # Python allows nested functions!
  # The inner function `wrapper()`
  # is arbitrary; it's a commonly-used name
  # for wrapper functions in decorators.
  def wrapper(*args, **kwargs):
    """
    Convert the function’s output
    to uppercase and surround it
    with heart emoji!
    """
    return f"❤️ {func(*args, **kwargs).upper()} ❤️"

  return wrapper

As you can see from the code above, a decorator takes a function and returns another function called a wrapper. The wrapper is a function that performs some operation on the result of the function passed to the decorator.

Note: The first line in the function begins with a multiline comment. This is the docstring. It explains what the function does. You’ll cover the docstring more in the next section.

Notice how the decorator uses *args and **kwargs to pass any arguments to the wrapper function it contains.

Once you’ve defined a decorator function, you can decorate an existing function by using @ to annotate that function with the decorator’s name:

@enhance
def hello():
  return "Hello!"

print(hello())  # ❤️ HELLO! ❤️

@enhance
def greeting(name, previous_visit_count=0):
  if previous_visit_count > 0:
    message = f"I see you've visited {previous_visit_count} times before."
  else:
    message = "I see this is your first visit."
  return f"Welcome, {name}! {message}"


print(greeting("Bob"))  # ❤️ WELCOME, BOB! I SEE THIS IS YOUR FIRST VISIT. ❤️

print(greeting("Carol", 3))  # ❤️ WELCOME, CAROL! I SEE YOU'VE VISITED 3 TIMES BEFORE. ❤️

You’ll find decorators used in all sorts of Python code, including classes, which will be covered next.

See forum comments
Download course materials from Github
Previous: Comprehensions Next: Classes & Objects