Imperative to declarative: loops into pipelines
Say what you want from a list, and let the pipeline do the walking.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–8Payoff
Steps 9–10Recap
Steps 11–12The code: before and after
Before
import (
"cmp"
"fmt"
"slices"
)
type Order struct {
Customer string
Status string
Total int
}
type SalesReport struct{}
func (SalesReport) ShippedRevenue(orders []Order) int {
sum := 0
for _, o := range orders {
if o.Status == "SHIPPED" {
sum += o.Total
}
}
return sum
}
func (SalesReport) TopCustomers(orders []Order) []string {
var shipped []Order
for _, o := range orders {
if o.Status == "SHIPPED" {
shipped = append(shipped, o)
}
}
slices.SortFunc(shipped, func(a, b Order) int {
return cmp.Compare(b.Total, a.Total)
})
var names []string
for _, o := range shipped {
if len(names) == 3 {
break
}
if !slices.Contains(names, o.Customer) {
names = append(names, o.Customer)
}
}
return names
}
func DailyDigest(report SalesReport) {
orders := []Order{
{"ana@shop.com", "SHIPPED", 120},
{"qa@test.shop", "SHIPPED", 999},
{"bo@shop.com", "PENDING", 80},
{"cy@shop.com", "SHIPPED", 45},
{"ana@shop.com", "SHIPPED", 60},
}
fmt.Println(report.ShippedRevenue(orders))
fmt.Println(report.TopCustomers(orders))
}After
import (
"cmp"
"fmt"
"slices"
"strings"
)
type Order struct {
Customer string
Status string
Total int
}
type SalesReport struct{}
func (SalesReport) ShippedRevenue(orders []Order) int {
return sumTotals(sales(orders))
}
func (SalesReport) TopCustomers(orders []Order) []string {
ranked := sales(orders)
slices.SortFunc(ranked, func(a, b Order) int {
return cmp.Compare(b.Total, a.Total)
})
names := uniqueCustomers(ranked)
return names[:min(3, len(names))]
}
func sales(orders []Order) []Order {
var out []Order
for _, o := range orders {
if o.Status == "SHIPPED" &&
!strings.HasSuffix(o.Customer, "@test.shop") {
out = append(out, o)
}
}
return out
}
func sumTotals(orders []Order) int {
sum := 0
for _, o := range orders {
sum += o.Total
}
return sum
}
func uniqueCustomers(orders []Order) []string {
var names []string
for _, o := range orders {
if !slices.Contains(names, o.Customer) {
names = append(names, o.Customer)
}
}
return names
}
func DailyDigest(report SalesReport) {
orders := []Order{
{"ana@shop.com", "SHIPPED", 120},
{"qa@test.shop", "SHIPPED", 999},
{"bo@shop.com", "PENDING", 80},
{"cy@shop.com", "SHIPPED", 45},
{"ana@shop.com", "SHIPPED", 60},
}
fmt.Println(report.ShippedRevenue(orders))
fmt.Println(report.TopCustomers(orders))
}Transcript, step by step
Problem
Step 1: A sales report in loops Before
This sales report works on a slice of orders, and each order has a customer email, a status and a total. Shipped Revenue walks the orders and adds the total of every shipped one to a running sum. Top Customers copies the shipped orders into a slice, sorts them by total, and walks them again to collect three different customers. 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 sum here, the temporary shipped slice 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 name each job Plan
Let's back that change out. Declarative code says what you want, and a collection pipeline is a chain of steps where each step has one job. Go's standard library has no filter or map, so here each job moves out of the loops into a small function named for what it does, and the rule comes after that.
Refactor
Step 5: Name the filter Extract Function
The loop in Shipped Revenue did two jobs, so the first one, keeping the shipped orders, moves into a function called sales. Sales walks the orders and returns only the ones that count as a sale, which today means shipped. Shipped Revenue's loop now only adds totals, and it walks the result of sales, so the digest still prints twelve twenty-four.
Step 6: Name the total Extract Function
The loop that's left moves into sum totals, a function with one job. Shipped Revenue is now one line that says what it wants, the sum of the totals of the sales. The running sum still exists inside sum totals, because Go has no built-in sum for a slice of orders, but you no longer read it in the middle of the report.
Step 7: Reuse the filter Replace Inline Code with Function Call
The first loop in Top Customers did the same job as sales, so it now calls sales and sorts what comes back. The status test lives in one place now, and both methods ask sales which orders count. Nobody fills a temporary slice by hand in Top Customers anymore, and the second loop reads ranked, so the output stays the same.
Step 8: Unique names, first three Extract Function
The second loop moves into unique customers, which keeps each customer's email the first time it shows up. Top Customers then keeps the first three names with a slice expression, and the built-in min stops it from running past the end. The output doesn't change, because unique customers keeps a name the first time it sees it, as the contains check did, and the slice to three keeps the same names the break did.
Payoff
Step 9: Test orders, one edit Payoff
Now Finance's rule goes in again, and this time it's one edit inside sales. Neither method changed, because both of them ask sales 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 sum totals here, the temporary slice became the result of sales here, and the break and the contains check became unique customers and a slice to three here. Top Customers is now four jobs on four lines, so you can see at a glance that a new sale rule belongs in sales. The loops still run inside the small functions, because that's how Go walks a slice, and each of them has one job and a name that says it. The single home for the rule comes from Extract Function, and the one-job steps made that home easy to find.
Recap
Step 11: Where we started, where we ended Before / after
On the left, Shipped Revenue keeps a running sum, and Top Customers fills a temporary slice, checks contains, and breaks at three. On the right, each method is a short chain of named steps, and each line in it does one job. And sales 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 it 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. You'll spot it by a temporary slice filled in a loop, a running total, or a break or flag that stops early. Go's newer iterators work this way, because Keys from the maps package yields each key and Sorted from the slices package collects them in order, with no loop for you to write.