{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "ed7c7e8b-9381-4a10-a211-fa0853b53e54",
   "metadata": {},
   "source": [
    "# Functional Programming"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "c9f1a523",
   "metadata": {},
   "outputs": [],
   "source": [
    "from functools import wraps"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bc36e197",
   "metadata": {},
   "source": [
    "```{contents} Outline\n",
    ":local:\n",
    ":depth: 2\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "148683c7",
   "metadata": {},
   "source": [
    "## FP in Python\n",
    "\n",
    "**Functional programming (FP)** is a programming paradigm that treats computation as the evaluation of mathematical functions. Rather than describing *how* to do something step-by-step by mutating program state such as in imperative programming paradigm, functional programs describe *what* to compute by transforming inputs into outputs.\n",
    "\n",
    "```text\n",
    "                         ┌──────────────────────┐\n",
    "                         │ Programming Paradigm │\n",
    "                         └──────────┬───────────┘\n",
    "                  ┌─────────────────┴─────────────────┐     \n",
    "                  ▼                                   ▼\n",
    "          ┌───────────────┐                   ┌───────────────┐\n",
    "          │  Imperative   │                   │  Declarative  │\n",
    "          └───────┬───────┘                   └───────┬───────┘\n",
    "          ┌───────┴───────┐                   ┌───────┴───────┐\n",
    "          ▼               ▼                   ▼               ▼\n",
    "┌────────────────┐ ┌────────────────┐ ┌────────────┐ ┌─────────┐\n",
    "│  Procedural    │ │ Object-Oriented│ │ Functional │ │ Logic   │\n",
    "└───────┬────────┘ └───────┬────────┘ └─────┬──────┘ └────┬────┘\n",
    "        ▼                  ▼                ▼             ▼\n",
    "  C, Basic, Fortran     C++, Java      Haskell, Elm   Prolog, Datalog\n",
    "        \n",
    "```\n",
    "[Programming Paradigms](https://pradeesh-kumar.medium.com/programming-paradigms-66248c83b39a)\n",
    "\n",
    "Popular Functional Programming Languages:\n",
    "- Pure: Haskell, Elm\n",
    "- Hybrid/Multi-paradigm: Scala (dual-first), Kotlin (OO-first), F#, (function-first) Elixir, Clojure.\n",
    "- FP-enabled: JavaScript, Python, Rust, Swift\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "014a0078",
   "metadata": {},
   "source": [
    "Key principles of functional programming:\n",
    "\n",
    "- **Pure functions**: a function always returns the same output for the same input and has no side effects (it doesn't modify external state, e.g., no modifying external state, no I/O, no randomness). For example, `math.sqrt(4)` is pure; `random.random()` is not.\n",
    "- **Immutability**: data is not modified in place; new values are produced instead. For example, instead of modifying a list, you produce a new one. This eliminates an entire class of bugs: shared mutable state is the root cause of most concurrency (multithread) bugs.\n",
    "- **First-class functions**: functions are treated like any other value: they can be assigned to variables, passed as arguments, and returned from other functions.\n",
    "- **Higher-order functions**: functions that accept other functions as arguments or return them (e.g., `map()`, `filter()`, `sorted()`).\n",
    "\n",
    "The FP ideas that have leaked into mainstream programming over the past 20 years: `map`/`filter`/`reduce`, `lambda` expressions, immutable data structures, `list comprehensions`, pipelines; all trace back to functional languages. Even Java and C++ have absorbed them. Understanding FP conceptually makes you better at Python even if you never write a line of Haskell, because you start to recognize when a pure function + transformation pipeline is cleaner than a loop with mutation."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8277fb35",
   "metadata": {},
   "source": [
    "**Benefits of functional programming**:\n",
    "\n",
    "- Easier testing and maintenance: Pure functions depend only on their inputs, simplifying testing.\n",
    "- Reduced side effects: By eliminating hidden state changes, debugging becomes more straightforward.\n",
    "- Easy parallelization: Immutable data means no locks or race conditions, making parallel programming easier.\n",
    "- Improved modularity: Reusable components are easier to build and combine.\n",
    "\n",
    "**Drawbacks of functional programming**:\n",
    "- Steep learning curve: Concepts like monads, immutability, and higher-order functions can be difficult for beginners.\n",
    "- Performance concerns: Immutability can cause high memory usage due to frequent creation of new objects, which may impact performance in resource-constrained systems.\n",
    "- Recursion overhead: Lacking loops, recursive functions can lead to stack overflow if not handled properly. (FP discourages loops for immutability and referential transparency, and uses recursion instead.)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f9380125",
   "metadata": {},
   "source": [
    "Python is not a purely functional language. It is multi-paradigm: you can write imperative code, object-oriented code, and functional-style code in the same program. In practice, Python's functional tools are most useful when they make data transformations clearer, reduce hidden side effects, or let you reuse behavior through functions.\n",
    "\n",
    "These ideas are not just theory. They directly explain why Python features like comprehensions, `map()`, `filter()`, `sorted(key=...)`, decorators, and `functools` are useful.\n",
    "\n",
    "The goal is not to make Python look like a different language. The goal is to write transformations that are easy to test, easy to reason about, and hard to accidentally break with hidden shared state."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "98178000",
   "metadata": {},
   "source": [
    "## Purity & Immutability"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6cc76d6",
   "metadata": {},
   "source": [
    "### Pure functions\n",
    "\n",
    "A **pure function** has two properties:\n",
    "1. **Deterministic**: given the same inputs, it always returns the same output\n",
    "2. **No side effects**: it doesn't modify anything outside itself (e.g., define a variable outside loop then update it through looping)\n",
    "\n",
    "These two properties together mean you can understand a pure function in complete *isolation*. You don't need to know the history of the program, what other functions have run, or what global state looks like. The function is a black box: inputs in, output out, nothing else changes. This makes pure functions:\n",
    "- **Easy to test**: just call the function with inputs and check the output; no setup or teardown needed\n",
    "- **Easy to reason about**: you can read the function body without tracking down every variable it might touch\n",
    "- **Safe to reuse**: calling the function twice won't produce different results or corrupt shared state"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9065e2e3",
   "metadata": {},
   "source": [
    "### Immutability\n",
    "\n",
    "**Immutability** means producing new values instead of modifying existing ones. For example, rather than changing a list in place, you return a new list with the change applied.\n",
    "\n",
    "This matters because Python passes objects *by reference*. When you pass a list to a function and the function calls `.append()` on it, the *original* list outside the function is also changed; this is a common source of bugs that can be hard to track down.\n",
    "\n",
    "The fix is simple: instead of mutating, return something new. `lst + [x]` creates a new list. `{**d, 'key': val}` creates a new dictionary. The original is untouched."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e8c1a16e",
   "metadata": {},
   "source": [
    "An **impure** function mutates the list passed in — the caller's list is silently changed.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "deb49b04",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[1, 2, 3, 0]\n"
     ]
    }
   ],
   "source": [
    "def append_zero_impure(lst):\n",
    "    lst.append(0)               ### modifies the input list, which is a side effect\n",
    "    return lst\n",
    "\n",
    "data = [1, 2, 3]\n",
    "append_zero_impure(data)\n",
    "print(data)                     ### [1, 2, 3, 0]  ← original changed, even though we didn't ask for it\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "55c24dee",
   "metadata": {},
   "source": [
    "A **pure function** returns a new list — the original is untouched.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "295859db",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[1, 2, 3]\n",
      "[1, 2, 3, 0]\n"
     ]
    }
   ],
   "source": [
    "def append_zero_pure(lst):\n",
    "    return lst + [0]\n",
    "\n",
    "data = [1, 2, 3]\n",
    "result = append_zero_pure(data)\n",
    "\n",
    "print(data)           # [1, 2, 3] -> original data unchanged\n",
    "print(result)         # [1, 2, 3, 0]\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "991950f9",
   "metadata": {},
   "source": [
    "The same idea applies to dictionaries. In the following example, we use dictionary unpacking operator **\\*\\*** creates a new dictionary by copying the key-value pairs from the original. The original settings dictionary remains unchanged, and we get a new dictionary with the updated theme.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "b2d7c11a",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "{'theme': 'light', 'font_size': 12}\n",
      "{'theme': 'dark', 'font_size': 12}\n"
     ]
    }
   ],
   "source": [
    "settings = {'theme': 'light', 'font_size': 12}\n",
    "\n",
    "updated_settings = {**settings, 'theme': 'dark'}\n",
    "\n",
    "print(settings)         # {'theme': 'light', 'font_size': 12}   ### settings is unchanged\n",
    "print(updated_settings) # {'theme': 'dark', 'font_size': 12}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6d9c5f6d",
   "metadata": {},
   "source": [
    "In the dictionary example above we see that:\n",
    "\n",
    "- **Original State**: `settings` is defined in memory.\n",
    "- **Unpacking**: `{**settings, ...}` copies the key-value pairs from the original dictionary into a **new** one.\n",
    "- **Overwriting**: By placing `'theme': 'dark'` after the unpacking, you tell Python to use the new value if the key already exists.\n",
    "- **Purity**: The original settings remains untouched, preventing side effects in other parts of your program that might be relying on those original values."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "479bb20f",
   "metadata": {},
   "source": [
    "## Functions as Values\n",
    "\n",
    "In Python, functions are **first-class values**; they can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions.\n",
    "\n",
    "**Lambda expressions** create small anonymous functions in a single line. They are most useful as short key function or callback functions:\n",
    "\n",
    "```python\n",
    "# Syntax: lambda arguments: expression\n",
    "square = lambda x: x ** 2\n",
    "add    = lambda x, y: x + y\n",
    "```\n",
    "\n",
    "Lambdas are commonly used with `map()`, `filter()`, `sorted()`, and `reduce()`: the core **higher-order functions** of functional programming.\n",
    "\n",
    "| Function | Syntax | Description |\n",
    "|---|---|---|\n",
    "| `map()` | `map(function, iterable)` | Applies a function to every item in an iterable |\n",
    "| `filter()` | `filter(function, iterable)` | Keeps only the items that match a condition |\n",
    "| `reduce()` | `reduce(f, seq)` | Repeatedly combines items into a single value by applying a function cumulatively from left to right: f(f(f(f(1,2),3),4),5) |\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "50bb4742",
   "metadata": {},
   "source": [
    "```{admonition} MapReduce\n",
    ":class: dropdown\n",
    "**MapReduce** is a programming model and processing framework used for parallel computing on large data sets across distributed clusters. It works by breaking tasks into a Map phase (splitting and processing data into key/value pairs) and a Reduce phase (aggregating data by key). It is crucial for big data tasks, providing fault tolerance, scalability, and handling large data volumes on commodity hardware.\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3fda4b8d",
   "metadata": {},
   "source": [
    "Here we see some examples of `map()`, `filter()`, and `sorted()` working with lambda. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "6be08f1c",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[1, 4, 9, 16, 25]\n",
      "[2, 4]\n",
      "[('Charlie', 78), ('Alice', 85), ('Bob', 90)]\n"
     ]
    }
   ],
   "source": [
    "# Using lambda with map(): apply a function to each item in a list\n",
    "numbers = [1, 2, 3, 4, 5]\n",
    "squared = list(map(lambda x: x ** 2, numbers))          ### map(function, iterable)\n",
    "print(squared)  # [1, 4, 9, 16, 25]                     ### function applies to each element of numbers\n",
    "                                                        ### nowadays list comprehensions are often preferred for\n",
    "                                                        ### readability, but map with lambda can be concise for simple transformations\n",
    "# Using lambda with filter(): decide which items to keep based on a condition\n",
    "even_numbers = list(filter(lambda x: x % 2 == 0, numbers))  ### filter(function, iterable) keeps only \n",
    "print(even_numbers)  # [2, 4]                               ### elements where the function returns True (even numbers in this case)     \n",
    "\n",
    "# Using lambda with sorted(): sort a list of tuples by a specific element\n",
    "students = [('Alice', 85), ('Bob', 90), ('Charlie', 78)]\n",
    "sorted_by_grade = sorted(students, key=lambda student: student[1])  ### key function extracts the grade \n",
    "                                                                    ### (the second element of the tuple) for sorting\n",
    "\n",
    "print(sorted_by_grade)  # [('Charlie', 78), ('Alice', 85), ('Bob', 90)]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5ae1a45",
   "metadata": {},
   "source": [
    "With `reduce()`, you have to import `reduce` from `functools` first. When accumulator is not initialized, it takes the first element from the iterable to begin with."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4dc18f72",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "15\n",
      "120\n",
      "15\n",
      "5\n"
     ]
    }
   ],
   "source": [
    "from functools import reduce\n",
    "\n",
    "numbers = [1, 2, 3, 4, 5]\n",
    "\n",
    "# reduce(f, seq) applies f cumulatively: f(f(f(f(1,2),3),4),5)\n",
    "total = reduce(lambda acc, x: acc + x, numbers) ### acc == accumulator, x == current element\n",
    "print(total)   # 15\n",
    "\n",
    "product = reduce(lambda acc, x: acc * x, numbers)   # acc starts at 1 and multiplies each number in the list\n",
    "print(product) # 120"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "53c780b6",
   "metadata": {},
   "source": [
    "Modern Python often prefers clearer built-ins alternatives for common cases like:\n",
    "- `sum()`          # prefer over reduce for addition\n",
    "- `max()`          # prefer over reduce for maximum\n",
    "- `loops`\n",
    "- `comprehensions`\n",
    "\n",
    "Because `reduce()` can become harder to read for complex logic. You would reach for `reduce()` only when there is genuinely no built-in equivalent. Custom accumulations are where `reduce()` starts to earn its place."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dc0202cc",
   "metadata": {},
   "source": [
    "## Higher-Order Functions\n",
    "\n",
    "A **higher-order function** is a function that either accepts a function as an argument or returns a function as its result. Python's built-in `map()`, `filter()`, and `sorted()` are all higher-order functions, but You can write your own higher-order functions:\n",
    "\n",
    "```python\n",
    "def apply_twice(f, x):\n",
    "    return f(f(x))\n",
    "\n",
    "apply_twice(lambda x: x + 3, 10)  # 16\n",
    "```\n",
    "\n",
    "**Decorators** are a Python idiom built entirely on this idea: a decorator is a higher-order function that takes a function and returns an enhanced version of it."
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "id": "793c5539-e20d-470f-b47d-8d6a1632f01f",
   "metadata": {},
   "source": [
    "### Decorators\n",
    "\n",
    "A **decorator** is a higher-order function; it takes a function as input and returns a modified version. The `@decorator_name` syntax is Python's shorthand for `func = decorator(func)`, making it easy to add cross-cutting behavior (logging, timing, validation) without changing the original function's code.\n",
    "\n",
    "**How decorators work:**\n",
    "1. They take a function as input\n",
    "2. Wrap it in a new function that adds behavior\n",
    "3. Return the wrapper, applied automatically with `@decorator_name`"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ee8d5baf-f618-4ac6-b569-a0cd10505810",
   "metadata": {},
   "source": [
    "### Built-in Decorators\n",
    "\n",
    "- `@property`: Creates getter/setter methods\n",
    "- `@staticmethod`: Method doesn't need self or cls (covered in ch12)\n",
    "- `@classmethod`: Method receives class as first argument (covered in ch12)\n",
    "\n",
    "`@functools.wraps` is a helper used inside custom decorators. It preserves the wrapped function's name, docstring, and other metadata."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e0ca1d1",
   "metadata": {},
   "source": [
    "Let's take a look at `@property` decorator, which we have used when creating access to internal/private attribute.\n",
    "\n",
    "```python\n",
    "class BankAccount:\n",
    "    def __init__(self):\n",
    "        self._balance = 100\n",
    "\n",
    "    @property\n",
    "    def balance(self):\n",
    "        \"\"\"Current balance in dollars (read-only).\"\"\"\n",
    "        return self._balance\n",
    "```\n",
    "\n",
    "Here at first glance, the `@property` decorator helps us so that we don't have to type parentheses `()` when using it as an API for accessing the attribute. \n",
    "\n",
    "```python\n",
    "acct = BankAccount()\n",
    "print(acct.balance)\n",
    "```\n",
    "But the bigger idea is that it turns a method into a *managed attribute*, which means you can:\n",
    "\n",
    "- compute values dynamically\n",
    "- validate data\n",
    "- make attributes read-only\n",
    "- keep internal implementation hidden\n",
    "\n",
    "For example, you may:\n",
    "\n",
    "```python\n",
    "class Circle:\n",
    "    def __init__(self, radius):\n",
    "        self.radius = radius\n",
    "\n",
    "    @property\n",
    "    def area(self):\n",
    "        return 3.14 * self.radius ** 2\n",
    "```\n",
    "then,\n",
    "```\n",
    "c = Circle(5)\n",
    "print(c.area)\n",
    "```\n",
    "So now you know the `@property` works more than passing data."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d01c7226",
   "metadata": {},
   "source": [
    "Now, to see an example of how a basic decorator can be built and used."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "233f2bd2",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Something before the function\n",
      "Hello!\n",
      "Something after the function\n"
     ]
    }
   ],
   "source": [
    "# Basic decorator example\n",
    "from functools import wraps\n",
    "\n",
    "def my_decorator(func): ### the wrapper function is defined inside the decorator and takes the original function as an argument. It adds some behavior before and after calling the original function, and then returns the wrapper.\n",
    "    @wraps(func)        ### replaces the wrapper's metadata with the original function's metadata, so that the decorated function retains its name and docstring\n",
    "    def wrapper():\n",
    "        print(\"Something before the function\")\n",
    "        result = func() ### calls the original function and stores its result\n",
    "        print(\"Something after the function\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "@my_decorator\n",
    "def say_hello():\n",
    "    print(\"Hello!\")\n",
    "\n",
    "say_hello()\n",
    "# Output:\n",
    "# Something before the function\n",
    "# Hello!\n",
    "# Something after the function"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4b24e275",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Call #1 of say_hi\n",
      "Hi there!\n",
      "Call #2 of say_hi\n",
      "Hi there!\n",
      "Call #3 of say_hi\n",
      "Hi there!\n"
     ]
    }
   ],
   "source": [
    "# Class-based decorator\n",
    "class CountCalls:\n",
    "    def __init__(self, func):\n",
    "        self.func = func\n",
    "        self.count = 0\n",
    "    ### __call__ makes an instance of the class behave like a function — it's invoked whenever you call the object with ().\n",
    "    def __call__(self, *args, **kwargs):\n",
    "        self.count += 1\n",
    "        print(f\"Call #{self.count} of {self.func.__name__}\")\n",
    "        return self.func(*args, **kwargs)\n",
    "\n",
    "@CountCalls     ### say_hi = CountCalls(say_hi)  \n",
    "def say_hi():   ### # say_hi is now a CountCalls instance\n",
    "    print(\"Hi there!\")\n",
    "\n",
    "say_hi()        ### triggers __call__\n",
    "say_hi()\n",
    "say_hi()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e83a2f69",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "slow_function took 1.0050 seconds\n",
      "Done!\n"
     ]
    }
   ],
   "source": [
    "# Decorator for functions with arguments\n",
    "from functools import wraps\n",
    "\n",
    "def timer_decorator(func):      \n",
    "    from time import perf_counter\n",
    "    @wraps(func)    ### preserves decorated function's metadata (like name and docstring) in the wrapper, so that the decorated function retains its identity\n",
    "    def wrapper(*args, **kwargs):   ### the wrapper function takes the original function as an argument. It adds some behavior before and after calling the original function, and then returns the wrapper.\n",
    "        start_time = perf_counter()\n",
    "        result = func(*args, **kwargs)\n",
    "        end_time = perf_counter()\n",
    "        print(f\"{func.__name__} took {end_time - start_time:.4f} seconds\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "@timer_decorator        ### apply decorator timer_decorator function to decorated slow_function()\n",
    "def slow_function():    ### is passed to timer_decorator as the argument func, and the wrapper function is returned and replaces slow_function\n",
    "    import time\n",
    "    time.sleep(1)\n",
    "    return \"Done!\"\n",
    "\n",
    "result = slow_function()\n",
    "print(result)"
   ]
  },
  {
   "attachments": {},
   "cell_type": "markdown",
   "id": "b60a322b-5451-4b56-a234-7fa0fec95155",
   "metadata": {},
   "source": [
    "## Comprehensions\n",
    "\n",
    "Comprehensions provide a concise way to create lists, dictionaries, and sets. They're more Pythonic and often more efficient than traditional loops.\n",
    "\n",
    "**Advantages of comprehensions:**\n",
    "- More concise and readable\n",
    "- Often faster execution\n",
    "- More Pythonic\n",
    "- Can be used in-line\n",
    "\n",
    "**When to use traditional loops:**\n",
    "- Complex logic that doesn't fit in one line\n",
    "- Multiple operations per iteration\n",
    "- Need to break or continue based on conditions\n",
    "- Debugging complex transformations"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "33f29ce6",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Traditional: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]\n",
      "Comprehension: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]\n",
      "Even squares: [0, 4, 16, 36, 64]\n",
      "Word lengths: {'apple': 5, 'banana': 6, 'cherry': 6}\n",
      "Unique remainders mod 4: {0, 1, 2, 3}\n"
     ]
    }
   ],
   "source": [
    "# Basic list comprehension syntax: [expression for item in iterable if condition]\n",
    "\n",
    "# Traditional loop approach\n",
    "squares = []\n",
    "for x in range(10):\n",
    "    squares.append(x ** 2)\n",
    "print(\"Traditional:\", squares)\n",
    "\n",
    "# List comprehension — same result, one line\n",
    "squares = [x ** 2 for x in range(10)]\n",
    "print(\"Comprehension:\", squares)\n",
    "\n",
    "# With a filter condition\n",
    "even_squares = [x ** 2 for x in range(10) if x % 2 == 0]\n",
    "print(\"Even squares:\", even_squares)\n",
    "\n",
    "# Dictionary comprehension\n",
    "word_lengths = {word: len(word) for word in [\"apple\", \"banana\", \"cherry\"]}\n",
    "print(\"Word lengths:\", word_lengths)\n",
    "\n",
    "# Set comprehension (automatically deduplicates)\n",
    "unique_remainders = {x % 4 for x in range(12)}\n",
    "print(\"Unique remainders mod 4:\", unique_remainders)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "ff6e4576",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Absolute values: [5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5]\n",
      "Divisible by 2 and 3: [0, 6, 12, 18]\n",
      "Grade book: {'Alice': {'Math': 0, 'Science': 0, 'English': 0}, 'Bob': {'Math': 0, 'Science': 0, 'English': 0}, 'Charlie': {'Math': 0, 'Science': 0, 'English': 0}}\n"
     ]
    }
   ],
   "source": [
    "# Advanced comprehension techniques\n",
    "\n",
    "# Conditional expression (ternary operator) in comprehension\n",
    "numbers = range(-5, 6)\n",
    "absolute_or_zero = [x if x >= 0 else -x for x in numbers]\n",
    "print(\"Absolute values:\", absolute_or_zero)\n",
    "\n",
    "# Multiple conditions\n",
    "filtered_numbers = [x for x in range(20) if x % 2 == 0 if x % 3 == 0]\n",
    "print(\"Divisible by 2 and 3:\", filtered_numbers)\n",
    "\n",
    "# Nested dictionary comprehension\n",
    "students = ['Alice', 'Bob', 'Charlie']\n",
    "subjects = ['Math', 'Science', 'English']\n",
    "grades = {student: {subject: 0 for subject in subjects} for student in students}\n",
    "print(\"Grade book:\", grades)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11f5de95",
   "metadata": {},
   "source": [
    "## Choosing a Style\n",
    "\n",
    "Both `comprehension` and `map`/`filter` approaches transform or filter sequences, but they communicate intent differently.\n",
    "\n",
    "| Situation | Prefer |\n",
    "|---|---|\n",
    "| Simple transformation (`x * 2`, `s.upper()`) | Comprehension: reads like English |\n",
    "| Complex multi-argument function already defined | `map(fn, seq)`: no need to rewrap in a lambda |\n",
    "| Transforming *and* filtering in one pass | Comprehension: `[f(x) for x in seq if cond(x)]` is cleaner |\n",
    "| Chaining multiple transformations | `map`/`filter`: function composition can stay readable |\n",
    "| Result must be lazy / memory-sensitive | `map`/`filter`: they return iterators, not lists |\n",
    "\n",
    "**Readability rule of thumb**: if the lambda inside `map` or `filter` is longer than ~30 characters, replace it with a named function or a comprehension.\n",
    "\n",
    "```python\n",
    "# Hard to read — lambda is too complex\n",
    "result = list(filter(lambda s: len(s) > 3 and s.startswith('a'), words))\n",
    "\n",
    "# Clearer as a comprehension\n",
    "result = [s for s in words if len(s) > 3 and s.startswith('a')]\n",
    "```\n",
    "\n",
    "**Pipeline readability check**: a functional pipeline of 2–3 steps is fine. For longer chains, break steps into named intermediate variables so the intent stays obvious."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "32e4389e",
   "metadata": {
    "tags": [
     "thebe-interactive"
    ]
   },
   "outputs": [],
   "source": [
    "### Exercise: Sorting and Filtering with Lambda\n",
    "#   Given the student records below (name, grade, year):\n",
    "#   1. Use filter() with a lambda to keep students with grade >= 80.\n",
    "#   2. Use sorted() with a lambda to rank passing students by grade, highest first.\n",
    "#   3. Use map() with a lambda to extract just the names.\n",
    "#   4. Print the final list of names.\n",
    "### Your code starts here.\n",
    "students = [\n",
    "    ('Alice', 85, 'junior'), ('Bob', 72, 'senior'),\n",
    "    ('Charlie', 91, 'sophomore'), ('Diana', 65, 'junior'), ('Eve', 88, 'senior')\n",
    "]\n",
    "\n",
    "### Your code ends here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "40a86c47",
   "metadata": {
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "['Charlie', 'Eve', 'Alice']\n"
     ]
    }
   ],
   "source": [
    "### Solution\n",
    "students = [\n",
    "    ('Alice', 85, 'junior'), ('Bob', 72, 'senior'),\n",
    "    ('Charlie', 91, 'sophomore'), ('Diana', 65, 'junior'), ('Eve', 88, 'senior')\n",
    "]\n",
    "passing = list(filter(lambda s: s[1] >= 80, students))\n",
    "ranked  = sorted(passing, key=lambda s: s[1], reverse=True)\n",
    "names   = list(map(lambda s: s[0], ranked))\n",
    "print(names)  # ['Charlie', 'Eve', 'Alice']"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "704e19df",
   "metadata": {
    "language": "markdown"
   },
   "source": [
    "## Summary\n",
    "\n",
    "- **Pure functions** always return the same output for the same input and have no side effects — they are easy to test and reason about.\n",
    "- **Immutability** means producing new values instead of mutating existing ones, avoiding hidden bugs from shared state.\n",
    "- **First-class functions** let you pass and return functions like any other value.\n",
    "- **Lambda expressions** create short anonymous functions for use with `map()`, `filter()`, and `sorted()`.\n",
    "- **Decorators** are higher-order functions that wrap and extend behavior without modifying the original function.\n",
    "- **Comprehensions** offer a concise, readable syntax for transforming and filtering sequences.\n",
    "- Choose comprehensions for readability; prefer `map`/`filter` when working with lazy iterators or pre-defined functions.\n",
    "\n",
    "Next: recursion, context managers, and `functools` utilities."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv (3.13.7)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
