An In-Depth Introduction to Rust for C++ Developers
This post dives deeper into Rust by covering specific language features and comparing them to similar concepts in C++.
Motivation
There are several reasons why C++ developers may want to look into Rust:
- Tooling - Rust has excellent built-in tooling like Cargo, which makes creating, building and distributing projects much easier compared to C++. Cargo handles building, testing, dependency management, packaging etc in a standard way.
- Safety - Rust's ownership and borrowing system helps prevent memory errors and dangling pointers at compile time. The compiler ensures memory safety.
- Performance - Rust has performance comparable to C++ without requiring manual memory management.
- Modern language - Rust incorporates lessons learned from other languages with a focus on productivity, reliability, and performance. The language and tooling is designed to be very ergonomic and promote good practices.
Syntax and Mutability
Rust and C++ have relatively similar syntax for basic things like functions, variables, and control flow:
int add(int x, int y) { // C++ function
return x + y;
}
int main() {
int a = 5; // immutable by default
int b = 10;
int result = add(a, b);
std::cout << result; // print output
}
fn add(x: i32, y: i32) -> i32 { // Rust function
x + y // no semicolon needed
}
fn main() {
let a = 5; // immutable by default
let b = 10;
let result = add(a, b); // call add function
println!("{}", result); // print macro
}
Some key differences:
- Rust uses
fn
instead of C++'sint
to define functions. - Variables are immutable by default in Rust.
mut
keyword is required to make something mutable. - Semicolons are optional for expressions in Rust.
- Rust has built-in macros like
println!
Rust also has support for constants with the const
keyword, similar to constexpr
in modern C++:
const PI: f32 = 3.141592; // typed constant
Ownership and Borrowing
A key difference between Rust and C++ is Rust's ownership and borrowing system. Some key rules:
- Each value has a variable that is its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
- At any time, you can have either one mutable reference or any number of immutable references.
- References must always be valid.
This allows Rust to prevent dangling pointers, double frees, and data races at compile time.
For example:
let s = String::from("hello"); // s owns this string
let r1 = &s; // immutable borrow of s
let r2 = &s; // immutable borrow
println!("{} and {}", r1, r2); // use the borrows
let r3 = &mut s; // mutable borrow
println!("{}", r3);
This works because the borrows don't overlap. s is borrowed immutably, then borrowed mutably after the immutable borrows end. The compiler tracks this.
Constants
Rust has constant values similar to const
in C++:
const int MAX = 100;
const MAX: i32 = 100;
Constants can be primitives or the result of a constant expression.
Variables and References
Variables are immutable by default in Rust:
let x = 10;
To make them mutable, use mut
:
let mut x = 10;
x = 15;
References in Rust are like C++ references:
int x = 10;
int& rx = x; // reference
let x = 10;
let rx = &x;
Lifetimes
Rust uses lifetimes to ensure references are valid - they live at least as long as the data they refer to.
// 'a is a lifetime
fn print_ref<'a>(x: &'a i32) {
println!("{}", x);
}
fn main() {
let x = 10;
print_ref(&x); // 'x lifetime is valid
}
Lifetimes are usually implicit and inferred automatically.
Copy Semantics
For simple types like integers, Rust copies them by default like C++:
int x = 10;
int y = x; // copies x
let x = 10;
let y = x; // copies
For non-primitive types, Rust moves by default.
Move Semantics
Rust has move semantics similar to C++ moves:
std::string s1 = "Hello";
std::string s2 = std::move(s1); // moves s1
let s1 = String::from("Hello");
let s2 = s1; // moves s1
But Rust moves are destructive - the old variable can no longer be used.
Expressions
Rust has expression-based control flow like if
and loop
:
let x = if true { 1 } else { 0 }; // if expression
let y = loop { // loop expression
counter += 1;
if counter == 10 {
break counter;
}
};
The result of the block is the result of the expression.
Structs
Structs are similar to C++:
struct Point {
int x;
int y;
};
struct Point {
x: i32,
y: i32,
}
Rust struct fields are immutable by default.
Methods
Methods are defined within impl
blocks:
struct Circle {
radius: f32,
}
impl Circle {
fn area(&self) -> f32 {
std::f32::consts::PI * (self.radius * self.radius)
}
}
Vectors
Vectors are resizeable arrays like std::vector
in C++:
std::vector<int> v = {1, 2, 3};
v.push_back(4);
let mut v = vec![1, 2, 3];
v.push(4);
Slices
Slices are views into sequences like std::span
in C++:
std::vector v = {1, 2, 3};
auto s = std::span(v); // slice
let v = vec![1, 2, 3];
let s = &v[..]; // slice
Here is a markdown section comparing traits in Rust and C++17/20:
Traits
Traits in Rust are similar to type classes in Haskell or concepts in C++20. They define shared behavior that types can implement.
For example, we can define a Shape
trait:
trait Shape {
fn area(&self) -> f32;
}
This is like a C++ concept:
template<typename T>
concept Shape = requires(T t) {
{ t.area() } -> std::same_as<float>;
};
Now we can implement the trait/concept for a type:
struct Circle {
radius: f32
}
impl Shape for Circle {
fn area(&self) -> f32 {
std::f32::consts::PI * (self.radius * self.radius)
}
}
struct Circle {
float radius;
float area() {
return std::numbers::pi * (radius * radius);
}
};
And use trait bounds to write generic code:
fn print_area<T: Shape>(shape: &T) {
println!("{}", shape.area());
}
template<Shape T>
void print_area(const T& shape) {
std::cout << shape.area() << std::endl;
}
Some key differences between Rust traits and C++ concepts:
- Traits are defined separately from implementations, concepts are defined together with concrete types.
- Rust has inherent impls to provide implementations for a concrete type.
- Traits allow multiple dispatch - calling a method based on the dynamic types of multiple arguments.
- Trait objects enable dynamic polymorphism at runtime like C++ virtual functions.
Overall, Rust's trait system provides behavior sharing like interfaces in other languages, while still having zero-cost abstractions through compile-time static checking as in C++ generics.
Enums
Rust enums are more powerful than C++ enums:
enum Message {
Quit,
Move{x: i32, y: i32}, // struct-like
ChangeColor(i32, i32, i32), // tuple-like
}
Pattern Matching
Pattern matching via match
is used to match on enums:
fn process_message(msg: Message) {
match msg {
Message::Quit => ...,
Message::Move{x, y} => ...,
Message::ChangeColor(r, g, b) => ...
}
}
Option
Option<T>
is an enum for possibly missing values:
enum Option<T> {
Some(T),
None,
}
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
Error Handling
Rust has robust error handling built-in similar to std::expected<T>
in C++17:
enum Result<T, E> {
Ok(T),
Err(E),
}
This Result
type is used pervasively in Rust for returning errors:
use std::fs::File;
fn read_file(path: &str) -> Result<File, std::io::Error> {
let f = File::open(path); // returns a Result
f // return the Result
}
The ?
operator provides a concise way to handle errors:
fn read_file(path: &str) -> Result<File, std::io::Error> {
let f = File::open(path)?; // handle error
Ok(f) // return Ok value
}
Much more ergonomic than error handling in C++.
Iterators
Rust has iterators for lazy sequences:
struct Counter {
count: u32,
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// increment counter
if self.count < 5 {
Some(self.count)
} else {
None
}
}
}
This allows use with for
loops, LINQ-style methods like map
and filter
, etc.
No comments:
Post a Comment