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. .. code-block:: python *name - That stores any number of positional parameters in a tuple, while this stores them in a dictionary. .. code-block:: python **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. .. code-block:: python # ------------------------------------------------------------------- # 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 ------------------ .. code-block:: python 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"))