Chapters Five and Six Python Program/Notes

Chapter 5 - Advanced Functions

  • Functions can be passed variables or values and this is called an argument.

  • You just do that like Java.

  • You can also assign variables in the function definition.

  • You can give a function place holder parameters in the event someone calls your function without the proper arguments.

  • Forcing parameters can be done in a couple ways. One with a / that is positional, and * which is positional or keyword.

  • If you’re making a function where you can accept any number of parameters you put a little * in front.

*name
  • That stores any number of positional parameters in a tuple, while this stores them in a dictionary.

**name
  • Return statements can return actually anything because in python everything is an object.

  • Pass by references allow parameters to change values, but in Python that doesn’t really work.

  • Python does pass objects, but only as a reference to that object, so it won’t change the original.

  • If you wanna change something you gotta make an object, I guess.

  • In Python it’s possible to return multiple values using a tuple, which can be unpacked into variables using destructing assignment.

  • Annotations can be applied to parameters and return values, usually to indicate type.

Chapter 5 Program

I wanted to keep the programs separate for chapter 5 and 6, because these chapters are getting a little juicer.

# -------------------------------------------------------------------
# 1. Arguments — passing variables or values into a function (like Java)
# -------------------------------------------------------------------
print("=== 1. Arguments ===")

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

greet("Ada", "Hello")           # passing values directly
language = "Python"
version  = 3
greet(language, f"Welcome to version {version}")


# -------------------------------------------------------------------
# 2. Default (placeholder) parameters
#    If the caller skips an argument, the default kicks in
# -------------------------------------------------------------------
print("\n=== 2. Default parameters ===")

def power(base, exp=2):         # exp has a placeholder default of 2
    return base ** exp

print(f"  power(4)    = {power(4)}")       # uses default exp=2
print(f"  power(4, 3) = {power(4, 3)}")   # caller overrides it


# -------------------------------------------------------------------
# 3. Forcing parameter style
#    /  = everything before it must be POSITIONAL
#    *  = everything after it must be KEYWORD
# -------------------------------------------------------------------
print("\n=== 3. Forced positional ( / ) and keyword ( * ) ===")

def strict(pos_only, /, normal, *, kw_only):
    print(f"  pos_only={pos_only}  normal={normal}  kw_only={kw_only}")

# pos_only must be positional, kw_only must be keyword
strict(1, 2, kw_only=3)
strict(10, normal=20, kw_only=30)

# These would raise TypeErrors — uncomment to prove it:
# strict(pos_only=1, normal=2, kw_only=3)   # pos_only can't be keyword
# strict(1, 2, 3)                            # kw_only can't be positional


# -------------------------------------------------------------------
# 4. *args — accept any number of positional arguments (stored as tuple)
# -------------------------------------------------------------------
print("\n=== 4. *args (any number of positional params → tuple) ===")

def total(*numbers):
    print(f"  Received tuple: {numbers}")
    return sum(numbers)

print(f"  total(1,2,3)        = {total(1, 2, 3)}")
print(f"  total(10,20,30,40)  = {total(10, 20, 30, 40)}")


# -------------------------------------------------------------------
# 5. **kwargs — accept any number of keyword arguments (stored as dict)
# -------------------------------------------------------------------
print("\n=== 5. **kwargs (any number of keyword params → dict) ===")

def display_info(**details):
    print(f"  Received dict: {details}")
    for key, value in details.items():
        print(f"    {key}: {value}")

display_info(name="Guido", language="Python", year=1991)


# -------------------------------------------------------------------
# 6. Mixing *args and **kwargs together
# -------------------------------------------------------------------
print("\n=== 6. *args + **kwargs together ===")

def mixed(required, *args, **kwargs):
    print(f"  required={required}")
    print(f"  extra positional (tuple): {args}")
    print(f"  extra keyword   (dict):   {kwargs}")

mixed("must-have", 1, 2, 3, color="blue", size="large")


