I'll Be Backend
Principles · Python

Imperative to declarative: loops into pipelines

Say what you want from a list, and let the pipeline do the walking.

12 steps4 chapters~5 min at 1.25×#declarative code#collection pipeline#generator expressions#loops
Language
JavaPython+10 soon
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–8

Payoff

Steps 9–10

Recap

Steps 11–12

The code: before and after

Before

@dataclass(frozen=True)
class Order:
    customer: str
    status: str
    total: int


def shipped_revenue(orders: list[Order]) -> int:
    revenue = 0
    for order in orders:
        if order.status == "SHIPPED":
            revenue += order.total
    return revenue


def top_customers(orders: list[Order]) -> list[str]:
    shipped: list[Order] = []
    for order in orders:
        if order.status == "SHIPPED":
            shipped.append(order)
    shipped.sort(key=lambda o: o.total, reverse=True)
    names: list[str] = []
    for order in shipped:
        if len(names) == 3:
            break
        if order.customer not in names:
            names.append(order.customer)
    return names


def daily_digest() -> None:
    orders = [
        Order("ana@shop.com", "SHIPPED", 120),
        Order("qa@test.shop", "SHIPPED", 999),
        Order("bo@shop.com", "PENDING", 80),
        Order("cy@shop.com", "SHIPPED", 45),
        Order("ana@shop.com", "SHIPPED", 60),
    ]
    print(shipped_revenue(orders))
    print(top_customers(orders))

After

@dataclass(frozen=True)
class Order:
    customer: str
    status: str
    total: int


def _counts_as_sale(order: Order) -> bool:
    return (order.status == "SHIPPED"
            and not order.customer.endswith("@test.shop"))


def shipped_revenue(orders: list[Order]) -> int:
    return sum(o.total for o in orders if _counts_as_sale(o))


def top_customers(orders: list[Order]) -> list[str]:
    shipped = sorted(
        (o for o in orders if _counts_as_sale(o)),
        key=lambda o: o.total,
        reverse=True,
    )
    customers = (o.customer for o in shipped)
    return list(dict.fromkeys(customers))[:3]


def daily_digest() -> None:
    orders = [
        Order("ana@shop.com", "SHIPPED", 120),
        Order("qa@test.shop", "SHIPPED", 999),
        Order("bo@shop.com", "PENDING", 80),
        Order("cy@shop.com", "SHIPPED", 45),
        Order("ana@shop.com", "SHIPPED", 60),
    ]
    print(shipped_revenue(orders))
    print(top_customers(orders))

Transcript, step by step

Problem

Step 1: A sales report in loops Before

This sales report works on a list of orders, and each order has a customer email, a status and a total. Shipped revenue walks the list and adds the total of every shipped order to a running total. Top customers copies the shipped orders into a list, sorts them by total, and walks them again to collect three different customers. The daily digest builds today's orders and prints both numbers. One of those orders comes from a test account at test dot shop, and it's the biggest order of the day.

Step 2: Leave out test orders Code smell

Finance says test orders, from customers at test dot shop, must not count in either number. So the check goes into the if in shipped revenue, and into the if in the first loop of top customers. This code is imperative, because it spells out how to walk the list step by step, so you have to replay the loop in your head to learn what it's for. You can see it in the running total here, the temporary shipped list here, and the break and the membership check here.

Step 3: The rule, twice Code smell

The rule for what counts as a sale is now written twice, so the next rule has to be found and added twice too. And in top customers, the filter, the ranking, the repeat check and the stop at three are tangled across two loops. So you replay both loops line by line to see which one decides what counts as a sale, and where a new condition belongs.

Step 4: Back it out, then pipelines Plan

Let's back that change out. Declarative code says what you want, so we'll turn each loop into a collection pipeline, a chain of operations where each one has one job, and add the rule after that.

Refactor

Step 5: Shipped revenue as one sum Replace Loop with Pipeline

Shipped revenue is now one call to sum over a generator expression. The if clause keeps the shipped orders, the front part takes each order's total, and sum adds them up. The running total is gone, because sum keeps that total for you, and the digest still prints twelve twenty-four.

Step 6: Filter and sort in one call Replace Loop with Pipeline

In top customers, the first loop and the sort become one call to sorted. A generator expression keeps the shipped orders, the key ranks them by total, and reverse puts the biggest first. Nobody appends to a temporary list by hand anymore, and the second loop still reads from shipped, so the output stays the same.

Step 7: The second loop joins Replace Loop with Pipeline

Now the second loop joins the same pipeline. A generator expression takes each order's customer, dict from keys drops the repeats, and the slice keeps the first three. The output doesn't change, because a dict keeps each key where it first arrived, as the membership check did, and the slice keeps three names, as the break did.

Step 8: Name the sale rule Extract Function

The status test now sits in two generator expressions, so we extract it into one function called counts as sale. Both pipelines call it by name, first in shipped revenue and then in top customers. Now the diagram shows both functions asking one place what a sale is.

Payoff

Step 9: Test orders, one edit Payoff

Now Finance's rule goes in again, and this time it's one edit inside counts as sale. Neither pipeline changed, because both of them ask counts as sale and don't care what it checks. So the digest now prints two twenty-five, with Ana and Cy as the top customers.

Step 10: Why it was easy Payoff

The running total became sum here, the temporary list became sorted here, and the break and the membership check became dict from keys and a slice here. Top customers now gives each job its own line, so you can see at a glance that a new sale rule belongs in the if clause. Giving that rule one home for both pipelines came from Extract Function, and the pipelines made the spot easy to find.

Recap

Step 11: Where we started, where we ended Before / after

On the left, shipped revenue keeps a running total, and top customers fills a temporary list, checks membership, and breaks at three. On the right, each function is one pipeline, and each line in it does one job. And counts as sale holds the rule for both of them, so Finance's next rule is one edit too.

Step 12: When to reach for it Takeaways

Reach for a pipeline when a loop filters, transforms or totals a collection, and you want each job readable on its own line. Skip it when each step depends on the one before, like a running balance that stops at the first overdraft, because the loop says that more plainly than a reduce. You'll spot it by a temporary list filled in a loop, a running total, or a break or flag that stops early. Python's any and all already work this way, because they take a generator and stop at the first answer, the way a flag and a break do.