First steps porting Hasgo to “Go2”

Dylan Meeus
5 min readJun 22, 2020

--

Some time ago I created Hasgo, a Go library implementing generic functions using code generation. As of last week however, a development branch of Go supports actual generics. Hence, I started porting my Hasgo functions over to this new ‘version’ of Go. I’ve named it Hasgo2 for the time being. It should be mention that the current design for generics is a proposal and as such the implementation might change over time.

Now, whether or not you are ‘pro’ or ‘contra’ generics, you can tell a lot of hard & good work is being put into making generics in Go “feel” like Go. They don’t make the decision to implement this lightly. Regardless, it looks like generics will eventually be a thing in Go. For the remainder of this blogpost I’ll refer to ‘Go with generics’ simply as Go2.

Comparison between Hasgo in Go / Go2

Let’s start off with a small comparison. This is how the “filter” function is defined in Hasgo without having generics in Go.

func (s SliceType) Filter(f func(ElementType) bool) (out SliceType) {
for _, v := range s {
if f(v) {
out = append(out, v)
}
}
return
}

And now in Hasgo2 / Go2:

func Filter(type T)(ts []T, f func(T) bool) (out []T) {
for _, t := range ts {
if f(t) {
out = append(out, t)
}
}
return out
}

The idea is of course similar. In Hasgo SliceType can become any type as long as it’s a slice, whilst in Hasgo (type T) ([]T) also informs us that it needs to be any slice-type.

Another example is the TakeWhile function:

func (s SliceType) TakeWhile(p func(ElementType) bool) (out SliceType) {
for _, e := range s {
if !p(e) {
return
}
out = append(out, e)
}
return
}

And in Hasgo2

// TakeWhile returns all elements of ts until the predicate f fails
func TakeWhile(type T) (ts []T, f func(T) bool) (out []T) {
for _,t := range ts {
if !f(t) {
return
}
out = append(out, t)
}
return
}

Type Constraints

Type constraints in Hasgo are somewhat (very) hacky. Essentially for each function we have to declare what types we can generate it for:

"length.go":      {ForNumbers, ForStrings, ForStructs},
"map.go": {ForNumbers, ForStrings, ForStructs},
"maximum.go": {ForNumbers},

This is then parsed by the Hasgo ‘compiler’ to generate the correct functions for the correct types, by matching ForNumbers against every possible number type. And working by exlusions (if it’s not a string and not a number it’s a struct).

It’s an imperfect way of working, here is part of the generator logic: https://github.com/DylanMeeus/hasgo/blob/master/hasgo.go#L89

With Go2 however, this type constraint is much more naturally expressed:

type Number interface {    
type int, int8, int16, int32, int64, uint, uint8,
uint16, uint32, uint64,float32, float64
}

Which can be seen here: https://github.com/DylanMeeus/hasgo2/blob/master/functions/types.go2

This allows me to write functions with type constraints rather easily, this is the current implementation of the sum function:

func Sum(type T Number) (ts []T) (out T) {
for _,t := range ts {
out += t
}
return
}

The end result actually looks pretty similar to Hasgo:

func (s SliceType) Sum() ElementType {
var sum ElementType
for _, v := range s {
sum += v
}
return sum
}

Advantages in Hasgo2

I think hasgo2 will have two clear advantages of hasgo. The first being that now a function is not attached to a type anymore. In our former example, you’d need to use a type Ints []int type to which the Filter method could be applied. Otherwise we could not overload the function for multiple types.

In Hasgo2, there is no such problem. Which is of course the entire point of having generics. 😃

Another advantage I noticed is in testing. In my unit tests I frequently need to compare the slice returned by a function with my expected correct result. For many of these the order matters (functions like DropWhile, TakeWhile, ..).

In Go1, I had unit tests per “type” and a “EqualsOrdered” function per SliceType. Now, this has become just one function:

type comparable(type T) interface {
Equals(T) bool
}

// equalsOrdered returns true if all elements in the slice are equal, and in the same order.
func equalsOrdered(type T comparable) (ts1, ts2 []T) bool {
if len(ts1) != len(ts2) {
return false
}

for i,t := range ts1 {
if !ts2[i].Equals(t) {
return false
}
}
return true
}

The only downside to this is that primitives do not support a .Equals() method, so I had to wrap the primitives and I’ve added a convenience function to creating them from an int slice:

// cmpInt is an integer wrapper with .Equals(cmpInt) 
type cmpInt int

func (c cmpInt) Equals(o cmpInt) bool {
return int(c) == int(o)
}

func newCmpInts(is []int) ([]cmpInt) {
cmpints := make([]cmpInt, len(is))
for i,v := range is {
cmpints[i] = cmpInt(v)
}
return cmpints
}

Differences in usage

With hasgo2, using the generic functions starts to look more like Haskell and less like Java. Still, it mostly resembles Python. This is an example of Hasgo:

func EpicFunction() {
result := IntRange(-10,10).
Abs().
Filter(func(i int64) bool {
return i % 2 == 0
}).
Sum()
// result = 60
}

Which now becomes:

result := Sum(Filter(func i int) bool { return i % 2 == 0 },
Abs(IntRange(-10, 10)))

Without lambda syntax, I think the hasgo function is more readable. Even when splitting the Hasgo2 one:

result := Sum(Filter(func i int) bool { 
return i % 2 == 0
},
Abs(IntRange(-10, 10)))

First impressions

The current implementation of generics has some quirks. I ran into plenty of issues (such as: https://github.com/golang/go/issues/39749) and general struggles with finding out the correct syntax here and there. Yes, this is because I’m living on the bleeding edge.

So honestly, I can’t form a complete opinion on this implementation yet. I’ll continue playing around with it and see how I feel about it in a few months. 😃

References

If you liked this post and 💙 Go as well, consider:

  • Following me here, on Medium
  • Or twitter Twitter
  • Leaving a ⭐️ on github (#fishingForStars)

--

--