Revisiting arrays and slices with generics
The code for this chapter is a continuation from Arrays and Slices, found here
Take a look at both SumAll
and SumAllTails
that we wrote in arrays and slices. If you don't have your version please copy the code from the arrays and slices chapter along with the tests.
Do you see a recurring pattern?
Create some kind of "initial" result value.
Iterate over the collection, applying some kind of operation (or function) to the result and the next item in the slice, setting a new value for the result
Return the result.
This idea is commonly talked about in functional programming circles, often times called 'reduce' or fold.
In functional programming, fold (also termed reduce, accumulate, aggregate, compress, or inject) refers to a family of higher-order functions that analyze a recursive data structure and through use of a given combining operation, recombine the results of recursively processing its constituent parts, building up a return value. Typically, a fold is presented with a combining function, a top node of a data structure, and possibly some default values to be used under certain conditions. The fold then proceeds to combine elements of the data structure's hierarchy, using the function in a systematic way.
Go has always had higher-order functions, and as of version 1.18 it also has generics, so it is now possible to define some of these functions discussed in our wider field. There's no point burying your head in the sand, this is a very common abstraction outside the Go ecosystem and it'll be beneficial to understand it.
Now, I know some of you are probably cringing at this.
Go is supposed to be simple
Don't conflate easiness, with simplicity. Doing loops and copy-pasting code is easy, but it's not necessarily simple. For more on simple vs easy, watch Rich Hickey's masterpiece of a talk - Simple Made Easy.
Don't conflate unfamiliarity, with complexity. Fold/reduce may initially sound scary and computer-sciencey but all it really is, is an abstraction over a very common operation. Taking a collection, and combining it into one item. When you step back, you'll realise you probably do this a lot.
A generic refactor
A mistake people often make with shiny new language features is they start by using them without having a concrete use-case. They rely on conjecture and guesswork to guide their efforts.
Thankfully we've written our "useful" functions and have tests around them, so now we are free to experiment with ideas in the refactoring stage of TDD and know that whatever we're trying, has a verification of its value via our unit tests.
Using generics as a tool for simplifying code via the refactoring step is far more likely to guide you to useful improvements, rather than premature abstractions.
We are safe to try things out, re-run our tests, if we like the change we can commit. If not, just revert the change. This freedom to experiment is one of the truly huge values of TDD.
You should be familiar with the generics syntax from the previous chapter, try and write your own Reduce
function and use it inside Sum
and SumAllTails
.
Hints
If you think about the arguments to your function first, it'll give you a very small set of valid solutions
The array you want to reduce
Some kind of combining function
"Reduce" is an incredibly well documented pattern, there's no need to re-invent the wheel. Read the wiki, in particular the lists section, it should prompt you for another argument you'll need.
In practice, it is convenient and natural to have an initial value
My first-pass of Reduce
Reduce
Reduce captures the essence of the pattern, it's a function that takes a collection, an accumulating function, an initial value, and returns a single value. There's no messy distractions around concrete types.
If you understand generics syntax, you should have no problem understanding what this function does. By using the recognised term Reduce
, programmers from other languages understand the intent too.
The usage
Sum
and SumAllTails
now describe the behaviour of their computations as the functions declared on their first lines respectively. The act of running the computation on the collection is abstracted away in Reduce
.
Further applications of reduce
Using tests we can play around with our reduce function to see how re-usable it is. I have copied over our generic assertion functions from the previous chapter.
The zero value
In the multiplication example, we show the reason for having a default value as an argument to Reduce
. If we relied on Go's default value of 0 for int
, we'd multiply our initial value by 0, and then the following ones, so you'd only ever get 0. By setting it to 1, the first element in the slice will stay the same, and the rest will multiply by the next elements.
If you wish to sound clever with your nerd friends, you'd call this The Identity Element.
In mathematics, an identity element, or neutral element, of a binary operation operating on a set is an element of the set which leaves unchanged every element of the set when the operation is applied.
In addition, the identity element is 0.
1 + 0 = 1
With multiplication, it is 1.
1 * 1 = 1
What if we wish to reduce into a different type from A
?
A
?Suppose we had a list of transactions Transaction
and we wanted a function that would take them, plus a name to figure out their bank balance.
Let's follow the TDD process.
Write the test first
Try to run the test
Write the minimal amount of code for the test to run and check the failing test output
We don't have our types or functions yet, add them to make the test run.
When you run the test you should see the following:
Write enough code to make it pass
Let's write the code as if we didn't have a Reduce
function first.
Refactor
At this point, have some source control discipline and commit your work. We have working software, ready to challenge Monzo, Barclays, et al.
Now our work is committed, we are free to play around with it, and try some different ideas out in the refactoring phase. To be fair, the code we have isn't exactly bad, but for the sake of this exercise, I want to demonstrate the same code using Reduce
.
But this won't compile.
The reason is we're trying to reduce to a different type than the type of the collection. This sounds scary, but actually just requires us to adjust the type signature of Reduce
to make it work. We won't have to change the function body, and we won't have to change any of our existing callers.
We've added a second type constraint which has allowed us to loosen the constraints on Reduce
. This allows people to Reduce
from a collection of A
into a B
. In our case from Transaction
to float64
.
This makes Reduce
more general-purpose and reusable, and still type-safe. If you try and run the tests again they should compile, and pass.
Extending the bank
For fun, I wanted to improve the ergonomics of the bank code. I've omitted the TDD process for brevity.
And here's the updated code
I feel this really shows the power of using concepts like Reduce
. The NewBalanceFor
feels more declarative, describing what happens, rather than how. Often when we're reading code, we're darting through lots of files, and we're trying to understand what is happening, rather than how, and this style of code facilitates this well.
If I wish to dig in to the detail I can, and I can see the business logic of applyTransaction
without worrying about loops and mutating state; Reduce
takes care of that separately.
Fold/reduce are pretty universal
The possibilities are endless™️ with Reduce
(or Fold
). It's a common pattern for a reason, it's not just for arithmetic or string concatenation. Try a few other applications.
Why not mix some
color.RGBA
into a single colour?Total up the number of votes in a poll, or items in a shopping basket.
More or less anything involving processing a list.
Find
Now that Go has generics, combining them with higher-order-functions, we can reduce a lot of boilerplate code within our projects, to help our systems be easier to understand and manage.
No longer do you need to write specific Find
functions for each type of collection you want to search, instead re-use or write a Find
function. If you understood the Reduce
function above, writing a Find
function will be trivial.
Here's a test
And here's the implementation
Again, because it takes a generic type, we can re-use it in many ways
As you can see, this code is flawless.
Wrapping up
When done tastefully, higher-order functions like these will make your code simpler to read and maintain, but remember the rule of thumb:
Use the TDD process to drive out real, specific behaviour that you actually need, in the refactoring stage you then might discover some useful abstractions to help tidy the code up.
Practice combining TDD with good source control habits. Commit your work when your test is passing, before trying to refactor. This way if you make a mess, you can easily get yourself back to your working state.
Names matter
Make an effort to do some research outside of Go, so you don't re-invent patterns that already exist with an already established name.
Writing a function takes a collection of A
and converts them to B
? Don't call it Convert
, that's Map
. Using the "proper" name for these items will reduce the cognitive burden for others and make it more search engine friendly to learn more.
This doesn't feel idiomatic?
Try to have an open-mind.
Whilst the idioms of Go won't, and shouldn't radically change due to generics being released, the idioms will change - due to the language changing! This should not be a controversial point.
Saying
This is not idiomatic
Without any more detail, is not an actionable, or useful thing to say. Especially when discussing new language features.
Discuss with your colleagues patterns and style of code based on their merits rather than dogma. So long as you have well-designed tests, you'll always be able to refactor and shift things as you understand what works well for you, and your team.
Resources
Fold is a real fundamental in computer science. Here's some interesting resources if you wish to dig more into it
Last updated