Areas I need to learn more deeply
- Lifetimes
- Collections
- Async
- Tests
- Cargo and modules
Learning Materials
Variables
- Variable bindings are immutable by default
Packages and Crates
Hierarchy
- Workspace = collection of crates that share dependencies
- Crate = compilation unit (a library or binary)
- Module = namespace within a crate (can be a file or directory)
mod x;= “include module x from either x.rs or x/mod.rs”
- Package (Cargo.toml)
- Crate
- Module
- Submodule
- Function
- Submodule
- Module
- Crate
// To bring into scope use CRATE::MODULE::FUNCTION;
use std::cmp::min;- Crates are either:
- binary crates
- must have a
mainfunction
- must have a
- library crates
- binary crates
- A package is a bundle of one or more crates that provides a set of functionality. A package contains a Cargo.toml file that describes how to build those crates.
- A package can have many binary crates, but at most only one library crate
- If a package contains src/main.rs and src/lib.rs, it has two crates: a binary and a library, both with the same name as the package.
- A package can have multiple binary crates by placing files in the src/bin directory: Each file will be a separate binary crate.
- The
modkeyword - Visibility Modifiers
mod foo;Private to parent modulepub(crate) mod foo;Visible within entire cratepub mod foo;Visible outside the crate
extern crate radicle_oid as oid;to give an external crate a shorthand name
Structs and methods
struct Number {
odd: bool,
value: i32,
}
impl Number {
fn is_positive(self) -> bool {
self.value > 0
}
}Tuple struct
Tuple struct (often called the “newtype pattern” in Rust):
It creates a new type ObjectId that wraps a single Oid value. The Oid is stored as an unnamed field accessed via .0.
This is different from a regular struct with named fields:
// Named field version (not used here)
pub struct ObjectId { oid: Oid }The newtype pattern is commonly used to:
-
Add type safety -
ObjectIdandOidare distinct types, so the compiler prevents mixing them up -
Implement traits - You can implement traits for ObjectId that you couldn’t for Oid (due to orphan rules)
-
Control the API - Hide or expose different functionality than the wrapped type
In this file, you can see the pattern in action:
- Line 65-71: Deref lets you use ObjectId like an Oid when needed
- Lines 39-63: Various From implementations for conversions
- Line 27: #[serde(transparent)] makes it serialize/deserialize as if it were just an Oid
Deref and auto-deref coercion
Implementing Deref on a wrapper type lets Rust automatically forward method calls to the inner type. When you call a method on &MyWrapper and MyWrapper doesn’t have that method, Rust dereferences it to &InnerType and tries again — recursively, as many times as needed.
use std::ops::Deref;
struct Meters(f64);
impl Deref for Meters {
type Target = f64;
fn deref(&self) -> &f64 {
&self.0
}
}
fn main() {
let m = Meters(3.5);
// These all work because Rust auto-derefs &Meters → &f64:
println!("{}", m.abs()); // f64::abs()
println!("{}", m.sqrt()); // f64::sqrt()
let x: f64 = *m; // explicit deref also works
}This is especially useful with the newtype pattern — you get type safety at the call site while still being able to use all the inner type’s methods without wrapping each one manually.
Coercion sites — Rust inserts the deref automatically at:
- function/method arguments:
fn takes_str(s: &str)accepts&String - assignment with a known target type
*dereference expressions
Caution: Deref is intended for smart pointer types (Box<T>, Rc<T>, Arc<T>, String, Vec<T> all use it). Implementing it on a general newtype just for convenience can make code harder to reason about — if you want to expose the inner type’s full API, prefer Deref; if you want a sealed wrapper, don’t implement it.
Ownership & Borrowing
- When assigning a value to a variable, it’s ownership can be moved or the value can be copied.
- Moving or copying depends on the type: if it’s stored on the heap, it’s moved (unless it implements the
Copytrait) - Integer, Boolean, Character, and Floating Point types, implement the copy trait, and are therefore copied on assignment
- By default, passing variables stored on the heap to functions will be moved.
- This can be tedious because you need to return ownership.
- To avoid this, the function can take a reference, leading to borrowing instead of moving.
- A reference’s scope starts from where it is introduced and continues through the last time that reference is used
fn main() {
// MOVING (tedious - need to return ownership)
let s1 = String::from("hello");
let s1 = takes_ownership(s1); // must reassign to get it back
println!("{}", s1);
// BORROWING (cleaner)
let s2 = String::from("world");
borrows(&s2); // s2 still valid, no reassignment needed
println!("{}", s2);
}
fn takes_ownership(s: String) -> String {
println!("{}", s);
s // must return to give back ownership
}
fn borrows(s: &String) {
println!("{}", s);
// no return needed
}For a function to borrow it needs a reference:
fn print_number(n: &Number) {
println!("{} number {}", if n.odd { "odd" } else { "even" }, n.value);
}
fn main() {
let n = Number { odd: true, value: 51 };
print_number(&n); // `n` is borrowed for the time of the call
print_number(&n); // `n` is borrowed again
}Traits
Traits are something multiple types can have in common:
trait Signed {
fn is_strictly_negative(self) -> bool;
}Orphan Rules
You can implement:
- one of your traits on anyone’s type
- anyone’s trait on one of your types
- but not a foreign trait on a foreign type
Trait Bounds
Trait bounds constrain generic type parameters to only types that implement certain traits:
// T must implement Display
fn print<T: std::fmt::Display>(val: T) {
println!("{}", val);
}
// Multiple bounds with +
fn print_and_clone<T: std::fmt::Display + Clone>(val: T) { }
// where clause - same meaning, cleaner for complex bounds
fn compare<T>(left: T, right: T)
where
T: std::fmt::Debug + PartialEq,
{ }Supertraits
A supertrait bound on a trait declaration requires implementors to also implement another trait:
pub trait Copy: Clone {} // anything implementing Copy must also implement CloneThis means wherever Copy is required, Clone is implied — you don’t need to write T: Copy + Clone, just T: Copy.
Deriving
#[derive(Clone, Copy)]
struct Number {
odd: bool,
value: i32,
}
// this expands to `impl Clone for Number` and `impl Copy for Number` blocks.Copy vs Clone
Both duplicate a value, but they differ in cost and intent:
Copy | Clone | |
|---|---|---|
| How | Implicit bitwise copy | Explicit .clone() call |
| Cost | Always cheap (stack only) | Can be expensive (heap allocation) |
| When | Automatic on assignment/pass | Only when you call .clone() |
| Requires | Clone (Copy implies Clone) | Nothing |
Copytypes are duplicated silently — integers, booleans,char,f64, references&TClonetypes require an explicit call —String,Vec<T>,HashMapetc.- A type can be
Clonewithout beingCopy(e.g.String) - A type cannot be
Copyif it contains non-Copyfields
// Copy - implicit, no .clone() needed
let x: i32 = 5;
let y = x; // x is copied, both x and y are valid
// Clone - explicit
let s = String::from("hello");
let t = s.clone(); // explicit deep copy; s is still validYou cannot implement Copy on a type that manages heap memory (like String), because the compiler relies on Copy meaning “a simple memcpy is sufficient”.
Why Copy requires Clone
Copy is a subtrait of Clone (Copy: Clone), so every Copy type must also implement Clone. This is a deliberate design choice:
Cloneis the general “I can duplicate myself” contractCopyis a stronger promise: “I can be duplicated trivially (bitwise)”- Making
Copya subtrait enforces that relationship — aCopytype is just aClonetype where.clone()is guaranteed to be a no-op bitwise copy
This means generic code that requires Clone will also accept Copy types, and you can always call .clone() on a Copy type (redundant but valid).
When you #[derive(Copy, Clone)], the derived Clone impl just does *self — consistent with what Copy does implicitly.
Marker Traits
- Have no method
Default trait
The Default trait provides a way to create a default value for a type.
struct Greeting {
greeting: String,
whom: String,
}
impl Default for Greeting {
fn default() -> Self {
Self {
greeting: "howdy".into(),
whom: "partner".into(),
}
}
}Generics
use std::fmt::Debug;
// Type parameters usually have _constraints_, so you can actually do something with them.
fn compare<T>(left: T, right: T)
where
T: Debug + PartialEq,
{
println!("{:?} {} {:?}", left, if left == right { "==" } else { "!=" }, right);
}
fn main() {
compare("tea", "coffee");
// prints: "tea" != "coffee"
}Vectors
- ~ Heap allocated array
let mut v1 = Vec::new();
v1.push(1);
// or
let mut v1 = vec![1, 2, 3];
v1.push(4);Iterating over Vectors
let v = vec![1, 2, 3];
// iter() - borrows, yields &T
for x in v.iter() { } // x: &i32, v still usable
// iter_mut() - mutably borrows, yields &mut T
for x in v.iter_mut() { *x += 1; }
// into_iter() - takes ownership, yields T
for x in v.into_iter() { } // x: i32, v is consumed
// v no longer usable here
// iter().copied() - borrows but yields owned copies (for Copy types)
for x in v.iter().copied() { } // x: i32, v still usableMacros
- All of
name!(),name![]orname!{}invoke a macro. - Macros just expand to regular code.
Lifetimes
The lifetime of a reference cannot exceed the lifetime of the variable binding it borrows.
Owned types vs reference types
For many types in Rust, there are owned and non-owned variants:
- Strings:
Stringis owned,&stris a reference. - Paths:
PathBufis owned,&Pathis a reference. - Collections:
Vec<T>is owned,&[T]is a reference.
Error Handling
Rust uses Result<T, E> for recoverable errors and panic! for unrecoverable errors.
use std::fs::File;
fn main() {
// Returns Result<File, std::io::Error>
let file = File::open("hello.txt");
let file = match file {
Ok(f) => f,
Err(e) => panic!("Problem opening the file: {:?}", e),
};
}The ? operator
Propagates errors up to the calling function:
fn read_username() -> Result<String, std::io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}Swallowing errors
Instead of propagating with ?, use if let Err to log and continue:
// Try to delete a cache file, but don't fail if it doesn't exist
if let Err(e) = std::fs::remove_file("cache.tmp") {
eprintln!("Could not remove cache: {e}");
}
// Program continues regardlessOr with unwrap_or_else:
std::fs::remove_file("cache.tmp")
.unwrap_or_else(|e| eprintln!("Could not remove cache: {e}"));Both approaches swallow the error so the program continues running. The if let version is more idiomatic when you don’t need the success value (or it’s ()).
Custom Error Types
Define your own error type by implementing std::error::Error:
#[derive(Debug)]
struct MyError {
message: String,
}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for MyError {}The thiserror crate
thiserror reduces boilerplate when defining custom error types. It derives Display and Error implementations automatically.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("failed to read file: {0}")]
IoError(#[from] std::io::Error),
#[error("invalid data format at line {line}")]
ParseError { line: usize },
#[error("missing required field: {0}")]
MissingField(String),
}Key features:
#[error("...")]- generates theDisplayimpl with the message#[from]- generates aFromimpl for automatic conversion (enables?to convert errors)#[source]- marks a field as the underlying cause (for error chaining)#[error(transparent)]- forwards theDisplayandsource()of the wrapped error directly- Supports named fields, tuple variants, and unit variants
#[error(transparent)] is useful when you want your error to act as a thin wrapper:
#[derive(Error, Debug)]
pub enum MyError {
#[error("database error: {0}")]
Database(#[from] DbError), // adds context with custom message
#[error(transparent)]
Other(#[from] anyhow::Error), // passes through as-is, no extra message
}With transparent, calling .to_string() or source() on MyError::Other returns exactly what the inner anyhow::Error would return.
Using the error:
fn process_file(path: &str) -> Result<Data, DataError> {
let contents = std::fs::read_to_string(path)?; // IoError auto-converted via #[from]
if contents.is_empty() {
return Err(DataError::MissingField("content".to_string()));
}
// ...
Ok(data)
}thiserror is best for library code where you want typed, structured errors. For application code, consider anyhow which provides simpler ad-hoc error handling.
Serializing errors for Tauri
Tauri commands return results to the frontend as JSON. If your command returns a Result<T, E>, the error type E must implement serde::Serialize:
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug, Serialize)]
pub enum CommandError {
#[error("file not found: {0}")]
NotFound(String),
#[error("permission denied")]
PermissionDenied,
}
#[tauri::command]
fn read_config() -> Result<Config, CommandError> {
// ...
}For errors that wrap types which don’t implement Serialize (like std::io::Error), convert them to a string:
#[derive(Error, Debug, Serialize)]
pub enum CommandError {
#[error("{0}")]
Io(String),
}
impl From<std::io::Error> for CommandError {
fn from(err: std::io::Error) -> Self {
CommandError::Io(err.to_string())
}
}Now ? will automatically convert std::io::Error into your serializable CommandError.