I don't know how but I'm writing!

Review: Golang

I just started writing a real project in Go. Previously, I had done some simple tutorials, and sent a (very) small patch to an open-source project written in Go. I was really impressed with how practical, simple, and usable the language seemed, and I had told several friends that I was really interested in learning it.

Now that I'm a week into writing, the honeymoon phase is definitely over; Go has some incredibly weird footguns and strange behaviors that seem completely antithetical to it's policy of simplicity and usability.

The %x Debacle

Consider this example code:

package main
import "fmt"

type Enum int32
const (
  ENUM0 Enum = iota
  ENUM1
  // ...
)

func (enum Enum) String() string {
   switch enum {
     case ENUM0: return "Enum(0)"
     case ENUM1: return "Enum(1)"
     default: return ""
   }
}

func main() {
	fmt.Printf("Enum: %s\n", ENUM0)
	fmt.Printf("Enum: %d\n", ENUM0)
	fmt.Printf("Enum: %x\n", ENUM0)
}

Looking at this I would immediately expect the following output:

$ go build main.go
$ ./main
Enum: Enum(0)
Enum: 0
Enum: 0x0

Instead, you get this:

Enum: Enum(0)
Enum: 0
Enum: 456e756d283029

I'm not 100% sure what's happening here but it appears that %x causes fmt.Printf to call Enum.String(). You can confirm this by removing the definition of String() and see that now everything works as expected. I don't know what shenanigans are happening behind the scenes but the behavior is unintuitive at best and dangerous at worse.

Function receivers

Go allows you to define functions on many types, which is pretty cool and useful:

func (en *Enum) foo() {
	fmt.Println("Hello, world!")
}

func main() {
	v := ENUM1
	v.foo() // Prints "Hello, world!"
}

Even though v is not a pointer, Go is smart enough to determine that we can call foo() binding en to the address of v. (Or at least that's how I think it works.) However there are many unintuitive consequences of this:

  1. For a shockingly long time I thought the there wasn't a real difference between pointer and object receivers. For someone new to the language, it doesn't seem like there is. THERE IS! If you define an object receiver it can copy the object being called. This is very bad if some other functionfunc (obj YourStruct) bar() mutates the received object because the object will be essentially copied and thrown out. I had several bugs related to something along the lines of calling obj.setValue(10) and then sometime later in the code, if obj.getValue() != 0 { ... } blows up because it didn't actually change the object the function was called on. It was confusing and the only way I figured it out was spam-printing memory addresses to confirm that the two structs were not the same.

  2. It would seem intuitive that you could add (not override) functions to types defined externally. You can't! I guess I could see how this might be dangerous if you are writing a library, but it would be very useful.

  3. Some library interfaces only define functions with object receivers. If you need to implement one of those interfaces, you have to copy the object, which sucks.

Slices

Similar to the receivers, slices caused a ton of memory-related bugs in my programs:

// ...
type MyStruct struct { a int }

func main() {
	objs := make([]MyStruct, 3)
	for ii := range 3 {
		fmt.Printf("ii: %d\n", ii)
		objs[ii] = MyStruct{ii}
		fmt.Printf("Value of .a: %d\n", objs[ii].a)
	}
	// Some time later...
	for _, st := range objs {
		st.a *= 2
	}
	// And yet later ...
	fmt.Printf("Value of obs[2].a: %d\n", objs[2].a)
}
$ go build main.go
$ ./main
Value of obs[2].a: 2

WHAT is THAT?! It's because range copies the slice it is iterating over.

Worse yet, if you try to write a traditional for <init>; <condition>; <update> loop, gopls will fuss at you and tell you to use a 'modernized' for-loop with the range syntax. It's pushing people towards unintuitive behavior.

I even had trouble with iterating over slices of pointers, which in my mind should solve the problem but didn't. I am still not 100% sure what caused these bugs, but I ended up changing every for-loop in the code to look something like this:

	for index := 0; index < len(objs); index++ {
		someFuncThatTakesPointers(&objs[index])
	}

Operating on slices is not very fun. If you search Google for 'how to delete from the middle of a slice in Go', you will get this code:

objs = append(objs[:index], objs[index + 1:]...)

Which is hideous and easy to mess up. The modern way is to use slices.Delete which is, well essentially the same thing, and has an unintuitive second parameter, and since Go does not support optional arguments, you can't "hide" that extra complexity.

Unintuitive type casting syntax

Maybe I'm just too noob to understand, but this really got to me:

type MyType int32

type Iface interface {
	// ...
}

type Iface2 interface {
	// ...
}

func foo(st Iface) {
	// OK
	st2 := st.(Iface2)

	// Not OK, which makes sense if you deep-dive but is unintuitive
	tmp := MyStruct{1}
	st3 := tmp.(Iface2)

	// Also not OK, which is even less intuitive
	st4 := (&tmp).(Iface2)

	// Not OK, which makes no sense
	var val int32 = 123
	val2 := val.(MyType)

	// OK, but why this syntax?...
	val3 := MyType(val)
}

Other random drive-bys

Conclusion

Go has a lot of things going for it, but the decisions here seem arbitrary. You could argue that Google wanted to reduce complexity and make the language simple and intuitive, but many of these choices result in more boilerplate code, and long comments explaining why it works differently here than in other languages. Maybe if I stick with the language this stuff will start to seem normal to me but right now it seems like a huge thorn in my side.