Chapter 03
Rust has no classes and no inheritance. Data is structs and enums; behavior is traits (next chapter). The enum does a lot of the work here: it's a discriminated union, and it's how Rust does away with null.
A struct holds data. It's not the C# struct value-type distinction you're thinking of — Rust decides stack vs heap by other means. Methods go in a separate impl block, which keeps data and behavior visually distinct.
class Point {
public double X, Y;
public double Dist() =>
Math.Sqrt(X*X + Y*Y);
}
struct Point { x: f64, y: f64 }
impl Point {
fn dist(&self) -> f64 {
(self.x*self.x + self.y*self.y).sqrt()
}
}
&self is just this, but borrowed — the method reads the struct without taking ownership, exactly the borrowing from chapter 2.
A C# enum is a glorified integer. A Rust enum is a discriminated union: each variant can carry its own differently-typed payload. It's one of the types you'll reach for most.
enum Shape {
Circle { r: f64 }, // struct-like variant
Rect { w: f64, h: f64 },
Point, // no payload
}
You'd model this with a class hierarchy plus pattern matching, or the newer records + sealed interface "DU emulation." Rust bakes it in, and crucially the compiler checks you've handled every variant.
match is C#'s switch expression, but the compiler refuses to compile if you miss a case. Add a new enum variant and every match that forgot it lights up red — which makes large refactors considerably safer.
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { r } => 3.14159 * r * r,
Shape::Rect { w, h } => w * h,
Shape::Point => 0.0,
// omit one arm → compile error
}
}
Rust has no null. Instead, a value that might be missing gets the type Option<T> — an enum with exactly two variants: Some(T) wraps a real value, and None means there's nothing there. Picture a box that either holds something or is empty; the type forces you to open it before you can use what's inside, so the NullReferenceException as a category does not exist.
// looking up a user's age — they might not exist
let found: Option<u32> = Some(34); // a box with 34 inside
let missing: Option<u32> = None; // an empty box
So a function that may or may not find something returns an Option, and the caller has to deal with both cases. Compare a lookup in each language:
int? age = FindAge(id);
if (age is not null)
Console.WriteLine($"{age} yrs");
// nothing FORCES the check
let age: Option<u32> = find_age(id);
match age {
Some(years) => println!("{years} yrs"),
None => println!("no such user"),
}
The nullable-reference-types feature in C# 8+ is Microsoft retrofitting exactly this idea — but it's opt-in and warns rather than blocks. Rust had it from day one and it's a hard error.
You won't write match for every option. Combinators like .map(), .unwrap_or(0), .and_then(), and if let Some(years) = find_age(id) make it terse — much like null-coalescing (??) and null-conditional (?.) chains, but type-checked end to end.
| C# | Rust |
|---|---|
int / long | i32 / i64 (size is explicit) |
string | String (owned) / &str (borrowed slice) |
List<T> | Vec<T> |
Dictionary<K,V> | HashMap<K,V> |
T? (nullable) | Option<T> |
tuple (int, string) | (i32, String) |
var | let (immutable by default; let mut to opt in) |
Note that last row: bindings are immutable unless you write mut. The opposite default from C#, and it nudges you toward safer code.