Golang - My First Impressions


I've been using Golang at work for about a week. and the language has turned out to be so different from what I expected that I've decided to pen down some of my thoughts about it. In a nutshell - I don't like it much, not yet at least. I'm hoping that getting more familiar with Go in the coming months might change my view.

OOP and Embedding

I find Object-Oriented Programming (OOP) to be the most reliable paradigm to build robust, maintainable code. Maybe not the wonky sort of OOP that Python offers but perhaps something like Swift or Java. However, Go does not exactly support OOP as we know it. It does not provide Classes and supports Composition over Inheritance. This is not necessarily an issue but just something that takes time to get used to.

Syntax and Implicit Interface Conformation

I found the syntax for ascribing structs methods a bit odd. Unlike what I was used to, these struct methods are defined outside the struct body. Ideally, they are placed right below the struct definition. However, I would have preferred if this was enforced by the language.

type IMove interface {
    move()
}

type Person struct {}
func (*Person) move() {
    fmt.Println("Person is moving")
}

func (*Person) eat() {
    fmt.Println("Person is eating")
}

Another thing that I found odd was that there was no need to declare whether a struct conforms to an interface. If it implements the interface's methods, it automatically conforms to the interface at runtime. However, with embedded interfaces and structs, without explicit interface conformation declaration, it can be quite tedious to figure out whether a struct conforms to a certain interface. The only way to check would be to compile the code and get the compiler to throw an error.

Embedding Interfaces

In Go, we can compose two interfaces to create a combined interface. We call it embedding multiple interfaces within another. Here is an example from Effective Go -

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

To make an interface ReadWriter which has both Read and Write methods, all we need to do is to embed Reader and Writer within ReadWriter -

type ReadWriter interface {
    Reader
    Writer
}

Embedding Structs

We can do the same to combine two structs to make a combined struct. In the example below, ReadWriter stores pointers to a Reader and a Writer. By doing so, it is borrowing the functionalities of both the Reader and Writer. Also, this ensures that ReadWriter. Pretty sweet!

type ReadWriter struct {
    *Reader  // *bufio.Reader
    *Writer  // *bufio.Writer
}

It does have some issues such as when both the struct implement a common method. In such a case you'll be met with a ambiguous selector error.

Error Handling

In Go, there is no try-catch. Most functions that are error-prone return two arguments - the actual return value and an optional error object. In a try-catch world (as illustrated by the Python snippet below), code would be much simpler as it is easy to distinguish the happy path from the error handling part. Furthermore, you can choose to either handle the error by yourself or throw it further up the call stack to the appropriate component to handle -

# Happy Path
try:
    file = open('file.txt')
    line = file.readline()
    number = int(line.strip())

# Error Handling
except OSError as err:
    print(f"OS error: {err}")
except ValueError:
    print("Could not convert data to an integer.")
    raise

However, in Go, you have to explicitly check for errors before proceeding. This leads to your code being riddled with if err != nil code blocks -

file, err := os.Open("file.txt")

// Explicitly checking for err
if err != nil {
    log.Fatal(err)
}

defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}

// Checking for err again
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}

Furthermore, in a call stack, every intermediate caller needs to check for errors because errors are not automatically thrown up the stack. If one intermediate caller omits the check, it could sent an erroneous value to its caller who would not be notified of the error and will treat the returned value as the expected result. Annoyances like these are ther reason why GORM, Golang's popular ORM does not use Go's idiomatic error handling constructs.

The Lack of Generics

Let's first talk about the problem that generics solve. Let's say you wrote a function in Swift that swaps two integers -

func swapTwoInts(_ a:  Int, _ b: Int) -> (Int, Int) {
    return (b, a)
}

You can do the same with ease in Go too -

func swapTwoInts(a int, b int) (int, int) {
    return b, a
}

Now, if you decide that you need this swapping function for flipping floats and strings as well, you can save yourself a lot of time and effort by using generics. By making the function accept generic arguments, the function does not need to be re-written for different types -

func swapTwoInts<T>(_ a:  T, _ b: T) -> (T, T) {
    return (b, a)
}

The T here is a type parameter that is a placeholder for any type. And so, this function works as long as a and b are both of the same types. However, in Go, we have no choice but to duplicate functions for different types of arguments.

func swapTwoInts(a float64, b float64) (float64, float64) {
    return b, a
}

func swapTwoInts(a string, b string) (string, string) {
    return b, a
}

Go does provide one option - the empty interface. Declaring the type of an argument as interface{} is like a wildcard for any and every type -

func swapTwoInts(a interface{}, b interface{}) (interface{}, interface{}) {
    return b, a
}

However, this does not check if a and b are of the same type. It does not even ensure that the return types are of the expected type either. Tl;dr using interface{} should be avoided as much as possible and that leaves us with our earlier option of duplicating the function multiple times.

It is good to note however that after years of debate on this issue, Go is finally set to add type parameters and generics to its syntax in a release soon.

The Lack of Optionals / Sum Types

Let's say you write a function which gets a user from a database by ID. If no user with the specified ID exists, it perhaps makes sense to either return a null value or throw an exception to notify the caller. In Golang, we cannot return nil when the expected return type is a User struct. So instead, we opt for option 2 - throwing an error. But we can't throw an error since Golang has decided to not provide a way to do so. Instead we have to return the error as a second value in a return tuple like -

func getUserByID(id int64) (models.User, error) {
    ...
    return nil, errors.new("User not found")
}

But this still not valid in Golang since we are trying to return nil in place of a models.User struct. The compiler is going to shout back at you for doing so and the only option you have left is to return an empty User struct as such -

func getUserByID(id int64) (models.User, error) {
    ...
    var usr User
    return usr, errors.new("User not found")
}

Now if the caller of this function forgets the err != nil check, they could possibly call one of the User struct's methods on this empty struct which could result in a chain of errors. And the compiler would not be able to preemptively check for this since it is a valid call.

This could have been again solved by better language design such as providing Sum Types (i.e allowing returning User | nil) or at least by providing Optionals.

Where Is My (Syntactic) Sugar?

Ternary Operator

Map, Filter, Contains

https://github.com/novalagung/gubrak

String Interpolation

fmt.Sprintf %v https://github.com/golang/go/issues/34174

String Enums

. . . to be continued

Optional Arguments and Default Values

Discussion

References

Effective Go Proposal for Type Parameters in Go


Published on 19 March 2021