group-interface
The purpose of this repository is to introduce a design pattern for grouping types in Go.
Problem description
An IP address type can be represented like the following (in pseudo-ML):
type IP = IPv4 [4]byte
| IPv6 [16]byte
Then we can define a version function that works on both types:
let version (addr: IP) : int =
match addr with
| IPv4 -> 4
| IPv6 -> 6
In Go, we could use an empty interface{}, but that would mean that there is no compile-time type-checking and things can go wrong during runtime.
Solution
We could define an interface that is only implemented by the types we are interested in. This interface groups several types, hence I call it a "group interface".
type IPv4 [4]byte
type IPv6 [16]byte
// Group the types
type IP interface {
GroupTypes(IPv4, IPv6)
}
// Implement the group's method
func (IPv4) GroupTypes(IPv4, IPv6) {}
func (IPv6) GroupTypes(IPv4, IPv6) {}
The purpose of the GroupTypes method is solely to tell the type-checker that IPv4 and IPv6 are grouped and can represent an IP.
Then, we will be able to leverage Go's type switch:
func Version(ip IP) int {
switch ip.(type) {
case IPv4:
return 4
case IPv6:
return 6
case nil:
panic("nil interface")
default:
panic("never reached")
}
}
func main() {
fmt.Println("version is", Version(IPv4{127, 0, 0, 1}))
fmt.Println("version is", Version(IPv6{}))
}
// Output:
// version is 4
// version is 6
The advantage of doing this is that the compiler is now able to check the types given in each switch-case. For example, you will not be able to do
// This does not compile, unless AnotherType
// implements IP's GroupTypes(IPv4, IPv6) method.
switch ip.(type) {
case IPv4:
return 4
case IPv6:
return 6
case string: // <-- ERR, thank goodness
}
Generator
This repository contains a simple program that generates that generates the methods for all types included in a group interface with a single method called "GroupTypes" inside *_group_interface.go. All you need to do is to define your types and an interface with a method named GroupTypes, accepting all types that you need to group.
For example, let's say we have a example/example.go file:
//go:generate group-interface -f example.go
type IP interface {
GroupTypes(IPv4, IPv6)
}
Then run
go install .
go generate ./example
The following lines are generate and placed in a *_group_interface.go file.
// DO NOT EDIT
// Generated by group-interface
package p
// IP group
func (IPv4) GroupTypes(IPv4, IPv6) {}
func (IPv6) GroupTypes(IPv4, IPv6) {}
Limitations
- For basic types and types outside the package, we are not able to add any methods. One workaround is to wrap the type inside a locally defined type, for example, type Float64 float64.
- Using the same variable name for everything is a bit awkward.
- We need to handle nil cases as well, because all interface values can be nil.
- The compiler is not able to check whether all types are included in the switch-case statements. Most importantly, interface values can be nil. For example, values of type IP can have 3 type: IPv4, IPv6, and nil.
// missing cases (IPv6 and nil) is allowed by the type-checker.
switch ip.(type) {
case IPv4:
return 4
}
- Pattern matching by value is verbose. We need a type-switch then a value-switch after that.
- Embeding a value inside the type is verbose. We need to define the type and then assign a value to it.
- Defining a common function for all types is awkward.
A workaround would be to define a wrapper type which the required function.
For example, if we want to define a
String() string
method for all types, we could do this:
type Stringify struct {
ConnState
}
func (s Stringify) String() string {
switch s.ConnState.(type) {
case StateNew: return "new"
case StateActive: return "active"
case StateIdle: return "idle"
case StateHijacked: return "hijacked"
case StateClosed: return "closed"
case nil: panic("nil interface")
default: panic("never reached")
}
}
func main() {
fmt.Println(Stringify{StateNew{}})
fmt.Println(Stringify{StateActive{}})
}
// Output:
// new
// active
Experimenting with net/http.IP
Both IPv4 and IPv5 are represented as a []byte. This type has a String() string in which we try to detect the version of that IP:
// IP represents both IPv4 and IPv6
type IP []byte
func (ip IP) String() string {
if p4 := p.To4(); len(p4) == IPv4len { // <-- no type checking
// ...
}
}
There are good reasons for keeping the type a simple []byte wrapper. I am not suggesting that this code should be changed at all. We are just exploring possibilities.
Experimenting with net/http.ConnState
The following code snippet is taken from the net/http for ConnState:
type ConnState int
const (
StateNew ConnState = iota
StateActive
StateIdle
StateHijacked
StateClosed
)
var stateName = map[ConnState]string{
StateNew: "new",
StateActive: "active",
StateIdle: "idle",
StateHijacked: "hijacked",
StateClosed: "closed",
}
func (c ConnState) String() string {
return stateName[c]
}
If we were to use our approach, we could write it like this:
//go:generate group-interface
type ConnState interface {
GroupTypes(
StateNew,
StateActive,
StateIdle,
StateHijacked,
StateClosed,
)
}
type StateNew struct{}
type StateActive struct{}
type StateIdle struct{}
type StateHijacked struct{}
type StateClosed struct{}
There are two ways to implement the stringer interface on each state. One approach is to use a type switch:
type Stringify struct {
ConnState
}
func (s Stringify) String() string {
switch s.ConnState.(type) {
case StateNew: return "new"
case StateActive: return "active"
case StateIdle: return "idle"
case StateHijacked: return "hijacked"
case StateClosed: return "closed"
case nil: panic("nil interface")
default: panic("never reached")
}
}
The other approach would be to use a map:
type Stringify struct {
ConnState
}
func (s Stringify) String() string {
reutnr stateName[s.ConnState]
}
var stateName = map[ConnState]string{
StateNew{}: "new",
StateActive{}: "active",
StateIdle{}: "idle",
StateHijacked{}: "hijacked",
StateClosed{}: "closed",
}
Both approaches requires a wrapper struct (Stringify).