Just How Becoming a C++ Developer Made Me Appreciate Go Even More

Parvez M Robin
8 min readSep 3, 2024

--

The image is generated by DALL-E after I described the article. I do not know how the image is relevant to the content. I certainly will not hit a robot-looking thingy with a hammer just before the plausible AI uprise.

For the last six months, I have been working at Siemens, where my primary language is C++. Before that my primary language was Go, and before that TypeScript and C#. I have always loved Go, but living and dealing with C++ for 40 hours on a weekly basis made me appreciate Go even more. I will present my experience with quite thorough examples. So, if you have not had your coffee yet, it is time to get some before continuing.

Implicit Type Conversion

Let us start with something simple and well-known. C++ lets you assign variables of bigger data types to smaller ones, ignoring the data loss it might cause.

int main() {
double d = 5.5;
int i = d;
bool b = i;
}

On the other hand, Go not only prevents assigning a float64 (Go variant of double) to and int without an explicit conversion, you cannot even assign an int to a float64 without an explicit conversion. This is because even during int to float64 conversion, you might lose some precision. Even if you do not lose precision, int to float64 conversion is not a simple operation (see the assembly they generate), and Go will not let you pretend that it is.

Possible Data Loss During Copy

This data loss becomes even more significant when this happens for objects. Consider the following code.

#include <iostream>

class Parent {
public:
int i;
};

class Child: public Parent {
public:
int j;
Child(int i, int j): Parent{i}, j{j} {}
};

int main() {
Child child = Child(5, 6);
Parent parent = child;
// no matching conversion for C-style cast from 'Parent' to 'Child'
Child childish = (Child) parent;
}

In the main function, when you assign a Child variable to a Parent variable, only Child.i gets copied. This becomes especially confusing if you come from a managed language like Java or C#, where everything is passed around by reference. So, when you assign a Child to a Parent variable, it still stays a Child and contains all data of a Child. This is not the case in C++.

Of course, this makes sense when you realize that Parent has space for only one int and, by default, C++ copies everything by values. So, only the fields defined inside Parent are copied. But, when you are building your software and thinking about the logic, you will not notice these small nitpicks. Then, when you attempt to cast a Parent back to a Child, the compiler complains that — no matching conversion for C-style cast from ‘Parent’ to ‘Child’, because you do not have Child-specific data anymore.

But, following how Go does it.

type Parent struct {
i int
}

type Child struct {
Parent
j int
}

int main() {
// variable types could be omitted.
var child Child = Child{7, 13}
var parent Parent = child.Parent
}

You have to explicitly say that I am just copying the data inchild.Parent. No data loss will ever happen behind the scenes. You know that you copied onlyParent-specific data, you would not expect that a Parent would be convertible to a Child.

Error-Prone Overriding

#include <iostream>

class Parent {
public:
void foo(bool) { std::cout << "Parent::foo \n"; }
};

class Child0 : public Parent {};

class Child1 : public Parent {
public:
void foo(int) { std::cout << "Child1::foo int \n"; }
};

class Child2 : public Parent {
public:
void foo(bool) { std::cout << "Child2::foo bool \n"; }
};

int main() {
Child0().foo(false); // Parent::foo
Child1().foo(false); // Child1::foo int
Child2().foo(false); // Child2::foo bool
}

Here, we have a class Parent with a method Parent::foo that takes a bool. Child0 extends Parent, but does nothing else. So, when we call Child0::foo with a bool argument, it executes Parent::foo.

Now consider Child1. It also extends Parent but adds a new implementation of the foo method that takes an int. So, now we can call Child1::foo with both bool (using Parent::foo(bool) implementation) and int (using Child1::foo(int) implementation). The right method will be called based on the parameter type. Not really! Not in C++. Even if you call Child1::foo with a bool argument Child1::foo(int) will be executed.

On the contrary, this is how Go does it.

package main

import (
"fmt"
)

type Parent struct{}

func (Parent) foo(_ bool) {
fmt.Println("Parent::foo")
}

type Child0 struct{ Parent }

type Child1 struct{ Parent }

func (Child1) foo(_ int) {
fmt.Println("Child1::foo int")
}

type Child2 struct{ Parent }

func (Child2) foo(_ bool) {
fmt.Println("Child2::foo bool")
}

func main() {
Child0{}.foo(true) // Parent::foo
// error: cannot use true (untyped bool constant) as int value in argument to Child1{}.foo
Child1{}.foo(true)
Child1{}.Parent.foo(true) // Parent::foo
Child2{}.foo(true) // Parent::int
}

When you have only one implementation of foo in Child0, then calling Child0::foo can execute Parent::foo without confusion. But, when you have implementations of a foo method both in Parent and Child1 with different signatures, then you have to explicitly tell that you want to call. So, just by looking at the code, you know exactly what is happening. Simply put — Go does not even allow you to write confusing code.

Templates Playing in God Mode

I have always been a big fan of Generics. As a matter of fact, as a lazy engineer, I have always been a big fan of writing less code. But, only as long as that less code does not contribute to increased development time. Remember, it is not about writing less code, it is about programming less.

C++ helped me in neither case. The funny thing is — it is not because its generic implementation (known as templates) is limited by any means, but rather because it is too powerful. Let me explain.

