In his free time, the author of the material advises on Go and parses the code. Naturally, in the course of such activities, he reads a lot of code written by other people. Recently, the author of this article had the impression (it’s an impression, no statistics) that programmers began to work more often with interfaces in the “Java style”.
This post contains recommendations from the author of the material on the optimal use of interfaces in Go, based on his experience in writing code.')
In the examples of this post, we will use two packages of
animal
and
circus
. Many things in this post describe working with code that borders on regular use of packages.
How not to do
A very common phenomenon that I observe:
package animals type Animal interface { Speaks() string }
package circus import "animals" func Perform(a animal.Animal) string { return a.Speaks() }
This is the so-called use of Java-style interfaces. It can be characterized by the following steps:
- Define an interface.
- Define one type that satisfies the interface behavior.
- Define methods that satisfy the interface implementation.
In summary, we are dealing with "writing types that satisfy the interfaces." This code has its own distinct
smell , suggesting the following thoughts:
- The interface satisfies only one type, without any intention of expanding it further.
- Functions usually take specific types instead of interface ones.
How to do instead
The interfaces in Go encourage a lazy approach, and that's good. Instead of writing types that satisfy interfaces, you should write interfaces that meet real practical requirements.
What is meant: instead of defining
Animal
in the package of
animals
, define it at the point of use, that is, the
circus
* package.
package animals type Dog struct{} func (a Dog) Speaks() string { return "woof" }
package circus type Speaker interface { Speaks() string } func Perform(a Speaker) string { return a.Speaks() }
A more natural way to do this is this:
- Define types
- Define the interface at the point of use.
This approach reduces dependence on the components of the
animals
package. Reducing dependencies is the right way to create fault-tolerant software.
Postel law
There is one good principle for writing good software. This is the
Postel law , which is often formulated as follows:
“Treat conservatively what you refer and, liberally, what you accept”
In terms of Go, the law is:
“Accept interfaces, return structures”
In general, this is a very good rule for designing fault-tolerant, stable things
* . The main unit of code in Go is the function. When designing functions and methods, it is useful to adhere to the following pattern:
func funcName(a INTERFACETYPE) CONCRETETYPE
Here we accept everything that implements the interface, which can be anything, including empty. Given the value of a particular type. Of course, the limitation of what can be
a
has its meaning. As one go-proverb says:
"The empty interface says nothing," - Rob Pike
Therefore, it is highly desirable to prevent the
interface{}
taking over.
Application Example: Imitation
A striking example of the benefits of applying Postel’s law is testing cases. Suppose you have a function that looks like this:
func Takes(db Database) error
If the
Database
is an interface, then in the test code you can simply provide a simulation of the implementation of the
Database
without the need to transfer a real DB object.
When the definition of an interface is acceptable in advance
To be honest, programming is a fairly free way to express ideas. There are no immutable rules. Of course, you can always define interfaces in advance, without fear of being arrested by the police code. In the context of multiple packages, if you know your functions and are going to accept a specific interface within a package, then do so.
The definition of an interface usually smacks of over-engineering, but there are situations in which you obviously should do this. In particular, the following examples come to mind:
- Sealed Interfaces
- Abstract data types
- Recursive interfaces
Next, a brief look at each of them.
Sealed Interfaces
Sealed interfaces can only be discussed in the context of multiple packages. A sealed interface is an interface with unexported methods. This means that users outside this package cannot create types that satisfy this interface. This is useful for emulating a variant type in order to exhaustively search for interface-matching types.
If you define something like this:
type Fooer interface { Foo() sealed() }
Only the package that
Fooer
defined can use it and create something valuable from it. This allows you to create brute force switch operators for types.
The sealed interface also allows analysis tools to easily pick up any non-bulkhead pattern matches.
BurntSushi sumtypes package is aimed at solving this problem.
Abstract data types
Another case of defining an interface in advance is related to the creation of abstract data types. They can be either sealed or unsealed.
A good example of this case is the
sort
package included in the standard library. It defines the collection to be sorted as follows.
type Interface interface {
This code fragment has upset a lot of people, because if you want to use the
sort
package, you will have to implement methods for the interface. Many people dislike the need to add three extra lines of code.
However, I think this is a very elegant form of generics in Go. Its use should be encouraged more often.
Alternative and at the same time elegant design options will require higher order types. In this post we will not consider them.
Recursive interfaces
This is probably another example of the code with the smell, but there are cases when it is simply impossible to avoid using it. Simple manipulations allow you to get something like
type Fooer interface { Foo() Fooer }
The recursive interface pattern will obviously require its definition in advance. The recommendation for defining the interface at the point of use is not applicable here.
This pattern is useful for creating contexts and then working on them. The code loaded by the context usually encloses itself inside a package with exporting only contexts (ala
tensor package), so in practice I do not encounter this case as often. I can tell you something else about contextual patterns, but I will leave it for another post.
Conclusion
Despite the fact that one of the headlines of the post reads "How not to do it," I am in no way trying to forbid anything. Rather, I want to make sure that readers often think about border conditions, since it is in such cases that various abnormal situations arise.
I find the ad principle at the point of use extremely useful. As a result of its application in practice, I do not encounter problems that arise in case of neglect of them.
Nevertheless, I also sometimes inadvertently write Java-style interfaces. As a rule, this happens if shortly before this I wrote a lot of Java or Python code. The desire for excessive complexity and the “presentation of everything as classes” is sometimes very strong, especially if you write Go code after writing a lot of object-oriented code.
Thus, this post also serves as a reminder to yourself about what the path to writing code looks like, which will not later cause a headache. Waiting for your comments!
