//

Wednesday, August 2, 2023

Intro Rust for C++ programmers

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++'s int 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

Effective Branching Strategies in Development Teams

Effective Branching Strategies in Development Teams Effective Branching Strategies in Developme...