Following the Learn Go with Tests guide, revisiting arrays and slices with Go’s generics system.
This post builds upon previous exploration of arrays and slices in Go.
Original Implementation #
Basic implementations of Sum and SumAllTails functions:
// Sum calculates the total from a slice of numbers.
func Sum(numbers []int) int {
var sum int
for _, number := range numbers {
sum += number
}
return sum
}// SumAllTails calculates the sums of all but the first number
// given a collection of slices.
func SumAllTails(numbersToSum ...[]int) []int {
var sums []int
for _, numbers := range numbersToSum {
if len(numbers) == 0 {
sums = append(sums, 0)
} else {
tail := numbers[1:]
sums = append(sums, Sum(tail))
}
}
return sums
}Reduce Pattern #
Looking at the Sum function, might notice a recurring pattern:
- Create an initial result value (e.g.,
var sum int) - Iterate over the collection, applying an operation to the result and each item
- Return the accumulated result
This pattern is known as reduce (or fold) in functional programming.
Reduce Implementation #
Functional Programming in Go: Reduce/fold originates from functional languages. Go now supports these patterns through:
- Generics (v1.18+) for type-safe abstractions
- First-class functions - functions can be passed as arguments, returned as values, and stored in variables
Implementation of generic Reduce function:
func Reduce[A any](collection []A, f func(A, A) A, initialValue A) A {
var result = initialValue
// Start with the initial value
for _, x := range collection {
result = f(result, x)
// Apply combining function
}
return result
// Return final accumulated result
}Now refactor Sum function to use Reduce
func Sum(numbers []int) int {
add := func(acc, x int) int {
return acc + x
}
return Reduce(numbers, add, 0)
}And update SumAllTails
func SumAllTails(numbersToSum ...[]int) []int {
sumTail := func(acc, x []int) []int {
if len(x) == 0 {
acc = append(acc, 0)
} else {
tail := x[1:]
acc = append(acc, Sum(tail))
}
return acc
}
return Reduce(numbersToSum, sumTail, []int{})
}Testing Reduce #
Test with different data types and operations.
Testing with multiplication:
func TestReduce(t *testing.T) {
t.Run("multiplication of all elements", func(t *testing.T) {
multiply := func(x, y int) int {
return x * y
}
AssertEqual(t, Reduce([]int{1, 2, 3}, multiply, 1), 6)
})Testing with string concatenation:
t.Run("concatenate strings", func(t *testing.T) {
concatenate := func(x, y string) string {
return x + y
}
result := Reduce([]string{"a", "b", "c"}, concatenate, "")
AssertEqual(t, result, "abc")
})
}Reducing to Different Types #
Sometimes we need to reduce a collection into a different type than the collection elements.
Example test
func TestBadBank(t *testing.T) {
transactions := []Transaction{
{
From: "Chris",
To: "Riya",
Sum: 100,
},
{
From: "Adil",
To: "Chris",
Sum: 25,
},
}
AssertEqual(t, BalanceFor(transactions, "Riya"), 100)
AssertEqual(t, BalanceFor(transactions, "Chris"), -75)
AssertEqual(t, BalanceFor(transactions, "Adil"), -25)
}Transaction struct:
type Transaction struct {
From string
To string
Sum float64
}The traditional approach to calculate balances:
func BalanceFor(transactions []Transaction, name string) float64 {
var balance float64
for _, t := range transactions {
if t.From == name {
balance -= t.Sum
}
if t.To == name {
balance += t.Sum
}
}
return balance
}Enhanced Reduce #
Current Reduce function is limited because it requires the accumulator and collection elements to be the same type. If we tried to use the original Reduce function for our banking example, we’d get a compile error:
./bank.go:32:30: in call to Reduce, type func(currentBalance float64, t Transaction) float64 of adjustBalance does not match inferred type func(Transaction, Transaction) Transaction for func(A, A) A
Need a more flexible version:
func Reduce_v2[A, B any](
// A = collection element type
// B = result type
collection []A,
f func(B, A) B,
initialValue B,
) B {
var result = initialValue
for _, x := range collection {
result = f(result, x)
}
return result
}Two generic types (A, B) allow the accumulator type to differ from the collection element type. This enables reducing:
[]Transactionintofloat64[]stringintoint- etc.
Now can implement BalanceFor using reduce
func BalanceForReduce(transactions []Transaction, name string) float64 {
adjustBalance := func(
currentBalance float64,
t Transaction,
) float64 {
if t.From == name {
return currentBalance - t.Sum
}
if t.To == name {
return currentBalance + t.Sum
}
return currentBalance
}
return Reduce_v2(transactions, adjustBalance, 0.0)
}