# -------------------------------------------------------------------
# 7. Return statements — can return ANYTHING (everything is an object)
# -------------------------------------------------------------------
print("\n=== 7. Returning anything ===")

def get_function(mode):
    if mode == "shout":
        return lambda s: s.upper() + "!!!"   # returns a function object
    return lambda s: s.lower() + "..."

shout   = get_function("shout")
whisper = get_function("quiet")
print(f"  {shout('hello')}")
print(f"  {whisper('HELLO')}")

def get_class():
    class Surprise:                           # returns a class object
        value = 42
    return Surprise

MyClass = get_class()
print(f"  Returned class value: {MyClass.value}")


# -------------------------------------------------------------------
# 8. Pass by object reference — Python passes a reference,
#    but rebinding inside the function does NOT change the original.
#    Mutating the object itself DOES affect the original.
# -------------------------------------------------------------------
print("\n=== 8. Pass-by-object-reference ===")

def try_rebind(x):
    x = 9999            # just rebinds the local name — original unchanged
    print(f"  inside try_rebind: x = {x}")

num = 42
try_rebind(num)
print(f"  after try_rebind:  num = {num}")   # still 42

# But mutating a mutable object DOES affect the caller
def append_item(lst, item):
    lst.append(item)    # mutates the object the reference points to

my_list = [1, 2, 3]
append_item(my_list, 99)
print(f"  after append_item: my_list = {my_list}")  # [1, 2, 3, 99]

# "If you wanna change something you gotta make an object" — use a list/dict
def increment(counter):
    counter["value"] += 1   # mutate the dict object

c = {"value": 0}
increment(c)
increment(c)
print(f"  counter after 2 increments: {c}")


# -------------------------------------------------------------------
# 9. Returning multiple values via tuple + destructuring assignment
# -------------------------------------------------------------------
print("\n=== 9. Multiple return values + tuple unpacking ===")

def min_max(numbers):
    return min(numbers), max(numbers)   # returns a tuple

data = [4, 1, 9, 2, 7]
result = min_max(data)
print(f"  raw return (tuple): {result}")

low, high = min_max(data)               # destructuring assignment
print(f"  unpacked → low={low}, high={high}")

def divmod_custom(a, b):
    return a // b, a % b                # quotient and remainder

quotient, remainder = divmod_custom(17, 5)
print(f"  17 ÷ 5 → quotient={quotient}, remainder={remainder}")


# -------------------------------------------------------------------
# 10. Type annotations — hint at expected types for params and return
#     Python does NOT enforce these at runtime; they're documentation
# -------------------------------------------------------------------
print("\n=== 10. Type annotations ===")

def add(a: int, b: int) -> int:
    return a + b

def repeat_str(s: str, times: int = 2) -> str:
    return s * times

def stats(numbers: list[float]) -> tuple[float, float, float]:
    return min(numbers), max(numbers), sum(numbers) / len(numbers)

print(f"  add(3, 4)                  = {add(3, 4)}")
print(f"  repeat_str('ha')           = {repeat_str('ha')}")
print(f"  repeat_str('ha', 4)        = {repeat_str('ha', 4)}")

lo, hi, avg = stats([3.0, 1.5, 9.2, 4.8])
print(f"  stats([3.0,1.5,9.2,4.8])  → min={lo}, max={hi}, avg={avg:.2f}")

# Annotations are stored on the function object itself — totally inspectable
print(f"\n  add.__annotations__        = {add.__annotations__}")
print(f"  stats.__annotations__      = {stats.__annotations__}")

Chapter 6 - Decorators

  • Ts makes functions much more useful.

  • So first off you can pass functions into functions, because a function is an object and you can pass any object.

  • Wrapper functions can be used to pass all of the parameters of one function on to another.

  • In Python 3 a decorator is a little prefix before a function defection.

  • You gotta use @dec in front of a function.

  • They can add or modify stuff.

  • Decorator factories have parameters and provide parameters to a decorator.

Chapter 6 Program

