Golang Project Structure

Tips and tricks for writing and structuring Go code

Functional Programming With Slices

Language

  • unknown

by

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.

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.

Rusty metal gears turning on a piece of machinery.
Each function should interact with other functions in simple, specified ways, just as these cogs touch only at a few points.

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 splash of clean and pure water.
When water is pure, it is safe to drink. Likewise, when writing computer code, pure functions are often safer to think about than other types of functions.

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.

Leave a Reply

Your email address will not be published.