CUSTOMISED
Expert-led training for your team
Dismiss
Intermediate Rust: Taking Your Skills to the Next Level

7 September 2023

Intermediate Rust: Taking Your Skills to the Next Level

So you've learned the basics of Rust - now what? In this comprehensive guide, we'll cover key intermediate concepts that will level up your Rust skills. We'll look at managing complex projects, advanced trait usage, smart pointers, asynchronous programming, functional concepts, testing, interoperability, web development, and more through practical examples.

This article is part of our JBI Training course in Rust training

By the end, you'll feel empowered to build robust, fast, and reliable applications in Rust. So let's dive in!

Managing Complex Rust Projects with Cargo Workspaces

As your Rust projects grow in scope, you'll want to organize code into multiple crates and libraries. Cargo provides workspaces to manage multiple related packages under one umbrella.

A workspace allows you to:

  • Split code into logical components
  • Share common code between crates
  • Build/test all crates in one step

Let's walk through a simple workspace setup:

  1. Create a new cargo project:

    cargo new my-workspace --lib
  2. Add a members section in Cargo.toml:

    
     

    toml

    [workspace] members = ["my-lib"]

  3. Create a Rust library crate:

  4. cargo new my-lib

    Depend on this crate from the workspace:
  5. [dependencies] my-lib = { path = "./my-lib" }

Now both crates are part of the workspace. You can build them together:


 
cargo build --workspace

This structure lets you easily share code while still keeping crates decoupled.

When should you use Cargo workspaces?

Use workspaces when:

  • Sharing common code between sibling crates
  • Managing a suite of related packages / tools
  • Releasing or testing groups of packages together

They provide organization for complex Rust projects.

Advanced Trait Techniques

Traits abstract over behavior in Rust. You've likely used basic traits like Debug and Display. But traits have even more powerful capabilities.

Some advanced trait patterns include:

Associated Types

Traits can declare associated types for abstraction:


 

 

trait Graph { type Node; type Edge; fn add_node(&mut self, Node); fn add_edge(&mut self, Edge); }

Implementors specify the concrete types:


 

 

