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.