go

Go, I love you, but you're bringing me down.

loderunner

Charles Francoise

Posted on July 24, 2017

Go, I love you, but you're bringing me down.

I've said it before, and I'll say it again: I really like Go. It's the most fun I've had learning a language since Python. It's statically typed and compiled – and as a systems-oriented programmer, I never found anything that could really move me away from C because of this – it has a great standard library, it handles code reuse without inheritance beautifully, and those concurrency paradigms have changed my life for ever.

That being said, I quickly had a few issues with some of the language's specifics. And while I've been trying to learn its patterns and conventions as best I can, I'm pretty certain that some of them could be fixed inside the language, and not by using a specific pattern or abstraction. For the sake of simplicity.

As I was reading Russ Cox's Gophercon 2017 talk about Go 2, I decided to take him up on this:

We need your help.
Today, what we need most is experience reports. Please tell us how Go is working for you, and more importantly not working for you. Write a blog post, include real examples, concrete detail, and real experience. That's how we'll start talking about what we, the Go community, might want to change about Go.

So here are, as a new Go programmer, my 2 cents about what's missing.

Operator overloading

Got your attention? Good. Because I hate operator overloading. I've been through too many C++ code bases where developers thought it was better to be clever than to write a clean interface. I've had my share of over-enthusiastic coders re-defining fundamental semantics such as the dot(.), arrow(->) or call(()) operators to do very unexpected things. The cost in documentation and on-boarding new developers was huge, and the mental work of switching paradigms when coding made the whole thing quite error-prone. The fact that the stdlib does weird things pretty freely (std::cout << "Hello, World!", anyone?) doesn't really help.

But operators are also a great shorthand for some clearly defined semantics. In other words, if semantics precede the operator, there's a far lesser chance to abuse them. An example would be the [] operator for integer-based indexing (such as arrays or slices), or for key-value operations (such as maps). Or the usage of the range keyword to iterate over arrays, slices, maps and channels.

One way to make sure these semantics are well respected would be to make the operators as syntactic sugar to well-defined interfaces.

Key-value operator:

struct KeyValueMap interface {
    Value(interface{}) interface{}
    SetValue(interface{}, interface{})
}
Enter fullscreen mode Exit fullscreen mode

We could then use the [] operator as a shorthand to these functions.

foo := keyVal["bar"]
Enter fullscreen mode Exit fullscreen mode

would be equivalent to

foo := keyVal.Value("bar")
Enter fullscreen mode Exit fullscreen mode

The objects used as key would need to be hashable or comparable in some way. Perhaps the compiler could enforce the same rules it does with maps.

Range iteration could be done similarly:

type Iterator interface {
    Next() (interface{}, bool)
    Reset()
}
Enter fullscreen mode Exit fullscreen mode

Next would return the next value in the iteration, and a bool that would be true when the iterator is finished. Reset would be used to initialize the iterator at the beginning of the for loop.

for v := range it {
Enter fullscreen mode Exit fullscreen mode

would be equivalent to

it.Reset()
for !done {
    i, done := it.Next()
    if done {
        break
    }
Enter fullscreen mode Exit fullscreen mode

I think this would allow Go developers to implement much more go-ish structures and I can see many cases of memory-efficient iterators that would make use of this clean syntax.

Of course, this kind of breaks the strong typing in Go. Maybe a feature like this would go hand-in-hand with generics?

Initialization of embedded interfaces

We recently had a case of an issue that took us a while to debug. We were using type embedding to compose different services in one structure. At first we had just one handler.

type FooHandler interface {
    // some `FooHandler` methods
}

type Handler struct {
   FooHandler
}

func main() {
    // Foo is a concrete type implementing FooHandler
    h := Handler{FooHandler: &Foo{}}
    RegisterHandler(h)
Enter fullscreen mode Exit fullscreen mode

We embedded a second handler, but forgot to add it in the Handler initialization. The error message was pretty cryptic.

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x20 pc=0x10871d6]

goroutine 1 [running]:
main.RegisterHandler(0x1108140, 0x1126ac8, 0x0, 0x0)
Enter fullscreen mode Exit fullscreen mode

This was fixed by adding the initialization.

    h := Handler{FooHandler: &Foo{}, BarHandler: &Bar{}}
Enter fullscreen mode Exit fullscreen mode

I wish the error message would have been clearer. Such a simple omission shouldn't require 30 minutes and two programmers. Maybe we're just new at Go, but I wish the error would have been more explicit. That or the compiler could warn against using zero values for embedded types. I don't know if there's a case where you would want to embed a nil value. This would probably be a breaking change for a few code bases, but the benefit in memory safety could be worth it.

Closed channels

EDIT: The issue I detailed here was only because of a feature of the language I didn't know of. It was pointed out in the comments and on Twitter (and here). I'll be showing Go way to avoid the problem I had.

In the following program, the final loop loops forever while receiving zero values from both channels, ca and cb.

func main() {
    // Create a first channel
    ca := make(chan int)
    go func() {
        // Send one integer over the channel every millisecond up to 9
        for i := 0; i < 10; i++ {
            ca <- i
            time.Sleep(1 * time.Millisecond)
        }
        close(ca)
    }()

    // Create a second channel
    cb := make(chan int)
    go func() {
        // Send one integer over the channel every millisecond up to 99
        for i := 0; i < 100; i++ {
            cb <- i
            time.Sleep(1 * time.Millisecond)
        }
        close(cb)
    }()

    // Receive from the first available channel and loop
    for {
        select {
        case n := <-ca:
            fmt.Printf("a%d ", n)
        case n := <-cb:
            fmt.Printf("b%d ", n)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This problem can be fixed by reading two values from the channel. By using n, ok := <-ca, ok will be false if the channel is closed. Once the channel has been closed, we set it to nil as receiving from a nil channel blocks.

    // Read from first available channel and loop
    // until both channels are nil
    for ca != nil || cb != nil {
        select {
        case n, ok := <-ca:
            if ok {
                fmt.Printf("a%d ", n)
            } else {
                // If the channel has been closed, set it to nil
                // Receiving from a nil channel blocks, so we know
                // this select branch will be unreachable after this
                ca = nil
            }
        case n, ok := <-cb:
            if ok {
                fmt.Printf("b%d ", n)
            } else {
                cb = nil
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

I'm still pretty new to Go, so I'm fairly certain these are either misconceptions on my part, or things that have been tried and abandoned because they didn't work. Feel free to give me any documentation or pointers in the comments.

💖 💪 🙅 🚩
loderunner
Charles Francoise

Posted on July 24, 2017

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Where GitOps Meets ClickOps
devops Where GitOps Meets ClickOps

November 29, 2024

How to Use KitOps with MLflow
beginners How to Use KitOps with MLflow

November 29, 2024