In a previous blog post I examined how interfaces can be used to abstract away the details of an application's IO creating code that is easier to test and easier to extend. Although interfaces are a very effective tool for abstraction, they are not the only tool available for separating business logic and IO. Higher order functions are another method we can use to separate the details about file systems or databases from our core business logic.
Higher order functions are functions that rely on other functions as either a parameter type or a return type. Functions like map
and filter
in Java, Python and many other languages fit the definition of a higher order function. Both of these functions accept another function as an argument; one that either describes how the elements of a collection should be filtered or how the elements should be transformed.
nums = [1, 2, 3, 4, 5]
# using higher order functions in Python
squared_nums = map(lambda n: n * n, nums)
even_nums = filter(lambda n: n % 2 == 0, nums)
Or if you have used the http
package in Go, you may be familiar with another higher order function, the http.HandleFunc
method. This method takes a function that defines how an http request should be handled for a given route as its second argument.
func handleHomePage(writer http.ResponseWriter, _ *http.Request) {
_, err := fmt.Fprintf(writer, "This is the Home Page")
if err != nil {
log.Fatalln(err)
}
}
func main() {
http.HandleFunc("/", handleHomePage)
log.Fatalln(http.ListenAndServe(":8080", nil))
}
We can illustrate how higher order functions are able to hide IO details using the same example from the previous blog post; an application that calculates an order total from a list of line items. We'll start by writing a version of the CalculateOrderTotal
function that takes a function as an argument rather than a struct that satisfies the OrderProvider
interface.
func CalculateOrderTotal(providerFunc func() []LineItem) float64 {
lineItems := providerFunc()
var orderTotal float64
for _, lineItem := range lineItems {
orderTotal += lineItem.Price
}
return orderTotal
}
When using higher order functions, the type signature of the function is key. The new parameter providerFunc
is a function that takes no arguments and returns an array slice of line items. Any function that matches the type signature func() []LineItem
can be used as an argument for this version of CalculateOrderTotal
.
The implementation details regarding how this function will retrieve and then return a list of line items is kept hidden from CalculateOrderTotal
just as it was before when using the OrderProvider
interface.
To test out this new version of CalculateOrderTotal
, we can define a function that returns a list of line items stored in memory. However instead of just writing a function that returns a predetermined list of line items, we'll create a provider function factory that creates a function with the desired signature func() []LineItem
using a list of line items passed as an argument to the factory function.
func InMemoryLineItemsProviderFuncFactory(lineItems []LineItem) func() []LineItem {
return func() []LineItem {
return lineItems
}
}
This implementation may seem unnecessarily complex, and if all we needed was a static list of line items it certainly would be. But the goal is to keep IO details away from CalculateOrderTotal
and to have the freedom to swap out those details on the fly. This factory approach will allow us to test the new version of CalculateOrderTotal
with different sets of values stored in memory to make sure it still works the same as before.
var testCases = []struct {
lineItems []LineItem
expected float64
}{
{
lineItems: []LineItem{
{Description: "A", Price: 85},
{Description: "B", Price: 15},
},
expected: 100,
},
{
lineItems: []LineItem{
{Description: "A", Price: 35.25},
{Description: "B", Price: 95.5},
},
expected: 130.75,
},
}
func TestHigherOrderFunctionCalculateOrderTotal(t *testing.T) {
for _, test := range testCases {
providerFunc := InMemoryLineItemsProviderFuncFactory(test.lineItems)
if actual := CalculateOrderTotal(providerFunc); actual != test.expected {
t.Fatalf("expected %.2f, actual %.2f", test.expected, actual)
}
}
}
This test suite is nearly identical to the one we used to test the final version of CalculateOrderTotal
in the previous blog post. The line items and expected output are the same however instead of encapsulating the line items with an InMemoryOrderProvider
struct that implements the OrderProvider
interface we are using InMemoryLineItemsProviderFuncFactory
to encapsulate the details in a function.
When running the test suite using the new CalculateOrderTotal
it produces the same results as the previous version. The new implementation required very few changes to our code and still prevented the details of our IO from leaking into CalculateOrderTotal
.
Now lets take a look at how the same function signature can be used to retrieve the line item data from a JSON file.
func FileBasedLineItemsProviderFuncFactory(filepath string) func() []LineItem {
return func() []LineItem {
file, err := ioutil.ReadFile(filepath)
if err != nil {
log.Fatalf("unable to read file %s", filepath)
}
var lineItems []LineItem
err = json.Unmarshal(file, &lineItems)
if err != nil {
log.Fatalf("unable to parse json for file %s", filepath)
}
return lineItems
}
}
The function FileBasedLineItemsProviderFuncFactory
is another provider function factory that returns a function with the signature func() []LineItem
based on the filepath
parameter of the factory function.
func main() {
providerFunc := FileBasedLineItemsProviderFuncFactory("data/order_items.json")
orderTotal := CalculateOrderTotal(providerFunc)
fmt.Printf("Your total comes to %.2f", orderTotal)
}
At this point our higher order function solution is providing more or less the same functionality as the interface solution from the previous blog post. For most programming languages this would be a good place to stop, however Go has a unique language feature that allows us to use function types as receiver arguments for methods opening up an additional means of abstraction using both higher order functions and interfaces together.
A receiver argument determines the type that can invoke a method in Go in the same way that defining a public method in a C# or Java class allows you to invoke the method from an instance of that class type using the receiver.methodName()
syntax.
In Go a receiver can be any type, not just a struct. This allows us to attach a method to a function type and then use that method to satisfy the requirements of an interface.
To start off, we'll need to take our function signature func() []LineItem
and assign it to a named type.
type ProviderFunc func() []LineItem
With the function type defined, we can now create a GetLineItems
method that uses the ProviderFunc
type as a receiver.
// the OrderProvider type from the previous blog post for reference
type OrderProvider interface {
GetLineItems() []LineItem
}
func (f ProviderFunc) GetLineItems() []LineItem {
return f()
}
The GetLineItems
method of the ProviderFunc
type returns the results of calling the function f
. In other words we are able to satisfy the GetLineItems
method of the OrderProvider
interface by having the ProviderFunc
function type invoke itself.
Before we can use our two factory functions to create ProviderFunc
functions that satisfy the OrderProvider
interface, we'll need to change their return type from func() []LineItem
to ProviderFunc
. Nothing needs to be changed in the body of the factories as the functions they return already satisfy the ProviderFunc
type. They just have to be explicitly returned as such.
func InMemoryLineItemsProviderFuncFactory(lineItems []LineItem) ProviderFunc {
// ...function body remains the same as before
}
func FileBasedLineItemsProviderFuncFactory(filepath string) ProviderFunc {
// ...function body remains the same as before
}
Now the functions returned by both factories satisfy the parameter type of the new CalculateOrderTotal
function (a ProviderFunc
can still be used as a func() []LinetItem
type) as well as the previous version of CalculateOrderTotal
with its OrderProvider
parameter type. Whether we use the old or new version of CalculateOrderTotal
we are still able to switch data sources with relative ease, going from in memory to file based data retrieval without having to rewrite either version of CalculateOrderTotal
.
func NewCalculateOrderTotal(providerFunc func() []LineItem) float64 {
lineItems := providerFunc()
var orderTotal float64
for _, lineItem := range lineItems {
orderTotal += lineItem.Price
}
return orderTotal
}
func PrevCalculateOrderTotal(provider OrderProvider) float64 {
lineItems := provider.GetLineItems()
var orderTotal float64
for _, lineItem := range lineItems {
orderTotal += lineItem.Price
}
return orderTotal
}
func main() {
fileBasedProviderFunc := FileBasedLineItemsProviderFuncFactory("data/order_items.json")
// using the file based provider function as a 'func() []LineItem' function type
orderTotal := NewCalculateOrderTotal(fileBasedProviderFunc)
fmt.Printf("Your total comes to %.2f", orderTotal)
// using the file based provider function as an 'OrderProvider' interface type
orderTotal = PrevCalculateOrderTotal(fileBasedProviderFunc)
fmt.Printf("Your total comes to %.2f", orderTotal)
lineItems := []LineItem{
{Description: "Leather Recliner", Price: 2499},
{Description: "End Table", Price: 249},
}
inMemoryProviderFunc := InMemoryLineItemsProviderFuncFactory(lineItems)
// using the in memory provider function as a 'func() []LineItem' function type
orderTotal = NewCalculateOrderTotal(inMemoryProviderFunc)
fmt.Printf("Your total comes to %.2f", orderTotal)
// using the in memory provider function as an 'OrderProvider' interface type
orderTotal = PrevCalculateOrderTotal(inMemoryProviderFunc)
fmt.Printf("Your total comes to %.2f", orderTotal)
}
Determining which method you use β a struct satisfying an interface type, a higher order function, or a function satisfying an interface type β will depend on details about the problems you're trying to solve, the application you're building, and personal preference. It is outside the scope of this blog post to discuss how those factors might impact your design decisions.
Regardless of which method best suites the requirements of your application, creating separation between business logic and IO through these abstractions will make your code resilient to change and easier to test.
All example code from both blog posts on abstracting application IO can be found here.
Top comments (0)