What makes Rust easier than C++
When people talk about learning the Rust programming language they fear its steep learning curve. But let’s do the comparison right: As a systems programming language with many high-level features its most obvious competitor is C++, which is also hard to learn.
While Rust is pedantic in forcing the programmer to write safe code, there are other areas where it is much easier to handle than C++. As a young language Rust brings simplicity and many convenient features.
Special member functions
The C++ special member functions enable custom implementations of basic operations such as creating, destroying, copying, moving and assigning objects. If you are unlucky, you must implement all of them yourself (see the well-known rule of five). This results in a lot of boilerplate code to be written (and hopefully also unit-tested!).
C++ example:
class Foo { public: // Constructor Foo(int input) { /* ... */ } // Destructor ~Foo() { /* ... */ } // Copy constructor Foo(const Foo& other) { /* ... */ } // Move constructor Foo(Foo&& other) noexcept { /* ... */ } // Copy assignment operator Foo& operator=(const Foo& rhs) { /* ... */ } // Move assignment operator Foo& operator=(Foo&& rhs) noexcept { /* ... */ } private: char* value1; int value2; };
Rust example:
pub struct Foo { value1: Box<[u8]>, value2: i32 } impl Foo { // Rust equivalent of a constructor pub fn new(input: i32) -> Foo { /* ... */ } } // Rust equivalent of a destructor impl Drop for Foo { fn drop(&mut self) { /* ... */ } }
Surprisingly, the Rust language itself doesn’t have any special member functions at all. Copying and moving objects is done just like a “memcpy”, without custom behavior.
Usually that’s not a limitation:
- There are no constructors in Rust. The programmer provides a regular static method named “new” that does the same thing.
- Rust prefers moving over copying: Passing values to a function or returning them is considered a move operation by default. An expensive copy implementation (including duplication of all contained objects) is less common and therefore not mandatory. It can still be provided via Clone trait.
- Rust completely ignores the original object after a move operation. No destructor will be called in contrast to C++. Often a custom move implementation is required in C++ to reset some pointers to null (preventing any double-free issues).
As a bonus, you don’t have to deal with C++ language features such as rvalue references and std::move in Rust.
You can also forget about other language quirks like prevention of self-assignment or the copy and swap idiom.
Data classes: Common operators and methods
Some classes are just plain data structures with little abstraction. Users of these classes expect that operators such as equality or other comparison operators are available. So again a lot of boilerplate code must be written and tested.
C++ example:
class Person { public: // ... friend bool operator==(const Person& lhs, const Person& rhs) { return lhs.first_name == rhs.first_name && lhs.last_name == rhs.last_name; } friend bool operator<(const Person& lhs, const Person& rhs) { if (lhs.first_name < rhs.first_name) { return true; } else if (rhs.first_name < lhs.first_name) { return false; } if (lhs.last_name < rhs.last_name) { return true; } else if (rhs.last_name < lhs.last_name) { return false; } return false; } private: std::string first_name; std::string last_name; };
Rust example:
#[derive(PartialEq, Eq, PartialOrd, Ord)] pub struct Person { first_name: String, last_name: String }
In Rust the operators above don’t have to be implemented manually. The magic happens in the derive macros that can generate such code for standard use cases (by applying the operator individually on each struct member).
Here a list of a few useful derive macros from the Rust standard library:
Derive macro | Result |
PartialEq
Eq |
Generates code for the operators:
|
PartialOrd
Ord |
Generates code for the operators:
|
Clone | Generates code for a deep copy |
Debug | Generates code to print the object content as text in a debug output |
Default | Generates code for a “default constructor” |
Hash | Generates code for calculating a hash from the object content |
Virtual and static members
The declaration of a C++ class contains a mix of all kinds of members. There are fields and methods, some of them are static and some methods are virtual. But Rust separates different member categories into distinct code blocks:
- Fields (data definition) and methods (operating on the data) are in separate blocks.
- There are no static fields. They are replaced by static variables on module scope.
- Methods implementing a particular trait (interface) are in a separate block. They can be regarded as “virtual” (associated with a vtable). Other methods are always non-virtual because Rust doesn’t support inheritance.
This separation results in a clean struct definition that simply shows how an object looks like in memory.
C++ example:
class Printable { public: // Pure virtual method virtual void print() = 0; }; class Person : public Printable { public: // Constructor Person(std::string name) { /* ... */ } // Instance method uint32_t get_age() { /* ... */ } // Overridden virtual method void print() override { /* ... */ } // Static method static Person& get_oldest_from(std::span<Person> persons) { /* ... */ } private: // Instance fields Date birthday; std::string name; uint32_t id; // Static field static uin32_t next_person_id = 0; };
Rust example:
trait Printable { // Rust equivalent of pure virtual methods fn print(&self); } // Clean struct definition (only instance fields) pub struct Person { birthday: Date<Utc>, name: String, id: u32, } // Static fields are not part of the struct static mut next_person_id: u32 = 0; impl Person { // Rust equivalent of static methods (without "self" argument) pub fn new(name: String) -> Person { /* ... */ } pub fn get_oldest_from(persons: &[Person]) -> &Person { /* ... */ } // Rust equivalent of instance methods (with "self" argument) pub fn get_age(&self) -> u32 { /* ... */ } } impl Printable for Person { // Rust equivalent of overridden virtual methods // (trait implementation) fn print(&self) { /* ... */ } }
Tuples and enums
In statically typed languages like C++ and Rust it is essential to compose new types from basic ones. Rust has introduced a lot of syntactic sugar to simplify working with compound types such as tuples or enums (similar to variants in C++).
C++ example:
// Tuple auto coordinate = std::make_tuple(200, 100); // Access tuple members (since C++17) auto [x, y] = coordinate; std::cout << std::format("x is {}, y is {}", x, y) << std::endl; // Before C++17: // auto x = std::get<0>(coordinate); // auto y = std::get<1>(coordinate); // Variant (supported since C++17) struct CreateCommand {}; struct MoveCommand { int32_t x; int32_t y; }; struct WriteCommand { std::string text; }; using Command = std::variant< CreateCommand, MoveCommand, WriteCommand>; // Assign variant value auto cmd1 = Command(CreateCommand{}); auto cmd2 = Command(WriteCommand{"Hello"}); // Evaluate variant value struct Visitor { void operator()(const CreateCommand&) { } void operator()(const MoveCommand& c) { std::cout << std::format("Move by {}, {}", c.x, c.y) << std::endl; } void operator()(const WriteCommand& c) { std::cout << std::format("Text: {}", c.text) << std::endl; } }; std::visit(Visitor(), cmd2);
Rust example:
// Tuple let coordinate = (200, 100); // Access tuple members let (x, y) = coordinate; println!("x is {x}, y is {y}"); // Enum enum Command { Create, Move(i32, i32), Write(String), } // Assign enum value let cmd1 = Command::Create; let cmd2 = Command::Write("Hello".to_string()); // Evaluate enum value match cmd2 { Command::Create => {}, Command::Move(x, y) => println!("Move by {x}, {y}"), Command::Write(text) => println!("Text: {}", text), }
Other areas
There are even more areas where the Rust language is more concise and less complicated than C++:
- Adding a library only requires a single line in the Cargo.toml file
- No separation into header/source file, no include guards, no forward declarations
- No unexpected side effects of preprocessor defines or macros somewhere in the included headers
- Panics and result types instead of exceptions: No need to think about exception safety everywhere
- Defensive programming already enforced by default (const, smart pointers, strong type safety, etc.), no extra keywords or encapsulation required
- More effective type inference (looking backward and forward)
- More self-explaining compiler errors
- etc.
Conclusion
C++ has a long history and must remain compatible with a large existing code base. Therefore, it has many legacy features and inconsistencies, making it complicated. The existence of books such as the “Effective C++” series indicates that a programmer must learn many language quirks and possible workarounds.
Rust on the other hand is young and focuses more on commonly used language features instead of special cases. That’s why it takes much more time for a beginner to become an expert in C++ than in Rust. Nevertheless also advanced programmers will appreciate Rust’s approach of realizing safety features with moderate language complexity.