Jauhar's Blog


Go’s Implicit Interface Implementation

2022/11/29

In Golang, we can’t explicitly say that type A implements interface B. Implementing an interface is done implicitly. It’s kind of like Python’s duck typing: if it looks like a duck and quacks like a duck, it’s a duck. In Go, if a type A has all the methods defined by interface B, it means A implements B. Consider the code below:

 1// package cache
 2type KeyValueStore interface {
 3  Get(ctx context.Context, key string) (string, error)
 4  Set(ctx context.Context, key string, value string) error
 5}
 6
 7// package config
 8type KeyValueStore interface {
 9  Get(ctx context.Context, key string) (string, error)
10  Set(ctx context.Context, key string, value string) error
11}
12
13// package memcached
14type client struct {}
15
16func (*client) Get(ctx context.Context, key string) (string, error) { ... }
17
18func (*client) Set(ctx context.Context, key string, value string) error { ... }

In the example above, there are interfaces called cache.KeyValueStore and config.KeyValueStore that have identical contracts. In this example, *client implements both cache.KeyValueStore and config.KeyValueStore. We never explicitly say that *client implements those two interfaces, but since it has all the methods required for those two interfaces, it is considered an implementation of cache.KeyValueStore and config.KeyValueStore.

One good thing about implicit interface implementation is that you don’t have to depend on the thing you implement just to implement it. By doing this, you can write the interface later without updating the implementation. Patterns in software development are hard to see in the beginning; thus, it’s hard to define the “correct” interface in the early stage. It will be easier to just write the implementation and see how the patterns show up. When we see the pattern, we can decide to harden the pattern by creating the interface without changing the implementation.

But, the implicit interface implementation is actually not good in a lot of aspects. For starters, the advantage is not that big. You can create an interface without changing the implementation, so what? Changing the implementation is not hard. Adding a simple implements KeyValueStore is not a big job. Considering the issue it causes, the advantage is not that useful.

The fact that you don’t need to import the interface is a lie in most cases. Usually, the interfaces that we have not only contain methods, but also some structs. The KeyValueStore above is actually very generic, thus this problem is not visible. But, consider how most interfaces are written. Here is an example of more realistic interface:

 1package auth
 2
 3type User struct {
 4  ID   int64
 5  Name string
 6}
 7
 8type UserRepository interface {
 9  InsertUser(ctx context.Context, user User) error
10}

In the example above, I defined the interface auth.UserRepository that can be used to insert a user to persistent storage like MySQL. To implement the auth.UserRepository, you need to import the auth package because you need auth.User. This happens very often in actual software. So, most of the time, implementing an interface means you need to import the interface package and have a dependency on the interface’s package. Only with a very generic interface like io.Reader and io.Writer can you gain the advantages of implicit interface implementation.

Now, implicitly implementing an interface can cause confusion, which is dangerous. Consider the first example where we have KeyValueStore interface. Based on the definition of the interface, *memcached.Client should be considered as the implementation of both cache.KeyValueStore and config.KeyValueStore. But, if you think about it, it really shouldn’t. *memcached.Client is an implementation of key-value storage for caching purposes. Whereas the config.KeyValueStore probably needs a more persistent property. You don’t want your config gone when the Memcached storing it gets destroyed or the eviction policy of Memcached causes it to be evicted, right? But, Golang doesn’t forbid you to use *memcached.Client as the implementation of config.KeyValueStore interface. Identical interface can have different semantic meaning depending on their context. Programming languages with explicit interface implementation such as Java and Rust don’t allow you to do this. You need to explicitly say that *memcached.Client implements config.KeyValueStore if you want to do it. But of course, the implementor of *memcached.Client will never do this because it’s not meant to be used as the implementation of config.KeyValueStore.

Having an implicit interface implementation causes bad error reporting. Most of the time, you never want to accidentally implement an interface. If you want to create a type that implements cache.KeyValueStore, you must first know what methods are required by cache.KeyValueStore before implementing it. Otherwise, you don’t know what methods you need to write. So, your type’s sole purpose is actually just to implement cache.KeyValueStore interface, nothing more. Now, let’s say the interface of cache.KeyValueStore changes, maybe now it doesn’t accept strings anymore. Instead, now it accepts []byte like this:

1package cache
2
3type KeyValueStore interface {
4  Get(ctx context.Context, key []byte) ([]byte, error)
5  Set(ctx context.Context, key []byte, value []byte) error
6}

Now, your type is not considered as the implementation of cache.KeyValueStore anymore since the methods don’t match anymore. This is a legitimate error. In any language, if you change the interface, you need to change the implementations, there is nothing wrong with this. So what’s the problem? The problem is, now the error doesn’t show up in your type implementation, but somewhere else. It shows up in the place where you are trying to pass your type as the implementation of cache.KeyValueStore. That is not a good place to report the error. A good place to report the error is as close as possible to its root cause. The root cause of the error is because you haven’t changed the implementation when you change the interface, but the error shows up somewhere else. And not only that, the error will show up multiple times in all places where you pass your type as cache.KeyValueStore, where it should only be reported once, in your implementation code.

The most annoying problem with implicit interface implementation is that it is hard to tell if a type is actually implementing an interface intentionally, or it just happens to have the same methods accidentally. Let me give you an example. Let’s consider the KeyValueStore example above. Say you have this code:

 1// package cache
 2type KeyValueStore interface {
 3  Get(ctx context.Context, key string) (string, error)
 4  Set(ctx context.Context, key string, value string) error
 5}
 6
 7// package config
 8type KeyValueStore interface {
 9  Get(ctx context.Context, key string) (string, error)
10  Set(ctx context.Context, key string, value string) error
11}
12
13// package memcached
14type client struct {}
15
16func (*client) Get(ctx context.Context, key string) (string, error) { ... }
17
18func (*client) Set(ctx context.Context, key string, value string) error { ... }

Now, you change the interface of config.KeyValueStore into:

 1// package cache
 2type KeyValueStore interface {
 3  Get(ctx context.Context, key string) (string, error)
 4  Set(ctx context.Context, key string, value string) error
 5}
 6
 7// package config
 8type Value struct {
 9  LastChanged time.Time
10  Author      int64
11  RawValue    []byte
12}
13
14type KeyValueStore interface {
15  Get(ctx context.Context, key string) (Value, error)
16  Set(ctx context.Context, key string, value Value) error
17}
18
19// package memcached
20type client struct {}
21
22func (*client) Get(ctx context.Context, key string) (string, error) { ... }
23
24func (*client) Set(ctx context.Context, key string, value string) error { ... }

Now, let me ask you a question: “should you update the memcached.client struct?”. It’s hard to tell, right? Previously, it implemented config.KeyValueStore, then we change the interface of config.KeyValueStore, so logically, we should change the memcached.client implementation as well, right? But, no. It was never meant to implement config.KeyValueStore from the beginning. The intention of memcached.client is to be the implementation of cache.KeyValueStore, and it just happens to have the same method as config.KeyValueStore. But, there is no way to deterministically tell whether you should change the memcached.client implementation or not. You need to understand the semantic meaning of the code to be able to answer those questions. This problem makes code-refactoring tools harder to build. If you have explicit interface implementation, it will be easier for computers to understand your intention and automate all the necessary fixes in your code. In this case, the refactoring tool can help you change the method definition automatically.