There are tons of backend programming languages out there. They all have their fair share of advantages for different situations. For example Java is great for enterprise backends, Typescript + NodeJS is useful for full stack development. There’s also PHP and Ruby, which… I don’t know why you’d choose those, but to each their own I guess. But on our end, whenever possible, we prefer Go for its balance of execution speed, development speed and its opinionated philosophy which leads to a more standardized code, and thus better coding practices.
And just like our beloved language, we are also quite opinionated 🤓. So without further ado, let’s jump into why we prefer Go.
Performance vs development time
We found that Go hits the best balance between performance and development time. This is due to many factors, some of which we will discuss right away.
Small syntax
This point is usually regurgitated by every Go apologist, so let’s get it out of the way 😅. The fact that go has one of the smallest set of keywords and an easy syntax is great. This means that you can spend most of the time learning how to use the language instead of having to spend time learning the language.
All-in-one
This point is twofold, first of all, pretty much all the basic things you need regarding a web server is right there in the standard library, so you don’t really have to waste that much time looking for a package that does what you need (looking at you node 😠).
Speaking of which, here comes the second point. Whether it’s dependency management, a linter, a test framework, benchmark tool, race detector, and even more, it’s all included in the go
CLI tool. There is even a standard formatter, gofmt. This means you don’t have to think about which package manager to use, what linter and style you’ll choose, what testing framework, etc. It’s all just there and you can jump into coding right away, and all your code will look homogeneous.
Speaking of which, this ties to our next point.
Opinionated
The compiler and linter are extremely obnoxious, which might sound like a bad thing, but we find it’s actually quite the opposite. Like for example with the error handling (expanding on what I already wrote). Lets say we have the following code:
package foobar
type myInterface interface{ Foo() (int, error) }
func Bar(i myInterface){ result, err := i.Foo() fmt.Println(result) }
This won’t compile because the err
variable is defined but never used. In order for this to compile, you have to either use the variable, for example:
func Bar(i myInterface){ result, err := i.Foo() if err != nil{ panic(err) } fmt.Println(result) }
Or rather actively choose to ignore it with a _
instead of a name:
func Bar(i myInterface){ result, _ := i.Foo() fmt.Println(result) }
The constant nagging from the compiler might seem petty, but it will help you making your code shorter hence easier to read, and more importantly, it leads to a more stable code by enforcing proper error handling. We’ll dive a little more into this point in a little while
Memory management
For most node, Java, etc. developers this will be either a foreign or an irrelevant concept. But when it comes to speed being able to use the memory stack in stead of allocating memory in the heap can make a huge difference. While you don’t have such a granular control as in C and C++, you do have some amount of control. If you do not use pointers, you will not allocate memory, while if you do, you might allocate memory.
“Wait, did you say pointers?!”
Yes, but not to worry, you don’t need to malloc
nor free
. In fact, most languages you use DO have pointers. Let’s think of Java and C#, in them, every instance of a class is actually a pointer to the object, that’s why you might hear that objects are “passed by reference” which is actually not true, you are just passing a copy of the pointer to an object. The difference in Go is that you are the one who chooses whether it is a pointer or not, but just as those languages, allocated memory is freed by a garbage collector.
Garbage collection
Go is garbage collected, so you might think of it as the middle point between a C and a Java in terms of memory management. You have some control, but you leave all the hard parts to the garbage collector. Which in all honesty, has been a point for contention. Go’s garbage collector was not always great, and while it is great now, we still believe the benefits in memory management outweigh the benefits of a better garbage collection system, since in most programs you would not use that much memory allocation anyways. And any kind of garbage collection outweigh the benefits of manual memory management in most applications.
Great typing system
In our eyes, having a strongly statically typed language is non-negotiable. Look everybody thinks they’ll never make a mistake in such an obvious manner as using the wrong type, but that’s bull$#!%. You’ll always have a bad day, or come to write a quick feature your PM asked you on a Friday and you just half-ass it. And having a good typing system can save you from those mistakes.
On top of that, we actually like Go’s type system quite a bit. Being able to define interfaces only where you need them, instead of having a class implement loads of interfaces that all are subsets of the class helps a ton towards complying with ISP
Object Oriented, but not all the way
In OOP it’s often said to use composition over inheritance. In fact, according to Alan Kay (the father of OOP), it’s not even a necessary part of the definition. According to early versions of SmallTalk
- Objects communicate by sending and receiving messages.
- Objects have their own memory.
- Every object is an instance of a class.
- The class holds the shared behavior for its instances
In this definition of object and class, there is no mention of inheritance. However, in practice this is rarely the case. Not only languages favor inheritance over composition syntax-wise, but also most patterns are modeled with inheritance.
Which in all honesty kind of makes sense, Liskov’s Substitution Principle (LSP) is awesome. But you don’t really need inheritance for LSP. So if you could reach something alike with composition instead of inheritance, it would be great.
Ok, so where does Go come into this point? In go there is no such thing as inheritance amongst structs, BUT there is an amazing way to use composition, embedding. And if you work with interfaces, you can follow LSP with no drama.
type A struct{ n int }
func (a *A) SetNum(int n){ a.n = n }
func (a *A) String() string{ return fmt.Sprintf("A: %d", n) }
type B struct{ *A }
Here we have the struct A
which is embedded inside the struct B
. So… what does it do? Well, we now have every method defined for A
accessible from a B
object. Like so:
func main(){ b := &B{&A{}} b.SetNum(4) fmt.Println(b) }
This will print A: 4
Wait, isn’t this just inheritance with extra steps?
No, not at all. This is composition through and through. It’s just a convenience that the language gives us so that we don’t need to write methods that do nothing but call another method. In fact, when we call b.SetNum(4)
it’s acctually calling b.A.SetNum(4)
. So we are actually modifying the the A
object and never b
.
We can see this if we add to B
methods with the same name, we can still access A
’s methods and both objects do not affect each other. Like so:
type B struct { *A n int } func (b *B) SetNum(n int) { b.n = n } func (b *B) String() string { return fmt.Sprintf("B: %d, A:%d", b.n, b.A.n) } func main(){ b := &B{A:&A{}} b.A.SetNum(5) //the A object is still accessible b.SetNum(4) //we are accessing the b object fmt.Println(b) }
This will print B:4, A:5
This way we can comply with LSP without needing to deal with that nasty inheritance 🤢
Error handling
Let’s get back to error handling. One of the hardest things in software development (besides naming and cache invalidation 🙂) is what to do on an unsuccessful function call. There are two ways to handle this, one is through exception handling, the other one is through error handling. Exception handling is the norm, but lately new languages (such as Go and Rust) opt for the latter.
But why? In exception handling, you only handle the exception when you want to. This means you usually do not handle it, because obviously you will never program a bug into your code-base, so why even bother? Or you might do some catch-all exception handling just because without thinking much about what might happen.
On the other hand, on error handling, it’s the other way around, be it via a Result monad (Rust), or an obnoxious compiler that won’t let you have unused variables (Go), you have to handle it by default, and you can then choose to ignore it. While this does mean you will probably have more error handling code (the dreaded if err != nil
) this also means your code will probably be less error prone.
Green threads
One of the biggest features of Go are goroutines, which is a fancy name for a green thread. The difference between threads and green (or virtual) threads, is that green threads do not run directly against the OS, instead depending on a runtime. The runtime then managing the native OS threads as it sees fit.
But wait, is this not a bad thing? Not necessarily, first of all, you do not depend on the OS’s ability to multi-thread, which to be honest, nowadays is not a big issue.
However, one big advantage is that since this threads do not need to create a whole new thread at the OS level, they can be much more lightweight than real threads. This means that you can effectively launch more threads for the same startup cost as native OS threads.
The last advantage is that since the runtime itself is managing the threading, it can both detect deadlocks and data-races easily. Which Go offers great tooling for.
Easy thread synchronization
Speaking of multi-threading, one of (if not the) biggest hurdle when speaking of multi-threading is synchronization. Luckily Go offers straight out of the box some great tools for this.
- Channels: this is the simplest way to communicate between and synchronize threads Go offers. You can send messages to channels and you can read from channels. By default, each time a goroutine writes to a channel it is blocked until another one reads from it, and vice-versa.
- WaitGroup: this a a struct with 3 methods.
Add(i int)
,Done()
andWait()
. You can add the number of tasks you want to wait for, mark a task as done, and wait until all tasks are done. - Mutex, Semaphores, et al: all the classic synchronization models are also available under the
sync
package in the standard library.
In conclusion
Even though we paint it like that, it’s not all rose colored. There are some drawbacks when it comes to the language, like for example the lack of enums, but all these drawbacks are usually really easy to deal with. Like for example to solve the enum, you could just use a custom type together with iota
.
And for pretty much any problem you may face, there is already a known solution, and also there is a great community with awesome packages for pretty much anything you might want.
So yeah, we are definitely full blown Go apologists!
- 登录 发表评论