Skip to content
Learn Rust
Theme

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.

The Rust project behind this page
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
  • Static types with inference. var x = 5; is let mut x = 5; if it changes later (Variables).
  • Generic constraints. where T : IComparable<T> becomes a bound such as T: Ord; nothing is allowed on a T that the bound does not name (Generics).
  • Pattern matching. switch expressions, is patterns and deconstruction become match, if let and let (a, b) = ... (Match, Patterns). A match must cover every case.
  • Lazy pipelines. LINQ’s Where and Select are filter and map, and nothing runs until the end of the chain asks for items (habit 6).
  • Scoped cleanup. using runs Dispose at the end of a block; Rust runs Drop at the end of the owner’s scope (habit 8).

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:

C#Shares.cs
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)}");
$ dotnet run
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:

Rustsrc/bin/moved.rs
fn main() {
let a = vec![1, 2, 3];
let mut b = a;
b.push(4);
println!("a has {} items", a.len());
}
$ cargo run
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:

C#StructCopy.cs
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 not
Console.WriteLine($"{a.Name} {a.Members.Count}");
Console.WriteLine($"{b.Name} {b.Members.Count}");
struct Team
{
public string Name;
public List<string> Members;
}
$ dotnet run
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:

Rustsrc/bin/copy_vec.rs
#[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());
}
$ cargo run
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:

Rustsrc/bin/team_clone.rs
#[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());
}
$ cargo run
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.

C#Inherit.cs
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;
}
$ dotnet run
circle: 3.14
square: 4.00
Rustsrc/bin/shapes.rs
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());
}
}
$ cargo run
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.

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:

C#Nullable.cs
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}");
}
$ dotnet run
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:

$ dotnet run # with <WarningsAsErrors>nullable</WarningsAsErrors> in the csproj
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:

Rustsrc/bin/option_missing.rs
fn find(key: &str) -> Option<&'static str> {
if key == "a" { Some("apple") } else { None }
}
fn main() {
let s = find("b");
println!("{}", s.to_uppercase());
}
$ cargo run
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.

Nothing in the signature of Double says it can throw. C# has no checked exceptions:

C#Exceptions.cs
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}");
}
$ dotnet run
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:

Rustsrc/bin/parse.rs
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}"),
}
}
$ cargo run
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.

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:

C#Linq.cs
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 1
$ dotnet run
built 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:

Rustsrc/bin/iter_resume.rs
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());
}
$ cargo run
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:

Rustsrc/bin/iter_twice.rs
fn main() {
let evens = (1..=6).filter(|n| n % 2 == 0).map(|n| n * 10);
println!("count: {}", evens.count());
println!("count: {}", evens.count());
}
$ cargo run
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.

A C# async method runs up to its first await that has to wait before the call returns. saving is printed before called Save:

C#Hot.cs
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 now
Console.WriteLine("called Save");
Console.WriteLine(await task);
$ dotnet run
saving
called Save
42
Rustsrc/bin/lazy.rs
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));
}
$ cargo run
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.

using is opt-in. a and b are disposed in reverse order, and c, declared without using, never is:

C#Dispose.cs
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 Dispose
Console.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}");
}
$ dotnet run
open a
open b
working
close b
close a
open c
end of Main
Rustsrc/bin/drop.rs
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");
}
$ cargo run
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.

C# lets foreach and Add compile. The list changes under the loop, and the check happens at run time, after one item was added:

C#Grow.cs
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));
$ dotnet run
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:

Rustsrc/bin/grow.rs
fn main() {
let mut xs = vec![1, 2, 3];
for x in &xs {
xs.push(x * 10);
}
println!("{xs:?}");
}
$ cargo run
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 collection

Build the new items first, then add them:

Rustsrc/bin/grow_ok.rs
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:?}");
}
$ cargo run
[1, 2, 3, 10, 20, 30]

See Mutable borrows.

In C# an unconstrained T has object’s members and can be asked for its type, because generics are kept at run time:

C#Generic.cs
static string Show<T>(T x) => $"{typeof(T).Name}: {x}";
Console.WriteLine(Show(5));
Console.WriteLine(Show("hi"));
$ dotnet run
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:

Rustsrc/bin/unbounded.rs
fn show<T>(x: T) -> String {
format!("{}: {}", std::any::type_name::<T>(), x)
}
fn main() {
println!("{}", show(5));
println!("{}", show("hi"));
}
$ cargo run
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:

Rustsrc/bin/bounded.rs
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"));
}
$ cargo run
i32: 5
&str: hi

