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. Interpolated strings ($"…") are checked, so they are 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. One thing to expect when you run this row here: the Compiler Explorer runner reports stdout only on a successful run, so the Console.Error line is missing from the C# cell in the browser while it is there when you run the file locally.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 any method through an in parameter makes a defensive copy, because the compiler cannot 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, but neither carries a lifetime, so nothing stops a reference from outliving what it points at — that is the garbage collector's job rather than the compiler's.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 struct compares members by default. 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 machine words, never pointers.
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 dies and there is no way to forget; 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 is collected as soon as nothing outside it is reachable, 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, at unmanaged memory, or 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: nothing gives a span a lifetime tied to its source, so the compiler protects you by restricting where a span may go rather than by tracking what it points at.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 — until [UnscopedRef] and the C# 13 relaxations — could not be used in an async method or an iterator. 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 check the compiler enforces a blunter rule instead. 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 anywhere the question could arise.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 that must be a literal expression, 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, never dynamic typing: the type is fixed at the declaration and nothing can change it afterwards.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 nothing stops two libraries from defining conflicting extension methods on the same type; the compiler picks by scope and you may not notice.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 cannot be confused with the non-nullable type by any amount of reflection. 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, TryDequeue are the whole 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 does enforce something here: out var port is definitely assigned only where the method returned true, so reading it in the false branch is an error. That is a narrow version of what match on a Result gives you, and it is as close as the built-in mechanism gets.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 and, by default in a binary, aborts the process, while an uncaught exception unwinds to the top of the thread — so a request handler that throws takes down one request and a Rust one that panics may take down the task or the process depending on configuration.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 monomorphised 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>, ISpanFormattable — 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 and copied bytewise, which is what makes Span<T> over a struct sound. 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. Rust's generators are still unstable, so writing an iterator by hand is the norm there and this is a real ergonomic win for C#. Note that a yield return method cannot take ref/out parameters or a ref struct, 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. Since a C# reference can always be null and an int can always be some unlisted value, a true closed-world check is only possible over an enum or a sealed 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: any assembly can add a third subtype unless the base is declared with a private constructor, so the compiler cannot prove the switch is complete and the
_ arm is required to silence the warning. The layout differs too — a Rust enum is one value the size of its largest variant, and this is three heap objects with headers. Discriminated unions have been the most-requested C# feature for years and a proposal is in progress; until it lands, this pattern is what every codebase uses.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. What is missing is @ bindings on subpatterns and any 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 there is no executor in std: tokio or async-std supplies it.
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, 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; there is no async generator in std.
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 has Stream in the futures crate, implemented by hand or with the async-stream macro, and it is still not in std. LINQ operators over async sequences come from the separate System.Linq.Async package, so the built-in support is the language half rather than the library half. 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 prints a number smaller than 4000.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 };
// Each stage allocates a delegate and an iterator, and every
// element crosses an interface call. 'static' forbids capture,
// which at least keeps the 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: each stage is an iterator object and each element crosses a virtual 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);
}// Needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj,
// which neither runner on this page sets — so this column is
// illustrative while the Rust column beside it runs.
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 unsafe fn that callers must acknowledge and no notion of an unsafe trait; a method may simply contain an unsafe block, and the project must set <AllowUnsafeBlocks> to compile at all.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"]
//
// #[no_mangle]
// pub extern "C" fn add_numbers(left: i32, right: i32) -> i32 {
// left + right
// }
//
// #[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/bool -> blittable, passed directly
// string -> UTF-16 by default; ask for UTF-8 explicitly
// struct -> [StructLayout(LayoutKind.Sequential)]
// 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 [StructLayout(LayoutKind.Sequential)] on the C# side 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 static binary, ~300 KB to a few MB
// 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 also invert: a Rust item is private unless marked pub, while a C# member defaults to private and a top-level type defaults to internal. 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 alone are pure METADATA — they generate nothing
// unless a generator or reflection reads them.
record Order(int Id);An attribute is metadata attached to a declaration: it changes nothing by itself, and is read either by reflection at run time or by a compile-time tool. 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.