Method set interfaces interfaces provide means to describe and implement useful, prescribed, and suggested behaviors.
I’ll refer to “method set interfaces” simply as “interfaces” throughout this blog post.
Among the primary reasons to export an interface type: 1) to provide a simple type that is integral to commonly-used packages (fmt.Stringer); 2) to provide a commonly-used building block (io.Reader, io.Writer, etc.), 3) to provide prescribed behaviors (sort.Interface, Twirp exported service interfaces, imported interfaces), 4) to provide suggested behaviors (logrus.StdLogger, logrus.FieldLogger, logrus.Ext1FieldLogger).
Among the most widely used and discussed types are the fmt.Stringer interface, and io family of interfaces (io.Reader, io.Writer, etc.)
In addition to understanding the abstract behavior of commonly implemented types, it’s illuminating to dig into how they are used in the Go Project in order to uncover how these interfaces are used.
Any type that implements a String() method implements the fmt.Stringer interface. obj.String() returns the description of the value. Give an object a String() method so we can allow the fmt’s print functions to operate as intended.
fmt/print.go
1// Stringer is implemented by any value that has a String method,
2// which defines the “native” format for that value.
3// The String method is used to print values passed as an operand
4// to any format that accepts a string or to an unformatted printer
5// such as Print.
6type Stringer interface {
7 String() string
8}
Here the fmt package checks the value of a fmt.pp, which stores a fmt.printer’s state. If the pp is a Stringer, this section returns the string representation of the value; if the pp is an error, it returns the string representation of the error:
fmt/print.go
1func (p *pp) handleMethods(verb rune) (handled bool) {
2...
3 // If a string is acceptable according to the format, see if
4 // the value satisfies one of the string-valued interfaces.
5 // Println etc. set verb to %v, which is "stringable".
6 switch verb {
7 case 'v', 's', 'x', 'X', 'q':
8 // Is it an error or Stringer?
9 // The duplication in the bodies is necessary:
10 // setting handled and deferring catchPanic
11 // must happen before calling the method.
12 switch v := p.arg.(type) {
13 case error:
14 handled = true
15 defer p.catchPanic(p.arg, verb, "Error")
16 p.fmtString(v.Error(), verb)
17 return
18
19 case Stringer:
20 handled = true
21 defer p.catchPanic(p.arg, verb, "String")
22 p.fmtString(v.String(), verb)
23 return
24 }
25 }
In other words, where I see a Stringer, I can be sure it is probably intended to be used with fmt, or a logger, or in some other similar way.
An embedded io.Reader / io.Writer indicates that reading or writing is delegated to the underlying io object, nearly always through the use of a resuseable buffer. I can’t think of a situation, aside from mocking, where something other than a reusable buffer would make any sense.
For example, with io.Writer, the idea is that where we write to a file, connection, or some other io, we use one reusable buffer so that there is the smallest possibel memory usage, and only one allocation.
1type Writer interface {
2 Write(p []byte) (n int, err error)
3}
Where I see an embedded io.Writer, without knowing exactly what the underlying object is, I know it behaves the same as an io.Writer that writes to a file, connection, etc.
Create an object that implements the Len, Swap, and Less methods of sort.Interface, and then you can pass that object to sort.Sort(), and that object will be sorted. While it’s true that there are built-in methods that allow for sorting of primitives (sort.Ints, sort.Float64s, sort.Strings), these methods each implement sort.Interface.
In other words, if you want to sort your collection, implement sort.Interface.
1type Interface interface {
2 // Len is the number of elements in the collection.
3 Len() int
4
5 // Less reports whether the element with index i
6 // must sort before the element with index j.
7 //
8 // If both Less(i, j) and Less(j, i) are false,
9 // then the elements at index i and j are considered equal.
10 // Sort may place equal elements in any order in the final result,
11 // while Stable preserves the original input order of equal elements.
12 //
13 // Less must describe a transitive ordering:
14 // - if both Less(i, j) and Less(j, k) are true, then Less(i, k) must be true as well.
15 // - if both Less(i, j) and Less(j, k) are false, then Less(i, k) must be false as well.
16 //
17 // Note that floating-point comparison (the < operator on float32 or float64 values)
18 // is not a transitive ordering when not-a-number (NaN) values are involved.
19 // See Float64Slice.Less for a correct implementation for floating-point values.
20 Less(i, j int) bool
21
22 // Swap swaps the elements with indexes i and j.
23 Swap(i, j int)
24}
(The sort package also provides other capabilities, like binary search, checking sortedness of slices, and more.)
Twirp provides exported types in the same way as the sort package. Twirp is an RPC framework developed by Twitch, where service interfaces, defined in .proto files, are the basis for defining HTTP clients and servers that communicate using both JSON and Protobuf messages. Create protobuf messages and services, and Twirp’s generator will define an exported interface type you must implement. See my Twirp post for a detailed explanation.
The story is the same as the sort package: If you want an HTTP protobuf / JSON server, create an object that implements your Twirp service’s exported interface.
Logrus provides three exported interfaces: StdLogger, FieldLogger, and Ext1FieldLogger:
logrus.go
1// StdLogger is what your logrus-enabled library should take, that way
2// it'll accept a stdlib logger and a logrus logger. There's no standard
3// interface, this is the closest we get, unfortunately.
4type StdLogger interface {
5 Print(...interface{})
6 Printf(string, ...interface{})
7 Println(...interface{})
8
9 Fatal(...interface{})
10 Fatalf(string, ...interface{})
11 Fatalln(...interface{})
12
13 Panic(...interface{})
14 Panicf(string, ...interface{})
15 Panicln(...interface{})
16}
17
18// The FieldLogger interface generalizes the Entry and Logger types
19type FieldLogger interface {
20 WithField(key string, value interface{}) *Entry
21 WithFields(fields Fields) *Entry
22 WithError(err error) *Entry
23
24 Debugf(format string, args ...interface{})
25 Infof(format string, args ...interface{})
26 Printf(format string, args ...interface{})
27 Warnf(format string, args ...interface{})
28 Warningf(format string, args ...interface{})
29 Errorf(format string, args ...interface{})
30 Fatalf(format string, args ...interface{})
31 Panicf(format string, args ...interface{})
32
33 Debug(args ...interface{})
34 Info(args ...interface{})
35 Print(args ...interface{})
36 Warn(args ...interface{})
37 Warning(args ...interface{})
38 Error(args ...interface{})
39 Fatal(args ...interface{})
40 Panic(args ...interface{})
41
42 Debugln(args ...interface{})
43 Infoln(args ...interface{})
44 Println(args ...interface{})
45 Warnln(args ...interface{})
46 Warningln(args ...interface{})
47 Errorln(args ...interface{})
48 Fatalln(args ...interface{})
49 Panicln(args ...interface{})
50
51 // IsDebugEnabled() bool
52 // IsInfoEnabled() bool
53 // IsWarnEnabled() bool
54 // IsErrorEnabled() bool
55 // IsFatalEnabled() bool
56 // IsPanicEnabled() bool
57}
58
59// Ext1FieldLogger (the first extension to FieldLogger) is superfluous, it is
60// here for consistancy. Do not use. Use Logger or Entry instead.
61type Ext1FieldLogger interface {
62 FieldLogger
63 Tracef(format string, args ...interface{})
64 Trace(args ...interface{})
65 Traceln(args ...interface{})
66}
Logrus’s story is, “A StdLogger provides this behavior, a FieldLogger provides that behavior, and a Ext1FieldLogger provides other behavior”. Each is a different type, designed to be used in distinct ways.
The exported interfaces in the sort package and in a Twirp service require that we implement the exported interface in order to be able to use those packages at all. This is not the case with Logrus. We can define the behavior we want by choosing only some of the exported functions from Logrus, as we’ll see below. This is why I’m calling Logrus’s exported interfaces “Suggested Types”.
In addition to providing useful behaviors, interfaces can provide encapsulation.
Suppose we have a package, and that package has a base object “server”, and we want our server to have a logger.
We could use a logrus.StdLogger:
1type server struct {
2 logger logrus.StdLogger
3}
4
5func NewServer() server {
6 l := logrus.New()
7 return server {
8 logger: l,
9 }
10}
We could also define our own interface MyLogger, and embed a logrus.FieldLogger inside it, thereby giving us the option to later swap out logrus.StdLogger with some other package that has an identical set of methods:
1type myLogger interface {
2 logrus.StdLogger
3}
4
5type server struct {
6 logger myLogger
7}
8
9func NewServer() server {
10 l := logrus.New()
11 return server{
12 logger: l,
13 }
14}
We can also define our own type, based on only some of Logrus’s exported methods:
1type errorLogger interface {
2 Errorf(format string, args ...interface{})
3}
4
5type server struct {
6 logger errorLogger
7}
8
9func NewServer() server {
10 l := logrus.New()
11 return server{
12 logger: l,
13 }
14}
In all three cases what we’re saying is, “This is the desired behavior or our logger”. What we don’t want to do with our logger is pass in a logrus.Logger to our server, then allow the use of any logrus.Logger method to be added at anytime throughout the life of our application. Interfaces provide a way for us to prescribe the behavior we want from our server’s logger.
Through the use of what I’m calling “Imported types”, we’re 1) defining the behavior we want our package to have, and 2) providing the ability to swap out Logrus for some other object that implements the same method set. Test mocks come immediately to mind.