Introduction to Interfaces in Go
In the last post, we looked at how to solve the use case from Chapter 1 of my Essential F# ebook using my limited knowledge of Go. In this post, we will use my new understanding of interfaces to solve the same problem.
Getting Started⌗
Open the folder you created in the last post in VS Code.
Add two new files: customer2.go and customer2_test.go to the folder.
Add the following code to the top of customer2.go;
package customer
The customer2_test.go file should start with the following code:
package customer_test
import (
"customer"
"testing"
)
We are now ready to start our task but first, a little about interfaces in Go.
Interfaces in Go⌗
You don’t explicitly implement interfaces in Go. If a concrete type implements all of the methods defined in the interface, then it implements the interface. It works more like duck typing in dynamic languages where you maintain decoupling but with the addition of type safety.
If like me, you’ve come from a statically typed language like F#, C#, or Java, this will be very different from what you are used to. My initial thoughts on interfaces in Go are unprintable but I’ve come to appreciate what the language designers were trying to achieve.
Initial Version⌗
The first thing to do is to create an interface for our required behaviour:
type CustomerDiscount interface {
Calculate(spend float64) float64
}
Interfaces in Go are about behaviour, not data
Next, we’ll add three structs for the Customer states:
type Eligible struct {
Name string
}
type Registered struct {
Name string
}
type Guest struct {
Name string
}
To implement the interface, we need to create methods for each of these structs that match the Calculate
method:
func (c Eligible) Calculate(spend float64) float64 {
if spend >= 100.0 {
return spend * 0.9
}
return spend
}
func (c Registered) Calculate(spend float64) float64 {
return spend
}
func (c Guest) Calculate(spend float64) float64 {
return spend
}
At this stage, we will add the discount calculation logic to each method. We will fix this later in this post.
We now need to add a function that we can call that takes the interface as a parameter and delegates the call to the methods we have just created:
func CalculateDiscount(c CustomerDiscount, spend float64) float64 {
return c.Calculate(spend)
}
We can’t make this a method because that is not supported behaviour for interfaces:
// Invalid receiver - pointer or interface type
func (c CustomerDiscount) CalculateDiscount(spend float64) float64 {
return c.Calculate(spend)
}
The next task is to create tests for our expected behaviour. We can do this in a single test:
func TestCalculateDiscount(t *testing.T) {
t.Parallel()
type testCase struct {
customer customer.CustomerDiscount
spend float64
want float64
}
testCases := []testCase{
{customer: customer.Eligible{Name: "Mary"}, spend: 100.0, want: 90.0},
{customer: customer.Eligible{Name: "John"}, spend: 90.0, want: 90.0},
{customer: customer.Registered{Name: "Richard"}, spend: 100.0, want: 100.0},
{customer: customer.Guest{Name: "Sarah"}, spend: 100.0, want: 100.0},
}
for _, tc := range testCases {
got := customer.CalculateDiscount(tc.customer, tc.spend)
if tc.want != got {
t.Errorf("want %f, got %f", tc.want, got)
}
}
}
We added a struct to store our test cases, a slice to store our test case data, and finally, we loop through the collection of test cases, calling our CalculateDiscount
function for each test case.
Using the range
function, we also have access to the index but we don’t need it, so we mark it as not required with the _
character.
Run the test by typing the following in the Terminal:
go test
Because we are not using the Name property in the tests, we can remove it. The values will be set to the zero value for the string:
func TestCalculateDiscount(t *testing.T) {
t.Parallel()
type testCase struct {
customer customer.CustomerDiscount
spend float64
want float64
}
testCases := []testCase{
{customer: customer.Eligible{}, spend: 100.0, want: 90.0},
{customer: customer.Eligible{}, spend: 90.0, want: 90.0},
{customer: customer.Registered{}, spend: 100.0, want: 100.0},
{customer: customer.Guest{}, spend: 100.0, want: 100.0},
}
for _, tc := range testCases {
got := customer.CalculateDiscount(tc.customer, tc.spend)
if tc.want != got {
t.Errorf("want %f, got %f", tc.want, got)
}
}
}
It’s looking quite nice already but there are a couple of things we will want to improve.
Improvements⌗
There are two things that need to be improved at this stage: centralising the logic into a single function and limiting the visibility of things outside the package.
Centralise Logic⌗
Rather than having the logic spread across three structs, we should move it all to a new function and call that from each method:
func calculateDiscount(c CustomerDiscount, spend float64) float64 {
switch c.(type) {
case Eligible:
if spend >= 100.0 {
return spend * 0.9
}
return spend
default:
return spend
}
}
The function name starts with a lower case letter which means that it is not visible outside this package.
We have used a switch expression with a built-in function that gives us access to the type that implements the CustomerDiscount
interface. This isn’t a closed set like an F# discriminated union, so we could have other concrete implementations of the interface that could be passed in outside of the ones that we have defined.
The next step is to replace the logic in the methods with a call to the new function:
func (c Eligible) Calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Registered) Calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Guest) Calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
Much nicer! All of the logic is now in one function rather than three.
Visibility⌗
We don’t need to expose the methods because we want the caller to use the CalculateDiscount
function. Rename the Calculate
method on the interface to calculate
:
type CustomerDiscount interface {
calculate(spend float64) float64
}
Rename the methods to match this new name:
func (c Eligible) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Registered) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Guest) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
Finally, use the new name in the CalculateDiscount
function:
func CalculateDiscount(c CustomerDiscount, spend float64) float64 {
return c.calculate(spend)
}
Run the tests by typing the following in the Terminal:
go test
This now means that only our types and the CalculateDiscount
function are visible outside the customer
package.
The Finished Code⌗
The final code for customer2.go is:
package customer
type CustomerDiscount interface {
calculate(spend float64) float64
}
type Eligible struct {
Name string
}
type Registered struct {
Name string
}
type Guest struct {
Name string
}
func calculateDiscount(c CustomerDiscount, spend float64) float64 {
switch c.(type) {
case Eligible:
if spend >= 100.0 {
return spend * 0.9
}
return spend
default:
return spend
}
}
func (c Eligible) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Registered) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func (c Guest) calculate(spend float64) float64 {
return calculateDiscount(c, spend)
}
func CalculateDiscount(c CustomerDiscount, spend float64) float64 {
return c.calculate(spend)
}
The final code for customer2_test.go is:
package customer_test
import (
"customer"
"testing"
)
func TestCalculateDiscount(t *testing.T) {
t.Parallel()
type testCase struct {
customer customer.CustomerDiscount
spend float64
want float64
}
testCases := []testCase{
{customer: customer.Eligible{}, spend: 100.0, want: 90.0},
{customer: customer.Eligible{}, spend: 90.0, want: 90.0},
{customer: customer.Registered{}, spend: 100.0, want: 100.0},
{customer: customer.Guest{}, spend: 100.0, want: 100.0},
}
for _, tc := range testCases {
got := customer.CalculateDiscount(tc.customer, tc.spend)
if tc.want != got {
t.Errorf("want %f, got %f", tc.want, got)
}
}
}
And Finally⌗
That’s my second post using Go complete. To my eyes, this post is a much nicer solution to our original problem than the const
approach from the previous post. The Go language is very different from seemingly similar ones like C# and Java. I’m actually enjoying my initial learning but I know that it will get harder when I start to look at pointers and channels.
In the next post, I’ll start looking at collections and generics.