Object-Oriented Carbon, C++ and Go Compared
Creating class or type hierarchies in the Go, C++ and Carbon programming languages
Let us go through a simple example of creating an abstract base class representing a rocket engine with some concrete implementations in Go, C++ and the new Carbon language from Google. To be accurate, Carbon is not an official Google project, but done by people working at Google.
There is no Carbon compiler presently and the interpreter is very bare bones with a lot of missing functionality, so the code I am writing here is based on the language specification, rather than being verified by a compiler. Keep that in mind as there are possible errors.
In my upcoming programming book, Julia as a Second Language, I take the reader through building a space rocket in code. The code project came about specifically to teach readers about how to model type hierarchies and understand how to do object composition.
I have found this example useful enough to replicated it in Go code, intending to some day writing a Go programming book with this theme. In this article, I will model as subset of this type hierarchy and object-composition in C++ and Carbon.
Since the whole type hierarchy example is explored over multiple chapters in my Julia book, we can only cover a small subset of it in this story. I have chosen to zoom in on the type hierarchy for rocket engines.
A rocket engine cluster is a collection of multiple engines. The Saturn V moon rocket for instance had an engine cluster of five F1 engines in the first stage. A modern Falcon 9 rocket from SpaceX has nine Merlin engines in the first stage and a single Merlin engine in the second stage.
Defining Interfaces
The original Go code defines an abstract interface Engine
for all concrete engines Merlin
and CustomeEngine
to implement.
// Go code - Base interface
package engine
// Force that causes 1kg to move 1m in one second.
type Newton float64
// standard measurement unit for mass, a kilogram
type Kg float64
type Engine interface {
Mass() Kg // Mass of whole engine
Thrust() Newton // How much engine pushes
Isp() float64 // Fuel efficiency of engine
}
You may notice that I am utilizing the ability of Go to let you define units like Newton and Kg, which prevents developers from accidentally mixing incompatible unites. For instance, I cannot, accidentally, add mass to thrust without getting a compilation error.
In C++ we don’t have this ability to create type safety for units, unless we define whole new classes for each unit type. Instead, the ported C++ code will use typedef
. A typedef
is just like an alias and does not offer any actual type safety. C++ does not have interfaces like Go and Java. Thus, we must define an abstract class instead. We do that by adding virtual methods which are not implemented.
// C++ code - Base interface
namespace engine {
// Force that causes 1kg to move 1m in one second.
typedef double Newton;
// standard measurement unit for mass, a kilogram.
typedef double Kg;
class Engine {
public:
virtual Kg Mass() = 0; // Mass of whole engine
virtual Newton Thrust() = 0; // Power of engine
virtual double Isp() = 0; // Fuel efficiency
};
}
There are several things I don’t like about the C++ solution. Because we don’t have the concept of an interface, we need to create more boilerplate by repeating the keyword virtual
for every method. We indicate that the method must be implemented with the = 0
expression as the end, which is very odd and unintuitive. namespace
in C++ cause nesting, which is very impractical in my view. Deep nesting should be avoided in code, in my opinion. It is not easy to visually keep track of many levels of nesting.
The Carbon solution solves some of these problems while retaining some C++ problems and introducing new ones. Namespace nesting is avoided with the use of the package
keyword. alias
is like a C++ typedef
and thus doesn't offer any type safety for different units.
// Carbon code - Base class
package Engine api;
alias Kg = f64;
alias Newton = f64;
abstract class Engine {
abstract fn Mass[me: Self]() -> Kg;
abstract fn Thrust[me: Self]() -> Kg;
abstract fn Isp[me: Self]() -> f64;
}
There are several important differences from Go and C++ you will notice:
Our package has an extra keyword
api
attached, which is used to make all type and functions declared in the file public by default. In Go, capitalized types and functions are public.You clarify that a function is a method rather than a class method by adding
[me: Self]
.Methods which are not implemented must be marked as
abstract
.
Relative to Go and C++ I think the use of [me: Self]
is a negative. It adds verbosity and noise to the code. On the other hand, using introducer keywords such as fn
means method names easily aligns and become easier to scan. In C++ programmers are forced to indent their code manually to achieve the same.
I will also say that using the abstract
keyword for the whole class makes it more explicit what the class is for. In C++ classes are abstract implicitly from the use of unimplemented virtual methods.
Implementation Classes
Let us look at how we can implement our Engine
interface in various concrete classes. We will first look at an engine implementation consisting of only method implementation and not data members.
Go uses what is called structural typing, which means you implement an interface simply by having all the methods defined on an interface. You don’t need explicitly state that you are implementing the interface.
// Go code - Merlin rocket engine
// Engine used on the Falcon 9 rocket made by SpaceX
type Merlin struct {
}
// The mass of the rocket engine.
func (engine Merlin) Mass() Kg {
return 470
}
// Think of this as similar to the horse power of a car.
func (engine Merlin) Thrust() Newton {
return 845e3
}
// Specific impulse of the rocket engine.
// A measure of fuel efficiency.
func (engine Merlin) Isp() float64 {
return 282
}
Both C++ and Carbon uses nominal typing, which means we must explicitly state what class or interface we are implementing. Since C++ we have had the override
keyword which allows the compiler to check if the method we are implementing is, in fact, overriding a function defined in a based interface as virtual. However, there is no requirement to use override
, so here we got a potential source of bugs.
// C++ code - Merlin rocket engine
class Merlin : public Engine {
public:
Engine() {} // constructor
~Engine() {} // destructor
Kg Mass() override {
return 470;
}
Newton Thrust() override {
return 845e3;
}
double Isp() override {
return 282;
}
};
Unlike Go and Carbon, C++ has constructors. A constructor is always run when a C++ object is created. When the C++ object is deallocated, the destructor will be run. Go avoids destructors because it is not deterministic when a Go object is deallocated, since it uses a garbage collector to manage memory. The Go solution is to use the defer
statement, which is often used to run cleanup code.
Carbon is designed to be compatible with C++ and easily interface with C++ code. For that reason, Carbon does not use garbage collection and actually has destructors. In the example below we are not showing destructors yet, but we have made a Make
class method to allocate instances of Merlin
on the heap.
// Carbon code - Merlin rocket engine
final class Merlin extends Engine {
fn Make() -> Merlin* {
let engine: Merlin = {};
return heap.New(engine);
}
impl fn Mass[me: Self]() -> Kg {
return 470;
}
impl fn Thrust[me: Self]() -> Newton {
return 845000;
}
impl fn Isp[me: Self]() -> f32 {
return 282;
}
}
In Carbon, we use the final
keyword to indicate that the class cannot be further subclasses. Using final
allows for better optimization. If you have a pointer of typeMerlin
, then no virtual method lookup has to be done when a class is final. In Go, every struct
is implicitly final, as you cannot subclass structs. In fact, final is default in Carbon, so you don't actually have to write it.
In Carbon, we modify methods with the keywords virtual
, abstract
and impl
. These allow for better compiler checks than you get in C++. If you don't write any of these keywords in front of a method, that means the method cannot be overridden in a subclass, nor is it overriding a method defined in a base class.
Thus, if you intend to allow subclasses to override your method later, you need to define it as either virtual
or abstract
. The former is just like virtual
in C++. You state that the method can be overridden while offering a default implementation.
Remember, in C++ we add the odd = 0
to virtual methods when we don't implement them. In Carbon, we use the abstract
keyword instead. I think that is a more sensible choice.
You may think impl
is the same as override
in C++, but it is not. You have to use impl
in Carbon to override a method, or you get a compiler error. The Carbon compiler will catch more problems, in other words. In C++ you can unintentionally override a method.
Let us do a class with member variables. Most rockets today have multiple rocket engines at their first stage, the so-called booster stage. The Saturn V moon rocket had five F1 rocket engines. The Falcon 9 rocket has nine Merlin engines at its first stage. To simplify the modeling of a rocket, we can represent a cluster of engines as just another kind of engine. That is what a Cluster
is for. The Cluster
type implements all the same methods as other engine types.
We use a Count
variable to keep track of how many engines there are in the cluster. In Go, we can utilize the concept of embedding, which allows us to embed another type and expose its interface.
// Go code - Rocket engine cluster
package engine
type Cluster struct {
Engine
count uint8
}
func (cluster *Cluster) Mass() Kg {
return cluster.Engine.Mass() * Kg(cluster.count)
}
func (cluster *Cluster) Thrust() Newton {
return cluster.Engine.Thrust() * Newton(cluster.count)
}
// Create a cluster. Go does not have constructors
func NewCluster(engine Engine, count uint8) *Cluster {
cluster := Cluster{
Engine: engine,
count: math.Max(count, 1.0)
}
return &cluster
}
In this case, we have embedded the Engine
interface which adds the Mass
, Thrust
and Isp
methods to Cluster
.
However, we shadow the Mass
and Thrust
methods explicitly. Such shadowing is not the same as overriding. If Isp
had called Mass
and Thrust
they would still call the implementation provided by the Engine
object embedded and not the one provided by the Cluster
type.
To learn more about how the Cluster
class is implemented in Carbon, subscribe or read the rest on Medium.
Keep reading with a 7-day free trial
Subscribe to Erik Explores to keep reading this post and get 7 days of free access to the full post archives.