Output & Running
Hello, World
A C# file used to open with a namespace, a class and
static void Main(string[] args) before it could print anything. Since C# 9 that ceremony is optional: a file may consist of top-level statements, which the compiler wraps in a generated entry point. Every C# example on this page is written that way.fn main() {
println!("Hello, World!");
}Console.WriteLine("Hello, World!");println! is a macro because it type-checks its format string at compile time; Console.WriteLine is an ordinary method, and a mismatched {0} is a run-time FormatException rather than a compile error. In an interpolated string ($"…") the compiler checks every expression inside the braces, though a bad format specifier after the colon still fails only at run time; it is the closer analogue and the one to prefer. Only one file in a C# project may use top-level statements, so this is a script affordance rather than a change to how the language is organized.Formatting and debug output
Rust splits display into two traits —
Display for users and Debug for programmers — and makes you opt into the second with a derive. C# has one ToString() and decides what it does by what kind of type you declared.#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let point = Point { x: 3, y: 4 };
println!("{:?}", point);
println!("{point:?}");
println!("{:.2}", 1.0_f64 / 3.0);
eprintln!("this goes to stderr");
}var point = new Point(3, 4);
Console.WriteLine(point);
Console.WriteLine($"{point}");
Console.WriteLine($"{1.0 / 3.0:F2}");
Console.Error.WriteLine("this goes to stderr");
record struct Point(int X, int Y);A
record or record struct generates a ToString() that prints the type name and every property, which is what #[derive(Debug)] gives you; a plain class or struct prints its type name and nothing else, so the missing derive shows up as Point rather than as a compile error. Format specifiers go after a colon inside the interpolation (:F2) rather than inside the braces before it (:.2). Note the helper record struct at the bottom — a top-level-statements file must declare its types after the executable code. Console.Error writes to standard error, a separate stream from Console.WriteLine's standard output, so where it appears depends on what is collecting the two — the servers that run the C# here report only standard output, so that line runs but does not show up below.What a project looks like
The two toolchains line up almost step for step, which is unusual and makes the differences underneath easier to see.
// Cargo.toml + src/main.rs
// cargo new myapp
// cargo add serde
// cargo run
// cargo build --release
fn main() {
println!("Rust: a manifest, a lockfile, and one binary");
}// MyApp.csproj + Program.cs
// dotnet new console -o MyApp
// dotnet add package Newtonsoft.Json
// dotnet run
// dotnet publish -c Release
Console.WriteLine("C#: a project file, a restore, and an assembly");dotnet add package is cargo add, NuGet is crates.io, and MyApp.csproj is Cargo.toml. The lockfile is packages.lock.json and — unlike Cargo.lock — it is opt-in, enabled with <RestorePackagesWithLockFile>, so a default .NET project does not have reproducible restores until someone turns them on. The output is an assembly plus a runtime dependency rather than a static binary, which is what the NativeAOT row at the end of this page is about.Value Types & Reference Types
struct against class is a real choice
This is the row that makes the page worth writing. C# is the managed language on this site where the value/reference distinction is yours to make, type by type, rather than fixed by the language as it is in Java, Kotlin and JavaScript.
#[derive(Clone, Copy, Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let first = Point { x: 1, y: 2 };
let mut second = first; // Copy: an independent value
second.x = 99;
println!("{} {}", first.x, second.x);
let boxed = Box::new(first); // explicitly on the heap
println!("{}", boxed.x);
}var first = new Point { X = 1, Y = 2 };
var second = first; // struct: copied
second.X = 99;
Console.WriteLine($"{first.X} {second.X}");
var firstRef = new PointClass { X = 1, Y = 2 };
var secondRef = firstRef; // class: one object, two names
secondRef.X = 99;
Console.WriteLine($"{firstRef.X} {secondRef.X}");
struct Point { public int X; public int Y; }
class PointClass { public int X; public int Y; }A
struct is copied on assignment, lives inline in whatever contains it, and is stack-allocated when it is a local — which is #[derive(Copy)] plus Rust's default placement. A class is a reference to a heap object with a header, and copying the variable copies the reference, which is Rc<T> without the count being visible. What C# does not give you is any control over when the class instance dies, and no compiler check that two references are not aliasing the same mutable data. Guidance in .NET is to keep a struct small (roughly 16 bytes or less) and immutable, because a large mutable struct copies more than you expect and behaves surprisingly when captured.readonly struct and in parameters
Passing a large struct by value copies it. C# has a parameter modifier that passes by reference without granting write access — the nearest thing in the language to
&T.#[derive(Clone, Copy)]
struct Matrix {
values: [f64; 4],
}
fn trace(matrix: &Matrix) -> f64 {
matrix.values[0] + matrix.values[3]
}
fn main() {
let matrix = Matrix { values: [1.0, 2.0, 3.0, 4.0] };
println!("{}", trace(&matrix));
}var matrix = new Matrix(new double[] { 1.0, 2.0, 3.0, 4.0 });
Console.WriteLine(Trace(in matrix));
static double Trace(in Matrix matrix) => matrix.Values[0] + matrix.Values[3];
readonly struct Matrix
{
public readonly double[] Values;
public Matrix(double[] values) => Values = values;
}in is a read-only reference: the callee cannot assign to the parameter and no copy is made at the call. The catch a Rust reader should know about is that on a struct which is not declared readonly, calling a method through an in parameter makes a defensive copy unless that method is marked readonly, because the compiler cannot otherwise prove the method does not mutate — so in without readonly struct can be slower than passing by value. Marking the struct readonly removes the copies and is the reason the two features are always mentioned together. There is also ref (mutable reference) and out. Neither carries a lifetime you can write, but the compiler still applies ref-safety rules — returning a ref to a local is a compile error — and a reference into the heap stays valid because the garbage collector keeps its target alive.Records: structural equality without a derive
A
record is C# 9's answer to the derive list a Rust struct carries: it generates a constructor, properties, value equality, a hash, a ToString, and a non-destructive copy.#[derive(Debug, Clone, PartialEq)]
struct Order {
id: u32,
label: String,
}
fn main() {
let first = Order { id: 7, label: "books".to_string() };
let second = first.clone();
println!("{}", first == second);
let changed = Order { label: "ink".to_string(), ..first.clone() };
println!("{:?}", changed);
}var first = new Order(7, "books");
var second = first with { };
Console.WriteLine(first == second);
var changed = first with { Label = "ink" };
Console.WriteLine(changed);
record Order(int Id, string Label);The
with expression is Rust's struct-update syntax and produces a new instance with the named members replaced. The important difference is which comparison changed: a record is a class, so == is overloaded to compare members, while an ordinary class compares references, and a plain struct has no == at all unless it declares one (its Equals compares members). That means == on a C# type does not tell you what it does without knowing how the type was declared — where Rust makes you write #[derive(PartialEq)] or implement it. record struct gives the same generated members with value semantics.Boxing: the hidden allocation
Assigning a value type to a variable of type
object — or to an interface it implements — copies it onto the heap. The syntax gives no hint that it happened.fn main() {
let value: i32 = 42;
// Putting it behind a trait object is EXPLICIT and visible.
let boxed: Box<dyn std::fmt::Display> = Box::new(value);
println!("{boxed}");
println!("{}", format!("{boxed}") == "42");
// A Vec<i32> stores the integers themselves, never pointers to them.
let numbers: Vec<i32> = vec![1, 2, 3];
println!("{}", numbers.iter().sum::<i32>());
// Each element here IS behind a pointer, and it is visible.
let as_objects: Vec<Box<dyn std::fmt::Display>> =
vec![Box::new(1), Box::new(2), Box::new(3)];
println!("{}", as_objects.len());
}var value = 42;
object boxed = value; // heap allocation, invisible
Console.WriteLine(boxed);
Console.WriteLine(((int)boxed) == value);
// A List<int> stores unboxed ints, because generics are reified.
var numbers = new List<int> { 1, 2, 3 };
Console.WriteLine(numbers.Sum());
// But this boxes on every element:
var asObjects = new List<object> { 1, 2, 3 };
Console.WriteLine(asObjects.Count);This is the tax the unified type system charges, and it is invisible where Rust makes you write
Box::new. Reified generics avoid most of it — List<int> genuinely stores unboxed integers, which is what makes .NET generics good — but boxing returns whenever a struct is stored as object, passed to a non-generic API, or used through an interface it implements without a generic constraint. That last case is subtle: a where T : IComparable<T> constraint calls the method directly on the struct, while an IComparable-typed variable boxes first. It also explains a real trap: mutating a struct through an interface changes the box, not your original.Ownership, GC & Disposal
Ownership and borrows simply vanish
Everything the borrow checker does for you is gone. State it plainly, because the second half of the sentence matters as much as the first: aliasing is back, and nothing tells you about it.
fn consume(text: String) -> usize {
text.len()
}
fn main() {
let owned = String::from("hello");
let length = consume(owned);
// println!("{}", owned); <- moved; will not compile
println!("{}", length);
let shared = String::from("world");
let borrowed = &shared;
println!("{} {}", shared, borrowed);
}var owned = "hello";
var length = Consume(owned);
Console.WriteLine(owned); // still perfectly usable
Console.WriteLine(length);
var shared = new List<int> { 1, 2, 3 };
var alias = shared;
alias.Add(4);
Console.WriteLine(shared.Count); // 4 — nothing warned about the aliasing
static int Consume(string text) => text.Length;You stop writing moves, borrows, lifetimes and
clone(), and the whole category of "fighting the borrow checker" disappears. What you get in exchange is a program where any two references can point at the same mutable object and no tool will say so — the bug class Rust exists to eliminate. In practice .NET codebases manage it the way every managed language does: immutability by convention, defensive copies at API boundaries, and record/readonly struct where it matters. The compiler will not help.Drop becomes IDisposable and using
Deterministic release survives the move to a garbage collector, and it survives as a convention that the compiler only partly enforces.
struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("closing {}", self.name);
}
}
fn main() {
{
let _connection = Connection { name: "db".to_string() };
println!("working");
}
println!("scope ended");
}using (var connection = new Connection("db"))
{
Console.WriteLine("working");
}
Console.WriteLine("scope ended");
sealed class Connection : IDisposable
{
private readonly string name;
public Connection(string name) => this.name = name;
public void Dispose() => Console.WriteLine($"closing {name}");
}Dispose() is drop() and using is the scope that calls it — including the newer declaration form using var connection = …;, which disposes at the end of the enclosing block and reads almost exactly like a Rust binding. The gap is that nothing requires the using: forgetting it compiles fine and leaks the handle until a finalizer runs, if there is one. The analyzer warning CA2000 catches many cases and is off by default. Rust's guarantee is that drop runs when the value goes out of scope without anyone having to ask — skipping it takes a deliberate std::mem::forget or a reference-counted cycle; C#'s is that Dispose runs if you remember to ask. That is the single biggest thing a Rust reader gives up here.Rc, Arc and Weak against a tracing collector
The machinery Rust needs for a graph —
Rc, RefCell, Weak, and the discipline to know which to use where — has no counterpart, because a tracing collector does not care about cycles.use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
name: String,
parent: RefCell<Weak<Node>>,
}
fn main() {
let parent = Rc::new(Node {
name: "parent".to_string(),
parent: RefCell::new(Weak::new()),
});
let child = Rc::new(Node {
name: "child".to_string(),
parent: RefCell::new(Rc::downgrade(&parent)),
});
println!("{} {}", parent.name, child.name);
println!("{}", child.parent.borrow().upgrade().is_some());
}var parent = new Node("parent");
var child = new Node("child") { Parent = parent };
parent.Child = child; // a cycle — and it does not matter
Console.WriteLine($"{parent.Name} {child.Name}");
var reference = new WeakReference<Node>(parent);
Console.WriteLine(reference.TryGetTarget(out _));
sealed class Node
{
public string Name { get; }
public Node? Parent { get; set; }
public Node? Child { get; set; }
public Node(string name) => Name = name;
}A .NET cycle becomes collectable as soon as nothing outside it refers to it, and is reclaimed at some later collection, so the parent-child back-reference that costs a Rust program a
Weak and a RefCell is just two fields. WeakReference<T> exists and is for caches, not for breaking cycles. What you pay is that the release is non-deterministic: you cannot say when the memory comes back, allocation-heavy code produces collection pauses, and Arc<Mutex<T>>'s explicit cost is replaced by an implicit one that shows up in a profiler rather than in the type.Spans & Stack Allocation
Span<T> is a slice
This is the closest one-to-one mapping on the page.
Span<T> is a pointer and a length over memory somebody else owns — a slice, with the same purpose and much the same performance story.fn sum(values: &[i32]) -> i32 {
values.iter().sum()
}
fn main() {
let numbers = [1, 2, 3, 4, 5, 6];
println!("{}", sum(&numbers));
println!("{}", sum(&numbers[1..4]));
}int[] numbers = { 1, 2, 3, 4, 5, 6 };
Console.WriteLine(Sum(numbers));
Console.WriteLine(Sum(numbers.AsSpan(1, 3)));
static int Sum(ReadOnlySpan<int> values)
{
var total = 0;
foreach (var value in values) total += value;
return total;
}ReadOnlySpan<T> is &[T] and Span<T> is &mut [T]. Both can point at an array, at stack memory or at unmanaged memory, and a ReadOnlySpan<char> can also point at a string, so a parser written against ReadOnlySpan<char> works on all of them without allocating — the same reason Rust code takes &str rather than String. Slicing is span[1..4] with range syntax, or Slice(1, 3) with a start and a length. The safety difference is the next row: a span has no lifetime you can write. The compiler instead tracks a coarse scope for each span — whether it came from the stack or from the heap — and rejects code that would let a stack span escape, backed by blanket restrictions on where a span may go.ref struct: a borrow-flavored restriction
A type holding a
Span<T> must itself be a ref struct, and the restrictions on a ref struct are the most borrow-checker-shaped thing in C#.struct Reader<'a> {
remaining: &'a [u8],
}
impl<'a> Reader<'a> {
fn next(&mut self) -> Option<u8> {
let (first, rest) = self.remaining.split_first()?;
self.remaining = rest;
Some(*first)
}
}
fn main() {
let data = [10u8, 20, 30];
let mut reader = Reader { remaining: &data };
println!("{:?} {:?}", reader.next(), reader.next());
}byte[] data = { 10, 20, 30 };
var reader = new Reader(data);
Console.WriteLine($"{reader.Next()} {reader.Next()}");
ref struct Reader
{
private ReadOnlySpan<byte> remaining;
public Reader(ReadOnlySpan<byte> data) => remaining = data;
public byte Next()
{
var first = remaining[0];
remaining = remaining[1..];
return first;
}
}A
ref struct cannot escape to the heap: it may not be a field of a class, may not be boxed, may not be captured by a lambda, and may appear in an async method or an iterator only if it is never held across an await or a yield return. The reason is exactly the one a Rust reader will guess: a span into stack memory must not outlive that stack frame, and with no lifetimes to write, the compiler pairs a coarse escape check with these blunter rules. Compare the two declarations above: Rust carries <'a> and permits the type anywhere the lifetime is satisfied; C# carries no lifetime and forbids the type from the places — the heap, a box, a lambda, a suspended method — where the question could not be answered.stackalloc against a stack array
C# can allocate on the stack, and since C# 7.2 it can do so in safe code as long as the result is held in a
Span<T>.fn main() {
let mut buffer = [0u8; 16];
for index in 0..buffer.len() {
buffer[index] = (index * 2) as u8;
}
let total: u32 = buffer.iter().map(|byte| *byte as u32).sum();
println!("{}", total);
}Span<byte> buffer = stackalloc byte[16];
for (var index = 0; index < buffer.Length; index++)
{
buffer[index] = (byte)(index * 2);
}
var total = 0;
foreach (var value in buffer) total += value;
Console.WriteLine(total);Assigned to a
Span<T>, stackalloc needs no unsafe block; assigned to a raw pointer, it does. The important operational difference from a Rust [u8; 16] is that the size may be a run-time value, which means stackalloc in a loop, or with a caller-supplied length, is a stack-overflow waiting to happen — the standard pattern is length <= 256 ? stackalloc byte[length] : new byte[length], and for larger buffers ArrayPool<T>.Shared. Bounds are still checked on every access, so this is not the same performance profile as the Rust version, though the JIT elides many of the checks in a foreach.Types & Inference
let becomes var, and mutability inverts
Inference works in both and the defaults are opposite. Rust makes a binding immutable unless you ask; C# makes every local mutable and offers no per-local way to say otherwise.
const LIMIT: i32 = 10;
fn main() {
let total = 0; // immutable by default
let mut counter = 0;
counter += 1;
let name: String = String::from("Ada");
let ratio = 1.0 / 3.0;
println!("{total} {counter} {name} {ratio:.3} {LIMIT}");
}var total = 0; // mutable
const int Limit = 10; // compile-time constant
var counter = 0;
counter += 1;
string name = "Ada";
var ratio = 1.0 / 3.0;
Console.WriteLine($"{total} {counter} {name} {ratio:F3} {Limit}");There is no
let-versus-let mut distinction to carry over: const is a compile-time constant whose value must be a constant expression (literals, and operators applied to other constants), and readonly applies to fields rather than locals. So the immutability discipline moves from the compiler to the type — a record, a readonly struct, an ImmutableArray<T> — and locals are simply mutable. var is only inference: the type is fixed at the declaration and nothing can change it afterwards — though when the initializer is dynamic, the type var infers is dynamic itself.Integer overflow is unchecked by default
Both languages let you choose what overflow does, and they choose different defaults — with Rust's default differing between debug and release, which is the part worth stating carefully.
fn main() {
let biggest: i32 = i32::MAX;
println!("{}", biggest.wrapping_add(1));
println!("{:?}", biggest.checked_add(1));
println!("{}", biggest.saturating_add(1));
// In debug builds a plain biggest + 1 PANICS.
let small: u8 = 250;
println!("{}", small.wrapping_add(10));
// 4 == 260 wrapped into a byte
}var biggest = int.MaxValue;
Console.WriteLine(biggest + 1); // wraps, silently
try
{
Console.WriteLine(checked(biggest + 1));
}
catch (OverflowException)
{
Console.WriteLine("OverflowException");
}
Console.WriteLine("no saturating arithmetic on the operators");
byte small = 250;
Console.WriteLine((byte)(small + 10));A plain
+ in C# wraps, and checked makes it throw OverflowException; the project-wide setting <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> flips the default for the whole assembly, which is the closest thing to Rust's debug behavior and is off in every default template. Rust panics in debug and wraps in release, with wrapping_, checked_, saturating_ and overflowing_ as the explicit forms. C# has checked/unchecked and no saturating arithmetic on the built-in operators. Note also that C# promotes byte arithmetic to int, so small + 10 is 260 and the wrap only happens at the cast.Type aliases and newtypes
Both languages distinguish a name for an existing type from a genuinely new type, and both make the same trade — the alias is free and checks nothing, the wrapper checks everything and costs a declaration.
type UserId = u64;
struct Meters(f64);
fn describe(distance: Meters) -> String {
format!("{} m", distance.0)
}
fn main() {
let id: UserId = 7;
println!("{}", id);
println!("{}", describe(Meters(4.5)));
// describe(4.5) does not compile.
}using UserId = System.UInt64;
UserId id = 7;
Console.WriteLine(id);
Console.WriteLine(Describe(new Meters(4.5)));
// Describe(4.5) does not compile.
static string Describe(Meters distance) => $"{distance.Value} m";
readonly record struct Meters(double Value);using X = Y; is type X = Y;: file-scoped, purely a name, and interchangeable with the underlying type. The newtype is a readonly record struct with one field, which is the standard .NET spelling and gets equality, ToString and deconstruction for free. It is a zero-cost abstraction in the same sense Rust's is — a one-field struct has no header and is passed in a register — so the safety is genuinely free. What C# lacks is Rust's coherence rules, so two libraries can each define an extension method with the same name on the same type. Importing both makes the call an ambiguity error, and an instance member with that name silently wins over every extension.cfg attributes become #if and a constant
Both languages compile code conditionally, and they disagree about where the condition lives — one in the type system's attribute syntax, one in a preprocessor that runs before parsing.
#[cfg(debug_assertions)]
fn mode() -> &'static str { "debug" }
#[cfg(not(debug_assertions))]
fn mode() -> &'static str { "release" }
fn main() {
println!("{}", mode());
println!("{}", cfg!(target_os = "linux"));
// Cargo features are cfg flags: #[cfg(feature = "json")]
}#if DEBUG
Console.WriteLine("debug");
#else
Console.WriteLine("release");
#endif
Console.WriteLine(OperatingSystem.IsLinux());
// Feature flags are <DefineConstants> in the .csproj.#if is a genuine preprocessor directive: the excluded text is not parsed, so it need not even be valid C#, and an IDE greys it out and stops checking it — which is exactly how #if blocks rot. Rust's #[cfg] is an attribute on an item the parser has already read, so excluded code must still be syntactically valid and the tooling can see it. Symbols come from <DefineConstants> in the project file, which is where a Cargo feature ends up. For platform checks specifically, prefer the run-time OperatingSystem.IsLinux() — the JIT folds it to a constant and it keeps every branch compiled and checked.Option & Nullability
Option<T> becomes a nullable annotation
C# 8 added nullable reference types, and the honest framing matters: they are a static analysis layered on a runtime where every reference has always been nullable, not a change to the type system.
fn find_name(id: u32) -> Option<String> {
if id == 1 { Some("Ada".to_string()) } else { None }
}
fn main() {
println!("{}", find_name(1).unwrap_or_else(|| "(nobody)".to_string()));
println!("{}", find_name(2).map(|name| name.len()).unwrap_or(0));
match find_name(1) {
Some(name) => println!("found {name}"),
None => println!("nothing"),
}
}#nullable enable
Console.WriteLine(FindName(1) ?? "(nobody)");
Console.WriteLine(FindName(2)?.Length ?? 0);
if (FindName(1) is string name)
{
Console.WriteLine($"found {name}");
}
static string? FindName(int id) => id == 1 ? "Ada" : null;string? and string are the same type at run time — the annotation is metadata the compiler reads, and violating it is a warning, not an error, unless the project sets <WarningsAsErrors>nullable</WarningsAsErrors>. It can be defeated by the ! null-forgiving operator, by reflection, by deserialization, and by any library compiled without the feature. Option<T> by contrast is a real enum you cannot look inside without matching. The operators map cleanly — ?. is map, ?? is unwrap_or, ??= is get_or_insert_with — and there is no and_then chain because ?. already short-circuits the whole expression. Note also that Option<T> nests (Option<Option<T>>) and string?? does not exist.Nullable<T> for value types is a real type
For a value type, C# is on much firmer ground:
int? is Nullable<int>, an actual struct with a flag and a value, and it behaves the way Option<u16> does.fn parse_port(text: &str) -> Option<u16> {
text.parse().ok()
}
fn main() {
println!("{:?}", parse_port("8080"));
println!("{:?}", parse_port("eighty"));
println!("{}", parse_port("eighty").unwrap_or(80));
}Console.WriteLine(ParsePort("8080"));
Console.WriteLine(ParsePort("eighty").HasValue);
Console.WriteLine(ParsePort("eighty") ?? 80);
static int? ParsePort(string text) =>
int.TryParse(text, out var value) ? value : null;It has
HasValue and Value where Option has is_some() and unwrap(), throws InvalidOperationException rather than panicking on an empty unwrap, and is a distinct type (typeof(int?) is Nullable<int>) — though boxing unwraps it, so GetType() on an int? holding 5 reports System.Int32. So C# has two entirely different nullability mechanisms with one syntax, and knowing which one a ? means requires knowing whether the underlying type is a struct or a class. The TryParse pattern with an out parameter is the .NET idiom for a fallible parse and is what str::parse().ok() does.Result & Exceptions
Result<T, E> becomes an exception
The default error mechanism is an exception, and the two properties a Rust reader relies on both disappear: failure is not in the signature, and nothing forces the caller to deal with it.
#[derive(Debug)]
enum ParseError {
NotANumber(String),
}
fn read_port(text: &str) -> Result<u16, ParseError> {
text.parse().map_err(|_| ParseError::NotANumber(text.to_string()))
}
fn main() {
match read_port("8080") {
Ok(port) => println!("{port}"),
Err(error) => println!("{error:?}"),
}
println!("{:?}", read_port("eighty"));
}try
{
Console.WriteLine(ReadPort("8080"));
Console.WriteLine(ReadPort("eighty"));
}
catch (FormatException error)
{
Console.WriteLine($"FormatException: {error.Message}");
}
static int ReadPort(string text) => int.Parse(text);A C# method that can fail looks exactly like one that cannot, so the only way to know is documentation or the source. There are no checked exceptions — that was a deliberate rejection of Java's design — so the compiler never asks you to handle or declare anything, and an unhandled exception terminates the process the way a panic does. The type hierarchy is a class tree rooted at
Exception rather than a per-function enum, so catch (FormatException) is the analogue of matching one variant and catch (Exception) of matching them all. The next two rows cover what a Rust reader will reach for instead.The Try pattern is the idiomatic Result
Where .NET expects failure to be routine it does not throw: it returns a
bool and hands the value back through an out parameter. That convention is everywhere in the standard library.fn read_port(text: &str) -> Result<u16, String> {
text.parse().map_err(|_| format!("not a number: {text}"))
}
fn describe(text: &str) -> String {
match read_port(text) {
Ok(port) => format!("port {port}"),
Err(message) => message,
}
}
fn main() {
println!("{}", describe("8080"));
println!("{}", describe("eighty"));
}Console.WriteLine(Describe("8080"));
Console.WriteLine(Describe("eighty"));
static string Describe(string text) =>
int.TryParse(text, out var port) ? $"port {port}" : $"not a number: {text}";TryParse, TryGetValue, TryAdd and TryDequeue are the everyday members of a large family, and they exist because exceptions in .NET are expensive — roughly microseconds, involving a stack walk — so using one for an expected outcome is a real performance problem as well as a design smell. The compiler enforces less here than it seems to: it makes TryParse assign port on every path, so port is readable in the false branch too, where it holds the default 0. Nothing ties the value to the success the way match on a Result does.The ? operator has no equivalent
This is the ergonomic loss, and it is worth being blunt about. There is no operator that unwraps a success and returns the failure to the caller.
fn read_port(text: &str) -> Result<u16, std::num::ParseIntError> {
let port: u16 = text.trim().parse()?;
Ok(port)
}
fn double_port(text: &str) -> Result<u32, std::num::ParseIntError> {
let port = read_port(text)?;
Ok(port as u32 * 2)
}
fn main() {
println!("{:?}", double_port(" 8080 "));
println!("{}", double_port("eighty").is_err());
}Console.WriteLine(DoublePort(" 8080 "));
Console.WriteLine(TryDoublePort("eighty", out _) == false);
static int DoublePort(string text) => int.Parse(text.Trim()) * 2;
static bool TryDoublePort(string text, out int result)
{
if (!int.TryParse(text.Trim(), out var port))
{
result = 0;
return false;
}
result = port * 2;
return true;
}With exceptions you do not need one — the propagation is the default and the intermediate function says nothing — which is exactly the trade: propagation is free and invisible rather than cheap and explicit. With the
Try pattern, propagation is entirely manual, and a three-step chain becomes the nested shape above, which is why Try methods are used at boundaries rather than threaded through a call graph. Libraries such as LanguageExt and ErrorOr supply a real Result type with monadic combinators, and they are used in some teams, but they cut against the grain of the base class library and nothing in it will accept one.panic!, assert! and their .NET counterparts
Rust distinguishes a recoverable error from a bug, and marks the second with a panic. C# uses the same exception mechanism for both, and distinguishes them by which exception type you throw.
fn withdraw(balance: i32, amount: i32) -> i32 {
assert!(amount > 0, "amount must be positive, got {amount}");
debug_assert!(balance >= 0);
if amount > balance {
panic!("insufficient funds");
}
balance - amount
}
fn main() {
println!("{}", withdraw(100, 30));
let outcome = std::panic::catch_unwind(|| withdraw(100, -5));
println!("{}", outcome.is_err());
}Console.WriteLine(Withdraw(100, 30));
try
{
Withdraw(100, -5);
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("ArgumentOutOfRangeException");
}
static int Withdraw(int balance, int amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
System.Diagnostics.Debug.Assert(balance >= 0);
if (amount > balance) throw new InvalidOperationException("insufficient funds");
return balance - amount;
}assert! becomes an explicit throw, and .NET 8 added a family of guard helpers — ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegativeOrZero — which read like assert! and produce a properly typed exception with the argument name filled in by the compiler. debug_assert! is Debug.Assert, compiled out of a release build exactly as its Rust counterpart is, which means neither is safe for validating real input. The genuine difference is the failure mode: a panic unwinds only the thread it happens in (ending the program with status 101 when that is the main thread) unless the build sets panic = "abort", which stops the whole process at once, while a C# exception that nothing catches ends the whole process from whichever thread threw it — except inside a Task, which stores the exception and hands it only to whoever awaits the task. In a server both are usually caught at the request or task boundary, and that configuration is what decides whether one failure takes down more than its own request.Traits & Interfaces
Traits become interfaces with default methods
C# 8 added default interface methods, which closes most of the gap between an interface and a trait — a trait's provided methods now have a direct counterpart.
trait Greeter {
fn name(&self) -> String;
fn greeting(&self) -> String {
format!("Hello, {}", self.name())
}
}
struct French;
impl Greeter for French {
fn name(&self) -> String {
"Amelie".to_string()
}
}
fn main() {
println!("{}", French.greeting());
}IGreeter speaker = new French();
Console.WriteLine(speaker.Greeting());
// new French().Greeting() does NOT compile — see below.
interface IGreeter
{
string Name();
string Greeting() => $"Hello, {Name()}";
}
sealed class French : IGreeter
{
public string Name() => "Amelie";
}Two differences matter. A C# interface must be implemented where the type is declared: you cannot write
impl MyTrait for String for a type you do not own, so the standard workaround is an extension method, which is resolved statically and therefore cannot be dispatched on dynamically. And a default interface method is only reachable through the interface — new French().Greeting() does not compile without a cast, because the method is not a member of the class. That is a genuine trap and the reason default interface methods are used far less than they might be. There are no coherence rules to worry about either, since the "implement a foreign trait for a foreign type" case is simply impossible.dyn Trait and impl Trait
Rust makes you choose between static and dynamic dispatch and spell the choice out. C# has one syntax and the choice is made by whether you named an interface or a type parameter.
trait Shape {
fn area(&self) -> f64;
}
struct Square(f64);
struct Circle(f64);
impl Shape for Square {
fn area(&self) -> f64 { self.0 * self.0 }
}
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.0 * self.0 }
}
fn total(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|shape| shape.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0)), Box::new(Circle(1.0))];
println!("{:.3}", total(&shapes));
}var shapes = new List<IShape> { new Square(2.0), new Circle(1.0) };
Console.WriteLine($"{Total(shapes):F3}");
static double Total(IEnumerable<IShape> shapes) => shapes.Sum(shape => shape.Area());
interface IShape { double Area(); }
sealed record Square(double Side) : IShape
{
public double Area() => Side * Side;
}
sealed record Circle(double Radius) : IShape
{
public double Area() => 3.14159 * Radius * Radius;
}An interface-typed variable is
Box<dyn Trait>: a reference plus a method table, dispatched at run time. There is no Box because every class instance is already a reference, and no object-safety rules, because a C# interface may have generic methods, static members and everything else — the restriction Rust needs in order to build a vtable does not arise the same way. A generic method with an interface constraint (static double Total<T>(…) where T : IShape) is the impl Trait equivalent, and it really is monomorphized for value types, which the next section covers.Operator traits and static abstract members
C# 11 added static abstract interface members, and with them the thing that had been missing for twenty years: a generic method that can call an operator or a static factory on its type parameter.
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
struct Money(i64);
impl Add for Money {
type Output = Money;
fn add(self, other: Money) -> Money {
Money(self.0 + other.0)
}
}
fn sum_all<T: Add<Output = T> + Copy + Default>(values: &[T]) -> T {
values.iter().fold(T::default(), |total, value| total + *value)
}
fn main() {
println!("{:?}", sum_all(&[Money(150), Money(99)]));
}using System.Numerics;
Console.WriteLine(SumAll(new[] { new Money(150), new Money(99) }));
static T SumAll<T>(T[] values) where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
var total = T.AdditiveIdentity;
foreach (var value in values) total += value;
return total;
}
readonly record struct Money(long Cents) :
IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
public static Money AdditiveIdentity => new(0);
public static Money operator +(Money left, Money right) => new(left.Cents + right.Cents);
}This is the direct counterpart of
T: Add<Output = T>, and .NET 7 shipped a whole hierarchy of these interfaces — IAdditionOperators, INumber<T>, IParsable<T> — which is std::ops and num-traits arriving in the base class library at once. Before this, a generic Sum over arbitrary numeric types simply could not be written and every library faked it with per-type overloads or runtime dispatch. Two remaining differences: C# operators are declared static on the type rather than through a separate impl block, and there is no associated-type syntax, so the output type is another type parameter.Extension methods against impl Trait for ForeignType
C# cannot implement an interface for a type it does not own, so the substitute is a static method with a
this modifier on its first parameter, callable as though it were an instance method.trait Shout {
fn shout(&self) -> String;
}
impl Shout for str {
fn shout(&self) -> String {
format!("{}!", self.to_uppercase())
}
}
trait Median {
fn median(&self) -> f64;
}
impl Median for [i32] {
fn median(&self) -> f64 {
let mut ordered = self.to_vec();
ordered.sort();
let middle = ordered.len() / 2;
if ordered.len() % 2 == 1 {
ordered[middle] as f64
} else {
(ordered[middle - 1] + ordered[middle]) as f64 / 2.0
}
}
}
fn main() {
// Legal only because both traits are defined in THIS crate:
// the orphan rule forbids impl ForeignTrait for ForeignType.
println!("{}", "hello".shout());
println!("{}", [5, 1, 3].median());
}Console.WriteLine("hello".Shout());
Console.WriteLine(new[] { 5, 1, 3 }.Median());
static class Extensions
{
public static string Shout(this string text) => text.ToUpperInvariant() + "!";
public static double Median(this int[] values)
{
var ordered = values.OrderBy(value => value).ToArray();
var middle = ordered.Length / 2;
return ordered.Length % 2 == 1
? ordered[middle]
: (ordered[middle - 1] + ordered[middle]) / 2.0;
}
}It is resolved statically — it compiles to
Extensions.Shout(text) — so it is not dynamic dispatch, cannot be overridden, and loses to a real member method of the same name, which means adding a member with your extension's name silently changes every call site. In exchange it is scoped by using rather than by a coherence rule, so the blast radius is one file and two libraries adding the same name simply need one of them not to be imported. This is how LINQ works — every one of those operators is an extension on IEnumerable<T>. What it cannot do is make a foreign type satisfy an interface, so impl ForeignTrait for ForeignType genuinely has no counterpart.Generics
Generics survive to run time
C# generics are reified: the type argument is real at run time, available through
typeof(T), visible to reflection, and part of the object's identity. This is the headline difference from Java, and it changes what the two languages have in common with Rust.use std::any::type_name;
fn describe<T>(_value: T) -> &'static str {
type_name::<T>()
}
fn main() {
println!("{}", describe(1i32));
println!("{}", describe("text"));
// The type parameter is gone after monomorphization;
// type_name is compiled in as a constant.
}Console.WriteLine(Describe(1));
Console.WriteLine(Describe("text"));
Console.WriteLine(new List<int>().GetType());
static string Describe<T>(T value) => typeof(T).Name;For a value type argument the runtime generates specialized machine code exactly as monomorphization does, so
List<int> stores unboxed integers and a generic method over int has no indirection. For a reference type argument all instantiations share one compiled body, since every reference is the same size — so there is one code path for List<string> and List<object> alike. The result is Rust's performance where it matters and none of the code bloat where it does not, which is a genuinely good trade. What you also get is a whole reflective capability Rust has no equivalent for, and no PhantomData, since an unused type parameter is simply allowed.Trait bounds become where clauses
The
where clause is the same idea and even the same keyword, and Rust's inline <T: Bound> form has no C# counterpart.use std::fmt::Display;
fn largest<T: PartialOrd + Copy + Display>(items: &[T]) -> T {
let mut result = items[0];
for item in items.iter() {
if *item > result {
result = *item;
}
}
println!("{result}");
result
}
fn main() {
largest(&[3, 9, 2]);
largest(&[1.5, 0.5]);
}Largest(new[] { 3, 9, 2 });
Largest(new[] { 1.5, 0.5 });
static T Largest<T>(T[] items) where T : IComparable<T>
{
var result = items[0];
foreach (var item in items)
{
if (item.CompareTo(result) > 0) result = item;
}
Console.WriteLine(result);
return result;
}The constraint vocabulary is different in kind: alongside interface constraints C# has
class, struct, notnull, unmanaged, new() and base-class constraints, several of which are about memory layout rather than behavior. unmanaged is the interesting one for a Rust reader — it means the type contains no references at all, so it can be pointed at, measured with sizeof and copied bytewise. Span<T> itself needs no such constraint: a span of structs that hold strings is fine. There is no negative reasoning and no specialization, and — until static abstract members — no way to require an operator, which is why IComparable<T>.CompareTo stands in for PartialOrd here.Variance is declared, and only on interfaces
C# lets a generic interface declare that it is covariant or contravariant in a type parameter, so a
List<Square> can be passed where an IEnumerable<IShape> is expected with no conversion.fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|shape| shape.area()).sum()
}
trait Shape { fn area(&self) -> f64; }
struct Square(f64);
impl Shape for Square {
fn area(&self) -> f64 { self.0 * self.0 }
}
fn main() {
// Rust's variance is INFERRED from how the parameter is used;
// there is nothing to declare, and &Vec<Square> is not a
// &Vec<Box<dyn Shape>> — you build the vec of trait objects.
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0))];
println!("{}", total_area(&shapes));
}var squares = new List<Square> { new Square(2.0) };
Console.WriteLine(TotalArea(squares)); // List<Square> as IEnumerable<IShape>
static double TotalArea(IEnumerable<IShape> shapes) => shapes.Sum(shape => shape.Area());
interface IShape { double Area(); }
sealed record Square(double Side) : IShape
{
public double Area() => Side * Side;
}IEnumerable<out T> is covariant — out meaning T appears only in output positions — and Action<in T> is contravariant. Rust infers variance from usage and never asks you to write it, and because there is no subtyping between concrete types the question mostly does not arise; the place it does is lifetimes, where &'long T coerces to &'short T. Two C# limits worth knowing: variance works only on interfaces and delegates, never on classes, so List<Square> is not a List<IShape> (which would be unsound, since you could add a Circle); and it works only for reference types, because a covariant conversion of a value type would need a representation change.Collections
Vec, HashMap and their .NET names
The mapping is direct —
Vec<T> is List<T>, HashMap is Dictionary, HashSet keeps its name — and the differences are in what a missing key does and in what the collection promises about mutation.use std::collections::{HashMap, HashSet};
fn main() {
let mut numbers = vec![1, 2, 3];
numbers.push(4);
println!("{:?} {}", numbers, numbers.len());
let mut ages: HashMap<&str, u32> = HashMap::new();
ages.insert("Ada", 36);
println!("{:?}", ages.get("Ada"));
println!("{:?}", ages.get("Bo"));
let unique: HashSet<i32> = [1, 2, 2, 3].into_iter().collect();
println!("{}", unique.len());
}var numbers = new List<int> { 1, 2, 3 };
numbers.Add(4);
Console.WriteLine($"{string.Join(",", numbers)} {numbers.Count}");
var ages = new Dictionary<string, int> { ["Ada"] = 36 };
Console.WriteLine(ages.TryGetValue("Ada", out var known) ? known.ToString() : "none");
Console.WriteLine(ages.TryGetValue("Bo", out _) ? "found" : "none");
var unique = new HashSet<int> { 1, 2, 2, 3 };
Console.WriteLine(unique.Count);Dictionary's indexer throws on a missing key rather than returning an option, so TryGetValue is the everyday form and GetValueOrDefault is the terse one. There is no &mut, so nothing prevents a collection from being mutated while something else holds a reference to it — foreach detects the case at run time and throws InvalidOperationException, which is where Rust gives you a compile error instead. Immutable collections live in System.Collections.Immutable, and the newer FrozenDictionary trades build cost for faster lookups.String, &str, and the UTF-16 surprise
A Rust
String is UTF-8 and its len() is a byte count. A .NET string is UTF-16 and its Length is a count of 16-bit code units — which is neither bytes nor characters.fn main() {
let owned: String = String::from("café");
let borrowed: &str = &owned;
println!("{} {}", owned.len(), borrowed.chars().count());
let flag = "\u{1F1EC}\u{1F1E7}";
println!("{} {}", flag.len(), flag.chars().count());
let mut built = String::new();
built.push_str("a");
built.push_str("b");
println!("{built}");
}var owned = "café";
Console.WriteLine($"{Encoding.UTF8.GetByteCount(owned)} {owned.Length}");
var flag = "\U0001F1EC\U0001F1E7";
Console.WriteLine($"{Encoding.UTF8.GetByteCount(flag)} {flag.Length}");
var built = new StringBuilder();
built.Append("a").Append("b");
Console.WriteLine(built);So the flag emoji, one grapheme built from two code points, is 8 bytes in UTF-8, 2
chars in Rust, and Length == 4 in C#, because each code point needs a surrogate pair. Anything doing real text work reaches for StringInfo or Rune, which is .NET's char-as-code-point type. The owned/borrowed split has no counterpart — a C# string is an immutable heap object and there is no &str, which is why ReadOnlySpan<char> exists and why string.Substring allocates where &text[..3] does not. StringBuilder is the mutable builder, since += in a loop allocates each time.Iterators & LINQ
Iterator adapters are LINQ
LINQ is the closest thing on this site to Rust's iterator adapters: lazy, chained, and terminated by an operation that forces the work.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let result: Vec<i32> = numbers
.iter()
.filter(|value| *value % 2 == 0)
.map(|value| value * 10)
.collect();
println!("{:?}", result);
println!("{}", numbers.iter().sum::<i32>());
println!("{:?}", numbers.iter().max());
}var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
var result = numbers
.Where(value => value % 2 == 0)
.Select(value => value * 10)
.ToList();
Console.WriteLine(string.Join(",", result));
Console.WriteLine(numbers.Sum());
Console.WriteLine(numbers.Max());filter is Where, map is Select, flat_map is SelectMany, fold is Aggregate, collect is ToList/ToArray/ToDictionary, and take/skip keep their names. The evaluation model is the same — nothing runs until something enumerates — with one behavioral difference worth internalizing: a LINQ query can be enumerated twice, and it re-runs the whole chain, so a query over an expensive source is a performance trap that a once-only Rust iterator cannot be. The cost model differs too: every LINQ stage is a delegate call through an interface, where Rust's adapters inline into a loop, so a hot path in .NET is written as a foreach.Implementing Iterator against yield return
Rust asks you to declare a struct holding the state and implement
next. C# lets the compiler build that struct for you from a method containing yield return.struct Countdown {
remaining: u32,
}
impl Iterator for Countdown {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.remaining == 0 {
None
} else {
self.remaining -= 1;
Some(self.remaining + 1)
}
}
}
fn main() {
let values: Vec<u32> = Countdown { remaining: 3 }.collect();
println!("{:?}", values);
}Console.WriteLine(string.Join(",", Countdown(3)));
static IEnumerable<int> Countdown(int from)
{
while (from > 0)
{
yield return from;
from--;
}
}A
yield return method is rewritten into a state machine class implementing IEnumerator<T> — literally the struct-plus-next shape, generated. That is far less to write, and the trade is that you cannot see or control the state, cannot implement size_hint, and get no specialized ExactSizeIterator-style behavior. In Rust the state is yours to write, as here, or to capture in a closure handed to std::iter::from_fn, so this is a real ergonomic win for C#. Note that a yield return method cannot take ref, in or out parameters, and cannot use a ref struct such as a Span<T> across a yield return — which rules out using one as a parameter at all — for the same escape reason as earlier.collect() into different shapes
Rust's
collect is one method that decides what to build from the type you asked for. C# has a differently named method per target, which is more to remember and never ambiguous.use std::collections::{HashMap, HashSet};
fn main() {
let words = vec!["fig", "apple", "pear", "plum"];
let lengths: Vec<usize> = words.iter().map(|word| word.len()).collect();
println!("{lengths:?}");
let unique: HashSet<usize> = words.iter().map(|word| word.len()).collect();
println!("{}", unique.len());
let by_word: HashMap<&str, usize> =
words.iter().map(|word| (*word, word.len())).collect();
println!("{}", by_word["apple"]);
}var words = new[] { "fig", "apple", "pear", "plum" };
var lengths = words.Select(word => word.Length).ToList();
Console.WriteLine(string.Join(",", lengths));
var unique = words.Select(word => word.Length).ToHashSet();
Console.WriteLine(unique.Count);
var byWord = words.ToDictionary(word => word, word => word.Length);
Console.WriteLine(byWord["apple"]);
var grouped = words.ToLookup(word => word.Length);
Console.WriteLine(grouped[4].Count());The consequence of Rust's design is the turbofish and the occasional need to annotate the binding; the consequence of C#'s is that
ToList, ToArray, ToHashSet, ToDictionary and ToLookup are five names you learn. ToLookup has no direct Rust counterpart — it is a one-to-many dictionary, so it is collect into a HashMap<K, Vec<V>> in one call, and it returns an empty sequence rather than throwing for a key it does not have. ToDictionary throws on a duplicate key, where Rust's collect into a HashMap silently keeps the last.Pattern Matching
match becomes a switch expression
The C# 8 switch expression is a direct borrowing of this construct, arrow and all, and it has grown relational, logical and list patterns since.
fn describe(value: i32) -> String {
match value {
0 => "zero".to_string(),
1..=9 => "single digit".to_string(),
n if n < 0 => format!("negative {n}"),
_ => "large".to_string(),
}
}
fn main() {
println!("{}", describe(0));
println!("{}", describe(5));
println!("{}", describe(-3));
println!("{}", describe(1000));
}Console.WriteLine(Describe(0));
Console.WriteLine(Describe(5));
Console.WriteLine(Describe(-3));
Console.WriteLine(Describe(1000));
static string Describe(int value) => value switch
{
0 => "zero",
>= 1 and <= 9 => "single digit",
< 0 => $"negative {value}",
_ => "large",
};The arms map closely:
1..=9 becomes >= 1 and <= 9 (C# has no range pattern for numbers, though it does for lists), a guard is when rather than if, and _ is the discard in both. Exhaustiveness is where they part company. C# checks it and emits a warning, then compiles a switch that throws SwitchExpressionException at run time if nothing matched — where Rust simply refuses to compile. The check reasons about values: a switch on an int that covers every range (< 0, 0, > 0) compiles with no warning, and so does one covering both bools. What it never proves is a closed set of names or types — a switch naming every member of an enum still warns, because any integer can be cast to the enum, and so does one naming every subtype of a hierarchy, which the next row covers.Enums with payloads become a sealed hierarchy
This is the biggest structural gap on the page. C# has no discriminated union, and the enum keyword names a set of integer constants with no payload at all.
enum FetchResult {
Success(String),
Failure { reason: String },
}
fn render(result: &FetchResult) -> String {
match result {
FetchResult::Success(value) => format!("ok: {value}"),
FetchResult::Failure { reason } => format!("failed: {reason}"),
}
}
fn main() {
println!("{}", render(&FetchResult::Success("data".to_string())));
println!("{}", render(&FetchResult::Failure { reason: "timeout".to_string() }));
}Console.WriteLine(Render(new Success("data")));
Console.WriteLine(Render(new Failure("timeout")));
static string Render(FetchResult result) => result switch
{
Success success => $"ok: {success.Value}",
Failure failure => $"failed: {failure.Reason}",
_ => "unreachable",
};
abstract record FetchResult;
sealed record Success(string Value) : FetchResult;
sealed record Failure(string Reason) : FetchResult;The standard workaround is an abstract record with sealed record subtypes, matched by type pattern — which gets you the shape and most of the ergonomics. What it does not get you is the closed world: the compiler never treats a class hierarchy as closed — not even one whose base has a private constructor, so that no other code can add a subtype — and cannot prove the switch complete, so the
_ arm is required to silence the warning. The layout differs too — a Rust enum is one value, its largest variant plus room for the tag, and here each value is a separate heap object with its own header. Discriminated unions have long been among the most requested C# features, and this pattern is what codebases write in their place.Destructuring and tuple patterns
Tuples and destructuring exist in both, and C# tuples can name their elements, which Rust's cannot.
fn main() {
let point = (3, 4);
let (x, y) = point;
println!("{x} {y}");
let quadrant = match point {
(0, 0) => "origin",
(x, y) if x > 0 && y > 0 => "first",
_ => "elsewhere",
};
println!("{quadrant}");
}var point = (3, 4);
var (x, y) = point;
Console.WriteLine($"{x} {y}");
var quadrant = point switch
{
(0, 0) => "origin",
( > 0, > 0) => "first",
_ => "elsewhere",
};
Console.WriteLine(quadrant);A C# tuple is
ValueTuple, a mutable struct, so it is a value type like Rust's and is passed in registers when small. Elements may be named — (int quotient, int remainder) — which makes a multi-value return self-documenting; the names are compiler metadata rather than part of the runtime type. Positional patterns work on any type with a Deconstruct method, and records generate one, so if (shape is Square(var side)) matches a record's components the way a Rust struct pattern does. There is no @, but the same job is done by naming a subpattern — value is int digit and >= 1 and <= 9 binds digit while testing it — and there is no equivalent of ref/ref mut in a pattern, since binding modes are a borrow-checker concept.Slice patterns become list patterns
C# 11 added list patterns, and the syntax is close enough to Rust's slice patterns — including the range slice — that it reads the same.
fn describe(values: &[i32]) -> String {
match values {
[] => "empty".to_string(),
[only] => format!("one: {only}"),
[first, .., last] => format!("{first} to {last}"),
}
}
fn main() {
println!("{}", describe(&[]));
println!("{}", describe(&[7]));
println!("{}", describe(&[1, 2, 3]));
}Console.WriteLine(Describe(Array.Empty<int>()));
Console.WriteLine(Describe(new[] { 7 }));
Console.WriteLine(Describe(new[] { 1, 2, 3 }));
static string Describe(int[] values) => values switch
{
[] => "empty",
[var only] => $"one: {only}",
[var first, .., var last] => $"{first} to {last}",
};The
.. is the slice pattern and may appear once, optionally binding: [var first, .. var middle, var last]. Two differences. The var keyword is required on each binding, since a bare identifier in a C# pattern would be a constant to compare against. And this switch is exhaustive in the compiler's view for a non-null array, but a null array falls through and throws SwitchExpressionException — which is the recurring theme: C# checks the cases you wrote and cannot rule out null.Async & Futures
Cold futures become hot Tasks
The syntax is nearly identical and the semantics are opposite in the one way that changes how code is written. This is the row to read twice.
// A Rust future does NOTHING until polled.
// Calling an async fn only builds the state machine.
//
// async fn fetch(name: &str) -> String {
// format!("data for {name}")
// }
//
// let future = fetch("first"); // nothing has run
// let value = future.await; // now it runs
//
// And std supplies no executor: a runtime such as tokio or smol does.
fn main() {
println!("Rust: a future is inert until awaited");
}var task = FetchAsync("first"); // ALREADY RUNNING
Console.WriteLine("started");
Console.WriteLine(await task);
static async Task<string> FetchAsync(string name)
{
await Task.Delay(10);
return $"data for {name}";
}Calling a C#
async method starts it immediately and returns a Task representing work already in flight; await only waits for the result. So creating a task and awaiting it later is how you get concurrency, and forgetting to await one leaves it running unobserved with its exception swallowed until finalization — the "fire and forget" hazard that a cold future cannot have. There is also no executor to choose or pass around: the runtime supplies a thread pool, and a synchronization context where the host installs one (a UI framework does; a console program has none), so #[tokio::main] has no counterpart and nothing is generic over it. ValueTask<T> exists for the case where the result is usually already available and allocating a Task is waste.join! becomes Task.WhenAll
Task.WhenAll is join! and Task.WhenAny is select!, and because tasks are hot the work has already started before either is called.// With tokio:
//
// let (first, second) = tokio::join!(
// fetch("first"),
// fetch("second"),
// );
//
// tokio::select! picks whichever finishes first, and
// dropping a future CANCELS it — cancellation is a Drop.
fn main() {
println!("Rust: join!, select!, and cancellation by drop");
}var first = FetchAsync("first", 30);
var second = FetchAsync("second", 10);
var results = await Task.WhenAll(first, second);
Console.WriteLine(string.Join(",", results));
var winner = await Task.WhenAny(first, second);
Console.WriteLine(await winner);
static async Task<string> FetchAsync(string name, int delay)
{
await Task.Delay(delay);
return $"{name} done";
}Cancellation is the real difference. Dropping a Rust future cancels it — cancellation is a consequence of ownership — while a .NET
Task cannot be cancelled from the outside at all. Instead a CancellationToken is threaded through every call as an explicit parameter, and each operation is responsible for checking it; a method that ignores its token simply runs to completion. That is more plumbing and it is also more predictable, since Rust's cancel-at-any-await-point is a well-known source of subtle bugs where a future is dropped between two operations that had to happen together.Streams become IAsyncEnumerable
An asynchronous sequence is a language feature in C# 8 and a trait from a crate in Rust — the same split as the previous section's iterators, one step further along.
// With futures/tokio-stream:
//
// let mut stream = tokio_stream::iter(0..3)
// .then(|value| async move { value * 10 });
//
// while let Some(value) = stream.next().await {
// println!("{value}");
// }
//
// Stream is a trait from the futures crate, implemented by hand.
fn main() {
println!("Rust: Stream, from a crate, implemented by hand");
}await foreach (var value in TicksAsync(3))
{
Console.WriteLine(value * 10);
}
static async IAsyncEnumerable<int> TicksAsync(int count)
{
for (var index = 0; index < count; index++)
{
await Task.Delay(1);
yield return index;
}
}async IAsyncEnumerable<T> combines async and yield return, and await foreach consumes it; the compiler generates the state machine. Rust's Stream trait comes from the futures crate and is implemented by hand or with the async-stream macro. LINQ operators over async sequences — Select, Where, ToListAsync — are part of the standard library too, so both the language half and the library half are built in; older .NET versions took them from the separate System.Linq.Async package. Cancellation flows in through [EnumeratorCancellation] on a token parameter, which is the plumbing the previous row described.Threads & Shared State
Send and Sync have no counterpart
The shapes look similar and the guarantee is entirely different. Removing the
lock from the C# version compiles, runs, and can print a number smaller than 4000 — though at these small counts it usually still prints 4000, which is exactly what makes the race easy to miss.use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
}var padlock = new object();
var counter = 0;
var tasks = new List<Task>();
for (var worker = 0; worker < 4; worker++)
{
tasks.Add(Task.Run(() =>
{
for (var index = 0; index < 1000; index++)
{
lock (padlock) { counter++; }
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine(counter);There is no
Send or Sync: any object may be touched from any thread, and no marker trait or compiler check stands between you and a data race. The lock is not attached to the data either — lock (padlock) guards a region of code by convention, where Mutex<T> makes the data unreachable without acquiring it, which is the design difference that matters most. Arc disappears because every reference is shared and the collector handles the count. What .NET offers instead is a good library — Interlocked, ConcurrentDictionary, Channel<T>, SemaphoreSlim — and a documented memory model, plus analyzers that catch some misuse. None of it is a proof.Channels
System.Threading.Channels is the direct counterpart of mpsc, and it is async-first rather than blocking, which fits the way .NET concurrency is actually written.use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for value in 0..3 {
sender.send(value).unwrap();
}
});
let received: Vec<i32> = receiver.iter().collect();
println!("{:?}", received);
}using System.Threading.Channels;
var channel = Channel.CreateUnbounded<int>();
_ = Task.Run(async () =>
{
for (var value = 0; value < 3; value++)
{
await channel.Writer.WriteAsync(value);
}
channel.Writer.Complete();
});
var received = new List<int>();
await foreach (var value in channel.Reader.ReadAllAsync())
{
received.Add(value);
}
Console.WriteLine(string.Join(",", received));The correspondence is close:
CreateUnbounded is mpsc::channel, CreateBounded is sync_channel, and Writer.Complete() is dropping the sender — except that here it is an explicit call, because there is no ownership to observe. Multiple producers and multiple consumers are both supported and configurable, where std::sync::mpsc is single-consumer by construction. What is lost is the compiler's help: nothing prevents you from continuing to hold and use a value after sending it, so the "channels transfer ownership" mental model becomes a convention you keep in your head.Closures & Delegates
Fn, FnMut and FnOnce become Func and Action
Rust needs three closure traits because it has to say what a closure does to the values it captured. C# needs none, and instead needs two family names because a delegate returning nothing is a different type from one returning something.
fn apply_twice<F: Fn(i32) -> i32>(function: F, value: i32) -> i32 {
function(function(value))
}
fn main() {
println!("{}", apply_twice(|value| value + 1, 5));
let mut count = 0;
let mut increment = || {
count += 1;
count
};
println!("{} {}", increment(), increment());
}Console.WriteLine(ApplyTwice(value => value + 1, 5));
var count = 0;
Func<int> increment = () => { count += 1; return count; };
Console.WriteLine($"{increment()} {increment()}");
Action<string> announce = message => Console.WriteLine(message);
announce("done");
static int ApplyTwice(Func<int, int> function, int value) => function(function(value));Func<A, R> is a function returning a value and Action<A> is one returning void, which is the split C# has instead of a unit type — the two are not interchangeable and the arity is baked into the type name. Capture is always by reference to a compiler-generated object, so a captured local is heap-allocated and outlives its scope: that is FnMut with no borrow checker, and it is why a lambda in a loop capturing the loop variable used to be a classic bug (fixed for foreach in C# 5, still live for a C-style for). There is no move; copy a value into a new local before the lambda if you need it frozen. A lambda that captures nothing is cached and allocates once.Allocation, and the ways to avoid it
A Rust closure is an anonymous struct on the stack and the adapters around it inline away. Neither is true here, and knowing the cost is what decides how a hot path gets written.
fn main() {
let numbers = vec![1, 2, 3, 4];
// A Rust closure is a stack value with no allocation;
// the adapters below inline into a single loop.
let total: i32 = numbers
.iter()
.filter(|value| **value % 2 == 0)
.map(|value| value * 10)
.sum();
println!("{total}");
}var numbers = new List<int> { 1, 2, 3, 4 };
// The chain allocates iterator objects (Where and Select fuse into
// one), and every element crosses a delegate call. 'static' forbids
// capture, which keeps each delegate cached.
var total = numbers.Where(static value => value % 2 == 0)
.Select(static value => value * 10)
.Sum();
Console.WriteLine(total);
var manual = 0;
foreach (var value in numbers)
{
if (value % 2 == 0) manual += value * 10;
}
Console.WriteLine(manual);A C# lambda that captures nothing is cached in a static field, so it allocates once for the program; one that captures becomes a heap object allocated per call. The
static modifier on a lambda (C# 9) is a compile-time assertion that it captures nothing — the closest thing to the guarantee a Rust closure gives you by construction, and worth using in a hot LINQ chain. The chain itself is not free either: it builds iterator objects (adjacent stages such as Where and Select are fused into one) and each element crosses a delegate call, where the Rust version compiles to one loop. That is why performance-sensitive .NET is written as foreach, and why Span<T>-based code avoids LINQ entirely.unsafe, fixed & P/Invoke
unsafe means something narrower here
Both languages have an
unsafe block and it buys a smaller set of powers in C#: raw pointer arithmetic and a few unmanaged operations, and nothing else.fn main() {
let mut values = [1, 2, 3];
let pointer = values.as_mut_ptr();
unsafe {
*pointer.add(1) = 99;
}
println!("{:?}", values);
}// A project must opt in with <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
// before this compiles; the examples here are built with it on.
var values = new int[] { 1, 2, 3 };
unsafe
{
fixed (int* pointer = values)
{
*(pointer + 1) = 99;
}
}
Console.WriteLine($"[{string.Join(", ", values)}]");The extra keyword is
fixed, and it exists for a reason with no Rust analogue: the garbage collector moves objects during compaction, so a pointer into a managed array is only valid while that array is pinned. Forgetting fixed is a compile error for a local, and pinning for too long fragments the heap — which is why Span<T> and ref exist as the safe way to do most of what pointers were used for. There is no general unsafe fn contract: a method marked unsafe needs an unsafe context at the call only when its signature mentions a pointer, a method may simply contain an unsafe block and ask nothing of its callers, and there is no notion of an unsafe trait. A project must opt in with <AllowUnsafeBlocks> before any of it compiles.Calling a Rust cdylib from C#
This is the row a Rust reader is most likely to have arrived looking for. It is illustrative because the library it would load is not present in either runner.
// src/lib.rs, with crate-type = ["cdylib"]
//
// #[unsafe(no_mangle)]
// pub extern "C" fn add_numbers(left: i32, right: i32) -> i32 {
// left + right
// }
//
// #[unsafe(no_mangle)]
// pub extern "C" fn free_text(pointer: *mut c_char) {
// unsafe { drop(CString::from_raw(pointer)) }
// }
//
// cargo build --release -> libmylib.dylib / .so / .dll
fn main() {
println!("Rust: extern \"C\", no_mangle, and a stable ABI surface");
}// [LibraryImport("mylib")]
// internal static partial int add_numbers(int left, int right);
//
// var total = add_numbers(2, 3);
//
// Marshaling rules that matter:
// int/long/double -> blittable, passed directly
// bool -> NOT blittable: marshaled, 4 bytes by default
// string -> no default: say StringMarshalling.Utf8 or Utf16
// struct -> sequential layout, already a struct's default
// returned char* -> free it by calling BACK into Rust
Console.WriteLine("C#: LibraryImport, blittable types, and manual lifetimes");[LibraryImport] is the modern replacement for [DllImport]: it is a source generator, so the marshaling code is generated at compile time, is visible, and works under NativeAOT — which [DllImport]'s runtime marshaling does not fully. The rules to hold on to: only blittable types cross for free, which means no String, no Vec, no enum with a payload; a struct needs sequential layout on the C# side — the default for a C# struct — and #[repr(C)] on the Rust side; and any pointer Rust allocated must be freed by calling back into Rust, because the two allocators are different. Ownership is entirely by documentation once the boundary is crossed.Cargo, NuGet & NativeAOT
NativeAOT against a Rust binary
NativeAOT compiles a .NET program to a single native executable ahead of time, and comparing what it costs against what a Rust binary costs is the fairest way to end this page.
// cargo build --release
//
// one binary with every Rust dependency linked in, ~300 KB to a few MB
// (the C library is still linked dynamically on most targets)
// startup measured in single-digit milliseconds
// no runtime to install
// full reflection is not a thing that exists
fn main() {
println!("Rust: compile once, ship one file");
}// <PublishAot>true</PublishAot> in the .csproj
// dotnet publish -c Release -r osx-arm64
//
// one native binary, ~2-15 MB after trimming
// startup in a few milliseconds instead of ~50
// no JIT: no tiered compilation, no dynamic code
// reflection is limited; Reflection.Emit is gone
Console.WriteLine("C#: AOT-compiled, at the cost of what the JIT gave you");What you gain is what a Rust reader expects: no runtime to install, no JIT warm-up, a fast, predictable start, and a much smaller memory footprint — which is why it is the default choice for a CLI or a serverless function. What you give up is the reflection-heavy half of the ecosystem, because the trimmer removes code nothing statically references:
Reflection.Emit is unavailable, and reflection-based serializers, ORMs and dependency-injection containers either need source-generated equivalents or do not work. The binary is also larger than the Rust equivalent, since the garbage collector and the type system's runtime support come along. Both a Rust binary and an AOT one still have a runtime; the difference is how much of it, and whether it can compile new code while running.Tests live in a separate project
Rust puts unit tests in the same file behind
#[cfg(test)], compiled out of the release build. .NET puts them in a separate project that references the one under test.fn add(left: i32, right: i32) -> i32 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
fn main() {
println!("{}", add(2, 3));
}// MyApp.Tests/BillingTests.cs, run with: dotnet test
//
// [Fact]
// public void AddsTwoNumbers() => Assert.Equal(5, Add(2, 3));
//
// A separate PROJECT that references the one under test —
// so nothing internal is visible without [InternalsVisibleTo].
Console.WriteLine(Add(2, 3));
static int Add(int left, int right) => left + right;The consequence is visibility: a Rust test module can see private items because it is a child module, while a C# test project sees only
public members unless the main project adds [assembly: InternalsVisibleTo("MyApp.Tests")]. There is no built-in framework — xUnit, NUnit and MSTest are packages, with xUnit the common default — and no equivalent of cargo test running doc tests, because doc comments are XML for tooling rather than compiled examples. What .NET has that Cargo does not is first-class parameterized tests ([Theory] with [InlineData]) without a macro crate.Modules and crates against namespaces and assemblies
Both languages have a namespace mechanism and a compilation-unit mechanism, and they line them up differently: a Rust module is both, and C# separates them.
mod billing {
pub struct Invoice {
pub id: u32,
total: u32, // private to this module
}
impl Invoice {
pub fn new(id: u32) -> Self { Invoice { id, total: 0 } }
pub fn total(&self) -> u32 { self.total }
}
}
use billing::Invoice;
fn main() {
let invoice = Invoice::new(7);
println!("{} {}", invoice.id, invoice.total());
}// namespace Billing; (in Billing/Invoice.cs)
var invoice = new Invoice(7);
Console.WriteLine($"{invoice.Id} {invoice.Total}");
sealed class Invoice
{
public int Id { get; }
private int total;
public int Total => total;
public Invoice(int id) => Id = id;
}A namespace is purely a name and carries no visibility at all —
namespace Billing nested inside another gives you a longer name and nothing else. An assembly is the compilation unit, and it is what internal (the default for a top-level type) is scoped to, making it the equivalent of pub(crate). There is no pub(super) or pub(in path). Visibility defaults mostly agree: a Rust item is private unless marked pub, and a C# member defaults to private too. The difference is a top-level type, which defaults to internal — visible to the whole assembly, like pub(crate). using is a compile-time alias with no runtime effect, so there are no import cycles and no import-time side effects.Macros become source generators and attributes
C# has no macros of any kind, and the two things a Rust reader might mistake for them behave quite differently.
#[derive(Debug, Clone, PartialEq)]
struct Order {
id: u32,
}
macro_rules! twice {
($value:expr) => { $value * 2 };
}
fn main() {
println!("{:?}", Order { id: 7 });
println!("{}", twice!(21));
// Procedural macros run at compile time and emit tokens.
}Console.WriteLine(new Order(7));
Console.WriteLine(21 * 2);
// There is no expression macro. The compile-time facility is a
// SOURCE GENERATOR: an analyzer that reads the compilation and
// adds files to it, driven by an attribute:
//
// [JsonSerializable(typeof(Order))]
// internal partial class OrderContext : JsonSerializerContext { }
//
// Attributes are METADATA — they generate no code unless a
// generator reads them.
record Order(int Id);An attribute is metadata attached to a declaration: most change nothing by themselves and are read either by reflection at run time or by a compile-time tool, though the compiler acts on a few itself —
[Obsolete("…", true)] turns every call into a compile error. A source generator is that compile-time tool — a Roslyn analyzer that sees the whole compilation and adds new source files to it — which is the closest thing to a procedural macro, and is how System.Text.Json, regex compilation and [LibraryImport] avoid reflection under NativeAOT. Two limits against a derive macro: a generator can only add code, never rewrite what you wrote, and it hooks in through a partial declaration rather than by transforming your type. There is no macro_rules! equivalent at all; that work is done by generics and by [CallerMemberName]-style compiler-supplied arguments.