Generics in Go: Type Inference Based on Parameter Constraints
In my small project of implementing a minimal DNS lookup utility in Go, I was trying to come up with a simplified logic of parsing the query response into a DNSPacket
struct, which had the following structure:
type DNSPacket struct {
Header *DNSHeader
Questions *[]DNSQuestion
Answers *[]DNSRecord
Authorities *[]DNSRecord
Additionals *[]DNSRecord
}
// initialize from Reader
func (dr *DNSQuestion) FromBytes(reader *bytes.Reader) error { //... }
func (dr *DNSRecord) FromBytes(reader *bytes.Reader) error { //... }
See how most of the fields are pointers to a slice? Since all instances of DNS-
type were created by a pointer receiver function, I wanted to create an interface that covered those types, and then define a generic function parseSlice
that would construct a slice of the given type, given a *bytes.Reader
to digest data from. That way, I won’t have to write duplicate logic for different DNS
types:
type DNS interface {
FromBytes(reader *bytes.Reader) error
}
func parseSlice[T DNS](size int, reader *bytes.Reader) ([]T, error) {
slice := make([]T, size)
for i := 0; i < size; i++ {
if err := slice[i].FromBytes(reader) {
return nil, err
}
}
return nil
}
Naive approaches
Now, would it be possible to create a slice of any DNS
implementation as in: parseSlice[DNSRecord](4, reader)
? Not really - passing DNSRecord
directly as a parameter type, we are saying that it implements the DNS
interface. However, DNSRecord
does not have a FromBytes
method (it’s *DNSRecord
that has it) making it uncompilable.
On the other hand, passing the pointer type instead to the type parameter (parseSlice[*DNSRecord](4, reader)
) and making the return type to be []*DNSRecord
would make the code compile, but lead to a panic in runtime: the local slice
of type []*DNSRecord
is initialized to a series of nil
, and calling FromBytes
will cause a nil
dereference error. So neither solution works.
How can we achieve such duality? The answer lies on type constraints.
Type constraints
While method signatures and embedded interface types are common in interface definitions, the proposal adds three new entities inside interfaces:
An arbitrary type constraint element allows any types to be present, not only interface types. This makes expressions such as type Num interface {int}
possible. However, a type parameter cannot be used plainly inside a definition.
Approximation constraint element indicate (~T
) the set of types that has an underlying type of the given type. For example, type Stringy interface {~string}
includes all type sets including string
and those that have an internal representation of string
.
Union constraint element allows the union of different type sets (e.g. int | int8 | int16
)
Note that if any of these elements are present inside an interface definition, it cannot be used as a variable type, but only as a type constraint! For the problem covered here, the first element would be the most relevant.
Tip: type conversions
For two type parameters From
and To
, a value of type From
can be converted to a value of type To
if the type set of From
equals the type set of To
.
Final solution
Back to the problem! To reiterate our objective, we want the parseSlice
function to take DNSRecord
(or any other DNS
interface implementations) as an argument but call its pointer method. The way to achieve this duality, as well described in this proposal, is to revise the interface and the type parameters of parseSlice
to fulfill both requirements using type constraints.
type DNS[P any] interface {
FromBytes(reader *bytes.Reader) error
*P // non-interface type constraint
}
Let’s break down what this means - or the type set it represents. There are two requirements here: a) it should have a FromBytes
method, and b) its type set is limited to the pointer type of the interface’s type parameter. So for an instantiated interface of DNS[DNSRecord]
, its type set is constrained to type *DNSRecord
. In other words, the pointer type of P
should have the method FromBytes
!
Now we can pass in a non-pointer type as a parameter for parseSlice
, thereby evading the nil
dereference issue caused by the slice
variable being initialized as a slice of pointers.
func parseSlice[T any, PT DNS[T]](size int, reader *bytes.Reader) ([]T, error) {
slice := make([]T, size)
for i := 0; i < size; i++ {
// &slice[i] is type *T, which meets the type conversion criteria
// (equality of type set). This exposes the FromBytes() method
p := PT(&slice[i])
if err := p.FromBytes(reader); err != nil {
return nil, err
}
}
return slice, nil
}
Finally, we can create a slice by calling parseSlice[DNSRecord](4,reader)
! Although there are two types required as a parameter for parseSlice
, Go is smart enough to infer the type of PT
from T
(type inference), so we don’t have to pass in [DNSRecord, *DNSRecord]
.