The purpose of this repository is to introduce a design pattern for grouping types in Go.
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.
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
}
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) {}
- 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
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.
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).