See Generics.

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
C#Idioms.cs
// 1 a local that changes
var x = 5;
x += 1;
Console.WriteLine($"1 {x}");
// 2 null with a default
string? name = args.Length > 0 ? args[0] : null;
Console.WriteLine($"2 {name ?? "anon"}");
// 3 call through a maybe-missing value
string? word = "ana";
Console.WriteLine($"3 {word?.Length}");
// 4 parse without an exception
string text = "42";
if (int.TryParse(text, out var n))
Console.WriteLine($"4 {n}");
// 5 string interpolation with width and zero padding
int seven = 7;
Console.WriteLine($"5 [{seven,5}] [{seven:D3}]");
// 6 a LINQ pipeline
var 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 max
Console.WriteLine($"7 {xs.Sum()} {xs.Max()}");
// 8 walk a sorted dictionary
var ages = new SortedDictionary<string, int> { ["bo"] = 31, ["ana"] = 30 };
foreach (var (k, v) in ages)
Console.WriteLine($"8 {k}={v}");
// 9 look a key up
if (ages.TryGetValue("bo", out var bo))
Console.WriteLine($"9 {bo}");
// 10 count words
var 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 expression
var size = x switch { 1 => "one", _ => "many" };
Console.WriteLine($"11 {size}");
// 12 test the kind of a value and take it apart
Shape 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 characters
string accent = "héllo";
string emoji = "a😀";
Console.WriteLine($"14 {accent.Length} {emoji.Length}");
// 15 a lock around shared state
var gate = new object();
int hits = 0;
lock (gate) { hits++; }
Console.WriteLine($"15 {hits}");
// 16 a property
var 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; }
}
$ dotnet run
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 31
Rustsrc/bin/idioms.rs
use 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);
}
$ cargo run
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 31

What the run shows beyond the spelling:

  • Row 3: both lines make a maybe-missing number (int? and Option<usize>). C# interpolation prints a missing one as empty text. Rust has no Display for Option, so the program prints it with {:?} as Some(3); unwrap_or or match picks the text for the missing case (Option).
  • Row 6 needs &&v because iter() yields references and filter borrows each item again.
  • Row 13: derive(PartialEq, Clone) gives the == and clone a record writes for you; you list what you want (Common traits). ..p1 fills the other fields from p1.
  • Row 14: Length counts UTF-16 units, so "a😀" is 3 in C#. Rust’s chars().count() gives 2 for it and 5 for héllo; len() is bytes and gives 6 for héllo (Strings).
  • Row 15: C# lock guards whatever you write inside the braces. In Rust the Mutex owns the data, so you cannot touch it without the lock (Shared state).
  • Row 16: Rust has no properties. A pub field is read and written directly; to keep control, keep the field private and write fn age(&self) and fn set_age(&mut self, ...) (Methods).
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:

terminal
$ 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:

terminal
$ 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:

terminal
$ 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.

  1. Rust at a glance: syntax with your language beside it.
  2. What Rust lacks: i++, ?:, overloads and named arguments are things you will type by habit.
  3. Stack and heap, then Copy or reference: the struct/class split and where values live (habits 1 and 2).
  4. Ownership, Borrowing and Mutable borrows: the rule that replaces the garbage collector (habits 1 and 9).
  5. Option and Errors: null and exceptions (habits 4 and 5).
  6. Traits, Generics and Trait objects: interfaces without classes (habits 3 and 10).
  7. Closures and Iterators: lambdas and the LINQ half of your toolbox (habit 6).
  8. Returning references and Lifetimes: how long a reference may live.
  9. Smart pointers: Rc and RefCell for when you want shared, changing data after all.
  10. Working with the borrow checker: “it was rejected, what now”.
  11. Threads, Shared state and Async basics: then Tokio for the runtime (habit 7).
  12. Modules and Project structure: namespaces, assemblies and internal.
  13. Rust and C/C++ interop: the Rust side of DllImport, if you need it.
  • Reflection. The first run lists a class’s properties at run time. The std::any module offers TypeId, type_name and 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.cs runs a file with no project. On this stable toolchain cargo one.rs was 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:

terminal
$ dotnet run
Name: String
Age: Int32
terminal
$ cargo run
Person { name: "Ana", age: 36 }
Ana is 36
Person {
    name: "Ana",
    age: 36,
}
$ cargo one.rs # a single-file script
error: running the file `one.rs` requires `-Zscript`
terminal
$ dotnet run one.cs   # a single-file program
hi

Tasks 1 and 2 are steps of the first session below, task 3 of the second.

  1. In habit 1, change let mut b = a; to let mut b = a.clone();. It compiles, and the last line says a has 3 items.
  2. In habit 8, add drop(_b); before println!("working");. close b now prints before working: drop ends the value’s life early, as calling Dispose yourself would.
  3. In habit 4, take the ? off string? Find. The warning moves to the return null side (CS8603), and the exception is still thrown.
terminal
$ 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
terminal
$ dotnet run   # Nullable.cs with the `?` taken off `string? Find`
Program.cs(1,28): warning CS8603: Possible null reference return.
caught NullReferenceException