I'll Be Backend
Principles · Swift

Imperative to declarative: loops into pipelines

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

13 steps5 chapters~5 min at 1.25×#declarative code#collection pipeline#streams#loops
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Swift video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–8

Payoff

Steps 9–10

Swift bonus

Step 11

Recap

Steps 12–13

The code: before and after

Before

struct Order {
    let customer: String
    let status: String
    let total: Int
}

struct SalesReport {
    func shippedRevenue(_ orders: [Order]) -> Int {
        var sum = 0
        for o in orders {
            if o.status == "SHIPPED" {
                sum += o.total
            }
        }
        return sum
    }

    func topCustomers(_ orders: [Order]) -> [String] {
        var shipped: [Order] = []
        for o in orders {
            if o.status == "SHIPPED" {
                shipped.append(o)
            }
        }
        shipped.sort { $0.total > $1.total }
        var names: [String] = []
        for o in shipped {
            if names.count == 3 {
                break
            }
            if !names.contains(o.customer) {
                names.append(o.customer)
            }
        }
        return names
    }
}

func dailyDigest(_ report: SalesReport) {
    let orders = [
        Order(customer: "ana@shop.com", status: "SHIPPED", total: 120),
        Order(customer: "qa@test.shop", status: "SHIPPED", total: 999),
        Order(customer: "bo@shop.com", status: "PENDING", total: 80),
        Order(customer: "cy@shop.com", status: "SHIPPED", total: 45),
        Order(customer: "ana@shop.com", status: "SHIPPED", total: 60),
    ]
    print(report.shippedRevenue(orders))
    print(report.topCustomers(orders))
}

After

struct Order {
    let customer: String
    let status: String
    let total: Int
}

struct SalesReport {
    func shippedRevenue(_ orders: [Order]) -> Int {
        orders
            .filter(countsAsSale)
            .map(\.total)
            .reduce(0, +)
    }

    func topCustomers(_ orders: [Order]) -> [String] {
        var seen: Set<String> = []
        let names = orders
            .filter(countsAsSale)
            .sorted { $0.total > $1.total }
            .map(\.customer)
            .filter { seen.insert($0).inserted }
            .prefix(3)
        return Array(names)
    }
}

func countsAsSale(_ order: Order) -> Bool {
    order.status == "SHIPPED" && !order.customer.hasSuffix("@test.shop")
}

func dailyDigest(_ report: SalesReport) {
    let orders = [
        Order(customer: "ana@shop.com", status: "SHIPPED", total: 120),
        Order(customer: "qa@test.shop", status: "SHIPPED", total: 999),
        Order(customer: "bo@shop.com", status: "PENDING", total: 80),
        Order(customer: "cy@shop.com", status: "SHIPPED", total: 45),
        Order(customer: "ana@shop.com", status: "SHIPPED", total: 60),
    ]
    print(report.shippedRevenue(orders))
    print(report.topCustomers(orders))
}

Transcript, step by step

Problem

Step 1: A sales report in loops Before

This sales report works on an array of orders, and each order has a customer email, a status and a total. Shipped revenue walks the array and adds the total of every shipped order to a running sum. Top customers appends the shipped orders to a temporary array, sorts it by total, and walks it again to collect three different customers. The daily digest builds today's orders and prints both results. 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 result. 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 array 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 sum here, the temporary shipped array here, and the break and the contains 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 calls where each one has one job, and add the rule after that.

Refactor

Step 5: Shipped revenue as a pipeline Replace Loop with Pipeline

Shipped revenue now hands the orders to a chain of three calls. Filter keeps the shipped ones, map takes each total, and reduce adds them up. Swift arrays have no sum method, so reduce starting at zero with plus is the usual way to write one. The running sum is gone, because reduce keeps that total for you, and the digest still prints twelve twenty-four.

Step 6: Filter, then sorted Replace Loop with Pipeline

In top customers, filter keeps the shipped orders, and sorted ranks them by total with the biggest first. The temporary array and its append are gone, and shipped is now a constant you can't change by accident. 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, so names comes straight out of the chain. Map takes each order's customer, the next filter drops the repeats, and prefix three keeps the first three names. The standard library has no call that drops repeats and keeps the order, so this filter holds state in a seen set, and insert reports inserted only the first time it meets a name. So the output doesn't change, because the filter keeps a name the first time, as the contains check did, and prefix three does what the break did. Prefix hands back a slice of the array, and Array turns it into the list the method returns.

Step 8: Name the sale rule Extract Function

The status test now sits in two closures, so we extract it into one function called counts as sale. Both filters take it by name with no braces, first in shipped revenue and then in top customers. Now the diagram shows both methods 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 method 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 sum became reduce here, the hand-filled array became filter and sorted here, and the break and the contains check became the seen filter and prefix three here. Top customers now gives each job its own line, filter, sort, drop repeats and keep three, so you can see at a glance that a new sale rule belongs in the filter. Giving that rule one home for both methods came from Extract Function, and the pipelines made the spot easy to find.

Swift bonus

Step 11: Key paths for map Swift bonus

Two map calls took a closure whose only job was to read one property of the order, first the total and then the customer. Since Swift five point two, a key path like backslash total can stand in for that closure, because Swift turns it into a function from an order to its total. The sort keeps its closure, because it compares two orders, and a key path only reads from one.

Recap

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

On the left, shipped revenue keeps a running sum, and top customers fills an array by hand, checks with contains, and breaks at three. On the right, each method is a chain of calls, 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 13: 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. Spot it by a temporary array filled in a loop, a running total, or a break that stops early. Every Swift sequence already has these calls, and with lazy in front, prefix stops pulling orders once it has three.