template <typename T>
void addAtIndex(T arr, size_t i, double value) {
arr[i] += value;
}

Looking at this code, you have no way of knowing the actual type of T. So, you also do not know why you can access the size_t i index of arr, let alone why you can add to the indexed value. Neither does the compiler.

Only when you call this function with an argument, then the compiler knows the actual type of T and it performs the type checking accordingly. So, if you call addToIndex(double*, size_t, double), it will work just fine. But, if you call addToIndex(double, size_t, double), then you will get an error saying

error: subscripted value is neither array nor pointer

There are three takeaways from this.

Firstly, to validate addToIndex, the compiler has to find all calls to this function and validate it. You can see why adding a templated function/class makes the compilation significantly slower.

Secondly, the reader has no idea what type of data addToIndex will operate on, unless they go through the whole function. It is common for a long-templated function that the user (a.k.a me) thought it would work for a certain data type. But, for a single operation hidden in the long function body, that data type does not work.

Finally, in the make system, C++ programs are compiled as a set of translation units. Generally, translation units have no idea of other translation units. So, if addToIndex is used with a certain type outside its own translation unit, the compiler will not know that and will not generate any definition of that type. To solve this, we have to use something called — template instantiation. That is within the same translation unit, we have to ‘kind of’ call addToIndex with all possible data types.

On the other hand, this is how Go does this.

package main

import (
"fmt"
)

type Number interface {
int | float32 | float64
}

func addToIndex[T Number, TSlice ~[]T](arr TSlice, i int, value T) {
arr[i] += value
}

func main() {
arr := []float32{1, 2, 3, 4}
addToIndex(arr, 0, 4.0)

fmt.Println(arr[0])
}

Here, in the definition of the template function, we are defining that T can only be one of the Number types, and TSlice can be a slice (kind of like std::vector in C++) of the T type. So, just by looking at the definition of addToIndex, you know that it works with slices of Numbers. You also, know that by Number, we mean int, float32, or float64 and nothing else. The compiler knows that too. So, it can compile the code blazing fast. You and I can read the code and understand it blazing fast.

Implicit Pass By Reference

In C++, you can implement call-by-reference in two ways — pointers and references. When a function takes a pointer argument, the caller explicitly has to pass a pointer using the &variable syntax. However, when a function takes a reference to an argument, the caller has no way of knowing that its data can be modified (without opening the function definition). Consider the following example.

#include <iostream>

class Foo {
public:
int i;
};

void print(Foo& foo) {
foo.i++;
std::cout << foo.i << std::endl;
}

int main() {
Foo foo{5};
print(foo); // prints 6
print(foo); // prints 7
}

So, when you are reading code containing only the main function, you have no way of knowing Parent.i is getting changed behind the scenes. Of course, C++ supports constant parameters, and that can prevent your data from getting modified without your acknowledgement, right? Well, consider the following example.

#include <iostream>

class Foo {
public:
int i;
};

void print(const Foo& foo) {
const_cast<Foo&>(foo).i++;
std::cout << foo.i << std::endl;
}

int main() {
Foo foo{5};
print(foo); // prints 6
print(foo); // prints 7
}

Even though print takes a const Parent&, it still has the capability of modifying the parameter, thanks to the broken casting system of C++.

On the contrary, in Go, if a function needs a reference, the caller explicitly has to pass a reference. So everybody (including the readers) knows where their data is getting modified.

Broken Casting System

In C++, you have a total of FIVE ways of casting one variable of one type to another.

  1. (int)doubleValue: Plain old C-style cast.
  2. static_cast<int>(doubleValue): The first sane casting system in C++. This happens in compile time and only works when the conversion is safe.
  3. dynamic_cast<Child*>(ptrToParent): The last sane casting system in C++. It converts a child class pointer to its parent class with some sanity check.
  4. const_cast<Foo&>(constRefToFoo): As we have seen in the previous example, yes, C++ has official support to remove the constant modifier of a variable.
  5. reinterpret_cast<anything>(anythingElse): It lets you literally convert any pointer type to any other pointer type, without any consideration of their compatibility. This tool really comes in handy when you need to blow up a computer.

Contrarily, in Go? You cast a variable with

int main() {
var parent Parent;

if child, ok := parent.(Child); ok {
// conversion successfull
// `child` has the casted value
} else {
// conversion failed
// `child` has zero value
}
}

So, you can only access your data when it is safe and sound. Note that, there is another version of this cast without this if/else check. In that syntax, if the conversion fails the program panics (analogous to Exception in many other languages). But, certainly, it is better to blow up a program than to blow up a computer or phone (ask old Samsung users for reference).

I understand that a head-to-head comparison of Go and C++ is absurd. Go does have a runtime, no matter how lightweight that is. On the contrary, in C++ you do not have a runtime when you do not use smart pointers and virtual tables; and prefer shooting yourself in the foot.

I know most of these behaviours have some explanations and/or workaround. My colleagues or C++ developer friends explained to me that once I understand what is going on under the hood, it will all become very straightforward. That reminded me of my teenage girlfriend — only if I knew what was going on inside her head, I would understand all her quirky behaviour perfectly. But, she never “showed” me what’s going on inside. Rather she just said “it’s alright” and exploded the very next moment. Just what C Plus Plus does to me now.

--

--