Maciej Raszplewicz
Posted on December 16, 2020
Recently I had to learn the Go programming language and now I want to share my thoughts from the perspective of a Java developer.
We decided to create Kubernetes operators for the DevOpsBox (https://www.devopsbox.io/) platform (read more about our reasons: https://dev.to/mraszplewicz/my-perfect-aws-and-kubernetes-role-based-access-control-and-the-reality-48fb). It turns out that it is easiest to create them in Golang - there are Kubebuilder and Operator SDK frameworks. The only thing is that we didn't have Golang skills, so I had to learn a new language...
I will start with things I like and move to those I don't. I will try to focus on the language itself.
Things I like
Easy to learn
It is amazing how easy it is to learn the Golang. A Tour of Go (https://tour.golang.org/) covers almost every aspect of the language and the language specification (https://golang.org/ref/spec) is reasonably short and readable. There are some not-so-easy-to-understand features like channels, but they are powerful and there is a reason why they exist.
Before version 5, Java didn't have so many features either, but it has never been as simple as Golang is now.
A fast developer feedback loop
By using the term "developer feedback loop", I mean the time from starting the program to seeing its results.
Sometimes you expect that you have to wait a long time to start a program written in a compiled language. Go main
or unit tests start almost instantly when run from the IDE, so the feedback loop can be very short. Results are similar to those in other modern programming languages and even though Go is statically compiled, you don't have to wait long for the compilation process.
It is funny that nowadays we have to build programs written in languages that are by design not statically compiled (e.g. JavaScript) and sometimes wait for the build process to complete.
Static type checking
Go has a very good static type checking system. You don't even have to declare a type of every variable, you simply write:
str := "Hello"
and it knows that variable str is of type string. The same is true when the variable is a result of a function call:
result := someFunction()
Implicit interface implementation
"If it walks like a duck and it quacks like a duck, then it must be a duck."
It means that you don't have to explicitly declare that you are implementing an interface. In Go you can write:
type Duck interface {
Quack()
}
type Mallard struct {
}
func(mallard *Mallard) Quack() {
}
Mallard is a duck because it can quack! You can write:
func main() {
var duck Duck
var mallard *Mallard
mallard = &Mallard{}
duck = mallard
duck.Quack()
}
In Go, you can create your own interfaces even for the existing external code.
Multiple return values
This one is simple - you can just return multiple values from a function:
func Pair() (x, y int) {
return 1, 2
}
In most languages I know, you will have to create a class or struct to achieve something similar.
But this feature is also used to return errors from functions - more about it in "Things I don't like".
Function values
Functions can be passed as parameters, assigned to variables, etc.:
func doJob(convert func(int) string) {
i := 1
fmt.Print(convert(i))
}
func main() {
convert := func(intVal int) string {
return strconv.Itoa(intVal)
}
doJob(convert)
}
It is somehow similar to method reference or lambda expressions in Java, but more "built-in" into the language.
Modules
Go has a really good built-in dependency management system. You don't need Gradle or Maven to download dependencies, you just use Go modules.
Unit tests
Unit test support is a part of the language itself. You just create a file with the "_test.go" suffix and write your tests. For example, for hello.go:
package hello
func sayHello() string {
return "Hello world!"
}
you create hello_test.go file:
package hello
import "testing"
func TestSayHello(t *testing.T) {
greetings := sayHello()
if greetings != "Hello world!" {
t.Errorf("Greeting is different than expected!")
}
}
and you can just run it from your IDE or in CI/CD pipeline.
If you want to write good tests in Golang, you should probably write "table-driven tests". A good article on this topic: https://dave.cheney.net/2019/05/07/prefer-table-driven-tests
Defer
Go provides something similar to Java's finally
keyword but, in my opinion, more powerful and simpler. If you want to run some code when your function returns, for example, to clean up some resources, you use the defer
keyword:
func writeHello() {
file, err := ioutil.TempFile(os.TempDir(), "hello-file")
if err != nil {
panic(err)
}
defer os.Remove(file.Name())
file.WriteString("Hello world!")
}
Here we create a temporary file that will be removed at the end of the function execution, just after writing "Hello world!".
Single binary
Go is famous for producing a single binary. When you build your program, you will get a single executable file containing all the dependencies. Of course, you have to prepare a separate binary for every target platform, but the distribution of your program is simpler compared to other languages. This is one of the reasons why Go is often used for creating command-line utilities.
Cobra library
https://github.com/spf13/cobra is the second reason... It is an extremely useful library, which helps to write command-line tools. Having created our operators in DevOpsBox, we also wanted to have our own CLI and it was a pleasure to write them using Cobra.
Things I don't like
Nothing is perfect and Go is no exception, it has some features that I don't like that much...
Error handling
I really don't like Go idiomatic error handling. There are a few main reasons why:
Error handling makes the code less readable
Often in Go programs, you see something like this:
func doJob() ([]string, error) {
result1, err := doFirstTask()
if err != nil {
log.Error(err, "Error while doing the first task")
return nil, err
}
result2, err := doSecondTask()
if err != nil {
log.Error(err, "Error while doing the second task")
return nil, err
}
result3, err := doThirdTask()
if err != nil {
log.Error(err, "Error while doing the third task")
return nil, err
}
return []string{
result1,
result2,
result3,
}, nil
}
I think that error handling adds a lot of noise here. Without it, this function would look like this:
func doJob() []string {
result1 := doFirstTask()
result2 := doSecondTask()
result3 := doThirdTask()
return []string {
result1,
result2,
result3,
}
}
Java, also, has some issues - namely checked exceptions, which add a lot of unnecessary noise. Thankfully, there are frameworks like Spring Framework, which wraps checked exceptions into runtime exceptions, so you can catch only those that you expect.
You can forget about handling an error
Consider this code:
func doJob() ([]string, error) {
result1, err := doFirstTask()
if err != nil {
log.Error(err, "Error while doing the first task")
return nil, err
}
result2, err := doSecondTask()
result3, err := doThirdTask()
if err != nil {
log.Error(err, "Error while doing the third task")
return nil, err
}
return []string {
result1,
result2,
result3,
}, nil
}
What's wrong with it? I forgot to handle an error! In a little bit more complicated cases, it is possible to miss it while doing a code review.
In other programming languages, you would have centralized exception handling and stack traces available for debugging purposes.
You don't have your stack trace
I have read some articles about error handling in Golang and there are opinions that stack traces are "unreadable, cryptic", but I got used to them and for me, it is easy to find a problem with help of a stack trace.
Problems with refactoring
IDEs have some issues with refactoring Golang code, for example, when extracting a method or a variable. I think that these problems are related to idiomatic error handling.
Sometimes too brief
Sometimes I feel that Golang is too brief. Why do we have keywords like func
, not function
? Why are we not forced to use surrounding parentheses in if
or for
? Why don't we have any keyword like public
and we have to declare upper case instead? But it is probably only my personal opinion...
Sometimes inconsistent
Go does not support function/method overloading (https://golang.org/doc/faq#overloading). I am ok with that because there are many programming languages without it, but why does the built-in make
function have many variants? Looking at the documentation:
Call Type T Result
make(T, n) slice slice of type T with length n and capacity n
make(T, n, m) slice slice of type T with length n and capacity m
make(T) map map of type T
make(T, n) map map of type T with initial space for approximately n elements
make(T) channel unbuffered channel of type T
make(T, n) channel buffered channel of type T, buffer size n
Features hard to remember
It is probably the case of any programming language, but Go is so simple that I thought I would easily remember how to use all the keywords and all the built-in functions. Unfortunately, the reality is quite different. After a year of using the language on a daily basis, I still need to look into the documentation or check examples to find how to use channels, or how to use make
. Maybe I have too many different programming languages in my mind...
No streaming API equivalent
There are some features in other languages that let you interact with collections using declarative syntax, like streaming API in Java. On the other hand, in Golang you won’t find anything similar (or maybe there is some library?) and it is idiomatic to use for
loops. Loops aren't that bad, but I like the streaming API and got used to it.
Not that good IDE support
I love JetBrains products and I use GoLand to write Go code. I have already mentioned issues with refactoring and here is an example. If you want to extract a method from this code:
func doJob() ([]string, error) {
result1, err := doFirstTask()
if err != nil {
log.Error(err, "Error while doing the first task")
return nil, err
}
result2, err := doSecondTask()
if err != nil {
log.Error(err, "Error while doing the second task")
return nil, err
}
result3, err := doThirdTask()
if err != nil {
log.Error(err, "Error while doing the third task")
return nil, err
}
return []string {
result1,
result2,
result3,
}, nil
}
GoLand will do it like this:
func doJob() ([]string, error) {
result1, err := doFirstTask()
if err != nil {
log.Error(err, "Error while doing the first task")
return nil, err
}
result2, result3, strings, err2 := doThirdAndSecond(err)
if err2 != nil {
return strings, err2
}
return []string{
result1,
result2,
result3,
}, nil
}
func doThirdAndSecond(err error) (string, string, []string, error) {
result2, err := doSecondTask()
if err != nil {
log.Error(err, "Error while doing the second task")
return "", "", nil, err
}
result3, err := doThirdTask()
if err != nil {
log.Error(err, "Error while doing the third task")
return "", "", nil, err
}
return result2, result3, nil, nil
}
which is not that bad, but requires additional manual fixes. IntelliJ does a better job for Java!
Conclusion
Although there are a lot of pros of Golang, cons are significant and therefore I have mixed feelings about the language. I can say that I like it, but it will probably never be my favorite. So which one is? C#, but that is a completely different story… I think it is a personal matter who likes which programming language, and it is all for good!
Posted on December 16, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.