Functional Programming With Slices
Language
- unknown
by James Smith (Golang Project Structure Admin)
This post will explore the programming paradigm known as functional programming and discuss what restrictions it places on programmers, as well as how it helps in the process of developing and structuring code.
We will then go through some practical examples of functional-style code written in Go, specifically looking at how generic functions can be used to work with data in slices.
Table of Contents
What Is Functional Programming?
Functional programming is a paradigm that involves making extensive use of functions, with a particular set of constraints applied, in order to produce elegant software systems.
This paradigm upholds that the function should be the most important level of abstraction that programmers work with and think about.
Relying on small self-contained functions can help to make debugging and testing easier, since each function can be tested in isolation and problems can be fixed wherever they occur.
The software architect Michael Feathers had this to say about functional programming:
[It] makes code understandable by minimizing moving parts.
The functional-programming paradigm is often seen as an alternative to other paradigms such as procedural programming or object-orientated programming (OOP).
However, these coding styles are not mutually exclusive: many real-world programmers will find themselves using all three, whether in combination or by utilizing a particular paradigm to meet the needs of a specific set of circumstances.
What Are Pure Functions?
Functional programming particularly encourages the use of pure functions.
Similar to the way that mathematicians may see certain equations as inherently beautiful, there is elegance and beauty in the simplicity of a well-defined pure function.
A pure function only interacts with other code through its arguments, which are its input, and its return value, which is its output.
In other words, pure functions have no side effects. They do not mutate global state or change what’s displayed on a user’s monitor.
What Is Idempotence?
Pure functions should also produce the same output whenever they are called with the same arguments as input.
Their results are, therefore, more easily predictable than other types of functions.
This quality — a one-to-one relationship between input and output — is called idempotence.
The doubler
function shown in the example code below is idempotent, because it will always return a predictable result based on its input:
package main
import (
"fmt"
)
func doubler(n int) int {
return n * 2
}
func main() {
fmt.Println(doubler(5)) // 10
fmt.Println(doubler(1)) // 2
fmt.Println(doubler(5)) // 10
}
While calling doubler
with a different input argument will produce different output, when it is given the same input, it will always return the same output.
This can be seen above, as calling doubler
with 5
as the argument will always return 10
(since that’s two times five).
On the other hand, the tempWriter
function shown below is not idempotent, because its return value can differ, even when it’s given identical input:
package main
import (
"fmt"
"log"
"os"
)
var tempFileHandle *os.File
func init() {
var err error
tempFileHandle, err = os.CreateTemp("", "example")
if err != nil {
log.Fatalln(err)
}
}
func tempWriter(bytes []byte) int64 {
if _, err := tempFileHandle.Write(bytes); err != nil {
log.Fatalln(err)
}
info, err := tempFileHandle.Stat()
if err != nil {
log.Fatalln(err)
}
return info.Size()
}
func main() {
defer os.Remove(tempFileHandle.Name())
fmt.Println(tempWriter([]byte("test"))) // 4
fmt.Println(tempWriter([]byte("example"))) // 11
fmt.Println(tempWriter([]byte("test"))) // 15
}
In the example above, we first use an init
function to create a temporary file.
Then the tempWriter
function writes data to this file every time that it’s called. The function returns the size of the entire file (as an int64
), which will grow larger every time that more data is written to it.
So when we call tempWriter
with []byte("test")
, that will write four bytes of data to our empty file. The function will thus return 4
.
When we call tempWriter
with []byte("example")
, the file is already four-bytes long and seven more bytes will be appended as part of the function call, so it will return 11
— four plus seven.
Finally, when we call tempWriter
with []byte("test")
again, we will get a different value than when we called the function with this argument initially, because the file already contains eleven bytes of data. So the function will return 15
— eleven plus four.
Since we get different return values on the two times that we call tempWriter
with []byte("test")
as an argument, we know that this function cannot be idempotent. We would, therefore, try to avoid its use, where possible, when writing code that adheres to the functional-programming paradigm.
Minimizing Mutation
Because functional programming discourages side effects, the arguments passed to functions should also be immutable.
This means that if you pass a pointer to a struct as an argument, for example, that struct should only be read via the pointer; its data should not be changed, since this could have unpredictable effects on other code outside the function.
The return value should be the only way of determining the work done inside the function.
This restriction makes it easier to understand how the function works with the data it’s given and how it operates as part of a larger and more complex codebase.
What Is a First-Class Function?
Functional programming often makes use of first-class functions.
A programming language can be is said to have first-class functions if, and only if, functions can be assigned to variables and passed to other functions as arguments.
Since this is the case for Go, we can be confident that our programming language supports first-class functions.
Look at the following example, where we define a function, assign it to a variable and pass it to another function:
package main
import "fmt"
func twoFunc(callback func(n int) int) int {
return callback(2)
}
func main() {
f := func(n int) int {
return n + 1
}
fmt.Println(twoFunc(f))
}
Running this code should output the number three, since our callback function adds one to the argument passed into it, which is two.
Iterating Over a Slice
Creating a function to help us iterate over a slice isn’t exactly an example of functional programming (because there’s no return value, as one would ordinarily expect from a pure function), but doing so will help us to think about the other examples that come later. The function is also useful in its own right.
So let’s start by looking at the following code, which should be fairly simple to understand:
package main
import "fmt"
func ForEach[T any](
slice []T,
callback func(value T, index int),
) {
for i, v := range slice {
callback(v, i)
}
}
func main() {
x := []int{2, 4, 6, 8}
ForEach(x, func(n int, index int) {
fmt.Printf("%d) %d\n", index, n)
})
}
Running the code example above will simply iterate through the slice we define, printing out each index and value, producing the following output:
0) 2
1) 4
2) 6
3) 8
Mapping the Values of a Slice
Now let’s build on that to create a pure function that returns a modified copy of the slice passed as an argument:
package main
import "fmt"
func Map[T any](
slice []T,
callback func(value T, index int) T,
) []T {
mappedSlice := make([]T, len(slice))
for i, v := range slice {
mappedSlice[i] = callback(v, i)
}
return mappedSlice
}
func main() {
x := []string{"top", "log", "pend"}
y := Map(x, func(value string, index int) string {
return "s" + value
})
fmt.Println(y)
}
We first create a new slice, with the same length as the one we’re given as an argument, in order to hold the mapped values, so that we don’t have to modify the values within the original slice.
Then we iterate over the slice, in the same way as we did for our ForEach
function. However, we now set the value at each index of our newly defined slice to equal the return value of our callback function.
The callback function takes the same two arguments as in our previous example, the value and the index currently being processed. However, it now has a return value of type T
, in order to allow the callback to give its updated value.
You can see that Map
is a pure function, even though our ForEach
function wasn’t pure. This is because it takes arguments, doesn’t modify them, and returns a single value that is produced by working with the arguments given.
Making Our Map Function Piggyback on Our Iteration Function
Before we move on, let’s modify our Map
function, so that it calls the ForEach
function we created earlier:
package main
import "fmt"
func ForEach[T any](
slice []T,
callback func(value T, index int),
) {
for i, v := range slice {
callback(v, i)
}
}
func Map[T any](
slice []T,
callback func(value T, index int) T,
) []T {
mappedSlice := make([]T, len(slice))
ForEach(slice, func(value T, index int) {
mappedSlice[index] = callback(value, index)
})
return mappedSlice
}
func main() {
x := []int64{2, 100, 7, 395, -99}
y := Map(x, func(value int64, index int) int64 {
return value * 2
})
fmt.Println(y)
}
This is a neat solution, since it reuses code, building on another function that we already have at our disposal. Even so, it does make our Map
function harder to reason about, since we now also have to understand how the ForEach
function works in order to understand it.
That isn’t a problem in such a simple case as this, but adding unnecessary complexity should not be encouraged.
As Albert Einstein said, “Genius is making complex ideas simple, not making simple ideas complex.” We should apply this principle to programming, too, and only introduce complexity into our code when it is strictly necessary.
The designers of the Go programming language intentionally tried to make the language as simple as possible, in order to help newcomers get to grips with it, and we should be proud to follow their example.
Filtering the Values of a Slice
The next type of function that we’re going to look at will operate on a slice and return a new slice with a subset of the original slice’s values — you can think of this process as resulting in some of the values being filtered out, hence why the function is called Filter
.
Let’s look at the code below:
package main
import "fmt"
func Filter[T any](
slice []T,
callback func(value T, index int) bool,
) []T {
filteredSlice := make([]T, 0, len(slice))
for i, v := range slice {
if callback(v, i) {
filteredSlice = append(filteredSlice, v)
}
}
return filteredSlice
}
func main() {
numbers := []float32{0.12, 0.97, 0.26, 0.83, 0.43, 0.04, 0.92}
filteredNumbers := Filter(numbers, func(value float32, index int) bool {
return value > 0.5
})
fmt.Println(filteredNumbers)
fmt.Printf("Filtered numbers count: %d\n", len(filteredNumbers))
fmt.Printf("Removed numbers count: %d\n", len(numbers)-len(filteredNumbers))
}
You can see that the Filter
function takes very similar arguments to our Map
function, except that its callback function now returns a boolean value.
If this bool
is true
, then the value currently being processed will be kept by being added to the new slice we’ve defined. Otherwise, the value will be filtered away.
So if you run our example code, you should get the following output:
[0.97 0.83 0.92]
Filtered numbers count: 3
Removed numbers count: 4
This shows that four numbers — 0.12
, 0.26
, 0.43
and 0.04
— were removed from our original slice, because they weren’t above 0.5
, which was the predicate test we set in our callback function.
Reducing the Values of a Slice
The last function that we’ll look at is perhaps the most elegant, since it provides a reusable way to reduce multiple values down to a single value:
package main
import "fmt"
func Reduce[T any](
slice []T,
initialValue T,
callback func(previousValue T, currentValue T, currentIndex int) T,
) T {
reducedValue := initialValue
for i, v := range slice {
reducedValue = callback(reducedValue, v, i)
}
return reducedValue
}
func main() {
numbers := []float32{0.12, 0.97, 0.26, 0.83, 0.43, 0.04, 0.92}
sumOfNumbers := Reduce(numbers, 0, func(previousValue, currentValue float32, index int) float32 {
return previousValue + currentValue
})
fmt.Printf("Reduced value: %.2f\n", sumOfNumbers)
}
As you can see, there are three arguments in Reduce
: the novel argument sets the initial value for the variable that will ultimately be returned from the function.
Then this variable is set to the result of the callback function, on every iteration through the slice. It is also passed as an argument to the callback, so it can be used as part of any calculations or other work that need to be done.
If you’re having trouble getting your head around how the Reduce
function works, it will perhaps be instructive to look at the specific example code in the main
function.
We first declare a slice of floating-point numbers. Then we pass this slice to the Reduce
function, in order to find out the sum of all the numbers in the slice added together.
We set the initialValue
argument to 0, because we want to start counting from zero. Then, within our callback, we add the current value to the accumulating value in each iteration of the loop, so that every number in our slice will be added in turn.
Creating Summation and Product Functions
Since adding a slice of numbers together to produce a sum is a fairly common operation, we can build on the Reduce
function to produce another function, called Sum
, that specifically performs this task. We can also create a similar function, called Product
, that multiplies a slice of numbers together to produce a product.
These two functions can be seen in the code below:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
func Reduce[T any](
slice []T,
initialValue T,
callback func(previousValue T, currentValue T, currentIndex int) T,
) T
func Sum[T constraints.Integer | constraints.Float](slice []T) T {
callback := func(previousValue T, currentValue T, currentIndex int) T {
return previousValue + currentValue
}
return Reduce(slice, 0, callback)
}
func Product[T constraints.Integer | constraints.Float](slice []T) T {
callback := func(previousValue T, currentValue T, currentIndex int) T {
return previousValue * currentValue
}
return Reduce(slice, 1, callback)
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
fmt.Printf(
"Sum: %d\nProduct: %d\n",
Sum(numbers),
Product(numbers),
)
}
The two functions that we’ve defined here both call Reduce
in predefined ways. The callback function for Sum
, of course, uses the addition operator, while Product
‘s callback uses the multiplication operator.
The initial value given to the Reduce
function for Sum
is zero, but it is one for Product
, because if we were to multiply values by zero, we would never end up with any value other than zero; therefore, we have to start from one.
Notice also how we have included constraints in the definitions of our two functions, so that they can only be called with a slice of numbers, not a slice containing any other type.
If you run the code example above, you will get the sum (fifty five) and the product (almost four million) of all the integers between one and ten inclusive.
Using Our Functions Together
Let’s tie everything together, by using three of our functions in a combined example:
package main
import (
"fmt"
"math"
)
func Map[T any](
slice []T,
callback func(value T, index int) T,
) []T
func Filter[T any](
slice []T,
callback func(value T, index int) bool,
) []T
func Reduce[T any](
slice []T,
initialValue T,
callback func(previousValue T, currentValue T, currentIndex int) T,
) T
func main() {
numbers := []float64{-0.67, 2.24, -5.46, 9.33, 6.46, 4.02}
sumOfRoundedDoubledPositiveNumbers := int(Reduce(
Map(
Filter(numbers, func(value float64, index int) bool {
return value > 0
}),
func(value float64, index int) float64 {
return value * 2
},
),
0,
func(previousValue, currentValue float64, index int) float64 {
return previousValue + math.Round(currentValue)
},
))
fmt.Printf("Result: %d\n", sumOfRoundedDoubledPositiveNumbers)
}
(I’ve just included the basic function declarations, without bodies, since we’ve already discussed how the three functions we’re using here actually work.)
If you want to understand the code in the main
function, you have to look at the innermost function calls first.
We start by removing all values from our slice that aren’t greater than zero. Then we multiply the remaining values by two. Finally, we sum all the numbers together, rounding each number before we add it (so 4.48
becomes 4
and 18.66
becomes 19
).
If you do the calculations in your head, you should be able to work out the correct answer. You can then check your working by running the code.
Functional Programming In JavaScript
If you’ve written any code for the web, you may already be familiar with the general concepts behind the functions we’ve been writing, because there are versions natively available in JavaScript.
To iterate through a JS array without using an ordinary loop, you can call the forEach
method on your array, passing a callback function similar to the ones that we’ve been using in our Go code.
Likewise, you can call map
, filter
and reduce
methods on a JavaScript array.
There are also some other functional methods included in the JavaScript standard library, which we haven’t looked at here, such as every
(which tests whether all elements in an array pass a test given in a callback function) and some
(which tests whether at least one element passes a test).
Using a Functional-Programming Library in Go
If you want to adopt functional programming but don’t want to have to write all of the fundamental functions yourself, you can import a package that provides them for you.
This is what we do in the example below:
package main
import (
"fmt"
. "github.com/DylanMeeus/hasgo/types"
)
func main() {
oddsFilterCallback := func(i int64) bool {
return i%2 == 1
}
result := IntRange(-100, 100).Abs().Filter(oddsFilterCallback).Sum()
fmt.Println(result) // 5000
}
We start by importing the hasgo
package, which attempts to port many of the functions provided in the Haskell programming language’s standard libraries to Go.
The dot before the import path tells the Go compiler that we want to import all of the package’s exported functions into the current namespace, allowing us to use them without having to prefix the package name.
The IntRange
function returns a slice of integers, in this case from negative one hundred to positive one hundred (so a total of 201 integers, including zero and bounds). This slice is wrapped in a custom type, on which further methods can be called.
Calling the Abs
method will convert all integers in the slice to their absolute value, meaning that negative numbers will become positive (yet positive numbers will remain positive).
The Filter
method is similar to the function that we created ourselves in an earlier example. However, its callback function takes only a single argument, the current value at each iteration through the slice.
We retain only the odd numbers in our slice when we call the Filter
method, using modular arithmetic in our callback function to ensure that all the even numbers are filtered out.
Remember that an odd number divided by two will always have a remainder of one, while an even number would have a remainder of zero, since even numbers can, by definition, always be divided into two.
Finally, we call the Sum
method in order to return a single integer, which, as we saw in an earlier section, contains the result of adding all the slice’s values together.