Make C++ a better place #4: Go as an alternative


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.

Go
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:
Bash
/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):

Go
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:

Go
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++:

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:

C++
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:

C++
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:

Go
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:

Go
if value == Read() {
    Run()
}

Is not the same as:

Go
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:

Go
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():

C++
T* ptr = new T();

The behavior of Go’s new behaves more like such code:

C++
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:

Go
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.

Go
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.