iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤔

When to Use Interface Types in Go

に公開

I read the article "Reasons why Golang constructors should return interface types" and the post linked from there:

https://selfnote.work/20201123/programming/how-to-use-interface-in-golang/

While I found them quite interesting, the "reasons" felt a bit weak to me, so I'd like to explore this topic a bit more in this article.

"Accept Interfaces, Return Structs"

There is a famous design principle in Go known as "Accept Interfaces, Return Structs." In short, it suggests that functions should return concrete types, but accept instances or function arguments as interface types.

For example, if we were to create two functions:

  1. Open a specified file
  2. Read all contents of the opened file

We could write them like this:

func OpenFile(path string) (*os.File, error) {
    return os.Open(path)
}

func ReadAll(r io.Reader) ([]byte, error) {
    buf := bytes.Buffer{}
    _, err := buf.ReadFrom(r)
    return buf.Bytes(), err
}

To actually run this, you might do something like the following (I'm skipping error handling here. Sorry!):

func main() {
    f, _ := OpenFile("sample.txt")
    defer f.Close()
    b, _ := ReadAll(f)
    ...
}

Note that while the return type of the OpenFile() function is a struct type (specifically a pointer to *os.File), the argument type of ReadAll() which receives it is the io.Reader interface type.

However, even if you were to change the return type of the OpenFile() function to the io.ReadCloser interface type like this:

func OpenFile(path string) (io.ReadCloser, error) {
    return os.Open(path)
}

it would still work perfectly fine without changing anything else. So, "which one is correct"?

In the article linked at the beginning, it seems the responsibility of deciding "which methods to 'allow' to be used" lies with the side returning the object. On the other hand, the "Accept Interfaces, Return Structs" principle considers that deciding "which methods to 'use'" is the responsibility of the side using the object.

In other words, this can be viewed as a matter of division of responsibility during design. Consequently, you can't generalized and say "which one is correct."

However, if you look at this series of flows, don't you notice something interesting?

In Go, the "User" Can Decide Which Methods to Use

Actually, no explicit "relationship" is defined between the os.File struct type and interface types like io.ReadCloser or io.Reader. They simply have "methods with the same type and name defined." Even so, they can behave as if they are related in the code.

So, for example, you can create an interface type on your own like:

type FileObject interface {
	Read(p []byte) (n int, err error)
	Close() error
}

And it will work perfectly fine even if you receive the return value like this:

var f FileObject
f, _ = OpenFile("sample.txt")

This is fundamentally different from "nominal typing" systems like C++ or Java. Thanks to this feature, the side using the object can decide "which methods to 'use'."

Looking at it from a different perspective, this also means that how an object is used can be determined by an agreement between the sender and the receiver through an interface type. In other words, an interface type is a "specification document."

This is the meaning behind "Accept Interfaces, Return Structs," and it is a very Go-like aspect.

When It Might Be Better to Return an Interface Type

That being said, I don't necessarily agree that you should always follow "Return Structs." This is my personal opinion, but I'll list two cases where returning an interface type might be preferable.

When There Is a Possibility of Returning Different Types

Error handling is an easy-to-understand example. In Go:

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

Any type that fits this definition is considered an error object. While it's impossible to know exactly when, where, or what kind of event will occur for a runtime error, if you generalize it into the error type for the time being, the receiving side should (theoretically) be able to handle it appropriately.

It Is Better to Return Interface Types at Layer Boundaries

One advantage of using abstract types is the ability to make relationships between objects "loose." This is a crucial point in object-oriented design, regardless of the programming language. It’s the "Don't Talk to Strangers" principle.

Especially when building a system in a team, human resources and progress often move forward individually for each layer. Earlier I mentioned that "an interface type is a specification document"; if you decide on the interface types to be passed between layers beforehand and write code to fit them, integration later becomes much easier.

Furthermore, if you create mocks that satisfy the defined interface types in files like xxx_test.go, you can at least perform unit tests independently. In this way, Go is designed to make test-driven development easier to implement.

Be Careful with nil for Interface Types

Interface types are a form of boxing, and you need to be careful when handling nil. For more details, please refer to my following post:

https://zenn.dev/spiegel/articles/20201010-ni-is-not-nil

Compiler Hints to Verify Compatibility with an Interface Type

To confirm whether a defined type fits a specific interface type, you might see code written like this:

var _ InterfaceType = (*StructType)(nil)

This statement is not actually compiled into the executable code, but it will cause a compilation error if the StructType does not conform to the InterfaceType. It functions as a compiler hint.

It is useful to remember this technique as it is quite commonly used.

GitHubで編集を提案

Discussion