Skip to main content

Two things that Rust does better than C++

· 4 min read
Bei

At Dozer, we have adopted Rust as our main programming language, despite many of our team members having a strong background in C++. This is because Rust offers a combination of expressiveness, safety and ergonomics through its language constructs, which we find appealing.

In this post, we will discuss two language features that we believe Rust handles better than C++, namely its ownership model and trait object system. These compare favorably to C++'s move semantics and virtual functions, respectively, and provide insights into why Rust has gained popularity among many developers.

Ownership vs Move Semantics

Phenomena

Consider following Rust code (playground):

struct Struct;

impl Drop for Struct {
fn drop(&mut self) {
println!("dropped");
}
}

fn main() {
let a: Struct = Struct;
let _b: Struct = a;
}

If you run it, there's a single output line:

dropped

The C++ code that behaves most similarly (playground):

#include <iostream>

struct Struct {
Struct() = default;
Struct(const Struct &) = delete;
Struct(Struct &&) = default;
Struct &operator=(const Struct&) = delete;
~Struct() {
std::cout << "destructed" << std::endl;
}
};

int main() {
Struct a;
Struct b = std::move(a);
return 0;
}

It outputs two lines:

destructed
destructed

We can see the C++ destructor is executed twice.

Analysis

The root of the problem is that C++ only provides rvalue references as a special type at the language level, and move semantics are implemented by the user according to convention. From the compiler's perspective, an object that has been moved is still an intact object. This brings not only the problem of destructors being executed multiple times (although this problem has already brought additional runtime overhead), but also imposes two burdens on class authors in C++:

  • The destructor must correctly handle objects that have been moved.
  • In all public interfaces, correctly handle objects that have been moved, or transfer this burden to the class user.

The first is obvious. Regarding the second, due to the fact that correctly handling objects that have been moved in all public interfaces usually brings runtime overhead, the responsibility of not using objects that have been moved has been imposed on almost all C++ users, while class authors usually only provide an interface for querying whether an object has been moved.

A typical example of the second is std::unique_ptr, and any user of std::unique_ptr must check if it is null.

C++'s move semantics greatly reduce the usability of RAII. When the user gets an object, they always need to consider whether the resource it manages has been moved. This increases the mental burden on the programmer and is a breeding ground for bugs.

Trait Object vs Virtual Function

Consider following Rust code (playground):

trait Trait {
fn f(&self);
}

struct Impl;

impl Trait for Impl {
fn f(&self) {
println!("f from Impl");
}
}

fn main() {
let a: Impl = Impl;
let b: &dyn Trait = &a;
b.f();
println!("Size of Impl is {}", std::mem::size_of::<Impl>());
}

The output is:

f from Impl
Size of Impl is 0

The fact that the size of the Impl struct is 0 means that whether or not runtime polymorphism is used has no impact on the memory layout of the struct itself.

The C++ code that behaves most similarly (playground):

#include <iostream>

class Trait {
public:
virtual void f() const = 0;
};

class Impl: public Trait {
public:
void f() const override {
std::cout << "f from Impl" << std::endl;
}
};

int main() {
Impl a;
Trait &b = a;
b.f();
std::cout << "Size of Impl is " << sizeof(a) << std::endl;
return 0;
}

The output is:

f from Impl
Size of Impl is 8

This is the output on a 64-bit system, where due to the use of runtime polymorphism, each Impl object holds an 8-byte virtual table pointer.

Compared to Rust's trait object, C++ runtime polymorphism is not a zero overhead abstraction. The additional 8-byte storage overhead is often unacceptable, and the fact that the virtual table pointer changes object memory layout greatly limits the scope of its application.