How developers use interfaces in Go, what to keep in mind and how an interface becomes a concrete type.
Interfaces are an important element of statically typed languages for writing functions with different input values. Here I will try to give an overview of how this feature is implemented in Go and how it affects the code. The most important basics and syntax are followed by a more detailed look at the idiomatic use of interfaces. Special attention is given to how dependencies in code can be avoided or how developers can reduce them. After all, this is one of the most important properties of interfaces.
The basics of interfaces: what can they be used for?
The Go programming language is statically typed. Therefore, once a variable is declared, it always preserves its type within its scope. Therefore, a variable of type string can only accept strings. Dynamically typed languages such as JavaScript or Python are more flexible.
However, functions must be able to handle different types. The underlying concept is called polymorphism. It assumes that specific types can be combined into a single type. Go, like other programming languages, uses an interface for this purpose.
type Stringer interface {
String() string
}
func printer(s Stringer) {
fmt.Println(s.String())
}
Interfaces are also a type. In our example, the stringer interface has only one method. This is typical for Go. According to the Interface Segregation Principle, it makes more sense to define many small interfaces than one large one. This approach is in line with the SOLID (I = Interface Segregation Principle) formulated by clean code advocate Robert C. Martin (Uncle Bob). According to this principle, an interface should have as many methods as it needs.
The design of the Go language deliberately supports this approach. For a type to implement an interface, it must have only certain methods, otherwise no additional instructions, such as implements stringers, are required. Implementation checking is taken care of by the compiler.
For small interfaces consisting of one to three methods, a naming convention has been established. It is consistently applied in the standard library. The interface name consists of the method name and the letters "er" at the end. The String() method becomes the Stringer interface, and Read() becomes the Reader interface. The advantage of this convention is obvious: even without documentation, it is clear which methods contain the ReadWriter or ReadWriteCloser interface.
Developers who have already programmed in Go are probably familiar with the stringer interface. It is defined in the fmt package of the standard library. The interface in the example above is a copy of it. This raises the question, does it make sense for us to define interfaces ourselves instead of using ready-made ones? What about the principle "do not repeat yourself?
In this case, the objection is justified. However, for interfaces that are interfaces to different implementations, it is perfectly acceptable for the user to write it himself. This is true even if it leads to duplication in the code. In this way, the code can be designed more independently. There is no need to import the fmt package if we only need to define a small interface.
For interfaces from the standard library, dependency minimization does not matter much, since they are a direct component of the language. Therefore, this example is primarily intended to illustrate the underlying principle. Since the stringer interface is part of the standard, there are many implementations of it in the library. For example, the type time.Time can be passed to printer:
func main() {
t := time.Now()
printer(t)
}
// 2009-11-10 23:00:00 +0000 UTC m=+0.000000001
The example works even if the time package does not know about the stringer interface. In Go, the time package does not need to explicitly specify which interfaces are implemented by the time.Time
type. The Go compiler takes care of checking the implementation. Since the time.Time
type has a string method, it can be used together with printer.
According to this logic, an interface is always designed to satisfy the requirements of a function. Thus, the function determines what methods the interface needs. Interfaces should be made as small as possible - according to the principle of interface separation - and developers automatically get a stronger abstraction.
A not-so-abstract example is the CheckContact function, which evaluates data of type Contact that is loaded from a database. To decouple this process from a particular database, a loader interface can be defined. As per the naming convention, this interface has only a Load()
method, which in turn has the ContactKey type as input and Contact as output. The requirements for the loader are defined by the CheckContact
function.
type Loader interface {
Load(ContactKey) (Contact, error)
}
func CheckContact(uk ContactKey, l Loader) error {
u, err := l.Load(uk)
if err != nil {
return err
}
// ...
}
The implementation consists only of a map into which test data can be directly entered. If any record is missing, the implementation returns an error. Ideally, the LoadTester type should be stored directly in the test file.
Interface pollution - preventing pollution
Defining and using interfaces in Go is very simple. In practice, this fact often leads developers to define too many interfaces for functions. The fact that this can be done effortlessly does not always make sense. If too many or even unnecessary interfaces are defined in the code, we talk about code pollution: interface pollution. But how much is too much?
As explained at the beginning, developers have to resort to interfaces if they want to use a function for more than one type. Based on this, it is easy to identify unnecessary interfaces. Interfaces that have only one implementation are unnecessary. Instead, it is sufficient to use a specific type in the function and thus avoid unnecessary abstraction.
Developers may have worked in C# or Java and it seemed natural to them to create interfaces before writing an implementation. However, this is not the case in Go.
As we said, interfaces are created to create abstractions. And the main caveat when programming using abstractions is to remember that abstractions should be exposed, not created. What this means: It means that we should not start creating abstractions in code unless there is an immediate reason to do so. We should not design interfaces and wait until we need them. In other words, we should design an interface when we need it, not when we anticipate that we might need it.
What is the main problem with overusing interfaces? The answer is that they complicate the flow of code. Adding a useless layer of abstraction does no good; it creates useless abstraction that makes it harder to read, understand, and reason about code. If we have no good reason to add an interface and it is unclear how the interface makes the code better, then we should question its purpose. Why not call the implementation directly?
When we call a method through an interface, we may also run into performance degradation. In order to find the specific type pointed to by the interface, we need to search the hash table data structure. However, in many cases this is not a problem because the overhead is minimal.
"Thus, we must be careful when creating abstractions in our code - abstractions must be discovered, not created. It is common for us software developers to over-complicate our code by trying to guess the ideal level of abstraction based on what we think we might need later. This process should be avoided because in most cases it pollutes our code with unnecessary abstractions, making it more difficult to understand.
Don't design interfaces, but discover them."
-Rob Pike
Let's not try to solve a problem in the abstract, but rather do what needs to be solved now. Last but not least, if it's not clear how an interface makes code better, you should probably consider removing it to make code simpler.
Runtime interfaces
Go has tools that allow you to examine interfaces more closely at runtime. You can use them to take a closer look at interface variables in your code. However, it is important to note that the base value is only assigned at runtime.
The first tool is used to check for the presence of nil. This pattern is commonly used for error handling.
err := openSomething(name)
if err != nil {
// error handling
}
Checking for nil only shows whether a value exists, but does not show what value is behind it. A type switch and a type statement exist for this purpose.
The type switch allows you to check interface variables for several different types. The corresponding check is performed in the case statement. In this block, the type of the variable is known and thus the base type can be used directly.
func myFunc(s Stringer) {
switch v := s.(type) {
case nil:
fmt.Println("nil Pointer")
case *bytes.Buffer:
fmt.Println("bytes.Buffer", v.Len())
default:
fmt.Println("Unknown type")
}
}
The example shows the syntax of the type switch. Here it is also possible to check for the presence of nil. In the case of *bytes.Buffer, all fields, properties or methods of this type can be accessed via the v variable. In this example, this would be the Len() method.
A type switch is always useful when several different types need to be checked. However, if developers only want to test a specific type, it is better to use a type statement. In this case, an attempt is made to assign a specific type to a variable.
The corresponding syntax is kept simple. After the interface variable, the type is enclosed in parentheses. A type assertion always has two return parameters, with the second parameter being optional. If it is not requested, panic can result. This will happen as soon as the type from the interface shell does not match the expected type.
func myFunc(r io.Reader) {
buf, ok := r.(*bytes.Buffer)
if ok {
fmt.Println(buf.Bytes())
}
}
This shows the standard type assertion pattern. The ok variable checks if the conversion was successful. The variable buf is of type *bytes.Buffer and can be used as such. If the type assertion was not successful, buf has a null value of the appropriate type. Since it is a pointer, it will be nil.
The type switch and type assertion also work with interface types. This means that an interface can be converted to another interface.
func ReadAndClose(r io.Reader) ([]byte, error) {
type closer interface {
Close()
}
c, ok := r.(closer)
if ok {
defer c.Close()
}
return ioutil.ReadAll(r)
}
The conversion of one interface to another is described here as an example. The type statement is very useful here. This is because the Close() method is not fundamentally necessary for a function. However, the function can still use this method. If ok ensures that the method will only be used if it exists.
When and how should we use interfaces in GO?
When should we create interfaces? There are three most popular cases where interfaces bring real value to an application:
- General Behavior
- Decoupling
- Restricted behavior.
Generic behavior
To illustrate the use of interfaces, let's look at a real-life example. Imagine that we have a system for managing different types of media content, such as books, songs, and movies. Although these types of media files are different, they share some common characteristics. For example, they can all be displayed on the screen and have a title.
We can define the Media interface as follows:
type Media interface {
Display() string
Title() string
}
The Display method returns a string representing how the media file is displayed, and the Title method returns the title of the media file. Any type that implements these two methods is said to satisfy the Media interface.
Now let's define some types that implement this interface:
type Book struct {
bookTitle string
author string
}
func (b Book) Display() string {
return "Book: " + b.bookTitle + " by " + b.author
}
func (b Book) Title() string {
return b.bookTitle
}
type Song struct {
songTitle string
artist string
}
func (s Song) Display() string {
return "Song: " + s.songTitle + " by " + s.artist
}
func (s Song) Title() string {
return s.songTitle
}
Here, both Book and Song implement the Media interface, since they define Display and Title methods.
One of the most powerful features of interfaces is that we can use them to write functions that will work with any type that satisfies the interface. For example, we can write a function that displays any type of media:
func displayMedia(m Media) {
fmt.Println(m.Display())
}
This function can be used with Book, Song or any other type that implements the Media interface. This is very convenient because it allows you to write universal code that can work with many different types.
Decoupling
Another very important example is abstracting our code from implementation. If we rely on abstraction instead of concrete implementation, the implementation of the functionality can simply be replaced by other logic without changing the main part of the code. Barbara Liskov's substitution principle tells us this.
One of the benefits of Decoupling is closely related to unit tests. Let's imagine that we want to write the CreateNewUser method, the task of which is to save a new user in the database. When implementing it, we decided to rely on a specific implementation and save it to the database using the mysql.Store structure:
type UserService struct {
store mysql.Store
}
func (us UserService) CreateNewUser(id string) error {
user := User{id: id}
return us.store.StoreUser(user)
}
Now, what to do with the tests? Since userService is based on a valid implementation to save the user, the only way to test this functionality is only with integration tests, which require us to bring up a MySQL instance. Or use an alternative technology such as go-sqlmock. While integration tests are very useful, it's not always what we'd like to use. To get more flexibility, we need to abstract away the implementation, and this can be done with an interface as follows:
type userStorer interface {
StoreUser(User) error
}
type UserService struct {
storer userStorer
}
func (us UserService) CreateNewUser(id string) error {
user := User{id: id}
return us.store.StoreUser(user)
}
Since user saving is now done using an interface, this gives us more flexibility in how we want to test the method. For example, we can:
- Use a concrete implementation and implement integration tests.
- Use mocks and write unit tests
- Or use both approaches.
Restricted behavior
The last example of proper use of interfaces may not seem so intuitive at first glance. It's about constrained behavior. Let's illustrate it with an example. Consider a situation where you have a configuration structure that you would like to pass around in your application, but you want to restrict modification of its properties to avoid possible side effects.
type IntConfig struct {
Value int
}
type intConfigGetter interface {
Get() int
}
func (ic IntConfig) Get() int {
return ic.Value
}
type Cache struct {
cfg intConfigGetter
}
func NewCache(cfg intConfigGetter) *Cache {
return &Cache{cfg: cfg}
}
func (f *Cache) Operate() {
fmt.Println("Config Value: ", f.cfg.Get())
}
In the above example, the IntConfig structure implements the intConfigGetter interface, which has a single Get() method. This method is used to get the configuration value. The Cache structure, in turn, receives the intConfigGetter interface in its NewCache() factory function, thereby providing access to the Get() method but restricting modification of the Value.
The client of the NewCache function can still pass the IntConfig structure because it implements the intConfigGetter interface. However, inside the Cache structure we can only read the configuration in the Operate method, but not modify it.
func main() {
cfg := IntConfig{Value: 10}
cache := NewCache(cfg)
cache.Operate()
// Outputs: Config Value: 10
}
Here, the NewCache function can accept any object that satisfies the intConfigGetter interface, thus separating the function from the specific IntConfig type. However, the functionality provided by this interface is limited, i.e. it can only receive a value, but not modify it.
Conclusion
The implementation of interfaces in Go differs only slightly from other programming languages. However, these differences are due to conscious decisions. Go supports the principle of separating interfaces due to its specific design. This allows small efficient interfaces to be defined effortlessly.
The compiler ensures that variables are compatible with the corresponding interfaces. Ideally, interfaces are defined together with the function that uses them. However, if there are interfaces that have only one implementation, this is a sign of interface collision. In such cases, you should check whether an interface is actually required.
Interfaces hide information about the variables behind them. When they are used, the underlying variable cannot be accessed directly. Developers should avoid empty interfaces, as they provide no context. With a type switch and type statement, a specific type can be safely extracted from the interface shell.