PONY λ M2 Modula-2

C.CodeCompared.To/Rust

An interactive executable cheatsheet comparing C and Rust

C17 (GCC) Rust 1.95
Hello World & Cargo
Hello, World
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
fn main() { println!("Hello, World!"); }
println! is a macro (note the !), not a function. No format-string types like %s or %d — Rust uses {} for any type that implements Display. No return 0 required; main returns () implicitly.
Compile & run
// Single file: // gcc -Wall -Wextra -o hello hello.c && ./hello // With optimisation: // gcc -O2 -o hello hello.c // With math library: // gcc hello.c -lm -o hello
// Single file (rare): // rustc hello.rs && ./hello // Project workflow (standard): // cargo new myproject // cd myproject // cargo run // compile + execute // cargo build // compile only // cargo build --release // optimized build
cargo run compiles and executes in one step. cargo build --release enables full optimisations (LLVM). The resulting binary is statically linked by default — no runtime shared library dependencies.
Cargo.toml vs Makefile
# Makefile (typical C project): # CC = gcc # CFLAGS = -Wall -O2 # LDLIBS = -lm # # all: myprogram # myprogram: main.o util.o # $(CC) $(CFLAGS) -o myprogram main.o util.o $(LDLIBS) # # Adding a library: # Download source, ./configure && make && sudo make install # or: pkg-config --libs --cflags libfoo
# Cargo.toml (generated by cargo new): # [package] # name = "myproject" # version = "0.1.0" # edition = "2021" # # [dependencies] # serde = { version = "1", features = ["derive"] } # rand = "0.8" # # Then: cargo build # Cargo downloads, compiles, and links everything automatically.
Cargo is Rust's integrated build system and package manager. crates.io hosts over 150,000 packages. Versions are pinned in Cargo.lock for reproducible builds — no pkg-config, no manual downloads, no vendoring.
Print with formatting
#include <stdio.h> int main(void) { int age = 30; double pi = 3.14159; const char *name = "Alice"; printf("Name: %s, Age: %d, Pi: %.2f\n", name, age, pi); return 0; }
fn main() { let age = 30; let pi = 3.14159_f64; let name = "Alice"; println!("Name: {name}, Age: {age}, Pi: {pi:.2}"); }
Rust format strings use {} for default, {:?} for debug output, {:.2} for two decimal places, {:>10} for right-aligned width. Since Rust 1.58, {name} captures local variables directly without positional arguments.
Variables & Types
Variable declaration
#include <stdio.h> int main(void) { int count = 0; double temperature = 98.6; int mutable_count = 10; mutable_count += 1; printf("count: %d, temp: %.1f, mutable: %d\n", count, temperature, mutable_count); return 0; }
fn main() { let count: i32 = 0; let temperature = 98.6_f64; // type inferred from literal let mut mutable_count = 10; mutable_count += 1; println!("count: {count}, temp: {temperature:.1}, mutable: {mutable_count}"); }
In Rust, all variables are immutable by default. You must add mut to allow mutation — the opposite of C where everything is mutable unless marked const. Rust also infers the type when it can be determined from context.
Integer types
#include <stdio.h> #include <stdint.h> int main(void) { int8_t tiny = 127; uint8_t byte_val = 255; int32_t medium = 2000000; uint64_t big = 18000000000ULL; size_t size = sizeof(int); printf("%d %u %d %llu %zu\n", tiny, byte_val, medium, big, size); return 0; }
fn main() { let tiny: i8 = 127; let byte_val: u8 = 255; let medium: i32 = 2_000_000; // underscores allowed in literals let big: u64 = 18_000_000_000; let size: usize = std::mem::size_of::<i32>(); println!("{tiny} {byte_val} {medium} {big} {size}"); }
Rust's integer types are explicit about sign and size: i8/u8 through i128/u128, plus isize/usize (pointer-sized, equivalent to ptrdiff_t/size_t). No implicit integer promotions — every numeric operation is on a defined type.
Type casting
#include <stdio.h> int main(void) { double pi = 3.14159; int truncated = (int)pi; // explicit cast unsigned bits = (unsigned)(-1); // -1 → UINT_MAX printf("%d %u\n", truncated, bits); return 0; }
fn main() { let pi: f64 = 3.14159; let truncated = pi as i32; // explicit cast with 'as' let bits = (-1_i32) as u32; // wrapping cast — same result as C println!("{truncated} {bits}"); }
Rust uses the as keyword for all primitive casts. Casts are always explicit — there are no implicit numeric conversions, eliminating an entire class of C bugs (sign extension, silent truncation, signed/unsigned comparison).
Constants and statics
#include <stdio.h> #define MAX_SIZE 100 // preprocessor — no type, no scope const int BUFFER_SIZE = 64; // typed, but still has an address static int counter = 0; // file-scoped, zero-initialized int main(void) { printf("%d %d %d\n", MAX_SIZE, BUFFER_SIZE, counter); return 0; }
fn main() { const MAX_SIZE: usize = 100; // compile-time constant, inlined at use site static BUFFER_SIZE: i32 = 64; // fixed address, safe to take a reference to static mut COUNTER: i32 = 0; // mutable static — reading it is unsafe println!("{MAX_SIZE} {BUFFER_SIZE}"); // Edition 2024 forbids an implicit reference to a mutable static, so read // through a raw pointer using the &raw const operator (stable since 1.82): unsafe { println!("{}", *(&raw const COUNTER)); } }
The Rust const is evaluated at compile time and inlined — similar to a typed #define. A static has a fixed address like a C global. Mutable statics require unsafe because concurrent access would be a data race, and the 2024 edition goes further: it forbids even taking an ordinary reference to one, so the read goes through the &raw const raw-pointer operator. In real code a mutable global is better expressed as an AtomicI32.
Variable shadowing
#include <stdio.h> int main(void) { int value = 5; { int value = 10; // inner scope shadows outer printf("%d\n", value); // 10 } printf("%d\n", value); // 5 — outer unchanged // Can't re-declare in the same scope with a different type return 0; }
fn main() { let value = 5; let value = value * 2; // shadow in the SAME scope let value = value.to_string(); // shadow with a different type! println!("{value}"); // "10" }
Rust's let creates a new binding even if the name already exists in the same scope. Each shadow can change the type — useful when transforming a value through stages without inventing names like value_str.
Type inference
#include <stdio.h> int main(void) { // C has no type inference for variables (auto is C23) int x = 42; double pi = 3.14; const char *label = "hello"; printf("%d %.2f %s\n", x, pi, label); return 0; }
fn main() { let x = 42; // inferred: i32 let pi = 3.14; // inferred: f64 let label = "hello"; // inferred: &str let items = vec![1, 2, 3]; // inferred: Vec<i32> println!("{x} {pi:.2} {label} {:?}", items); }
Rust's type inference is whole-function: the compiler looks at how a variable is used throughout the function to determine its type. It even infers type parameters — e.g., which integer type fits the operations performed on it.
Strings
Two string types
#include <stdio.h> #include <string.h> #include <stdlib.h> int main(void) { const char *literal = "Hello"; // in read-only data segment char buffer[64] = "World"; // stack-allocated, mutable char *heap_str = strdup("Heap"); // heap-allocated — must free! printf("%s %s %s\n", literal, buffer, heap_str); free(heap_str); return 0; }
fn main() { let literal: &str = "Hello"; // borrowed string slice — no allocation let buffer: String = "World".to_string(); // owned, heap-allocated, mutable let owned = String::from("Heap"); println!("{literal} {buffer} {owned}"); // all memory freed automatically when these go out of scope }
Rust has two string types: &str (a borrowed slice into existing data — like const char*) and String (owned heap-allocated storage — like a malloc'd buffer that frees itself). Neither uses a null terminator; both store their length.
String operations
#include <stdio.h> #include <string.h> int main(void) { char result[128]; strcpy(result, "Hello"); strcat(result, ", World"); printf("%s (len: %zu)\n", result, strlen(result)); // len is O(n)! if (strstr(result, "World") != NULL) printf("found 'World'\n"); return 0; }
fn main() { let mut result = String::from("Hello"); result.push_str(", World"); println!("{result} (len: {})", result.len()); // len is O(1) if result.contains("World") { println!("found 'World'"); } }
String grows on the heap as needed — no buffer overflow possible. .len() returns the byte count in O(1) (stored alongside the data). In C, strlen walks to the null terminator every time, making it O(n).
Formatting and parsing
#include <stdio.h> #include <stdlib.h> int main(void) { char buffer[64]; int number = 42; snprintf(buffer, sizeof(buffer), "Value is %d", number); printf("%s\n", buffer); // Parse string → int (no error checking with atoi!) const char *input = "123"; int parsed = atoi(input); // returns 0 on failure — silent! printf("%d\n", parsed); return 0; }
fn main() { let number = 42; let text = format!("Value is {number}"); // safe snprintf equivalent println!("{text}"); // Parse string → integer (with error handling) let input = "123"; let parsed: i32 = input.parse().expect("not a valid number"); println!("{parsed}"); }
format! is the heap-allocating equivalent of snprintf — it returns a String and can never overflow. .parse() returns a Result, so parse failures are impossible to ignore silently — unlike atoi, which returns 0 for bad input.
String slicing
#include <stdio.h> #include <string.h> int main(void) { const char *message = "Hello, World!"; const char *world = message + 7; // pointer arithmetic — no bounds check printf("%s\n", world); // "World!" char prefix[6]; strncpy(prefix, message, 5); prefix[5] = '\0'; printf("%s\n", prefix); // "Hello" return 0; }
fn main() { let message = "Hello, World!"; let world = &message[7..]; // slice — pointer + length, no copy println!("{world}"); // "World!" let prefix = &message[..5]; // first 5 bytes println!("{prefix}"); // "Hello" }
A Rust string slice is a fat pointer (address + byte length) into an existing string — equivalent to C pointer arithmetic, but bounds-checked at compile time where possible and at runtime otherwise. Slicing at a non-UTF-8 character boundary panics rather than producing garbage.
Arrays, Slices & Vectors
Fixed-size arrays
#include <stdio.h> int main(void) { int numbers[5] = {10, 20, 30, 40, 50}; printf("len: %zu\n", sizeof(numbers) / sizeof(numbers[0])); printf("%d %d\n", numbers[0], numbers[4]); // numbers[5] = 99; // undefined behavior — no bounds check return 0; }
fn main() { let numbers: [i32; 5] = [10, 20, 30, 40, 50]; println!("len: {}", numbers.len()); println!("{} {}", numbers[0], numbers[4]); // numbers[5]; // panics at runtime: index out of bounds }
In Rust the size is part of the type: [i32; 5] and [i32; 6] are different types. Array access is bounds-checked — an out-of-bounds index panics in both debug and release builds rather than silently reading unrelated memory.
Slices (fat pointers)
#include <stdio.h> // C: pointer + length — two separate arguments, easy to mismatch void print_sum(const int *values, size_t count) { int total = 0; for (size_t i = 0; i < count; i++) total += values[i]; printf("sum: %d\n", total); } int main(void) { int numbers[] = {1, 2, 3, 4, 5}; print_sum(numbers, 5); // whole array print_sum(numbers + 1, 3); // sub-array — count must be correct! return 0; }
fn print_sum(values: &[i32]) { // pointer + length bundled together let total: i32 = values.iter().sum(); println!("sum: {total}"); } fn main() { let numbers = [1, 2, 3, 4, 5]; print_sum(&numbers); // whole array print_sum(&numbers[1..4]); // sub-slice — bounds enforced }
A Rust slice &[i32] is a fat pointer — pointer + length together. There is no way to pass a mismatched count. Functions that accept slices work equally on arrays, Vecs, and sub-slices — no separate *_n variants needed.
Vec<T> (dynamic array)
#include <stdio.h> #include <stdlib.h> int main(void) { int capacity = 4, count = 0; int *numbers = malloc(capacity * sizeof(int)); for (int i = 1; i <= 5; i++) { if (count == capacity) { capacity *= 2; numbers = realloc(numbers, capacity * sizeof(int)); } numbers[count++] = i * 10; } for (int i = 0; i < count; i++) printf("%d ", numbers[i]); printf("\n"); free(numbers); return 0; }
fn main() { let mut numbers: Vec<i32> = Vec::new(); for i in 1..=5 { numbers.push(i * 10); // grows automatically } for n in &numbers { print!("{n} "); } println!(); // freed automatically when numbers goes out of scope }
Vec is Rust's dynamic array — it owns its heap memory and frees it automatically when it goes out of scope. Internally it is a (pointer, length, capacity) triplet — identical layout to a typical C dynamic array, with the same amortized O(1) push cost.
Array iteration
#include <stdio.h> int main(void) { int numbers[] = {10, 20, 30, 40, 50}; int count = sizeof(numbers) / sizeof(numbers[0]); for (int i = 0; i < count; i++) printf("%d\n", numbers[i]); for (int i = 0; i < count; i++) printf("[%d] = %d\n", i, numbers[i]); return 0; }
fn main() { let numbers = [10, 20, 30, 40, 50]; for n in &numbers { println!("{n}"); } for (index, n) in numbers.iter().enumerate() { println!("[{index}] = {n}"); } }
The for x in &array form iterates by reference. .iter().enumerate() pairs each element with its index. In release builds, the bounds check is hoisted out of the loop by the optimiser — no per-element overhead.
Const generics
#include <stdio.h> /* In C an array argument decays to a pointer, so the length has to be passed alongside it — it is never part of the type. */ int sum(const int *values, int count) { int total = 0; for (int index = 0; index < count; index++) { total += values[index]; } return total; } int main(void) { int a[] = {1, 2, 3}; int b[] = {1, 2, 3, 4, 5}; printf("%d\n", sum(a, 3)); printf("%d\n", sum(b, 5)); return 0; }
// N is a compile-time constant: each array length is its own type, // yet one definition covers them all — and the length is never lost. fn sum<const N: usize>(values: [i32; N]) -> i32 { values.iter().sum() } fn main() { println!("{}", sum([1, 2, 3])); println!("{}", sum([1, 2, 3, 4, 5])); }
Const generics let a definition be parameterized over a value — here the array length N. Because [i32; N] keeps the length in the type, Rust knows each array's size at compile time and you never pass a separate count, so the entire class of C bugs where length and pointer drift apart simply cannot occur. [i32; 3] and [i32; 5] are distinct types served by one sum, stored inline with no decay to a bare pointer.
Memory Management
Stack vs heap allocation
#include <stdio.h> #include <stdlib.h> int main(void) { // Stack: automatic, very fast int stack_value = 42; // Heap: manual management int *heap_value = malloc(sizeof(int)); *heap_value = 42; printf("%d %d\n", stack_value, *heap_value); free(heap_value); // must free — or it leaks return 0; }
fn main() { let stack_value: i32 = 42; // stack — same as C let heap_value: Box<i32> = Box::new(42); // heap — freed automatically println!("{stack_value} {heap_value}"); // heap_value dropped (freed) here — no free() needed }
Box allocates a single value on the heap and frees it automatically when it goes out of scope. It is equivalent to malloc(sizeof(T)) + free(), but there is no way to forget to call free().
Ownership and move
#include <stdio.h> #include <string.h> #include <stdlib.h> int main(void) { // C: raw pointers can alias freely — no ownership concept char *first = strdup("hello"); char *second = first; // both point to the same memory! free(first); // Using second here is use-after-free — undefined behavior // The compiler will NOT catch this return 0; }
fn main() { let first = String::from("hello"); let second = first; // first is MOVED into second // println!("{first}"); // compile error: value borrowed after move println!("{second}"); // only second is valid now } // second (and its heap memory) is freed here
Each heap value in Rust has exactly one owner. Assigning a heap value to another variable moves ownership — the original binding becomes invalid immediately. The compiler enforces this, making use-after-free and double-free impossible in safe code.
Borrowing (shared references)
#include <stdio.h> // C: const pointer = read-only borrow, but nothing enforces it void print_greeting(const char *message) { printf("%s\n", message); } int main(void) { char greeting[] = "Hello, World!"; print_greeting(greeting); printf("%s\n", greeting); // still valid return 0; }
fn print_greeting(message: &str) { // immutable borrow println!("{message}"); } fn main() { let greeting = String::from("Hello, World!"); print_greeting(&greeting); // lend to function println!("{greeting}"); // still valid — ownership not transferred }
&T is an immutable borrow — read-only access, like const T*. Any number of immutable borrows can coexist, but no mutable borrow can coexist with any other borrow. The borrow checker enforces this statically, preventing iterator invalidation and data races.
Mutable borrowing
#include <stdio.h> void increment(int *value) { (*value)++; } int main(void) { int counter = 0; increment(&counter); increment(&counter); printf("%d\n", counter); return 0; }
fn increment(value: &mut i32) { *value += 1; } fn main() { let mut counter = 0; increment(&mut counter); increment(&mut counter); println!("{counter}"); }
&mut T is an exclusive mutable borrow. At any given point in the code you may have either one &mut T or any number of &T references — never both simultaneously. This rule, enforced at compile time, eliminates the aliased-mutation bugs common in C.
Automatic cleanup (Drop)
#include <stdio.h> #include <stdlib.h> void process(void) { int *buffer = malloc(1024 * sizeof(int)); // ... use buffer ... // Every exit path — early return, goto, signal — must free buffer. // Missing one = memory leak. free(buffer); } int main(void) { process(); return 0; }
fn process() { let buffer: Vec<i32> = vec![0; 1024]; // ... use buffer ... // No matter how this function exits — normal return, early return, // or panic — buffer is freed here automatically. } fn main() { process(); }
When a Rust value goes out of scope, its Drop implementation runs — a guaranteed destructor. This is the core mechanism behind Rust's memory safety: no code path, not even a panic, can skip cleanup. It replaces the entire pattern of goto cleanup in C error handling.
Pointers & References
References vs pointers
#include <stdio.h> int main(void) { int value = 42; int *pointer = &value; // can be NULL, can do arithmetic *pointer = 100; printf("%d\n", value); pointer = NULL; // *pointer; // null dereference — undefined behavior, likely crash return 0; }
fn main() { let mut value = 42; let reference = &mut value; // always valid, never null *reference = 100; println!("{value}"); // References cannot be null — use Option<&T> for nullable references }
A Rust reference is guaranteed non-null, properly aligned, and pointing to initialized data of the correct type. It cannot be NULL, cannot dangle, and cannot alias a mutable reference. The borrow checker enforces all three at compile time.
NULL pointers → Option<T>
#include <stdio.h> // Returns NULL on failure — callers must know to check! const char *find_name(int id) { if (id == 1) return "Alice"; return NULL; } int main(void) { const char *name = find_name(1); if (name != NULL) printf("Found: %s\n", name); // Forgetting the NULL check: const char *missing = find_name(99); // printf("%s\n", missing); // null dereference — crash return 0; }
fn find_name(id: u32) -> Option<&'static str> { if id == 1 { Some("Alice") } else { None } } fn main() { if let Some(name) = find_name(1) { println!("Found: {name}"); } match find_name(99) { Some(name) => println!("Found: {name}"), None => println!("not found"), } }
Option replaces null pointers. The compiler forces every caller to handle both Some(value) and None — there is no way to accidentally dereference an absent value. This eliminates the single most common cause of crashes in C programs.
Pointer arithmetic
#include <stdio.h> int main(void) { int numbers[] = {10, 20, 30, 40, 50}; int *ptr = numbers; printf("%d\n", *ptr); // 10 printf("%d\n", *(ptr + 2)); // 30 — pointer arithmetic always allowed ptr++; printf("%d\n", *ptr); // 20 return 0; }
fn main() { let numbers = [10i32, 20, 30, 40, 50]; // Safe Rust: use indexing and slices — prefer this println!("{}", numbers[0]); // 10 println!("{}", numbers[2]); // 30 println!("{}", numbers[1]); // 20 // Raw pointer arithmetic is available but requires unsafe: let raw = numbers.as_ptr(); unsafe { println!("{}", *raw.add(2)); } // 30 }
Safe Rust replaces pointer arithmetic with indexed access and slices, both of which are bounds-checked. Raw pointer arithmetic exists for low-level code (allocators, FFI wrappers) but requires an unsafe block — a clear signal to reviewers that manual verification is needed.
Raw pointers
#include <stdio.h> #include <stdlib.h> int main(void) { int *raw = malloc(sizeof(int)); *raw = 42; printf("%d\n", *raw); free(raw); return 0; }
fn main() { let value: i32 = 42; let raw_const: *const i32 = &value as *const i32; // Dereferencing a raw pointer requires unsafe: unsafe { println!("{}", *raw_const); } }
Raw pointers (*const T and *mut T) behave like C pointers: they can be null, can dangle, and can alias. They bypass the borrow checker. Dereferencing them requires unsafe, scoping the dangerous operations so reviewers know exactly where to focus.
Dangling pointer prevention
#include <stdio.h> // Classic bug: return a pointer to a local variable int *get_value(void) { int local = 42; return &local; // UB: local is freed when function returns } int main(void) { int *dangling = get_value(); printf("%d\n", *dangling); // undefined behavior — stack corrupted return 0; }
// This does not compile: // fn get_value() -> &i32 { // let local = 42; // &local // error: local does not live long enough // } fn get_value() -> i32 { let local = 42; local // return the value, not a reference } fn main() { let value = get_value(); println!("{value}"); }
Lifetime analysis in Rust's borrow checker catches dangling references at compile time. Any attempt to return a reference to a local variable is a compile error. There is no way to create a use-after-free in safe Rust.
Structs & Methods
Struct definition
#include <stdio.h> // typedef needed to avoid writing 'struct Point' everywhere typedef struct { double x; double y; } Point; int main(void) { Point p = { .x = 3.0, .y = 4.0 }; // C99 designated initialiser printf("(%.1f, %.1f)\n", p.x, p.y); return 0; }
struct Point { x: f64, y: f64, } fn main() { let point = Point { x: 3.0, y: 4.0 }; println!("({:.1}, {:.1})", point.x, point.y); }
No typedef needed — the struct name is the type name. Fields are named and the compiler catches missing or extra fields at the call site. Fields are private by default within modules; add pub to export them.
Methods (impl blocks)
#include <stdio.h> #include <math.h> typedef struct { double x; double y; } Point; // C "methods" are free functions with an explicit self pointer double point_distance(const Point *self, const Point *other) { double dx = self->x - other->x; double dy = self->y - other->y; return sqrt(dx*dx + dy*dy); } int main(void) { Point origin = { 0.0, 0.0 }; Point target = { 3.0, 4.0 }; printf("%.1f\n", point_distance(&origin, &target)); return 0; }
struct Point { x: f64, y: f64 } impl Point { fn distance(&self, other: &Point) -> f64 { let dx = self.x - other.x; let dy = self.y - other.y; (dx * dx + dy * dy).sqrt() } } fn main() { let origin = Point { x: 0.0, y: 0.0 }; let target = Point { x: 3.0, y: 4.0 }; println!("{:.1}", origin.distance(&target)); }
impl blocks attach methods to a struct. &self is an immutable borrow (like const Point*), &mut self is a mutable borrow (like Point*), and self consumes the value. Methods are called with dot syntax just like C++ or Objective-C.
Constructor pattern
#include <stdio.h> #include <stdlib.h> typedef struct { unsigned width; unsigned height; int *pixels; } Image; Image *image_new(unsigned width, unsigned height) { Image *img = malloc(sizeof(Image)); img->width = width; img->height = height; img->pixels = calloc(width * height, sizeof(int)); return img; } void image_free(Image *img) { free(img->pixels); free(img); } int main(void) { Image *image = image_new(800, 600); printf("%ux%u\n", image->width, image->height); image_free(image); return 0; }
struct Image { width: u32, height: u32, pixels: Vec<u32>, } impl Image { fn new(width: u32, height: u32) -> Self { Image { width, height, pixels: vec![0; (width * height) as usize], } } } fn main() { let image = Image::new(800, 600); println!("{}x{}", image.width, image.height); // image (including pixels Vec) freed automatically here }
The Rust convention is to add a new associated function (no self parameter) that acts as a constructor. Self is an alias for the struct's own type. No separate free function needed — Drop handles cleanup automatically.
Deriving common behaviour
#include <stdio.h> // C: every operation Rust derives must be written by hand — // no standard attribute generates printing, copying, or comparison. typedef struct { unsigned char red, green, blue; } Color; void color_print(const Color *color) { // hand-written "Debug" printf("Color { red: %d, green: %d, blue: %d }\n", color->red, color->green, color->blue); } int color_equal(const Color *first, const Color *second) { // hand-written "PartialEq" return first->red == second->red && first->green == second->green && first->blue == second->blue; } int main(void) { Color red = { 255, 0, 0 }; Color copy = red; // struct assignment = "Clone" color_print(&red); printf("%s\n", color_equal(&red, &copy) ? "true" : "false"); return 0; }
#[derive(Debug, Clone, PartialEq)] struct Color { red: u8, green: u8, blue: u8, } fn main() { let red = Color { red: 255, green: 0, blue: 0 }; let copy = red.clone(); // deep copy println!("{:?}", red); // Debug printing println!("{}", red == copy); // PartialEq comparison }
#[derive(...)] auto-generates common trait implementations. Debug enables {:?} printing, Clone enables .clone(), and PartialEq enables ==. In C all of these require hand-written functions. Other useful derives include Hash, Copy, and Default.
Enums & Pattern Matching
Enums with data
#include <stdio.h> // C: tagged union for "enum with data" — verbose and error-prone typedef struct { enum { CIRCLE, RECTANGLE } kind; union { double radius; struct { double width; double height; } rect; }; } Shape; int main(void) { Shape circle = { .kind = CIRCLE, .radius = 5.0 }; if (circle.kind == CIRCLE) printf("area: %.2f\n", 3.14159 * circle.radius * circle.radius); return 0; }
enum Shape { Circle(f64), Rectangle(f64, f64), } fn main() { let circle = Shape::Circle(5.0); let rectangle = Shape::Rectangle(3.0, 4.0); let area = match circle { Shape::Circle(r) => std::f64::consts::PI * r * r, Shape::Rectangle(w, h) => w * h, }; println!("area: {area:.2}"); let _ = rectangle; }
Rust enums are algebraic data types — each variant carries its own data. They replace C's enum + union + tag-field pattern with a single, safe type. The compiler knows which variant is active, so reading the wrong union member is impossible.
Pattern matching with match
#include <stdio.h> typedef enum { PENDING, ACTIVE, CLOSED } Status; int main(void) { Status status = ACTIVE; switch (status) { case PENDING: printf("waiting\n"); break; case ACTIVE: printf("running\n"); break; case CLOSED: printf("done\n"); break; // Forget break → silent fallthrough (common bug) // Forget a case → no compile error without -Wswitch } return 0; }
enum Status { Pending, Active, Closed } fn main() { let status = Status::Active; match status { Status::Pending => println!("waiting"), Status::Active => println!("running"), Status::Closed => println!("done"), // No break needed — no fallthrough // Missing a variant is a compile error } }
match is exhaustive — the compiler requires every possible value to be handled. There is no fallthrough between arms. If you add a new enum variant later, every match on that type becomes a compile error until updated. Wildcard _ is the explicit opt-out.
Destructuring
#include <stdio.h> typedef struct { double x; double y; } Point; int main(void) { Point point = { 3.0, 4.0 }; // C: access fields one at a time printf("x=%.1f y=%.1f\n", point.x, point.y); // No tuple type — use separate variables int first = 10, second = 20; printf("%d %d\n", first, second); return 0; }
struct Point { x: f64, y: f64 } fn main() { let point = Point { x: 3.0, y: 4.0 }; let Point { x, y } = point; // destructure struct into locals println!("x={x:.1} y={y:.1}"); let pair = (10, 20); let (first, second) = pair; // destructure tuple println!("{first} {second}"); }
Pattern matching works in let bindings, function parameters, match arms, and if let — anywhere a value is bound. Destructuring a struct extracts its fields into named locals. Tuples are anonymous structs and destructure the same way.
if let and Option
#include <stdio.h> #include <string.h> // Sentinel return: -1 means "not found" — callers must know this convention int find_index(const char *text, char target) { for (int i = 0; text[i]; i++) if (text[i] == target) return i; return -1; } int main(void) { int idx = find_index("hello", 'l'); if (idx >= 0) printf("found at %d\n", idx); return 0; }
fn find_index(text: &str, target: char) -> Option<usize> { text.chars().position(|character| character == target) } fn main() { if let Some(index) = find_index("hello", 'l') { println!("found at {index}"); } match find_index("hello", 'z') { Some(index) => println!("found at {index}"), None => println!("not found"), } }
if let Some(x) = opt is shorthand for a match that handles only the Some case. Option replaces sentinel values (-1, NULL, 0) with a type-safe wrapper that forces callers to acknowledge the absent case.
while let
#include <stdio.h> int main(void) { int stack[] = {1, 2, 3}; int top = 2; while (top >= 0) { printf("popped: %d\n", stack[top--]); } return 0; }
fn main() { let mut stack = vec![1, 2, 3]; while let Some(top) = stack.pop() { println!("popped: {top}"); } }
while let loops as long as the pattern matches. Vec::pop() returns OptionSome(value) when non-empty, None when empty. There is no index or sentinel to manage manually.
if-let chains
#include <stdio.h> int main(void) { int timeout = 30; int *configured = &timeout; /* a non-NULL pointer means "present" */ /* C folds the NULL check, the dereference, and the comparison into one condition: */ if (configured != NULL && *configured > 10) { printf("long timeout: %d\n", *configured); } return 0; }
use std::collections::HashMap; fn main() { let settings: HashMap<&str, i32> = [("timeout", 30)].into_iter().collect(); // Edition 2024: chain a let pattern and a bool test with && if let Some(&value) = settings.get("timeout") && value > 10 { println!("long timeout: {value}"); } }
Stabilised in the 2024 edition, let chains let a let pattern and ordinary boolean tests be joined with && inside one if — the safe counterpart to C's configured != NULL && *configured > 10. The Some(..) pattern both confirms the key is present and binds its value, so there is no raw pointer to dereference and no way to forget the presence check. Before 2024 this needed a nested if let { if value > 10 { ... } }.
Error Handling
errno vs Result<T, E>
#include <stdio.h> #include <errno.h> #include <string.h> int main(void) { FILE *file = fopen("/nonexistent/path.txt", "r"); if (file == NULL) { printf("error: %s\n", strerror(errno)); return 1; } // ... use file ... fclose(file); return 0; }
use std::fs; fn main() { match fs::read_to_string("/nonexistent/path.txt") { Ok(contents) => println!("{contents}"), Err(error) => println!("error: {error}"), } }
Result bundles the return value and the error into one type. The compiler forces callers to check the result — there is no equivalent of ignoring errno or forgetting to check for a NULL return. Rust's #[must_use] makes it a warning to discard a Result without inspecting it.
The ? operator (early return)
#include <stdio.h> #include <errno.h> #include <string.h> // Propagating errors in C: check-and-return at every call site int read_config(const char *path, char *buffer, size_t size) { FILE *file = fopen(path, "r"); if (file == NULL) return errno; if (fgets(buffer, (int)size, file) == NULL) { fclose(file); return errno; } fclose(file); return 0; } int main(void) { char buffer[256]; int err = read_config("/etc/hostname", buffer, sizeof(buffer)); if (err) printf("error: %s\n", strerror(err)); else printf("%s", buffer); return 0; }
use std::fs; use std::io; fn read_config(path: &str) -> Result<String, io::Error> { let contents = fs::read_to_string(path)?; // ? returns early on Err Ok(contents) } fn main() { match read_config("/etc/hostname") { Ok(contents) => print!("{contents}"), Err(error) => println!("error: {error}"), } }
The ? operator means "if this is an error, return it immediately." It replaces the repetitive if (ret != OK) return ret; pattern from C, making error-propagating code concise without hiding the fact that errors are being propagated.
Panic (unrecoverable errors)
#include <stdio.h> #include <stdlib.h> #include <assert.h> int divide(int a, int b) { assert(b != 0); // or: if (b == 0) { fputs("divide by zero\n", stderr); abort(); } return a / b; } int main(void) { printf("%d\n", divide(10, 2)); return 0; }
fn divide(a: i32, b: i32) -> i32 { if b == 0 { panic!("division by zero"); } a / b } fn main() { println!("{}", divide(10, 2)); }
panic! is Rust's equivalent of abort() — it prints a message and unwinds (or aborts) the process. Use it for programming errors (violated invariants), not recoverable errors. Result is for errors callers should handle; panic is for bugs.
Custom error types
#include <stdio.h> // C: error codes as macros — no structured payload, no message #define ERR_NOT_FOUND 1 #define ERR_PARSE_FAIL 2 int parse_config(const char *path, int *result) { if (!path || !*path) return ERR_NOT_FOUND; *result = 42; return 0; } int main(void) { int value; int err = parse_config("/config", &value); if (err) printf("error code: %d\n", err); else printf("value: %d\n", value); return 0; }
use std::fmt; #[derive(Debug)] enum ConfigError { NotFound(String), ParseError(String), } impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ConfigError::NotFound(path) => write!(f, "not found: {path}"), ConfigError::ParseError(msg) => write!(f, "parse error: {msg}"), } } } fn parse_config(path: &str) -> Result<i32, ConfigError> { if path.is_empty() { return Err(ConfigError::NotFound(path.to_string())); } Ok(42) } fn main() { match parse_config("/config") { Ok(value) => println!("value: {value}"), Err(error) => println!("error: {error}"), } }
Rust error types are enums that carry structured information — not just an error code. Implementing Display provides human-readable messages; Debug (derivable) provides developer output. The thiserror crate on crates.io reduces this boilerplate to a few derive attributes.
let-else early return
#include <stdio.h> #include <stdlib.h> int main(void) { const char *text = "42"; char *end; long number = strtol(text, &end, 10); if (*end != '\0') { /* parsing failed partway */ printf("not a number\n"); return 1; } printf("%ld\n", number * 2); return 0; }
fn main() { let text = "42"; let Ok(number) = text.parse::<i64>() else { println!("not a number"); return; }; println!("{}", number * 2); }
A let ... else binding is Rust's structured form of the C "check the result, bail out on failure" pattern. It pattern-matches on success and binds number in the surrounding scope; if the match fails it runs the else block, which must diverge (return, break, continue, or panic!). Where C leaves it to you to remember the *end != '\0' check, Rust's parse returns a Result the compiler forces you to handle, and let-else keeps the happy path flat.
Closures & Iterators
Closures vs function pointers
#include <stdio.h> #include <stdlib.h> int compare_ints(const void *a, const void *b) { return *(int*)a - *(int*)b; } int main(void) { int numbers[] = {5, 2, 8, 1, 9, 3}; int count = sizeof(numbers) / sizeof(numbers[0]); qsort(numbers, count, sizeof(int), compare_ints); for (int i = 0; i < count; i++) printf("%d ", numbers[i]); printf("\n"); return 0; }
fn main() { let mut numbers = vec![5, 2, 8, 1, 9, 3]; numbers.sort_by(|a, b| a.cmp(b)); // closure — no separate function needed for n in &numbers { print!("{n} "); } println!(); }
Rust closures can capture local variables from the enclosing scope — unlike C function pointers, which cannot. A closure passed to sort_by can reference any local variable without a void* context parameter, eliminating the entire qsort_r / pthread_create callback pattern.
Iterator adapters
#include <stdio.h> int main(void) { int numbers[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // Filter even, square, sum — must write one combined loop int total = 0; for (int i = 0; i < 10; i++) { if (numbers[i] % 2 == 0) total += numbers[i] * numbers[i]; } printf("%d\n", total); return 0; }
fn main() { let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; let total: i32 = numbers.iter() .filter(|&&n| n % 2 == 0) .map(|&n| n * n) .sum(); println!("{total}"); }
Rust iterators are lazy — no intermediate allocations. .filter().map().sum() compiles to the same machine code as a single combined loop. There is zero runtime overhead for the functional style: the optimiser sees through the abstractions completely.
Transform and collect
#include <stdio.h> #include <stdlib.h> int main(void) { int numbers[] = {1, 2, 3, 4, 5}; int count = 5; int *doubled = malloc(count * sizeof(int)); for (int i = 0; i < count; i++) doubled[i] = numbers[i] * 2; for (int i = 0; i < count; i++) printf("%d ", doubled[i]); printf("\n"); free(doubled); return 0; }
fn main() { let numbers = [1, 2, 3, 4, 5]; let doubled: Vec<i32> = numbers.iter() .map(|&n| n * 2) .collect(); for n in &doubled { print!("{n} "); } println!(); }
.collect() materializes a lazy iterator into a collection. The type annotation (Vec) tells Rust which collection to build — it can also collect into String, HashMap, HashSet, and many others using the same method.
Closures capturing environment
#include <stdio.h> // C function pointers cannot capture context — need global or void* trick int threshold = 5; // global — poor encapsulation int above_threshold(int value) { return value > threshold; } int main(void) { int numbers[] = {1, 3, 5, 7, 9}; for (int i = 0; i < 5; i++) if (above_threshold(numbers[i])) printf("%d ", numbers[i]); printf("\n"); return 0; }
fn main() { let threshold = 5; // local variable let above = |n: &i32| *n > threshold; // captures threshold by reference let numbers = [1, 3, 5, 7, 9]; let result: Vec<i32> = numbers.iter() .filter(|n| above(n)) .copied() .collect(); for n in &result { print!("{n} "); } println!(); }
A closure captures variables from the enclosing scope automatically. This eliminates the void* context parameter that C APIs like qsort_r, pthread_create, and many callback-based libraries require to carry state into a function pointer.
Traits
Traits and dynamic dispatch
#include <stdio.h> // C polymorphism: manual vtable — function pointer struct typedef struct { void (*speak)(void *self); } AnimalVtable; typedef struct { AnimalVtable *vtable; } Dog; typedef struct { AnimalVtable *vtable; } Cat; void dog_speak(void *self) { (void)self; printf("Woof!\n"); } void cat_speak(void *self) { (void)self; printf("Meow!\n"); } AnimalVtable dog_vtable = { dog_speak }; AnimalVtable cat_vtable = { cat_speak }; int main(void) { Dog dog = { &dog_vtable }; Cat cat = { &cat_vtable }; dog.vtable->speak(&dog); cat.vtable->speak(&cat); return 0; }
trait Animal { fn speak(&self); } struct Dog; struct Cat; impl Animal for Dog { fn speak(&self) { println!("Woof!"); } } impl Animal for Cat { fn speak(&self) { println!("Meow!"); } } fn main() { let animals: Vec<Box<dyn Animal>> = vec![ Box::new(Dog), Box::new(Cat), ]; for animal in &animals { animal.speak(); } }
Traits are Rust's interfaces. Box uses dynamic dispatch — Rust generates a vtable automatically, exactly like the manual C approach above. The difference is that Rust's vtable is type-safe, and the compiler catches missing implementations at the impl site.
Trait bounds (static dispatch)
#include <stdio.h> // C: void* + function pointer — no type safety, callers must cast correctly void print_twice(const void *item, void (*printer)(const void*)) { printer(item); printer(item); } void print_int(const void *item) { printf("%d\n", *(const int*)item); } int main(void) { int value = 42; print_twice(&value, print_int); return 0; }
use std::fmt::Display; fn print_twice<T: Display>(item: &T) { // any type that can be displayed println!("{item}"); println!("{item}"); } fn main() { print_twice(&42); print_twice(&"hello"); print_twice(&3.14); }
T: Display is a trait bound — the function works for any type that implements Display. This is monomorphised (like C++ templates): Rust generates separate machine code for each concrete type used, with zero dynamic dispatch overhead. No void* casts, no runtime type confusion.
Display and Debug traits
#include <stdio.h> typedef struct { int x; int y; } Point; // Must write a custom print function for every type, with a unique name void print_point(const Point *p) { printf("(%d, %d)", p->x, p->y); } int main(void) { Point p = { 3, 4 }; print_point(&p); printf("\n"); return 0; }
use std::fmt; struct Point { x: i32, y: i32 } impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } fn main() { let point = Point { x: 3, y: 4 }; println!("{point}"); // uses Display // #[derive(Debug)] gives {:?} for free — no impl needed }
Implementing Display lets a type plug into Rust's format machinery ({} in println!, format!, etc.). Debug (derived with #[derive(Debug)]) enables {:?}. In C every type needs a unique print function with no standard interface.
Concurrency
Creating threads
#include <stdio.h> #include <pthread.h> void *worker(void *arg) { int value = *(int *)arg; printf("thread got: %d\n", value); return NULL; } int main(void) { pthread_t thread; int value = 42; pthread_create(&thread, NULL, worker, &value); pthread_join(thread, NULL); return 0; }
use std::thread; fn main() { let value = 42; let handle = thread::spawn(move || { println!("thread got: {value}"); // value moved into closure }); handle.join().unwrap(); }
thread::spawn takes a closure. The move keyword transfers ownership of captured variables into the thread — the compiler ensures nothing non-thread-safe is shared. No pthread_attr_t, no void* casting, and no way to accidentally share a non-Send type.
Mutex and shared state
#include <stdio.h> #include <pthread.h> int counter = 0; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void *increment(void *arg) { (void)arg; for (int i = 0; i < 1000; i++) { pthread_mutex_lock(&mutex); counter++; pthread_mutex_unlock(&mutex); } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, increment, NULL); pthread_create(&t2, NULL, increment, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("%d\n", counter); return 0; }
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0i32)); let mut handles = vec![]; for _ in 0..2 { let counter = Arc::clone(&counter); let handle = thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("{}", counter.lock().unwrap()); }
Arc> is Rust's equivalent of a mutex-protected global. Arc (atomic reference count) allows shared ownership across threads; Mutex provides exclusive access. You cannot access the data without going through the lock — forgetting to lock is a compile error, not a runtime bug.
Data race prevention
#include <pthread.h> // C: data races are undefined behavior — compiler and CPU can reorder reads/writes. // This is a data race if called from two threads without a lock: int shared = 0; void unsafe_increment(void) { shared++; // read-modify-write: NOT atomic — data race! } // The bug is invisible at compile time; shows up as flaky results at runtime.
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Arc; use std::thread; fn main() { let shared = Arc::new(AtomicI32::new(0)); let shared2 = Arc::clone(&shared); let handle = thread::spawn(move || { shared2.fetch_add(1, Ordering::SeqCst); }); shared.fetch_add(1, Ordering::SeqCst); handle.join().unwrap(); println!("{}", shared.load(Ordering::SeqCst)); }
The Rust type system prevents data races at compile time. Types that are not safe to share across threads do not implement Send/Sync, and the compiler rejects programs that would cause a data race. This is unique among systems languages — data races in Rust are a compile error, not a runtime bug.
Channels (message passing)
// C has no built-in channel type. // The typical approach is a ring buffer + mutex + condition variable: // // pthread_mutex_t lock; // pthread_cond_t not_empty; // int ring[CAPACITY]; // int head, tail, count; // // Producer: lock, write, signal, unlock. // Consumer: lock, wait while empty, read, unlock. // ~100 lines of careful code, with spurious wakeup handling.
use std::thread; use std::sync::mpsc; fn main() { let (sender, receiver) = mpsc::channel(); thread::spawn(move || { for i in 0..5 { sender.send(i * 10).unwrap(); } }); for received in receiver { println!("got: {received}"); } }
mpsc::channel() (multiple-producer, single-consumer) provides safe message passing. Channels transfer ownership of the sent value — no shared memory, no concurrent access. The receiver iterator produces values until all senders are dropped.
async / await
#include <stdio.h> int double_value(int value) { return value * 2; } int main(void) { /* C has no async/await. Concurrency means OS threads (pthreads) or hand-written callback / state-machine code; a plain call is synchronous and blocks until it returns. */ printf("%d\n", double_value(21)); return 0; }
use std::future::Future; use std::pin::pin; use std::task::{Context, Poll, Waker}; // std has async/await syntax but ships no executor, so here is a tiny one. // Real programs reach for tokio or async-std instead of hand-rolling this. fn block_on<F: Future>(future: F) -> F::Output { let mut future = pin!(future); let mut context = Context::from_waker(Waker::noop()); loop { if let Poll::Ready(value) = future.as_mut().poll(&mut context) { return value; } } } async fn double(value: i32) -> i32 { value * 2 } fn main() { let result = block_on(async { let first = double(21).await; first }); println!("{result}"); }
An async fn returns a Future — a state machine that does nothing until polled, which .await drives forward. This is how Rust expresses what C programmers write by hand as callbacks or explicit state machines, but the compiler generates the state machine for you and checks it. Rust's std ships no executor, so a real future needs a runtime such as tokio; the small block_on here polls one future to completion on the current thread. Unlike an OS thread, an async task carries no separate stack, so millions can coexist.
Unsafe & FFI
unsafe blocks
#include <stdio.h> int main(void) { int array[] = {10, 20, 30}; int *ptr = array; // Pointer arithmetic is always allowed in C — no opt-in required printf("%d\n", *(ptr + 1)); // 20 printf("%d\n", *(ptr + 5)); // undefined behavior — no warning by default return 0; }
fn main() { let array = [10i32, 20, 30]; // Safe Rust: bounds-checked indexing println!("{}", array[1]); // 20 // println!("{}", array[5]); // panics — index out of bounds // Unsafe: raw pointer dereference — opt-in required let raw = array.as_ptr(); unsafe { println!("{}", *raw.add(1)); // 20 } }
unsafe is a clearly delimited zone where the programmer takes responsibility for memory safety. Code reviewers know to scrutinize unsafe blocks carefully. In a typical Rust codebase, unsafe is rare and isolated — all surrounding code retains full static guarantees.
Calling C from Rust
/* C library header (math.h excerpt): double sqrt(double x); double cos(double x); Compiled to a shared or static library, then linked into the Rust binary. Link with: extern crate in build.rs or Cargo build-script. */
// Declare C function signatures (edition 2024 marks the block unsafe): unsafe extern "C" { fn sqrt(value: f64) -> f64; fn cos(angle: f64) -> f64; } fn main() { // Every call to a C function requires unsafe // because Rust cannot verify C's behavior let root = unsafe { sqrt(16.0) }; let angle = unsafe { cos(0.0) }; println!("{root} {angle}"); // 4 1 }
An extern "C" block declares foreign functions using the C ABI. Since the 2024 edition the block itself is written unsafe extern "C", and each call still needs its own unsafe because Rust cannot verify what the C function does. The libc crate on crates.io provides pre-declared, ready-to-use bindings for the entire C standard library.
Exposing Rust to C
/* Calling from C: extern double add_numbers(double a, double b); Compile Rust as a static library: rustc --crate-type=staticlib mylib.rs -o libmylib.a Then link from C: gcc main.c libmylib.a -o program */
// Rust function callable from C code: #[unsafe(no_mangle)] pub extern "C" fn add_numbers(a: f64, b: f64) -> f64 { a + b } fn main() { println!("{}", add_numbers(3.0, 4.0)); }
The #[unsafe(no_mangle)] attribute prevents Rust from mangling the function name (Rust mangles names like C++); the 2024 edition requires the unsafe(...) wrapper because an unmangled symbol can collide with another at link time. extern "C" selects the C calling convention. The resulting symbol is callable from any language that supports C FFI — Python's ctypes, Ruby's Fiddle, Java's JNI, etc.
Safe wrappers around unsafe code
// C: the caller is responsible for checking all preconditions — // nothing in the type system prevents misuse. // divide(10, 0) → undefined behavior; no static protection. int divide(int a, int b) { return a / b; }
// Idiomatic Rust: wrap unsafe or fallible logic in a safe public API. fn safe_divide(a: i32, b: i32) -> Option<i32> { if b == 0 { None } else { Some(a / b) } } fn main() { // Callers cannot forget to handle the error case: match safe_divide(10, 2) { Some(result) => println!("{result}"), None => println!("division by zero"), } match safe_divide(10, 0) { Some(result) => println!("{result}"), None => println!("division by zero"), } }
The Rust pattern is to isolate unsafe code (or fallible logic) in a small, carefully verified module and expose a safe public API. Library users get safety guarantees automatically — the library author takes responsibility for the unsafe interior, and callers cannot bypass it.
Cargo & Modules
Project layout
# Typical C project — no enforced structure: # src/main.c # src/util.c # include/util.h # Makefile (hand-written) # deps/ (manually downloaded or system-installed) # # No standard package registry. # Dependencies installed via: apt-get, brew, vcpkg, Conan, manual build
# Generated by: cargo new myproject # myproject/ # src/main.rs <- entry point # src/lib.rs <- library code (optional) # Cargo.toml <- package metadata and dependencies # Cargo.lock <- pinned dependency versions # tests/ <- integration tests # benches/ <- benchmarks # examples/ <- runnable examples # # cargo build -> downloads + compiles everything # cargo test -> runs all tests # cargo doc -> generates HTML documentation
Cargo enforces a standard layout and provides a unified command for building, testing, documenting, and publishing. crates.io is the central package registry — cargo add serde is equivalent to pip install or npm install.
Modules and visibility
/* C visibility model: util.h — declares public API (extern declarations) util.c — implements; static functions are file-private util.h: int add(int a, int b); extern int counter; util.c: static int helper(void) { return 1; } // private int counter = 0; int add(int a, int b) { return a + b; } */
mod math { pub fn add(a: i32, b: i32) -> i32 { // pub = exported a + b } fn helper() -> i32 { 42 } // no pub = private to this module } fn main() { println!("{}", math::add(3, 4)); // math::helper(); // error: function 'helper' is private }
Rust modules control visibility with pub. By default everything is private. There are no header files — the module system replaces both the header guard pattern and the public/private declaration split. Modules can be defined inline or split across files.
Built-in unit tests
#include <assert.h> #include <stdio.h> // C: no standard framework — use assert.h or a third-party library int add(int a, int b) { return a + b; } void test_add(void) { assert(add(1, 1) == 2); assert(add(2, 2) == 4); // Any failed assert aborts with no indication of which test failed } int main(void) { test_add(); printf("all tests passed\n"); return 0; }
fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn test_addition() { assert_eq!(add(1, 1), 2); assert_eq!(add(2, 2), 4); } } fn main() { println!("{}", add(3, 4)); }
Rust's test framework is built into the language. cargo test compiles and runs all #[test] functions, reporting exactly which tests passed or failed with descriptive output. The #[cfg(test)] module is excluded from production builds automatically.
Gotchas for C Programmers
Integer overflow panics in debug
#include <stdio.h> #include <stdint.h> int main(void) { // Signed overflow is undefined behavior in C // Unsigned overflow wraps silently — by specification int8_t small = 127; small++; // UB for signed — may wrap, may not printf("%d\n", small); return 0; }
fn main() { let mut small: i8 = 127; // small += 1; // debug build: panics "attempt to add with overflow" // // release build: wraps to -128 // Explicit behavior — works in any build: small = small.wrapping_add(1); // always wraps println!("{small}"); // -128 let result = 127i8.checked_add(1); // returns None on overflow println!("{:?}", result); // None }
In debug builds Rust panics on integer overflow instead of silently producing wrong results. In release builds it wraps, like C unsigned overflow. Use .wrapping_add(), .saturating_add(), or .checked_add() when overflow behavior matters.
No implicit numeric conversions
#include <stdio.h> int main(void) { int count = 10; double ratio = count / 3; // integer division! ratio = 3.0 double better = count / 3.0; // explicit float literal needed unsigned int length = 5; int offset = -1; if (offset < (int)length) // dangerous without cast: -1 becomes UINT_MAX printf("ok\n"); printf("%.3f %.3f\n", ratio, better); return 0; }
fn main() { let count: i32 = 10; // let ratio: f64 = count / 3; // error: mismatched types (i32 / i32 = i32) let ratio: f64 = count as f64 / 3.0; // explicit cast required println!("{ratio:.3}"); // 3.333 let length: usize = 5; let offset: i32 = -1; // if offset < length { } // error: can't compare i32 with usize directly if offset < length as i32 { println!("ok"); } }
Rust has no implicit numeric conversions. Every type change requires an explicit as cast. This eliminates an entire class of C bugs: silent integer promotion, signed-to-unsigned comparisons, and truncation on assignment.
Move vs copy semantics
#include <stdio.h> #include <string.h> int main(void) { // C: struct assignment always copies (shallow) int a = 42; int b = a; // copy — a unchanged printf("%d\n", a); // Pointer assignment copies the pointer, not the data char *text1 = "hello"; char *text2 = text1; // alias — not a copy! printf("%s %s\n", text1, text2); return 0; }
fn main() { // Scalar types implement Copy — assignment copies the value let a: i32 = 42; let b = a; // a is still valid — copied println!("{a} {b}"); // Heap types (String, Vec, Box) are MOVED, not copied let text1 = String::from("hello"); let text2 = text1; // text1 is moved into text2 // println!("{text1}"); // error: use of moved value println!("{text2}"); // Explicit deep copy: let text3 = text2.clone(); println!("{text2} {text3}"); }
Types that are cheap to copy (integers, floats, booleans) implement the Copy trait and are duplicated on assignment, just like C. Heap-owning types (String, Vec, Box) are moved. Use .clone() for an explicit deep copy.
Strings are UTF-8, not byte arrays
#include <stdio.h> #include <string.h> int main(void) { // C strings: raw bytes — indexing is always O(1) const char *greeting = "Hello!"; char third = greeting[2]; // 'l' — always works printf("%c len=%zu\n", third, strlen(greeting)); return 0; }
fn main() { let greeting = "Hello! 🦀"; // UTF-8; the crab is 4 bytes // Can't index directly: greeting[2] is a compile error let third_byte = greeting.as_bytes()[2]; // b'l' — raw byte access println!("{third_byte}"); let chars: Vec<char> = greeting.chars().collect(); println!("{}", chars[7]); // 🦀 — character index, not byte index println!("bytes: {} chars: {}", greeting.len(), greeting.chars().count()); }
Rust strings are guaranteed UTF-8. Direct byte indexing is available via .as_bytes(), but character indexing is not supported directly because characters vary in width (1–4 bytes). Use .chars() for Unicode-correct iteration. This prevents the silent multibyte-character bugs common in C string handling.
No uninitialized variables
#include <stdio.h> int main(void) { int count; // uninitialized — reading it is undefined behavior int buffer[100]; // uninitialized array printf("%d\n", count); // UB — may print garbage, may crash // The compiler MAY warn, but it's not a compile error return 0; }
fn main() { let count: i32; // println!("{count}"); // compile error: use of possibly-uninitialized 'count' // Must initialize before use: let count: i32 = 0; println!("{count}"); // Zero-initialized array (like calloc): let buffer = [0i32; 100]; println!("{}", buffer[0]); }
Rust's definite-initialisation analysis is a compile-time check — reading an uninitialised variable is always a compile error, not a warning. Every variable must be assigned before it is read. MaybeUninit exists for performance-critical cases where you need to control initialisation precisely.