In this project you’re going to get familiar with interfaces in Go.
Timebox: 2 days
Objectives:
- Describe when interfaces are useful
- Write tests in Go
- Implement existing interfaces on your own struct
Project
Read this blog post about interfaces in Go.
Interfaces are used to build abstractions. Abstractions in programming are when we can just think about what something does, and not how it does it.
For instance, when programming we never really think about how memory works, we just focus on the fact that we can store values.
Interfaces allow us to write code which relies on other types having some behaviour, regardless of how it has that behaviour. For instance, we can write code which relies on being able to read some bytes, without needing to worry about whether those bytes came from a file, a response to an HTTP request, or some variable we stored earlier in our code.
Two common interfaces in Go are io.Reader
and io.Writer
. We can write a function which accepts an io.Reader
as a parameter, and that function can know it can read bytes from that parameter, without worrying about where they’re coming from.
In this exercise, we’re going to implement two structs in Go, and have them implement some interfaces.
Make a new Go program, and we’ll begin:
Testing bytes.Buffer
The Go standard library has a type called bytes.Buffer
. Have a read of its documentation.
A bytes.Buffer
allows you store some bytes, and retrieve them later. You can do this in a number of ways:
- You can store some bytes when you create one, by using
bytes.NewBufferString
. - You can add bytes to the buffer by using
bytes.Buffer.Write
- this is the function on theio.Writer
interface, sobytes.Buffer
implementesio.Writer
. - You can read all of the bytes in the buffer using
bytes.Buffer.Bytes()
. - You can read some bytes from the buffer into a slice by using
bytes.Buffer.Read
- this is the function on theio.Reader
interface, sobytes.Buffer
implementsio.Reader
. There are also other ways of accessing and manipulating data inbytes.Buffer
, but we won’t worry about them for now.
The first task is to implement unit tests for this type.
You should write unit tests which show at least the following (you can write more if you want!):
- If you make a buffer named
b
containing some bytes, callingb.Bytes()
returns the same bytes you created it with. - If you write some extra bytes to that buffer using
b.Write()
, a call tob.Bytes()
returns both the initial bytes and the extra bytes. - If you call
b.Read()
with a slice big enough to read all of the bytes in the buffer, all of the bytes are read. - If you call
b.Read()
with a slice smaller than the contents of the buffer, some of the bytes are read. If you call it again, the next bytes are read.
Implementing our own bytes.Buffer
Now that we have some tests for the behaviour of this struct, let’s implement our own copy.
Make a new struct, we’ll call it OurByteBuffer
.
Change the tests we have to refer to our type instead of bytes.Buffer
.
Implement functions on OurByteBuffer
so that all the tests pass.
(Hint: We need to implement our functions on pointers to OurByteBuffer
, not to copies of it).
Limitations
When implementing a type we’re going to use in different places, it’s important to think about the limitations of our type.
Some examples of limitations for OurByteBuffer
may be:
- Is there a maximum about of data an
OurByteBuffer
can store? - What operations are safe or unsafe to perform concurrently on an
OurByteBuffer
from different threads? - Are there any important performance characteristics? e.g. is it much faster or slower to
Write
one large amount of data to it than write the same amount of data one byte at a time? - Are there any important memory characteristics? e.g. does an
OurByteBuffer
always retain all data that was stored in it, or does it free some of its memory after it’s been read?
It can be useful to note these limitations in a doc comment for the type, or method, they apply to. This will help someone using your type to use it the best way. Make sure you write these docs.
When you need to pick (or implement) a type which implements an interface, it’s important to think about these limitations.
Implementing a custom filter
Implement a new struct called FilteringPipe
.
When it’s constructed, it should be constructed with an io.Writer
which we’ll store as a field in the struct.
Implement io.Writer
for FilteringPipe
, which will write whatever is written to it to the io.Writer
it was constructed with, except it should skip any numbers.
So if we call:
filteringPipe := NewFilteringPipe(someWriter)
filteringPipe.Write([]byte("start=1, end=10"))
we’ll end up writing start=, end=
to someWriter
.
Make sure to write some tests for this type, too. These should probably be table-driven tests.
Thinking about trade-offs
When you’re finished, have a read of the sample implementation in the impl/interfaces
branch. Read through the IMPLEMENTATION.md file. It talks a lot about two different implementations of the tests for this project. Think about the trade-offs involved in both approaches.