struct MyGraph { nodes: Vec<MyNode>, edges: Vec<MyEdge>, } impl Graph for MyGraph { type Node = MyNode; type Edge = MyEdge; // ...implement methods... }

This allows generic graph handling code.

Default Methods

You can specify default behavior for traits:


 
trait Summary { fn summarize(&self) -> String { String::from("(Read more...)") } }

Implementors can override or use the default.

Trait Bounds

Trait bounds let you restrict generics to types that implement a trait:


 
fn notify<T: Summary>(item: T) { println!("Breaking news! {}", item.summarize()); }

T must implement Summary.

Trait Objects

You can use trait objects for dynamic dispatch:


 

 

fn print_summary(item: &dyn Summary) { println!("{}", item.summarize()); }

item can be any type that implements Summary.

Mastering advanced trait techniques unlocks generic, flexible code in Rust.

Smart Pointers for Advanced Memory Handling

Rust's ownership model means you don't need to manually allocate and free memory - but sometimes you still want more control over allocation.

Smart pointers provide custom ownership behavior while preventing leaks or dangling pointers.

Some useful smart pointers include:

Box<T>

Box<T> allocates a value on the heap instead of the stack:


 
let b = Box::new(5); // Stored on the heap

Useful for recursive types.

Rc<T>

Rc<T> provides reference counting for shared ownership:


 
let a = Rc::new(42); let b = a.clone(); // Increments ref count

a and b both own the data. Freed when count reaches 0.

Arc<T>

Arc<T> is an atomically reference counted pointer for concurrent code:


 
use std::sync::Arc; let a = Arc::new(Data); // Share across threads

Safely shares ownership between threads.

Smart pointers give you more tools to manage memory and ownership!

Asynchronous Rust with Async/Await

Writing asynchronous code efficiently is crucial for performance. Rust provides first-class support for async programming with async/await.

Here is an async HTTP server:


 
async fn handle_request(req: Request) -> Response { // ... } async fn listen() { let listener = TcpListener::bind("0.0.0.0:8080").await?; loop { let (stream, _) = listener.accept().await?; let response = handle_request(stream).await?; stream.send(response).await? } }

Some key async features:

  • async fn defines an asynchronous function
  • .await pauses execution until completed
  • Asynchronous code runs concurrently when spawned

Async Rust enables efficient network services, web apps, CLI tools, and more without compromising safety.

Functional Concepts in Rust

Rust isn't strictly a functional language, but it does provide useful functional constructs.

Some examples:

Closures

Closures are anonymous functions you can save in variables:


 

 

let sqrt = |num| num.powf(0.5); println!("{}", sqrt(4.0)); // Prints 2

Very useful with iterators.

Iterators

Iterators abstract collection processing:


 
let v = vec![1, 2, 3]; let sum = v.iter().map(|x| x * 2).fold(0, |acc, x| acc + x); // 12

Chaining iterator adapters promotes a functional style.

Options and Results

Option and Result enums can represent the outcomes of computations:


 

 

fn inverse(x: f64) -> Result<f64, String> { if x == 0.0 { Err("Cannot divide by zero".to_string()) } else { Ok(1.0 / x) } }

Models errors in a functional way.

Rust isn't Haskell, but it does provide abstractions for functional-style code. This can make your programs more robust and declarative.

Rigorously Testing Rust Code

Rust doesn't just catch errors at compile-time - it also encourages exhaustive testing. Here are some techniques for rigorously testing Rust code:

Unit Tests

Rust includes built-in unit test support:


 

rust

Copy code

#[cfg(test)] mod tests { #[test] fn test_add() { assert_eq!(2 + 2, 4); } }

Just cargo test to run them.

Integration Tests

Cargo supports integration test binaries:


 

rust

Copy code

// tests/int.rs #[test] fn test_integration() { // Run external program // Assert on outputs }

Test binaries with cargo test --test int.

Property Testing

Crates like quickcheck provide property based testing:


 

rust

Copy code

#[quickcheck] fn reverse_reverse(xs: Vec<isize>) -> bool { xs == xs.clone().into_iter().rev().rev().collect() }

Quickly generates random data as test cases.

Rust testing tools ensure your programs are correct - not just free of crashes.

Calling C Code from Rust

A huge benefit of Rust is easy interoperability with C libraries. Let's walk through a basic C FFI example.

First, a C library:


 

c

Copy code

// add.c int add(int x, int y) { return x + y; }

We can call this from Rust:


 

rust

Copy code

// src/lib.rs use libc::c_int; extern "C" { fn add(x: c_int, y: c_int) -> c_int; } pub fn call_add(x: i32, y: i32) -> i32 { unsafe { add(x, y) } }

Key steps:

  • extern "C" block declares C ABI
  • Call C function using raw pointer unsafe
  • Marshal Rust types to corresponding C types

This allows reusing battle-tested C libraries in Rust!

Developing Web Apps in Rust

Rust's performance and reliability make it a great backend language choice. Let's see a simple web app in Rust.

We'll use the Rocket web framework along with the Maud templating engine.

Our Cargo.toml will include:


 
[dependencies] rocket = "0.5.0-rc.2" maud = "0.24.0"

Here is a basic route handler:


 
#[get("/")] fn index() -> Template { let context = index_context(); Template::render("index", context) }

And the Maud template:


 

 

@(context: index::Context) h1 { "#[context.project] App" }

We can also easily expose Rust types to client JavaScript using WASM.

Rust is well-suited for safe, scalable web development.

Advanced Lifetime Syntax

Lifetimes - Rust's secret sauce for references. You've seen simple lifetime syntax like:


 
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {

This says x and y must live as long as output. But Rust has even more flexible lifetime capabilities:

Generic Lifetimes

We can parameterize functions over lifetimes:


 
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { x }

Now x and y can have different lifetimes.

Lifetime Elision

Rust can omit explicit lifetimes when they can be inferred:


 
fn len(s: &str) -> usize { s.len() }

Input and output lifetimes are inferred.

Static Lifetime

The 'static lifetime means data lives for the entire program:


 
let STATIC_STR: &'static str = "Hello";

Useful for global constants.

Lifetime syntax gives you fine-grained control over references in Rust.

When Unsafe Rust is Required

Rust keeps you safe - except for clearly marked unsafe code:


 

 

unsafe { let x = 5; }

Why unsafe? Low-level use cases like:

  • Dereferencing raw pointers
  • Calling FFI functions
  • Mutating static variables

Some guidelines for unsafe:

  • Isolate and minimize scope
  • Thoroughly comment and document
  • Provide safe abstractions if possible
  • Test rigorously!

While rare, unsafe is enabling for systems programming. Use judiciously.

We've covered a lot of ground here! You should now feel empowered to build more complex, high-performance applications in Rust. But this is just the beginning...

Where to Go From Here?

Rust has an incredibly rich ecosystem. Here are some ideas for furthering your Rust journey:

The Rust language and community are incredibly welcoming and eager to help you learn. Keep coding, stay curious, and have fun with Rust!

Frequently Asked Questions

How does Rust compare to C++?

Rust provides C++ level control over memory and performance without compromising on safety or productivity. Rust's enums, traits, patterns, and safety guarantees create a much more ergonomic systems language.

When is Rust a good choice?

Rust shines for performance critical applications like servers, game engines, operating systems, embedded devices, and any code where stability and speed are priorities.

How does Rust ensure memory safety?

Rust uses an ownership model checked at compile time to ensure references are always valid. The compiler verifies no dangling pointers, use-after-free, or data races can occur.

What are some companies using Rust?

Organizations using Rust in production include Microsoft, Amazon, Dropbox, Figma, Discord, Cloudflare, Stripe, Uber, Reddit, and many more!

Is Rust hard to learn?

Rust has a steeper initial learning curve than other languages like Python. But core concepts like ownership and borrowing become second nature pretty quickly. The compiler also provides helpful error messages as you learn. Overall, Rust rewards the time investment.

you might enjoy the Previous article to this Rust for Beginners: A Comprehensive 3-Hour Crash Course

About the author: Daniel West
Tech Blogger & Researcher for JBI Training

CONTACT
+44 (0)20 8446 7555

[email protected]

SHARE

 

Copyright © 2024 JBI Training. All Rights Reserved.
JB International Training Ltd  -  Company Registration Number: 08458005
Registered Address: Wohl Enterprise Hub, 2B Redbourne Avenue, London, N3 2BS

Modern Slavery Statement & Corporate Policies | Terms & Conditions | Contact Us

POPULAR

Rust training course                                                                          React training course

Threat modelling training course   Python for data analysts training course

Power BI training course                                   Machine Learning training course

Spring Boot Microservices training course              Terraform training course

Kubernetes training course                                                            C++ training course

Power Automate training course                               Clean Code training course