The Go programming language brings simplicity and a clear design philosophy that make it attractive for developers who are tired of the complexity of C++. In this article, we will explore the most interesting features of the Go language that distinguish it from C++.
Producer/consumer implementation with Go
If you didn’t see the first article of this series, please read it because I explained there what are the things I want to check about each C++ alternative and how I’m going to do that.
Below you case see the implementation of the reference producer/consumer application written in Go. I really wanted this implementation to be based on channels (more on them at the end of the article) because it’s a very interesting feature of Go, but because channels are by design meant to be used for one-to-one communication, they are not suitable for this use case.
package main
import (
"fmt" // needed for printing
"sync" // needed for Mutex
"sync/atomic" // needed for atomic variable
"time" // needed for time measuring
)
// global variables
var (
// mutex for shared resource access synchronization
mtx sync.Mutex
// allows to wait for all active go routines before main thead exits
wait_group sync.WaitGroup
// atomic variable to share a flag between the threads
is_finished atomic.Bool
)
// interface class
type WorkerInterface interface {
Run()
}
// template type definition
type Producer[T any] struct {
// public members
source_data [3]T
shared_container *[]T
}
// class constructor and members initialization
func NewProducer[T any](source_data [3]T, shared_container *[]T) Producer[T] {
// if statement
if shared_container == nil {
// error reporting by exception-like panic
panic("Given shared_container is nil!")
}
return Producer[T]{source_data: source_data, shared_container: shared_container}
}
func (producer Producer[T]) Run() {
// indexed for loop
for i := 0; i < 10000; i++ {
// range based for loop
for _, element := range producer.source_data {
// print statement
fmt.Println("Putting", element, "to the shared container")
// blocking reading and writing from/to the shared container
mtx.Lock()
// appending element to a variable-length array
*producer.shared_container = append(*producer.shared_container, element)
mtx.Unlock()
}
}
is_finished.Store(true)
fmt.Println("Producer done")
wait_group.Done()
}
type Consumer[T any] struct {
id uint
last_size int
shared_container *[]T
}
func NewConsumer[T any](id uint, shared_container *[]T) Consumer[T] {
if shared_container == nil {
panic("Given shared_container is nil!")
}
return Consumer[T]{id: id, shared_container: shared_container}
}
func (consumer Consumer[T]) Run() {
// infinite loop
for {
// waiting for the input from another thread
if (!(len(*consumer.shared_container) > consumer.last_size || is_finished.Load())) {
continue;
}
if (is_finished.Load()) {
break;
}
mtx.Lock()
fmt.Println("Consumer", consumer.id, "noticed new element:", (*consumer.shared_container)[len(*consumer.shared_container)-1])
consumer.last_size = len(*consumer.shared_container)
mtx.Unlock()
}
fmt.Println("Consumer", consumer.id, "done")
wait_group.Done()
}
// enum definition
type WorkerType int
const (
kProducer WorkerType = iota
kConsumer
)
// template function
func CreateWorker(worker_type WorkerType,
source_data [3]string,
shared_container *[]string,
id uint) WorkerInterface {
if worker_type == kProducer {
return NewProducer[string](source_data, shared_container)
} else if worker_type == kConsumer {
return NewConsumer[string](id, shared_container)
} else {
panic(fmt.Sprintf("Unsupported worker type: %d", worker_type))
}
}
// application entry point
func main() {
// error handling
defer func() {
if err := recover(); err != nil {
fmt.Println("Error:", err)
}
}()
// start measuring execution time
start := time.Now()
// measure execution time and display the result at the end
defer func() {
duration := time.Since(start)
fmt.Println("Time elapsed:", duration)
}()
// initialize is_finished global atomic variable
is_finished.Store(false)
// fixed-length array
source_data := [...]string {"Hello", "world", "!!!"}
// variable-length array
message_container := make([]string, 0)
shared_container := &message_container
// creation of the polymorphic types
producer := CreateWorker(kProducer, source_data, shared_container, _)
first_consumer := CreateWorker(kConsumer, source_data, shared_container, 0)
second_consumer := CreateWorker(kConsumer, source_data, shared_container, 1)
wait_group.Add(3)
// start 3 threads
go producer.Run()
go first_consumer.Run()
go second_consumer.Run()
// wait for threads to finish
wait_group.Wait()
}
So accordingly to the list of checks that I’m interested in, Go gives us the following statistics:
- length of the code – this code is 106 lines long (124 lines for C++)
- build time – it takes 91ms to build this application (2149ms for C++) with command:
/usr/local/go/bin/go build -ldflags "-s -w" producer_consumer.go
- execution time – it takes 2172ms to run it (597ms for C++)
- binary size – 1.4MB (105kB for C++)
What I like about this code?
Convenient array syntax
In Go you don’t need to specify size of the fixed-size array if you provide its elements during initialization. This is a very nice feature because whenever you want to change the content of the array, you just add or remove an element from the initialization list, without having to additionally change the size of the array.
Multi-threading
I love the fact that Go doesn’t force me to create a thread, specify its worker, start it, wait for every thread to join separately etc. – if you have a function that you want to run asynchronously, you just call go function()
and that’s it.
Compile-time checks
Go fails to build the program if a certain variable is not used. It’s not a warning or a suggestion – you just won’t get any executable binary out of such source code.
Easy importing
Go allows to provide all the imports in form of a list, so I didn’t need to repeat “import…” for every required element.
What I don’t like about this code?
Consts not applicable to all types
I was not able to create e.g. a constant array because Go doesn’t allow for that.
Name-based encapsulation
It’s not visible directly in the example, but Go’s encapsulation relies on the naming convention – if something starts with a upper-case letter, it’s public (available outside of the package) and if something starts with a lower-case letter, it’s private (not visible form outside of the package). Although it solves the problem of having special keywords like public
, private
etc., I think it can sometimes be a pain in the ass because I imagine a situation in which I want to just change the visibility of the function to private and suddenly I need to change the name of the function in all places where it has been already used within the package.
Missing handy features
In the Producer/Consumer example code I use a variadic template to provide the constructor’s arguments to CreateWorker
function depending on what worker type I am currently creating (Producer vs Consumer). Unfortunately, Go doesn’t have that. I thought that I’ll workaround that just by making some arguments having default values, so that I can provide only the ones which are relevant during creation of a specific worker. To my surprise, it turned out that Go doesn’t allow function’s argument to have a default value.
Moreover, there’s not even a built-in enum type which forces user to define enums in a pretty weird way, not fitting into the general simplicity of the Go language.
Using Go to write C++ code for the existing code bases
Go compiles directly to the machine code, so there’s no out-of-the-box way to generate C++ code out of it.
Using existing C++ code in Go
Go does not allow to use C++ code within Go programs. There is Cgo, but it requires to wrap all the C++ functions in a C interface, so effectively it does not allow for usage of all C++ features. For example, our our reference C++ library user uses templates which are not supported in C. So for me, the final conclusion is that I just can’t use the existing C++ code writing programs in Go.
Other interesting Go features
Concurrency – Go’s main achievement
Every programming language has something what its developers put at the center of its philosophy. For Go, concurrency may be considered as such thing. It has a very interesting approach to concurrency and thread-safety summarized by the sentence:
Do not communicate by sharing memory; instead, share memory by communicating.
Go implements it by usage of channels which are Go’s attempt to assure thread-safety by design and not by usage of synchronization primitives like mutexes. Channels may be considered shared resources (a channel may be as simple as a single integer value) which can be accessed only by one goroutine at a time. If you’re a C++ programmer, you can see the channel as a combination of std::mutex
, std::future
and std::condition_variable
. Below you can find the code showing channels in action (notice also how simple it is to spin up a new thread-like execution flow just by adding go
before the function call):
package main
import (
"fmt"
"time"
)
// Sender function
func Send(channel chan int) {
counter := 0
for {
// Send the current counter value to the channel
channel <- counter
counter++
time.Sleep(1000 * time.Millisecond)
}
}
// Receiver function
func Receive(channel chan int) {
for {
// Receive value from the channel and print it
value := <-channel
fmt.Println("Received:", value)
}
}
// Main function
func main() {
// Create an unbuffered integer channel
channel := make(chan int)
// Start sender goroutine
go Send(channel)
// Start receiver goroutine
go Receive(channel)
// Block the main function to allow goroutines to run indefinitely
select {}
}
Switch, but for threads
Channels have their own switch
-like statement which is called select
in Go. It allows to react upon incoming data from multiple channels:
package main
import (
"fmt"
"time"
)
// Sender function
func Send(channel chan int, delay time.Duration) {
counter := 0
for {
// Send the current counter value to the channel
channel <- counter
counter++
time.Sleep(delay * time.Millisecond)
}
}
// Main function
func main() {
// Create two channels
firstChannel := make(chan int)
secondChannel := make(chan int)
// Start 2 sending goroutines, each with a different delay between sends
go Send(firstChannel, 1000)
go Send(secondChannel, 1500)
// Use select to react upon the data incoming from 2 channels
for {
select {
case value := <-firstChannel:
fmt.Printf("Received value from channel 1: %d\n", value)
case value := <-secondChannel:
fmt.Printf("Received value from channel 2: %d\n", value)
}
}
}
Implicit conversions
I wondered if this point shouldn’t actually be at the top of the list because the situation with implicit conversions in C++ is so bad and so confusing for the beginners that I recently started to consider it as one of the most important features of the programming language. It’s mainly because C++ claims to be a strongly typed language, which lets your guard down, but in reality you come across multiple situations in which the language behaves as if it doesn’t care about the types. Here is an (abstract, but vivid example – I don’t want to repeat boring examples with assigning int
to a float
) example of what I mean – this is a valid and working code in C++:
class IntNumber
{
public:
IntNumber(const int value) {}
};
int main()
{
IntNumber value = 2;
}
Someone will say “hey, just add an explicit
before the constructor and the compilation will fail” and it’s true, but then I can ask what if something like this slips through the review:
class IntNumber
{
public:
explicit IntNumber(const int value) {}
IntNumber(const int &&value) {}
};
int main()
{
IntNumber value = 2;
}
It compiles again. Now I hear voice saying (because I already heard such argument) that “it is not a bug because you see IntNumber
type name explicitly written on the left of the value
which is being initialized with 2
, so it is basically a language feature and no implicit conversion here”. Ok, let’s then add a simple function to this code and tell me where do you see the IntNumber
type name during the function call:
class IntNumber
{
public:
explicit IntNumber(const int value) {}
IntNumber(const int &&value) {}
};
void SomeFunction(IntNumber obj) {}
int main()
{
SomeFunction(2);
}
The answer is: you don’t. And remember that IntNumber
class from the example can be arbitrarily complex type or can start some resource management directly in the constructor, so maybe you’ve just constructed a heavy communication proxy or a database connection broker directly out of an integer literal.
My point is that there’s just so many ways in C++ to trigger an implicit conversion that you must never forget about it. In Go the situation is simple – if you have any custom type definition, even a type which is basically an alias, like below:
type IntNumber int
you must convert everything explicitly to that type.
Uniform formatting with Gofmt
One of Go’s standout features is its commitment to uniformity of code formatting. The language comes with a built-in formatter gofmt
that imposes a consistent style across all Go codebases. Unlike C++, where formatting styles can vary greatly from one team to another, Go enforces a common standard. This means that Go developers spend less time debating style guidelines and more time focusing on solving real problems.
However, Go’s formatting solution isn’t perfect. For instance, it doesn’t care much about line length or whitespaces, which means that two developers using gofmt
may still produce different source code.
Scope syntax
Go has only one way to write scope curly brackets after if
, for
etc. The opening curly bracket must be placed in the same line because otherwise the Go lexer may insert a semicolon after the statement changing its meaning, for example:
if value == Read() {
Run()
}
Is not the same as:
if value == Read()
{
Run()
}
In fact, the latter version won’t even compile because the compiler will complain about an unexpected newline.
Named return values
One of Go’s interesting features is named return values, which allow you to name return variables directly in the function signature:
func MyFunction(arg int) (returnedValue int)
By doing so, you can use returnedValue
directly inside the function without explicitly declaring it again. This reduces boilerplate code and helps the code to be self-documented. The downside is that these named return values are zero-initalized to their default values, so it also allows to return value which was not modified during the function flow (so was not explicitly initialized to any particular value).
However, I admit that this may be a concern brought from other programming languages, a concern that is not applicable to Go. Go actually encourages to design types accordingly to zero-value-is-useful rule meaning that the memory initialized with zeros translates to some valid state of the object (for example, a zeroed mutex translates to an unlocked mutex). That brings us to the next interesting concept in Go.
Memory allocation: new
vs. make
In Go, there are two ways to allocate memory: new
and make
. These functions differ significantly from their similarly-sounding counterparts in C++ (new
, make_shared
, make_unique
).
The new
function allocates memory for an object, but does not initialize it. I must admit that at the beginning for me, as a person used to C++, it was pretty confusing because in C++ you can’t write the following code if T
doesn’t have a default constructor T()
:
T* ptr = new T();
The behavior of Go’s new
behaves more like such code:
T* ptr = (T*)malloc(sizeof(T));
In Go, if you want to:
- allocate memory for the object
- initialize the object
- get a pointer to such an allocated and initialized object
you use composite literals (Go’s constructors) like on the code snippet bellow:
func NewT(id int, name string) *T {
return &T{id, name}
}
The make
function is a totally different story, starting with the fact that it doesn’t even return a pointer. If you come from outside of C++ world, you could ask “Why would it?”, but if you’re a C++ insider you see that the connection is obvious. Its usage is also limited to slices, maps and channels. All these types happen to carry a reference to a data structure that must be initialized before use what make
is responsible for.
Resource management with defer
Go gives the ability to defer a statement until the end of functions scope (similar as the scope guard statements in D). The deferred statements are deferred in form of a stack, so their execution order is reversed. It’s helpful and definitely better then writing the same functions at the end of the scope, but as I mentioned in the article about D, I’m not a big fan of defer-like mechanisms because although it helps to not forget about releasing certain resource (e.g. after adding a new path to the function), it still must be manually typed in by the programmer who is responsible for remembering it, so we can be sure that sooner or later someone will forget about it anyway.
Error handling
Go differentiates between two types of errors: recoverable and unrecoverable. Recoverable error handling relies on multivalue returns because a function may return 2 values – the actual returned value and the associated error which can be checked by the caller before using the value.
In contrast, unrecoverable errors, such as accessing out-of-bounds slices, trigger a panic
, which immediately stops normal execution and begins stack unwinding. However, Go provides the recover
function (in my opinion, not the best name choice for the “unrecoverable” type of error handling) to regain control during the unwinding process. Because the only code that is able to run during stack unwinding is inside the deferred functions, recover
must be used inside of a deferred function as well.
func Process(arg int) {
defer func() {
if err := recover(); err != nil {
fmt.Println("Processing failed! Error:", err)
}
}()
// do something...
}
If the section // do something
panics, the control flow will be regained by the recover
and error will be printed.