import time
import functools
# -------------------------------------------------------------------
# 1. Functions are objects — you can pass them into other functions
# -------------------------------------------------------------------
print("=== 1. Functions as objects ===")

def shout(text):
    return text.upper() + "!!!"

def whisper(text):
    return text.lower() + "..."

def deliver_message(func, message):
    # func is just an object being passed in like any other argument
    result = func(message)
    print(result)

deliver_message(shout,   "hello there")
deliver_message(whisper, "hello there")


# -------------------------------------------------------------------
# 2. Wrapper function — passes ALL parameters through with *args/**kwargs
#    This is the manual version of what decorators automate
# -------------------------------------------------------------------
print("\n=== 2. Wrapper function (manual) ===")

def announce(func):
    def wrapper(*args, **kwargs):           # captures everything
        print(f"  >> Calling {func.__name__}")
        result = func(*args, **kwargs)      # forwards everything
        print(f"  >> Finished {func.__name__}")
        return result
    return wrapper

def add(a, b):
    print(f"     {a} + {b} = {a + b}")
    return a + b

announced_add = announce(add)   # wrap manually — no @ yet
announced_add(3, 4)


# -------------------------------------------------------------------
# 3. The @ decorator syntax — syntactic sugar for the same thing
#    @dec before a def is exactly: func = dec(func)
# -------------------------------------------------------------------
print("\n=== 3. @ decorator syntax ===")

def bold(func):
    @functools.wraps(func)      # keeps the original name/docstring intact
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return f"**{result}**"
    return wrapper

@bold                           # same as: greet = bold(greet)
def greet(name):
    return f"Hello, {name}"

print(greet("Guido"))
print(greet("Ada"))


# -------------------------------------------------------------------
# 4. Decorator that ADDS behavior (timing)
# -------------------------------------------------------------------
print("\n=== 4. Decorator that adds behavior (timer) ===")

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start  = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = (time.perf_counter() - start) * 1000
        print(f"  ⏱  {func.__name__} took {elapsed:.3f} ms")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

result = slow_sum(1_000_000)
print(f"  Result: {result}")


# -------------------------------------------------------------------
# 5. Decorator that MODIFIES behavior (block negative inputs)
# -------------------------------------------------------------------
print("\n=== 5. Decorator that modifies behavior (guard) ===")

def positive_only(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if any(a < 0 for a in args if isinstance(a, (int, float))):
            print(f"  ✗ {func.__name__} blocked — negative inputs not allowed")
            return None
        return func(*args, **kwargs)
    return wrapper

@positive_only
def square_root(n):
    return n ** 0.5

print(f"  √9  = {square_root(9)}")
print(f"  √-1 = {square_root(-1)}")


# -------------------------------------------------------------------
# 6. Stacking decorators — applied bottom-up
# -------------------------------------------------------------------
print("\n=== 6. Stacked decorators (applied bottom → top) ===")

@bold
@timer
def slow_greeting(name):
    time.sleep(0.001)
    return f"Greetings, {name}"

# timer wraps first, then bold wraps the timed version
print(slow_greeting("World"))


# -------------------------------------------------------------------
# 7. Decorator factory — a decorator that accepts its own parameters
#    factory(params)  →  returns a configured decorator
#    That decorator  →  returns the wrapper
# -------------------------------------------------------------------
print("\n=== 7. Decorator factory (parameters) ===")

def repeat(times):                  # factory: takes a parameter
    def decorator(func):            # the actual decorator
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator                # factory returns the ready-to-use decorator

@repeat(times=3)                    # factory runs first, then decorates say()
def say(message):
    print(f"  {message}")

say("echo!")

# Another factory — stamp a label onto every return value
def tag(label):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            return f"[{label}] {func(*args, **kwargs)}"
        return wrapper
    return decorator

@tag("INFO")
def status(msg):
    return msg

@tag("WARNING")
def warn(msg):
    return msg

print(status("All systems go"))
print(warn("Disk space low"))