January 31, 2022
I did a bit of experimenting over the weekend in an attempt to figure out how to write an interface that abstracted over methods whose return type was identical to the receiver’s type. This is frequently a thing that one wants to do when abstracting over self-cloning objects, or things that implement the Builder pattern.
For example, suppose we have a struct through which we log stuff:
type StdoutLogger struct {
.Writer
out ioutil.Mutex
outMu syncmap[string]interface{}
fields }
func (n *StdoutLogger) WithFields(fields map[string]interface{}) (out *StdoutLogger) {
for k, v := range fields {
.fields[k] = v
out}
return
}
func (n *StdoutLogger) Infof(format string, args ...interface{}) {
.outMu.Lock()
ndefer n.outMu.Unlock()
:= fmt.Sprintf(format, args...)
s
if len(n.fields) > 0 {
+= " "
s }
for k, v := range n.fields {
+= fmt.Sprintf("%s=%+v", k, v)
s }
.out.Write([]byte(s))
n}
…and we’ve got some other struct that we use during test which ignores all requests to log stuff:
type NoopLogger struct {}
func (n *NoopLogger) WithFields(fields map[string]interface{}) *NoopLogger {
return n
}
func (n NoopLogger) Infof(format string, args ...interface{}) {
return
}
Before the Go “generics” feature was released, defining an interface that abstracted over both structs was not possible (link). For example, if we had a pre-generics interface that looked like:
type Logger interface {
(fields map[string]interface{}) Logger
WithFields(format string, args ...interface{})
Infof}
…there would be no way to satisfy it with types that had these signatures:
func (n *NoopLogger) WithFields(fields map[string]interface{}) *NoopLogger
func (n *StdoutLogger) WithFields(fields map[string]interface{}) *StdoutLogger
…because of the different return types of each struct’s
WithFields
method.
Now that generics have landed, we can define an interface that abstracts over both of these structs:
type Logger[T any] interface {
(fields map[string]interface{}) T
WithFields(format string, args ...interface{})
Infof}
var x Logger[*NoopLogger] = new(NoopLogger)
var y Logger[*StdoutLogger] = new(StdoutLogger)
Using that interface involves using generic “constraints” (link), like so:
type Config[T Logger[T]] struct {
string
username string
password
logger T}
What we’re expressing with this Config
struct is that
the struct owns a logger of type T
, where T
is
constrained to any type which satisfies the Logger
interface (which itself is polymorphic).
While it is certainly cool that we can now define these sorts of polymorphic interfaces, Go’s type inference is so weak as to make use of those interfaces quite awkward. In my experiments, I frequently found it to be the case that I needed to explicitly set a type variable in a place where I would expect it to be inferred.
Suppose for a moment that we export a bunch of config-manipulating functions, and that one of those functions can be used to configure out application to use a logger of some type that the user provides.
// Config is a struct which holds our application's configuration.
type Config[T Logger[T]] struct {
string
username string
password
logger T}
// WithUsername configures the system to use the provided username.
func WithUsername[T Logger[T]](username string) func(config *Config[T]) {
return func(p *Config[T]) {
.username = username
p}
}
// WithPassword configures the system to use the provided password.
func WithPassword[T Logger[T]](password string) func(config *Config[T]) {
return func(p *Config[T]) {
.password = password
p}
}
// WithLogger configures the system to use the provided logger.
func WithLogger[T Logger[T]](logger T) func(config *Config[T]) {
return func(p *Config[T]) {
.logger = logger
p}
}
// NewConfig creates a new configuring from the provided options.
func NewConfig[T Logger[T]](opts ...func(config *Config[T])) Config[T] {
:= Config[T]{}
cfg
for _, opt := range opts {
(&cfg)
opt}
return cfg
}
Suppose we wanted to construct a configuration that used our
NoopLogger
. In many other languages, we’d load all of our
configuration functions into a monomorphized (i.e. non-polymorphic)
slice, and then we’d pass that slice around:
func main() {
:= []func(config *Config[*NoopLogger]){
opts ("foo"),
WithUsername("bar"),
WithPassword(&NoopLogger{}),
WithLogger}
:= NewConfig(opts...)
cfg
.Printf("cfg is: %+v", cfg)
fmt}
Unfortunately, Go’s type inference is not able to infer that
T
is *NoopLogger
for the
WithUsername
and WithPassword
functions, in
spite of the fact that opts
is not polymorphic.
If you try to run this code, you’ll see something like (link):
./prog.go:70:15: cannot infer T (prog.go:33:19)
./prog.go:71:15: cannot infer T (prog.go:40:19)
. Go build failed
To work around the lack of type inference, you’ll need to explicitly parameterize each function, like so:
func main() {
:= []func(config *Config[*NoopLogger]){
opts [*NoopLogger]("foo"),
WithUsername[*NoopLogger]("bar"),
WithPassword(&NoopLogger{}),
WithLogger}
:= NewConfig(opts...)
cfg
.Printf("cfg is: %+v", cfg)
fmt}
Not particularly awesome.
Go’s generics implementation allows us to solve some problems that were previously difficult or impossible to solve, but the inference algorithm is such that explicit parameterization is required in places where one would expect types to be inferred, which can be quite awkward.