siadat / group-interface

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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).

About


Languages

Language:Go 98.4%Language:Makefile 1.6%