Rust for C# developers
This page is for readers who write C# today. It does not teach Rust from the start: the course does that. It collects the places where a C# habit fails to compile, or does something different, in Rust. Each habit has a C# program and a Rust program, both run in this site’s image. Each C# file is copied to Program.cs in the console template, with nullable reference types and implicit usings on, so diagnostics name that file; the SDK version is in the C# session of the toolchain section. The Rust is 1.99.1.
RememberIn C# a variable of a class type holds a reference that the garbage collector keeps alive. In Rust a value has one owner, and the compiler checks every other name for it.
for-csharp/ ├── src/ │ └── bin/ one small program per example, run with cargo run --bin name │ ├── adder.rs │ ├── bounded.rs │ ├── copy_vec.rs │ ├── drop.rs │ ├── grow_ok.rs │ ├── grow.rs │ ├── idioms.rs │ ├── iter_resume.rs │ ├── iter_twice.rs │ ├── lazy.rs │ ├── moved.rs │ ├── option_missing.rs │ ├── parse.rs │ ├── reflect.rs │ ├── shapes.rs │ ├── team_clone.rs │ └── unbounded.rs ├── Cargo.lock └── Cargo.toml no dependencies
What carries over
Section titled “What carries over”- Static types with inference.
var x = 5;islet mut x = 5;if it changes later (Variables). - Generic constraints.
where T : IComparable<T>becomes a bound such asT: Ord; nothing is allowed on aTthat the bound does not name (Generics). - Pattern matching.
switchexpressions,ispatterns and deconstruction becomematch,if letandlet (a, b) = ...(Match, Patterns). Amatchmust cover every case. - Lazy pipelines. LINQ’s
WhereandSelectarefilterandmap, and nothing runs until the end of the chain asks for items (habit 6). - Scoped cleanup.
usingrunsDisposeat the end of a block; Rust runsDropat the end of the owner’s scope (habit 8).
Ten habits to unlearn
Section titled “Ten habits to unlearn”1. var b = a; makes a second name for the same object
Section titled “1. var b = a; makes a second name for the same object”For a class, including List<T>, assignment copies the reference. Both names see every change:
var a = new List<int> { 1, 2, 3 };var b = a;b.Add(4);Console.WriteLine($"a has {a.Count} items");Console.WriteLine($"same object: {ReferenceEquals(a, b)}");a has 4 items same object: True
Rust moves the value. a is finished after let mut b = a;, and using it is an error:
fn main() { let a = vec![1, 2, 3]; let mut b = a; b.push(4); println!("a has {} items", a.len());}error[E0382]: borrow of moved value: `a`
--> src/bin/moved.rs:5:32
|
2 | let a = vec![1, 2, 3];
| - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait
3 | let mut b = a;
| - value moved here
4 | b.push(4);
5 | println!("a has {} items", a.len());
| ^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let mut b = a.clone();
| ++++++++The Vec is freed when its single owner goes out of scope, with no collector to count names, so the compiler must know which name that is. To keep using a you copy (a.clone(), task 1 below) or borrow (&a, &mut a, habit 9). See Ownership and Copy or reference.
2. A struct copies, so it is safe to assign
Section titled “2. A struct copies, so it is safe to assign”A C# struct is copied on assignment, but only its own fields. A List inside it is a reference, and the copy shares it. The run prints red 2 for the original, because the other copy’s Add reached it:
var a = new Team { Name = "red", Members = new List<string> { "ana" } };var b = a; // a struct is copied...b.Name = "blue";b.Members.Add("bo"); // ...but the List inside it is notConsole.WriteLine($"{a.Name} {a.Members.Count}");Console.WriteLine($"{b.Name} {b.Members.Count}");
struct Team{ public string Name; public List<string> Members;}red 2 blue 2
Rust refuses it. Copy is only for types made of plain values, so a struct that owns a Vec cannot be Copy:
#[derive(Clone, Copy)]struct Team { name: &'static str, members: Vec<&'static str>,}
fn main() { let a = Team { name: "red", members: vec!["ana"], }; let b = a; println!("{} {}", a.name, b.members.len());}error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/bin/copy_vec.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct Team {
| ^^^^
3 | name: &'static str,
4 | members: Vec<&'static str>,
| -------------------------- this field does not implement `Copy`Without Copy, let b = a; moves. Clone is the opt-in copy, and it copies everything the value owns, so the two teams stay apart:
#[derive(Clone)]struct Team { name: String, members: Vec<String>,}
fn main() { let a = Team { name: "red".to_string(), members: vec!["ana".to_string()], }; let mut b = a.clone(); b.name = "blue".to_string(); b.members.push("bo".to_string()); println!("{} {}", a.name, a.members.len()); println!("{} {}", b.name, b.members.len());}red 1 blue 2
Rust has no struct/class split: every type holds its value, and a reference is something you write with &. See Copy or reference.
3. Sharing behaviour through a base class
Section titled “3. Sharing behaviour through a base class”Shape[] shapes = { new Circle(1.0), new Square(2.0) };foreach (var s in shapes) Console.WriteLine(s.Describe());
abstract class Shape{ public abstract string Name { get; } public abstract double Area(); public string Describe() => $"{Name}: {Area():F2}";}
class Circle(double r) : Shape{ public override string Name => "circle"; public override double Area() => Math.PI * r * r;}
class Square(double side) : Shape{ public override string Name => "square"; public override double Area() => side * side;}circle: 3.14 square: 4.00
use std::f64::consts::PI;
trait Shape { fn name(&self) -> String; fn area(&self) -> f64; fn describe(&self) -> String { format!("{}: {:.2}", self.name(), self.area()) }}
struct Circle { r: f64,}
struct Square { side: f64,}
impl Shape for Circle { fn name(&self) -> String { "circle".to_string() } fn area(&self) -> f64 { PI * self.r * self.r }}
impl Shape for Square { fn name(&self) -> String { "square".to_string() } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { r: 1.0 }), Box::new(Square { side: 2.0 })]; for s in &shapes { println!("{}", s.describe()); }}circle: 3.14 square: 4.00
Rust has no inheritance of fields or methods. The Rust Book says there is “no way to define a struct that inherits the parent struct’s fields and method implementations without using a macro” (chapter 18.1). A trait is an interface that can carry default methods, such as describe above, and a type opts in with its own impl block. Vec<Box<dyn Shape>> is the Shape[]. Data you would put in a base class becomes a field holding another struct. See Traits and Trait objects.
4. string? and a warning
Section titled “4. string? and a warning”With nullable reference types on, C# warns about the dereference below and, by default, builds anyway. Microsoft’s page on the feature says “the runtime behavior of your program is unchanged”, and the program throws:
string? Find(string key) => key == "a" ? "apple" : null;
var s = Find("b");try{ Console.WriteLine(s.Length);}catch (NullReferenceException e){ Console.WriteLine($"caught {e.GetType().Name}");}Program.cs(6,23): warning CS8602: Dereference of a possibly null reference. caught NullReferenceException
WarningsAsErrors turns the warning into an error; the template does not set it:
Program.cs(6,23): error CS8602: Dereference of a possibly null reference.
In Rust the maybe-missing value is a different type, Option<&str>, and it has none of &str’s methods until you decide what the None case does:
fn find(key: &str) -> Option<&'static str> { if key == "a" { Some("apple") } else { None }}
fn main() { let s = find("b"); println!("{}", s.to_uppercase());}error[E0599]: no method named `to_uppercase` found for enum `Option<T>` in the current scope
--> src/bin/option_missing.rs:7:22
|
7 | println!("{}", s.to_uppercase());
| ^^^^^^^^^^^^ method not found in `Option<&str>`
|
help: consider using `Option::expect` to unwrap the `&str` value, panicking if the value is an `Option::None`
|
7 | println!("{}", s.expect("REASON").to_uppercase());
| +++++++++++++++++
... (notes pointing into the standard library trimmed)match, if let, unwrap_or and ? are the ways through. Rows 2 to 4 of the idiom table run unwrap_or, map and if let; habit 5 runs ?. See Option.
5. Exceptions
Section titled “5. Exceptions”Nothing in the signature of Double says it can throw. C# has no checked exceptions:
static int Double(string text) => int.Parse(text) * 2; // nothing here says it can throw
Console.WriteLine(Double("21"));try{ Console.WriteLine(Double("x"));}catch (FormatException e){ Console.WriteLine($"caught: {e.Message}");}42 caught: The input string 'x' was not in a correct format.
A Rust function that can fail returns a Result, and ? hands the error up to the caller. A bug panics instead (Errors); Result is for input you expected could be wrong:
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { Ok(text.parse::<i32>()? * 2)}
fn main() { println!("{:?}", double("21")); match double("x") { Ok(n) => println!("{n}"), Err(e) => println!("error: {e}"), }}Ok(42) error: invalid digit found in string
The Err is in the return type, so the caller sees it in the signature, which Double above cannot do.
6. LINQ: lazy, and reusable
Section titled “6. LINQ: lazy, and reusable”LINQ is lazy, and so are Rust iterators: nothing prints until the end of the chain asks. The difference is what a query object is. A LINQ query over a re-enumerable source such as this Range restarts the whole chain on each terminal call, so Count() checks from 1 again:
var evens = Enumerable.Range(1, 6) .Where(n => { Console.WriteLine($"check {n}"); return n % 2 == 0; }) .Select(n => n * 10);Console.WriteLine("built the query");Console.WriteLine($"first: {evens.First()}");Console.WriteLine($"count: {evens.Count()}"); // starts over from 1built the query check 1 check 2 first: 20 check 1 check 2 check 3 check 4 check 5 check 6 count: 3
A Rust iterator is a value with a position. next() consumed two checks, and count() carried on:
fn main() { let mut evens = (1..=6) .filter(|n| { println!("check {n}"); n % 2 == 0 }) .map(|n| n * 10); println!("built the iterator"); println!("first: {:?}", evens.next()); println!("count: {}", evens.count());}built the iterator check 1 check 2 first: Some(20) check 3 check 4 check 5 check 6 count: 2
count() also takes the iterator by value, so a second call is a use after move:
fn main() { let evens = (1..=6).filter(|n| n % 2 == 0).map(|n| n * 10); println!("count: {}", evens.count()); println!("count: {}", evens.count());}error[E0382]: use of moved value: `evens`
--> src/bin/iter_twice.rs:4:27
|
2 | let evens = (1..=6).filter(|n| n % 2 == 0).map(|n| n * 10);
| ----- move occurs because `evens` has type `Map<Filter<RangeInclusive<i32>, {closure@...}>, {closure@...}>`, which does not implement the `Copy` trait
3 | println!("count: {}", evens.count());
| ------- `evens` moved due to this method call
4 | println!("count: {}", evens.count());
| ^^^^^ value used here after move
|
help: you can `clone` the value and consume it, but this might not be your desired behavior
|
3 | println!("count: {}", evens.clone().count());
| ++++++++
... (notes pointing into the standard library trimmed)To walk the items twice, collect them into a Vec or build the chain again. See Iterators, which has the full LINQ table.
7. A Task has already started
Section titled “7. A Task has already started”A C# async method runs up to its first await that has to wait before the call returns. saving is printed before called Save:
static async Task<int> Save(){ Console.WriteLine("saving"); await Task.Delay(1); return 42;}
var task = Save(); // runs up to its first await that has to wait, right nowConsole.WriteLine("called Save");Console.WriteLine(await task);saving called Save 42
use std::future::Future;use std::pin::pin;use std::task::{Context, Poll, Waker};
async fn save() -> i32 { println!("saving"); 42}
// A tiny executor, only to show when the body runs. It spins, so it suits only futures// that finish at once. Real programs use a runtime (Tokio).fn block_on<F: Future>(fut: F) -> F::Output { let mut fut = pin!(fut); let mut cx = Context::from_waker(Waker::noop()); loop { if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; } }}
fn main() { let fut = save(); println!("called save"); println!("{}", block_on(fut));}called save saving 42
A Rust async fn returns a future, a value that does nothing until something polls it. block_on here is a few lines of std, only to show the order; a real program uses a runtime such as Tokio. See Async basics and Tokio.
8. using and IDisposable
Section titled “8. using and IDisposable”using is opt-in. a and b are disposed in reverse order, and c, declared without using, never is:
using (var a = new Guard("a"))using (var b = new Guard("b")){ Console.WriteLine("working");}
var c = new Guard("c"); // no `using`: nothing ever calls DisposeConsole.WriteLine("end of Main");
class Guard : IDisposable{ private readonly string name;
public Guard(string name) { this.name = name; Console.WriteLine($"open {name}"); }
public void Dispose() => Console.WriteLine($"close {name}");}open a open b working close b close a open c end of Main
struct Guard(&'static str);
impl Guard { fn new(name: &'static str) -> Guard { println!("open {name}"); Guard(name) }}
impl Drop for Guard { fn drop(&mut self) { println!("close {}", self.0); }}
fn main() { { let _a = Guard::new("a"); let _b = Guard::new("b"); println!("working"); } let _c = Guard::new("c"); println!("end of main");}open a open b working close b close a open c end of main close c
In Rust a value runs its Drop when its owner goes out of scope, in reverse order of declaration, whether or not you thought of it. A leaked Rc cycle is the exception (Smart pointers). See Common traits for Drop.
9. Changing a list while you loop over it
Section titled “9. Changing a list while you loop over it”C# lets foreach and Add compile. The list changes under the loop, and the check happens at run time, after one item was added:
var xs = new List<int> { 1, 2, 3 };try{ foreach (var x in xs) xs.Add(x * 10);}catch (InvalidOperationException e){ Console.WriteLine($"caught: {e.Message}");}Console.WriteLine(string.Join(", ", xs));caught: Collection was modified; enumeration operation may not execute. 1, 2, 3, 10
for x in &xs borrows the Vec for the whole loop, and &mut is exclusive: nothing else may use the value while one exists. The same loop is a compile error:
fn main() { let mut xs = vec![1, 2, 3]; for x in &xs { xs.push(x * 10); } println!("{xs:?}");}error[E0502]: cannot borrow `xs` as mutable because it is also borrowed as immutable
--> src/bin/grow.rs:4:9
|
3 | for x in &xs {
| - ---
| | |
| | immutable borrow occurs here
| _____| immutable borrow later used here
| |
4 | | xs.push(x * 10);
| | ^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | | }
| |_____- this for loop borrows `xs` immutably, preventing mutation within its body
|
= help: consider using an index-based loop instead, or collecting modifications into a separate collectionBuild the new items first, then add them:
fn main() { let mut xs = vec![1, 2, 3]; let more: Vec<i32> = xs.iter().map(|x| x * 10).collect(); xs.extend(more); println!("{xs:?}");}[1, 2, 3, 10, 20, 30]
See Mutable borrows.
10. A T that still exists at run time
Section titled “10. A T that still exists at run time”In C# an unconstrained T has object’s members and can be asked for its type, because generics are kept at run time:
static string Show<T>(T x) => $"{typeof(T).Name}: {x}";
Console.WriteLine(Show(5));Console.WriteLine(Show("hi"));Int32: 5 String: hi
Rust compiles a separate copy of the function for each type (what T is at run time), and an unbounded T has none of object’s members: no ToString, so here not even a way to print it:
fn show<T>(x: T) -> String { format!("{}: {}", std::any::type_name::<T>(), x)}
fn main() { println!("{}", show(5)); println!("{}", show("hi"));}error[E0277]: `T` doesn't implement `std::fmt::Display`
--> src/bin/unbounded.rs:2:51
|
2 | format!("{}: {}", std::any::type_name::<T>(), x)
| -- ^ `T` cannot be formatted with the default formatter
| |
| required by this formatting parameter
|
= note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead
help: consider restricting type parameter `T` with trait `Display`
|
1 | fn show<T: std::fmt::Display>(x: T) -> String {
| +++++++++++++++++++The error names the fix, and the bound goes in the signature. type_name is a string the compiler fills in for each copy (its docs call the text best-effort), where C# keeps T as metadata it can be asked about at run time:
use std::fmt::Display;
fn show<T: Display>(x: T) -> String { format!("{}: {}", std::any::type_name::<T>(), x)}
fn main() { println!("{}", show(5)); println!("{}", show("hi"));}i32: 5 &str: hi
See Generics.
Idiom by idiom
Section titled “Idiom by idiom”Both programs below print one numbered line per row. The C# file is on the left and the Rust file on the right, and every cell in the table is a line from one of them. Rows 8 and 9 use SortedDictionary and BTreeMap, and row 10 BTreeMap, so the output order is fixed; Dictionary and HashMap are the unsorted pair.
| # | You write in C# | In Rust |
|---|---|---|
| 1 | var x = 5; x += 1; |
let mut x = 5; x += 1; |
| 2 | name ?? "anon" |
name.as_deref().unwrap_or("anon") |
| 3 | word?.Length |
word.map(str::len) |
| 4 | int.TryParse(text, out var n) |
text.parse::<i32>() |
| 5 | [{seven,5}] [{seven:D3}] inside $"..." |
[{seven:5}] [{seven:03}] inside println!("...") |
| 6 | xs.Where(v => v > 1).Select(v => v * 2).ToList() |
xs.iter().filter(|&&v| v > 1).map(|&v| v * 2).collect::<Vec<_>>() |
| 7 | xs.Sum(), xs.Max() |
xs.iter().sum::<i32>(), xs.iter().max() |
| 8 | foreach (var (k, v) in ages) |
for (k, v) in &ages |
| 9 | ages.TryGetValue("bo", out var bo) |
ages.get("bo") |
| 10 | counts[w] = counts.GetValueOrDefault(w) + 1; |
*counts.entry(w).or_insert(0) += 1; |
| 11 | x switch { 1 => "one", _ => "many" } |
match x { 1 => "one", _ => "many", } |
| 12 | sh is Circle c |
if let Shape::Circle(r) = sh |
| 13 | p1 == new Point(1, 2), p1 with { X = 3 } |
p1 == Point { x: 1, y: 2 }, Point { x: 3, ..p1 } |
| 14 | accent.Length |
accent.chars().count() |
| 15 | lock (gate) { hits++; } |
*hits.lock().unwrap() += 1; |
| 16 | public int Age { get; set; } |
pub age: u32 |
// 1 a local that changesvar x = 5;x += 1;Console.WriteLine($"1 {x}");
// 2 null with a defaultstring? name = args.Length > 0 ? args[0] : null;Console.WriteLine($"2 {name ?? "anon"}");
// 3 call through a maybe-missing valuestring? word = "ana";Console.WriteLine($"3 {word?.Length}");
// 4 parse without an exceptionstring text = "42";if (int.TryParse(text, out var n)) Console.WriteLine($"4 {n}");
// 5 string interpolation with width and zero paddingint seven = 7;Console.WriteLine($"5 [{seven,5}] [{seven:D3}]");
// 6 a LINQ pipelinevar xs = new List<int> { 3, 1, 2 };var doubled = xs.Where(v => v > 1).Select(v => v * 2).ToList();Console.WriteLine($"6 {string.Join(",", doubled)}");
// 7 sum and maxConsole.WriteLine($"7 {xs.Sum()} {xs.Max()}");
// 8 walk a sorted dictionaryvar ages = new SortedDictionary<string, int> { ["bo"] = 31, ["ana"] = 30 };foreach (var (k, v) in ages) Console.WriteLine($"8 {k}={v}");
// 9 look a key upif (ages.TryGetValue("bo", out var bo)) Console.WriteLine($"9 {bo}");
// 10 count wordsvar counts = new Dictionary<string, int>();foreach (var w in "a b a".Split(' ')) counts[w] = counts.GetValueOrDefault(w) + 1;Console.WriteLine($"10 a={counts["a"]} b={counts["b"]}");
// 11 a switch expressionvar size = x switch { 1 => "one", _ => "many" };Console.WriteLine($"11 {size}");
// 12 test the kind of a value and take it apartShape sh = x > 5 ? new Circle(2) : new Square(1);if (sh is Circle c) Console.WriteLine($"12 radius {c.R}");else if (sh is Square sq) Console.WriteLine($"12 side {sq.Side}");
// 13 a record: equality and `with`var p1 = new Point(1, 2);var p2 = p1 with { X = 3 };Console.WriteLine($"13 {p1 == new Point(1, 2)} {p2}");
// 14 length in charactersstring accent = "héllo";string emoji = "a😀";Console.WriteLine($"14 {accent.Length} {emoji.Length}");
// 15 a lock around shared statevar gate = new object();int hits = 0;lock (gate) { hits++; }Console.WriteLine($"15 {hits}");
// 16 a propertyvar person = new Person { Age = 30 };person.Age += 1;Console.WriteLine($"16 {person.Age}");
abstract record Shape;record Circle(double R) : Shape;record Square(double Side) : Shape;record Point(int X, int Y);
class Person{ public int Age { get; set; }}1 6
2 anon
3 3
4 42
5 [ 7] [007]
6 6,4
7 6 3
8 ana=30
8 bo=31
9 31
10 a=2 b=1
11 many
12 radius 2
13 True Point { X = 3, Y = 2 }
14 5 3
15 1
16 31use std::collections::BTreeMap;use std::sync::Mutex;
enum Shape { Circle(f64), Square(f64),}
#[derive(Debug, Clone, PartialEq)]struct Point { x: i32, y: i32,}
struct Person { pub age: u32,}
fn main() { // 1 a local that changes let mut x = 5; x += 1; println!("1 {x}");
// 2 null with a default let name = std::env::args().nth(1); println!("2 {}", name.as_deref().unwrap_or("anon"));
// 3 call through a maybe-missing value let word: Option<&str> = Some("ana"); println!("3 {:?}", word.map(str::len));
// 4 parse without an exception let text = "42"; if let Ok(n) = text.parse::<i32>() { println!("4 {n}"); }
// 5 string interpolation with width and zero padding let seven = 7; println!("5 [{seven:5}] [{seven:03}]");
// 6 an iterator pipeline let xs = vec![3, 1, 2]; let doubled = xs .iter() .filter(|&&v| v > 1) .map(|&v| v * 2) .collect::<Vec<_>>(); println!("6 {doubled:?}");
// 7 sum and max println!("7 {} {:?}", xs.iter().sum::<i32>(), xs.iter().max());
// 8 walk a sorted map let ages = BTreeMap::from([("bo", 31), ("ana", 30)]); for (k, v) in &ages { println!("8 {k}={v}"); }
// 9 look a key up if let Some(bo) = ages.get("bo") { println!("9 {bo}"); }
// 10 count words let mut counts = BTreeMap::new(); for w in "a b a".split(' ') { *counts.entry(w).or_insert(0) += 1; } println!("10 a={} b={}", counts["a"], counts["b"]);
// 11 a match expression let size = match x { 1 => "one", _ => "many", }; println!("11 {size}");
// 12 test the kind of a value and take it apart let sh = if x > 5 { Shape::Circle(2.0) } else { Shape::Square(1.0) }; if let Shape::Circle(r) = sh { println!("12 radius {r}"); } else if let Shape::Square(s) = sh { println!("12 side {s}"); }
// 13 derive equality, and struct update let p1 = Point { x: 1, y: 2 }; let p2 = Point { x: 3, ..p1 }; println!("13 {} {:?}", p1 == Point { x: 1, y: 2 }, p2);
// 14 length in characters (and in bytes) let accent = "héllo"; let emoji = "a😀"; println!( "14 {} {} {}", accent.chars().count(), accent.len(), emoji.chars().count() );
// 15 a lock around shared state let hits = Mutex::new(0); *hits.lock().unwrap() += 1; println!("15 {}", hits.lock().unwrap());
// 16 a field let mut person = Person { age: 30 }; person.age += 1; println!("16 {}", person.age);}1 6
2 anon
3 Some(3)
4 42
5 [ 7] [007]
6 [6, 4]
7 6 Some(3)
8 ana=30
8 bo=31
9 31
10 a=2 b=1
11 many
12 radius 2
13 true Point { x: 3, y: 2 }
14 5 6 2
15 1
16 31What the run shows beyond the spelling:
- Row 3: both lines make a maybe-missing number (
int?andOption<usize>). C# interpolation prints a missing one as empty text. Rust has noDisplayforOption, so the program prints it with{:?}asSome(3);unwrap_orormatchpicks the text for the missing case (Option). - Row 6 needs
&&vbecauseiter()yields references andfilterborrows each item again. - Row 13:
derive(PartialEq, Clone)gives the==andclonearecordwrites for you; you list what you want (Common traits)...p1fills the other fields fromp1. - Row 14:
Lengthcounts UTF-16 units, so"a😀"is 3 in C#. Rust’schars().count()gives 2 for it and 5 forhéllo;len()is bytes and gives 6 forhéllo(Strings). - Row 15: C#
lockguards whatever you write inside the braces. In Rust theMutexowns the data, so you cannot touch it without the lock (Shared state). - Row 16: Rust has no properties. A
pubfield is read and written directly; to keep control, keep the field private and writefn age(&self)andfn set_age(&mut self, ...)(Methods).
Toolchain map
Section titled “Toolchain map”| Job | C# | Rust | On this page |
|---|---|---|---|
| new project | dotnet new console |
cargo new |
both run |
| add a library | dotnet add package (NuGet) |
cargo add (crates.io) |
cargo add run, C# not run |
| build and run | dotnet build, dotnet run |
cargo build, cargo run |
both run in the habits above |
| tests | xUnit with dotnet test |
#[test] with cargo test |
both run |
| format | dotnet format |
cargo fmt |
both run |
| analyzers and lints | compiler warnings and Roslyn analyzers | cargo clippy |
Rust run; C#: the one warning in habit 4 |
| generated docs | /// XML comments, read by the IDE |
/// comments with cargo doc |
Rust run; C# not run |
| debugger | Visual Studio, or an IDE extension | rust-gdb or rust-lldb (gdb and lldb wrappers), started from an IDE through rust-analyzer |
not run |
| pin the SDK | global.json |
rust-toolchain.toml |
not run |
The Rust commands, run in this page’s project:
$ cargo --version && rustc --version cargo 1.99.1 (5f94df478 2026-08-27) rustc 1.99.1 (58cf8f9c3 2026-10-13) $ cargo fmt --check && echo "cargo fmt: nothing to change" cargo fmt: nothing to change $ cargo clippy --bin shapes -- -D warnings clippy: no warnings $ cargo test --bin adder running 1 test test tests::adds ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s $ cargo doc --no-deps --bin shapes Documenting for-csharp v0.1.0 (/home/you/for-csharp) Generated target/doc/shapes/index.html
This output changes from run to run. This is one real run.
The same jobs in C#. The last step needs the network (NuGet restore); only its last three lines are shown, a warning is switched off with -p:NoWarn=NETSDK1188, and the duration changes:
$ dotnet --version 10.0.402 $ dotnet new console -o app && dotnet format app --verify-no-changes The template "Console App" was created successfully. dotnet format: nothing to change $ dotnet new xunit -o tests && dotnet test -v q --nologo -p:NoWarn=NETSDK1188 2>&1 | tail -3 # needs network: NuGet restore A total of 1 test files matched the specified pattern. Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 3 ms - tests.dll (net10.0)
This output changes from run to run. This is one real run.
cargo add needs the network too, to read the crates.io index. The resolved version can change, so this output varies:
$ cargo new demo && cargo add itoa@1 # needs network: crates.io
Updating crates.io index
Adding itoa v1 to dependencies
Updating crates.io index
Locking 1 package to highest Rust 1.99.1 compatible version
This output changes from run to run. This is one real run.
See Using crates and Files and modules.
A reading path
Section titled “A reading path”- Rust at a glance: syntax with your language beside it.
- What Rust lacks:
i++,?:, overloads and named arguments are things you will type by habit. - Stack and heap, then Copy or reference: the struct/class split and where values live (habits 1 and 2).
- Ownership, Borrowing and Mutable borrows: the rule that replaces the garbage collector (habits 1 and 9).
- Option and Errors:
nulland exceptions (habits 4 and 5). - Traits, Generics and Trait objects: interfaces without classes (habits 3 and 10).
- Closures and Iterators: lambdas and the LINQ half of your toolbox (habit 6).
- Returning references and Lifetimes: how long a reference may live.
- Smart pointers:
RcandRefCellfor when you want shared, changing data after all. - Working with the borrow checker: “it was rejected, what now”.
- Threads, Shared state and Async basics: then Tokio for the runtime (habit 7).
- Modules and Project structure: namespaces, assemblies and
internal. - Rust and C/C++ interop: the Rust side of
DllImport, if you need it.
What you will miss
Section titled “What you will miss”- Reflection. The first run lists a class’s properties at run time. The
std::anymodule offersTypeId,type_nameand downcasting; its docs call it “utilities for dynamic typing or type reflection”, and I found nothing in it that lists a struct’s fields. The Rust side prints through#[derive(Debug)], generated at compile time, as Serde does. - Single-file programs.
dotnet run one.csruns a file with no project. On this stable toolchaincargo one.rswas refused: it requires-Zscript. - Overloads, optional and named arguments. These have no direct form; What Rust lacks gives the replacement for each. Properties are row 16 above.
- A collector for cycles. Graphs with back-pointers need indices or
Weak, as Smart pointers and Working with the borrow checker show. - Not covered here: GUI toolkits and desktop frameworks. The crates map lists what this site does cover.
The runs behind the first two bullets:
$ dotnet run
Name: String
Age: Int32
$ cargo run
Person { name: "Ana", age: 36 }
Ana is 36
Person {
name: "Ana",
age: 36,
}
error: running the file `one.rs` requires `-Zscript`
$ dotnet run one.cs # a single-file program
hi
Try it yourself
Section titled “Try it yourself”Tasks 1 and 2 are steps of the first session below, task 3 of the second.
- In habit 1, change
let mut b = a;tolet mut b = a.clone();. It compiles, and the last line saysa has 3 items. - In habit 8, add
drop(_b);beforeprintln!("working");.close bnow prints beforeworking:dropends the value’s life early, as callingDisposeyourself would. - In habit 4, take the
?offstring? Find. The warning moves to thereturn nullside (CS8603), and the exception is still thrown.
$ cargo run # moved.rs with `a.clone()` in place of `a` a has 3 items $ cargo run # drop.rs with `drop(_b);` before the "working" line open a open b close b working close a open c end of main close c
$ dotnet run # Nullable.cs with the `?` taken off `string? Find`
Program.cs(1,28): warning CS8603: Possible null reference return.
caught NullReferenceException
Read more
Section titled “Read more”- The Rust Book, chapter 18.1: what Rust has in place of inheritance
- The Rust Book, chapter 4: ownership, from the Book
- Nullable reference types in the C# docs, for what the analysis does and does not catch
- Generics in the runtime: how .NET keeps type arguments
std::any: the closest Rust has to run-time type information
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.