Functions#

A function is a reusable block of code defined with the def keyword. Calling it executes the code inside, achieving the “write once, use many times” effect. Python functions support default parameters, keyword arguments, variadic parameters, and can be passed around and returned as first-class objects.


Defining and Calling Functions#

1
2
3
4
5
6
7
# Define a function
def greet(name):
    print(f"Hello, {name}!")

# Call the function
greet("Alice")   # Hello, Alice!
greet("Bob")     # Hello, Bob!

Return Values#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def add(a, b):
    return a + b

result = add(3, 5)
print(result)  # 8

# Can return multiple values (actually returns a tuple)
def min_max(numbers):
    return min(numbers), max(numbers)

lo, hi = min_max([3, 1, 4, 1, 5, 9])
print(lo, hi)  # 1 9

# Without return, or return with no value, returns None
def do_nothing():
    pass

result = do_nothing()
print(result)  # None

Parameter Types#

Positional Arguments#

1
2
3
4
def describe(name, age, city):
    print(f"{name}, age {age}, lives in {city}")

describe("Alice", 25, "Taipei")  # passed in order

Default Arguments#

1
2
3
4
5
6
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alice")           # Hello, Alice!
greet("Bob", "Hi")       # Hi, Bob!
greet("Charlie", greeting="Hey")  # Hey, Charlie!

Note: Default parameters must come after non-default parameters.

Keyword Arguments#

1
2
3
4
5
def describe(name, age, city):
    print(f"{name}, age {age}, lives in {city}")

# Pass by keyword — order does not matter
describe(age=25, city="Taipei", name="Alice")

*args: Accept any number of positional arguments#

When you do not know in advance how many positional arguments the caller will pass, prefix a parameter with * to collect the extras into a tuple. The name args is just convention — the * is what matters; *nums or *items works just as well.

1
2
3
4
5
6
7
8
def total(*args):
    print(args)        # args is a tuple
    print(type(args))  # <class 'tuple'>
    return sum(args)

print(total())               # 0   (0 args  -> args = ())
print(total(1, 2, 3))        # 6   (args = (1, 2, 3))
print(total(1, 2, 3, 4, 5))  # 15  (args = (1, 2, 3, 4, 5))

Compare it with the version without * to see why this is useful:

1
2
3
4
5
def total_fixed(a, b, c):    # must pass exactly 3
    return a + b + c

total_fixed(1, 2)            # TypeError: missing argument
total_fixed(1, 2, 3, 4)      # TypeError: too many arguments

**kwargs: Accept any number of keyword arguments#

** collects extra name=value arguments into a dict, where each key is the parameter name (a string) and each value is what was passed. Again, kwargs is just convention.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def print_info(**kwargs):
    print(kwargs)        # kwargs is a dict
    print(type(kwargs))  # <class 'dict'>
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=25, city="Taipei")
# {'name': 'Alice', 'age': 25, 'city': 'Taipei'}
# name: Alice
# age: 25
# city: Taipei

*args gives you a tuple (values only, ordered); **kwargs gives you a dict (named, accessed by key). They solve different problems.

The reverse: unpacking with * / ** at the call site#

* and ** also work in the opposite direction when calling a function — they “unpack” an existing tuple/list/dict into individual arguments:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def describe(name, age, city):
    print(f"{name}, age {age}, lives in {city}")

# Unpack a list/tuple into positional arguments
data = ["Alice", 25, "Taipei"]
describe(*data)              # same as describe("Alice", 25, "Taipei")

# Unpack a dict into keyword arguments
info = {"name": "Bob", "age": 30, "city": "Tokyo"}
describe(**info)             # same as describe(name="Bob", age=30, city="Tokyo")

Rule of thumb: in a function definition, * / ** collect; at the call site, * / ** expand.

Combining all parameter types#

When all four kinds appear together, the order is fixed: positional → *args → keyword → **kwargs .

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def flexible(required, *args, default="hello", **kwargs):
    print(f"Required parameter: {required}")
    print(f"Extra positional args: {args}")
    print(f"Default parameter: {default}")
    print(f"Extra keyword args: {kwargs}")

flexible("mandatory", 1, 2, 3, default="world", x=10, y=20)
# Required parameter: mandatory
# Extra positional args: (1, 2, 3)
# Default parameter: world
# Extra keyword args: {'x': 10, 'y': 20}

Note: any parameter that comes after *args (like default above) becomes keyword-only — there is no positional slot left, because *args has consumed them all.

The most common real-world use is wrapping another function — passing arguments through without caring what they are:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def logged(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__} with {args}, {kwargs}")
        return func(*args, **kwargs)   # forward as-is
    return wrapper

@logged
def add(a, b):
    return a + b

add(3, 5)
# calling add with (3, 5), {}

Variable Scope#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
x = 10  # global variable

def foo():
    x = 20  # local variable, does not affect the global x
    print(x)  # 20

foo()
print(x)  # 10

# To modify a global variable inside a function, use global
def bar():
    global x
    x = 30

bar()
print(x)  # 30

Lambda Functions (Anonymous Functions)#

A lambda is a concise single-line function, commonly used with sorted(), map(), and filter():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Syntax: lambda parameters: expression
square = lambda x: x ** 2
print(square(5))  # 25

add = lambda a, b: a + b
print(add(3, 4))  # 7

# Use with sorted()
words = ["banana", "apple", "cherry", "kiwi"]
sorted_words = sorted(words, key=lambda w: len(w))
print(sorted_words)  # ['kiwi', 'apple', 'banana', 'cherry']

# Sort a list of dicts with sorted()
students = [
    {"name": "Alice", "score": 85},
    {"name": "Bob", "score": 92},
    {"name": "Charlie", "score": 78},
]
by_score = sorted(students, key=lambda s: s["score"], reverse=True)
for s in by_score:
    print(f"{s['name']}: {s['score']}")

Common Higher-Order Functions#

A higher-order function is one that accepts another function as an argument. Lambda expressions are most commonly used as arguments to these functions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# map(): apply a function to every element
squares = list(map(lambda x: x ** 2, numbers))
print(squares)   # [1, 4, 9, 16, 25]

# filter(): keep elements that satisfy a condition
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)     # [2, 4, 6, 8, 10]

# sorted(): customise the sort key
words = ["banana", "apple", "cherry", "kiwi"]
by_length = sorted(words, key=lambda w: len(w))
print(by_length) # ['kiwi', 'apple', 'banana', 'cherry']

For the full coverage of map(), filter(), and sorted() — including multiple sequences, multi-key sorting, and advanced patterns — see CH11: Common Built-in Functions.


Closures#

A function can remember variables from its enclosing scope:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def make_multiplier(factor):
    def multiplier(x):
        return x * factor  # remembers factor
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

Recursion#

A function can call itself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5))  # 120 (5 × 4 × 3 × 2 × 1)

# Fibonacci sequence
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

for i in range(10):
    print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34

Practical Examples#

Moving average#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def moving_average(prices, window):
    """Calculate a moving average."""
    result = []
    for i in range(len(prices) - window + 1):
        avg = sum(prices[i:i + window]) / window
        result.append(round(avg, 2))
    return result

prices = [100, 102, 98, 105, 110, 108, 112]
ma3 = moving_average(prices, 3)
print(ma3)  # [100.0, 101.67, 104.33, 107.67, 110.0]

Quicksort#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def quicksort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    return quicksort(left) + middle + quicksort(right)

data = [3, 6, 8, 10, 1, 2, 1]
print(quicksort(data))  # [1, 1, 2, 3, 6, 8, 10]