Output & Running
Hello, World
Both keep an entry point; the exclamation mark in the Rust column is the first thing worth asking about.
Option Strict On
Imports System
Module HelloWorld
Sub Main()
Console.WriteLine("Hello, World!")
End Sub
End Modulefn main() {
println!("Hello, World!");
}Module disappears — a Rust file is a module already — and Sub Main() becomes fn main(). The ! means println! is a macro, not a function: it is expanded at compile time, which is how it can check its format string against its arguments and accept a variable number of them. Braces replace End, statements end with semicolons, and nothing is imported because println! is in the prelude.Formatted output
Interpolation looks almost exactly like
$"...", and there are two extra verbs worth knowing on day one.Option Strict On
Imports System
Module FormatDemo
Sub Main()
Dim name As String = "Ada"
Dim score As Integer = 42
Dim ratio As Double = 0.8756
Console.WriteLine($"Hello, {name}! Score: {score}")
Console.WriteLine($"Padded: {score:D5}, rounded: {ratio:F2}")
End Sub
End Modulefn main() {
let name = "Ada";
let score = 42;
let ratio = 0.8756;
println!("Hello, {name}! Score: {score}");
println!("Padded: {score:05}, rounded: {ratio:.2}");
println!("{:?}", vec![1, 2, 3]);
println!("{:#?}", (1, "two"));
}A name in braces is interpolated directly, so
$"{name}" becomes "{name}" with no prefix. Format specifiers differ in vocabulary but not in idea: D5 becomes :05, F2 becomes :.2. The two additions are {:?}, the debug format, which prints any type that derives Debug — collections, structs, tuples — and {:#?}, the same thing pretty-printed across lines. Between them they remove most of the reasons to write a ToString override.The compiler talks back
Worth a row of its own, because the error messages are a large part of what learning Rust actually feels like.
Option Strict On
Imports System
Module WarningDemo
Sub Main()
Dim total As Integer = 10
Dim unusedValue As Integer = 5
' Unused local: a warning at most
Console.WriteLine(total)
End Sub
End Modulefn main() {
let total = 10;
// An unused variable is a WARNING with a suggested fix:
// let unused_value = 5;
// help: if this is intentional, prefix it with an underscore
let _unused_value = 5;
println!("{total}");
}Rust warns about an unused variable and tells you what to write instead — prefix it with
_. That is representative: borrow-checker errors name the two conflicting uses and their lines, type errors suggest the conversion, and many messages come with a rustc --explain code and a worked explanation. Expect to spend your first weeks fighting the compiler and your later ones grateful for it; the messages are the teaching material, not an obstacle to it.Syntax Fundamentals
Dim becomes let — immutable by default
The default flips: a binding cannot be reassigned unless you ask for it.
Option Strict On
Imports System
Module BindingDemo
Sub Main()
Dim count As Integer = 10
count = 20
Dim label As String = "widget"
Const MaximumRetries As Integer = 3
Console.WriteLine($"{count} {label} {MaximumRetries}")
End Sub
End Moduleconst MAXIMUM_RETRIES: u32 = 3;
fn main() {
let count = 10;
// count = 20; // error: cannot assign twice
let mut total = 10;
total = 20;
let label = "widget";
println!("{count} {total} {label} {MAXIMUM_RETRIES}");
}Dim becomes let, and a let binding is immutable — reassigning it is a compile error. let mut is the variable you meant. This is not stylistic: immutability is what lets the compiler reason about who may touch what, which is the whole ownership system. Const becomes const and requires an explicit type. Type annotations, where needed, read right to left as As Integer does: let count: u32 = 10.Almost everything is an expression
An
if produces a value, so the variable is assigned once and never left empty.Option Strict On
Imports System
Module ExpressionDemo
Sub Main()
Dim score As Integer = 72
Dim grade As String
If score >= 90 Then
grade = "A"
ElseIf score >= 70 Then
grade = "B"
Else
grade = "F"
End If
Console.WriteLine(grade)
End Sub
End Modulefn main() {
let score = 72;
let grade = if score >= 90 {
"A"
} else if score >= 70 {
"B"
} else {
"F"
};
let doubled = { let half = score / 2; half * 4 };
println!("{grade} {doubled}");
}An
if, a match, a loop and even a bare block all evaluate to something — the last expression inside, written without a semicolon. Adding a semicolon turns an expression into a statement and its value becomes (), the unit type, which is the single most common cause of a confusing early error message. A function body works the same way, which is why return is usually unnecessary.Case sensitivity and naming
Identifiers are case-sensitive, and the compiler has opinions about the case you chose.
Option Strict On
Imports System
Module NamingDemo
Sub Main()
Dim customerName As String = "Grace"
Console.WriteLine(customerName)
Console.WriteLine(CustomerName)
End Sub
End Modulefn main() {
let customer_name = "Grace";
let customer_count: u32 = 3;
// let customerName = "x"; // warning: should have a snake_case name
println!("{customer_name} {customer_count}");
}Names are case-sensitive as everywhere else on this anchor. What is unusual is that Rust warns about the convention:
snake_case for variables, functions and modules, CamelCase for types and traits, SCREAMING_SNAKE_CASE for constants. Writing customerName compiles but produces a non_snake_case warning, so the whole ecosystem looks alike — as with gofmt, and for the same reason.Ownership & Borrowing
Every value has exactly one owner
This is the idea with no counterpart anywhere in .NET, and everything else on the page follows from it.
Option Strict On
Imports System
Imports System.Collections.Generic
Module OwnershipDemo
Sub Main()
Dim original As New List(Of Integer) From {1, 2, 3}
Dim second As List(Of Integer) = original
' Both names refer to the SAME list, forever
second.Add(4)
Console.WriteLine(original.Count)
Console.WriteLine(second.Count)
End Sub
End Modulefn main() {
let original = vec![1, 2, 3];
let second = original; // MOVED — original is no longer usable
// println!("{:?}", original); // error: value borrowed after move
println!("{:?}", second);
let copied = 5;
let also = copied; // integers are Copy — both still usable
println!("{copied} {also}");
}Assigning a value moves it:
original gives up ownership and using it afterwards is a compile error. There is no garbage collector, so the compiler tracks who owns each value and frees it when that owner goes out of scope — deterministically, with no runtime cost. Small copyable types (integers, bool, char) implement Copy and are duplicated instead. Where .NET gives two names to one list and lets either mutate it, Rust makes you say which one owns it.Borrowing instead of moving
A function signature says whether it wants the value or only a look at it — and the caller can see which.
Option Strict On
Imports System
Imports System.Collections.Generic
Module BorrowDemo
Function Total(values As List(Of Integer)) As Integer
Dim sum As Integer = 0
For Each value As Integer In values
sum += value
Next
Return sum
End Function
Sub Main()
Dim numbers As New List(Of Integer) From {1, 2, 3}
Console.WriteLine(Total(numbers))
Console.WriteLine(Total(numbers))
End Sub
End Modulefn total(values: &[i32]) -> i32 {
values.iter().sum()
}
fn consume(values: Vec<i32>) -> usize {
values.len() // takes ownership; the caller loses it
}
fn main() {
let numbers = vec![1, 2, 3];
println!("{}", total(&numbers)); // borrowed
println!("{}", total(&numbers)); // still usable
println!("{}", consume(numbers)); // moved
// println!("{:?}", numbers); // error: moved
}A borrow, written
&value, lends access without transferring ownership, so the caller keeps the value. A parameter typed Vec<i32> takes ownership; one typed &[i32] borrows. This is information a .NET signature simply does not carry: Total(values As List(Of Integer)) tells you nothing about whether the method will keep a reference, store it somewhere, or mutate it. In Rust that is all in the type, and the compiler enforces it.One writer, or many readers
The rule the borrow checker enforces, and the reason data races cannot compile.
Option Strict On
Imports System
Imports System.Collections.Generic
Module MutableDemo
Sub AddItem(items As List(Of Integer))
items.Add(99)
End Sub
Sub Main()
Dim numbers As New List(Of Integer) From {1}
' Nothing stops two places holding this and both writing
Dim alias_ As List(Of Integer) = numbers
AddItem(numbers)
alias_.Add(100)
Console.WriteLine(numbers.Count)
End Sub
End Modulefn add_item(items: &mut Vec<i32>) {
items.push(99);
}
fn main() {
let mut numbers = vec![1];
add_item(&mut numbers);
numbers.push(100);
// Two mutable borrows at once will not compile:
// let first = &mut numbers;
// let second = &mut numbers; // error
let reader_one = &numbers;
let reader_two = &numbers; // many readers ARE allowed
println!("{} {} {:?}", reader_one.len(), reader_two.len(), numbers);
}At any moment a value may have either one mutable borrow or any number of immutable ones, never both. That single rule is what makes a data race impossible without
unsafe, and it is also what will reject perfectly reasonable-looking code while you are learning. In .NET nothing stops two references mutating one list from two threads; here the compiler will not build it. The cost is that some designs — a doubly linked list, a graph with back-references — need Rc, RefCell or an index-based layout instead.Copying on purpose, and cleaning up
Copying is never implicit, and cleanup happens at a moment you can point to.
Option Strict On
Imports System
Imports System.Collections.Generic
Module CloneDemo
Sub Main()
Dim original As New List(Of Integer) From {1, 2, 3}
Dim copy As New List(Of Integer)(original)
copy.Add(4)
Console.WriteLine($"{original.Count} {copy.Count}")
' The garbage collector frees both, eventually
End Sub
End Modulestruct Resource {
name: String,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("closing {}", self.name);
}
}
fn main() {
let original = vec![1, 2, 3];
let mut copy = original.clone(); // explicit, and visibly costly
copy.push(4);
println!("{} {}", original.len(), copy.len());
{
let _resource = Resource { name: String::from("file") };
println!("inside the block");
} // drop runs HERE, deterministically
println!("after the block");
}clone() is the deep copy, and it is always written out — so an expensive duplication is visible at every call site rather than hidden behind an assignment. Cleanup is the other half: when a value's owner goes out of scope its Drop implementation runs, at that exact point. That is what Using/IDisposable approximates in .NET, except that here it is automatic, cannot be forgotten, and applies to every type — there is no finalizer, no GC pause, and no Dispose to remember.Two string types
The one place ownership shows up before you expected it: a literal and a built-up string are different types.
Option Strict On
Imports System
Module StringDemo
Sub Main()
Dim literal As String = "Visual Basic"
Dim built As String = literal.ToUpper()
Console.WriteLine(literal.Length)
Console.WriteLine(built)
Console.WriteLine(literal.Substring(0, 6))
Console.WriteLine(literal.Contains("Basic"))
End Sub
End Modulefn main() {
let literal: &str = "Visual Basic"; // borrowed, fixed
let mut built: String = literal.to_uppercase(); // owned, growable
built.push('!');
println!("{}", literal.len());
println!("{built}");
println!("{}", &literal[0..6]);
println!("{}", literal.contains("Basic"));
println!("{}", literal.chars().count());
}&str is a borrowed view of text — what a literal is — and String is an owned, growable buffer. A function usually takes &str (accepting both) and returns String. Convert with to_string() or String::from. The other surprise: len() is the length in bytes, not characters, because Rust strings are UTF-8 — chars().count() is what .Length meant, and indexing by character position is deliberately not offered.Types, Structs & Enums
Numbers say their size
The names spell out size and signedness, and there is no implicit conversion of any kind.
Option Strict On
Imports System
Module ScalarDemo
Sub Main()
Dim small As Short = 1S
Dim whole As Integer = 2
Dim big As Long = 3L
Dim precise As Double = 6.7
Dim raw As Byte = 255
' Option Strict On still widens silently
Dim widened As Double = whole
Console.WriteLine($"{small} {whole} {big} {precise} {raw} {widened}")
End Sub
End Modulefn main() {
let small: i16 = 1;
let whole: i32 = 2;
let big: i64 = 3;
let precise: f64 = 6.7;
let raw: u8 = 255;
let widened = whole as f64; // required — nothing converts itself
println!("{small} {whole} {big} {precise} {raw} {widened}");
println!("{}", i32::MAX);
println!("{:?}", 250u8.checked_add(10));
}Short→i16, Integer→i32, Long→i64, Byte→u8, Double→f64, Single→f32; usize is the pointer-sized integer used for lengths and indices. Conversion needs as, even widening. Overflow is checked in debug builds and panics rather than wrapping, and checked_add returns an Option so you can handle it — a level of care .NET only offers behind a compiler switch. There is no Decimal; money uses integer minor units or the rust_decimal crate.Structs and derive
One attribute line generates what the anchor column spells out in thirty.
Option Strict On
Imports System
Public Class Person
Public ReadOnly Property Name As String
Public ReadOnly Property Age As Integer
Public Sub New(name As String, age As Integer)
Me.Name = name
Me.Age = age
End Sub
Public Overrides Function ToString() As String
Return $"Person({Name}, {Age})"
End Function
Public Overrides Function Equals(other As Object) As Boolean
Dim candidate = TryCast(other, Person)
Return candidate IsNot Nothing AndAlso
candidate.Name = Name AndAlso candidate.Age = Age
End Function
Public Overrides Function GetHashCode() As Integer
Return HashCode.Combine(Name, Age)
End Function
End Class
Module StructDemo
Sub Main()
Dim person As New Person("Ada", 36)
Console.WriteLine(person)
Console.WriteLine(person.Equals(New Person("Ada", 36)))
End Sub
End Module#[derive(Debug, Clone, PartialEq)]
struct Person {
name: String,
age: u32,
}
impl Person {
fn new(name: &str, age: u32) -> Self {
Person { name: name.to_string(), age }
}
fn describe(&self) -> String {
format!("{}, age {}", self.name, self.age)
}
}
fn main() {
let person = Person::new("Ada", 36);
println!("{:?}", person);
println!("{}", person.describe());
println!("{}", person == Person::new("Ada", 36));
}A
struct holds data and an impl block holds its methods, declared separately. #[derive(...)] asks the compiler to generate implementations: Debug for {:?} printing, PartialEq for ==, Clone for explicit copying, Hash, Default, PartialOrd. There is no constructor keyword — the convention is an associated function called new, invoked with ::. &self is Me, and taking it by reference means the method borrows rather than consumes.Enums that carry data
A Rust
enum is not a named integer — each variant may carry its own fields.Option Strict On
Imports System
Public MustInherit Class Shape
End Class
Public Class Circle
Inherits Shape
Public ReadOnly Radius As Double
Public Sub New(radius As Double)
Me.Radius = radius
End Sub
End Class
Public Class Rectangle
Inherits Shape
Public ReadOnly Width As Double
Public ReadOnly Height As Double
Public Sub New(width As Double, height As Double)
Me.Width = width
Me.Height = height
End Sub
End Class
Module EnumDemo
Function Area(shape As Shape) As Double
If TypeOf shape Is Circle Then Return Math.PI * DirectCast(shape, Circle).Radius ^ 2
Dim rect = DirectCast(shape, Rectangle)
Return rect.Width * rect.Height
End Function
Sub Main()
Console.WriteLine(Area(New Circle(2.0)).ToString("F2"))
Console.WriteLine(Area(New Rectangle(3.0, 4.0)).ToString("F2"))
End Sub
End Moduleenum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
fn main() {
println!("{:.2}", area(&Shape::Circle { radius: 2.0 }));
println!("{:.2}", area(&Shape::Rectangle { width: 3.0, height: 4.0 }));
}This is the same idea as F#'s discriminated union, and the whole class hierarchy in the anchor column collapses into four lines. The casts vanish because
match destructures the variant directly. The part that matters most: match must cover every variant, so adding a Triangle turns every incomplete match in the program into a compile error — where the anchor column would keep building and fail at runtime. A Visual Basic Enum is an Integer underneath and can hold a value you never declared; a Rust enum cannot.Nothing becomes Option
There is no null at all — absence is a type the compiler makes you unwrap.
Option Strict On
Imports System
Module OptionDemo
Function FindName(id As Integer) As String
If id = 1 Then Return "Ada"
Return Nothing
End Function
Sub Main()
Dim found As String = FindName(1)
If found IsNot Nothing Then Console.WriteLine(found)
Console.WriteLine(If(FindName(2), "(not found)"))
End Sub
End Modulefn find_name(id: u32) -> Option<&'static str> {
if id == 1 { Some("Ada") } else { None }
}
fn main() {
match find_name(1) {
Some(name) => println!("{name}"),
None => println!("(not found)"),
}
println!("{}", find_name(2).unwrap_or("(not found)"));
if let Some(name) = find_name(1) {
println!("{}", name.len());
}
println!("{:?}", find_name(1).map(|n| n.to_uppercase()));
}Rust has no null. A value that might be missing has type
Option<T>, which is Some(value) or None, and the compiler will not let you use the inner value without handling both. That eliminates the null-reference error as a category, not merely as a common bug. unwrap_or is the two-argument If(), map transforms the value if present, and if let is the shorthand for "do this only in the Some case".Vectors, Maps & Iterators
Vec replaces List(Of T)
The growable list, with two ways to reach an element that differ in what happens when you are wrong.
Option Strict On
Imports System
Imports System.Collections.Generic
Module VecDemo
Sub Main()
Dim fruits As New List(Of String) From {"apple", "banana"}
fruits.Add("cherry")
Console.WriteLine(fruits.Count)
Console.WriteLine(fruits(0))
Console.WriteLine(String.Join(", ", fruits))
End Sub
End Modulefn main() {
let mut fruits = vec!["apple", "banana"];
fruits.push("cherry");
println!("{}", fruits.len());
println!("{}", fruits[0]);
println!("{:?}", fruits.first());
println!("{}", fruits.join(", "));
println!("{:?}", fruits.get(99)); // None, not a panic
}List(Of String) becomes Vec<String>, built with the vec! macro. Add becomes push, Count becomes len(), and it must be declared mut to grow. Indexing with [0] panics if out of range; get(index) returns an Option instead, and so does first(). Preferring the Option-returning form is how Rust code avoids the whole family of index-out-of-range crashes.HashMap
The dictionary, with
TryGetValue replaced by a return type rather than an output parameter.Option Strict On
Imports System
Imports System.Collections.Generic
Module MapDemo
Sub Main()
Dim ages As New Dictionary(Of String, Integer) From {
{"Ada", 36},
{"Grace", 45}
}
ages("Alan") = 41
Console.WriteLine(ages.Count)
Console.WriteLine(ages("Ada"))
Dim found As Integer
ages.TryGetValue("Nobody", found)
Console.WriteLine(found)
End Sub
End Moduleuse std::collections::HashMap;
fn main() {
let mut ages = HashMap::from([("Ada", 36), ("Grace", 45)]);
ages.insert("Alan", 41);
println!("{}", ages.len());
println!("{:?}", ages.get("Ada"));
println!("{}", ages.get("Nobody").copied().unwrap_or(0));
*ages.entry("Ada").or_insert(0) += 1;
println!("{:?}", ages.get("Ada"));
}Dictionary(Of K, V) becomes HashMap<K, V>, needing use std::collections::HashMap. TryGetValue becomes get, which returns Option<&V> — so a missing key is in the type, not in a boolean and an output parameter. entry(key).or_insert(default) is the "get it or create it" idiom that replaces a check-then-insert pair, and it is what makes a tally loop one line. Iteration order is unspecified, as it is in Go.LINQ becomes iterators
Every LINQ operator has a counterpart, evaluated lazily and compiled down to a plain loop.
Option Strict On
Imports System
Imports System.Linq
Module IteratorDemo
Sub Main()
Dim numbers() As Integer = {5, 3, 9, 1, 7, 2}
Dim result = numbers.
Where(Function(number) number > 2).
Select(Function(number) number * 10).
ToList()
Console.WriteLine(String.Join(", ", result))
Console.WriteLine(numbers.Sum())
Console.WriteLine(numbers.Any(Function(number) number > 8))
Console.WriteLine(numbers.OrderBy(Function(number) number).First())
End Sub
End Modulefn main() {
let numbers = vec![5, 3, 9, 1, 7, 2];
let result: Vec<i32> = numbers
.iter()
.filter(|&&number| number > 2)
.map(|number| number * 10)
.collect();
println!("{result:?}");
println!("{}", numbers.iter().sum::<i32>());
println!("{}", numbers.iter().any(|&number| number > 8));
println!("{:?}", numbers.iter().min());
let mut sorted = numbers.clone();
sorted.sort();
println!("{sorted:?}");
}Where→filter, Select→map, Any→any, All→all, FirstOrDefault→find, Aggregate→fold, ToList→collect. Chains are lazy like LINQ, but with a difference that matters: they are zero-cost, compiling to the same machine code as a hand-written loop with no allocation per stage. Note .iter() borrows, .into_iter() consumes, and the double && in the filter closure is the borrow of a borrow the compiler will tell you about.Control Flow
Select Case becomes match
The direct counterpart, and it translates almost token for token.
Option Strict On
Imports System
Module MatchDemo
Function Describe(code As Integer) As String
Select Case code
Case 1
Return "one"
Case 2, 3
Return "two or three"
Case 4 To 6
Return "four to six"
Case Is > 100
Return "large"
Case Else
Return "something else"
End Select
End Function
Sub Main()
Console.WriteLine(Describe(1))
Console.WriteLine(Describe(3))
Console.WriteLine(Describe(5))
Console.WriteLine(Describe(200))
End Sub
End Modulefn describe(code: i32) -> &'static str {
match code {
1 => "one",
2 | 3 => "two or three",
4..=6 => "four to six",
n if n > 100 => "large",
_ => "something else",
}
}
fn main() {
println!("{}", describe(1));
println!("{}", describe(3));
println!("{}", describe(5));
println!("{}", describe(200));
}Case 2, 3 becomes 2 | 3, Case 4 To 6 becomes the inclusive range 4..=6, Case Is > 100 becomes a guard n if n > 100, and Case Else becomes _. It is an expression, so the Returns disappear, and it must be exhaustive — leave a case out and it will not compile. That exhaustiveness is what makes the enums in the previous section safe to extend.Loops
Three loop keywords, and the infinite one can hand back a value.
Option Strict On
Imports System
Imports System.Collections.Generic
Module LoopDemo
Sub Main()
For index As Integer = 1 To 5
Console.Write(index & " ")
Next
Console.WriteLine()
Dim words As New List(Of String) From {"alpha", "beta"}
For Each word As String In words
Console.WriteLine(word.ToUpper())
Next
Dim attempt As Integer = 0
Do
attempt += 1
Loop Until attempt >= 2
Console.WriteLine(attempt)
End Sub
End Modulefn main() {
for index in 1..=5 {
print!("{index} ");
}
println!();
let words = vec!["alpha", "beta"];
for word in &words {
println!("{}", word.to_uppercase());
}
let mut attempt = 0;
let stopped_at = loop {
attempt += 1;
if attempt >= 2 {
break attempt; // loop is an expression
}
};
println!("{stopped_at}");
}For index = 1 To 5 becomes for index in 1..=5 — the ..= is inclusive, .. is exclusive, so the off-by-one is a choice rather than an accident. For Each becomes the same for ... in, and &words borrows so the vector survives the loop. while exists, and loop is the infinite form — with the twist that break value makes the whole loop an expression. Labels ('outer:) let break leave an enclosing loop.if let and let else
Two shorthands that replace the
TryGetValue-plus-If pattern you write constantly.Option Strict On
Imports System
Imports System.Collections.Generic
Module IfLetDemo
Sub Main()
Dim ages As New Dictionary(Of String, Integer) From {{"Ada", 36}}
Dim value As Integer
If ages.TryGetValue("Ada", value) Then
Console.WriteLine(value * 2)
End If
If Not ages.TryGetValue("Nobody", value) Then
Console.WriteLine("missing")
End If
End Sub
End Moduleuse std::collections::HashMap;
fn double_age(ages: &HashMap<&str, i32>, name: &str) -> i32 {
let Some(age) = ages.get(name) else {
println!("missing");
return 0;
};
age * 2
}
fn main() {
let ages = HashMap::from([("Ada", 36)]);
if let Some(age) = ages.get("Ada") {
println!("{}", age * 2);
}
println!("{}", double_age(&ages, "Nobody"));
}if let Some(x) = ... matches one pattern and binds it, running the block only on success — the common half of a match. let ... else is the inverse and is the closest thing Rust has to a guard clause: it binds on success and must diverge (return, break or panic) on failure, so the bound name is available for the rest of the function with no nesting. Both replace what TryGetValue plus an If does, with the value only in scope where it is valid.Functions & Closures
Sub and Function both become fn
One keyword for both, types after names, and the return written by leaving off a semicolon.
Option Strict On
Imports System
Module FunctionDemo
Sub Announce(message As String)
Console.WriteLine($"** {message} **")
End Sub
Function Add(left As Integer, right As Integer) As Integer
Return left + right
End Function
Sub Main()
Announce("starting")
Console.WriteLine(Add(2, 3))
End Sub
End Modulefn announce(message: &str) {
println!("** {message} **");
}
fn add(left: i32, right: i32) -> i32 {
left + right // no semicolon: this IS the return
}
fn main() {
announce("starting");
println!("{}", add(2, 3));
}Sub becomes an fn with no ->; Function ... As Integer becomes -> i32. A function returns its final expression, which must have no semicolon — adding one turns it into a statement and produces a "mismatched types: expected i32, found ()" error, the classic first Rust mistake. return exists for early exits. Every parameter type is mandatory; only local let bindings infer.Closures
Vertical bars instead of
Function(...), and a parameter type that says how the closure captures.Option Strict On
Imports System
Imports System.Linq
Module ClosureDemo
Sub Main()
Dim factor As Integer = 3
Dim scale As Func(Of Integer, Integer) = Function(value) value * factor
Dim numbers() As Integer = {1, 2, 3}
Console.WriteLine(String.Join(", ", numbers.Select(scale)))
Console.WriteLine(scale(7))
End Sub
End Modulefn apply(value: i32, operation: impl Fn(i32) -> i32) -> i32 {
operation(value)
}
fn main() {
let factor = 3;
let scale = |value: i32| value * factor;
let numbers = vec![1, 2, 3];
let scaled: Vec<i32> = numbers.iter().map(|n| scale(*n)).collect();
println!("{scaled:?}");
println!("{}", apply(7, scale));
}Function(value) value * factor becomes |value| value * factor. There is no Func/Action type: a closure's type is one of three traits describing how it uses what it captured — Fn (borrows), FnMut (borrows mutably), FnOnce (consumes). A function taking a closure declares impl Fn(i32) -> i32. Adding move before the bars forces the closure to take ownership of what it captures, which is what you need to send one to another thread.Option, Result & Panic
There are no exceptions
Failure is a value in the return type, and the compiler will not let you ignore it.
Option Strict On
Imports System
Module ResultDemo
Function Parse(text As String) As Integer
Return Integer.Parse(text)
End Function
Sub Main()
Try
Console.WriteLine(Parse("123"))
Console.WriteLine(Parse("oops"))
Catch error_ As FormatException
Console.WriteLine($"failed: {error_.Message}")
End Try
End Sub
End Modulefn parse(text: &str) -> Result<i32, String> {
text.parse::<i32>()
.map_err(|error| format!("failed: {error}"))
}
fn main() {
match parse("123") {
Ok(value) => println!("{value}"),
Err(message) => println!("{message}"),
}
match parse("oops") {
Ok(value) => println!("{value}"),
Err(message) => println!("{message}"),
}
}Rust has no exceptions. A fallible function returns
Result<T, E> — Ok(value) or Err(problem) — and the compiler warns loudly if the result is discarded. So every failure path is visible in the signature, exactly as in Go, with one improvement: because Result is an ordinary enum, forgetting to handle it is a type error rather than a forgotten if. TryParse's boolean-plus-ByRef becomes this, with room for a reason.The ? operator
One character does what exception propagation does, without the invisible control flow.
Option Strict On
Imports System
Module PropagateDemo
Function Total(first As String, second As String) As Integer
' An exception propagates by itself
Return Integer.Parse(first) + Integer.Parse(second)
End Function
Sub Main()
Try
Console.WriteLine(Total("2", "3"))
Console.WriteLine(Total("2", "oops"))
Catch error_ As FormatException
Console.WriteLine("failed")
End Try
End Sub
End Moduleuse std::num::ParseIntError;
fn total(first: &str, second: &str) -> Result<i32, ParseIntError> {
let left: i32 = first.parse()?; // returns early on Err
let right: i32 = second.parse()?;
Ok(left + right)
}
fn main() {
println!("{:?}", total("2", "3"));
println!("{:?}", total("2", "oops").is_err());
}The
? operator unwraps an Ok and returns the Err from the enclosing function if it is one. So the three-line if err != nil dance that Go needs becomes a single character, and error propagation reads almost like exceptions — with the crucial difference that ? is visible on the page, so you can see exactly which calls can bail out. It works on Option too, and converts between error types automatically when a From conversion exists.panic, unwrap and expect
There is an abort-the-program path, and the two functions that take it are the ones beginners overuse.
Option Strict On
Imports System
Module PanicDemo
Sub Main()
Dim numbers() As Integer = {1, 2, 3}
Try
Console.WriteLine(numbers(10))
Catch error_ As IndexOutOfRangeException
Console.WriteLine("caught, and the program continues")
End Try
Console.WriteLine("still running")
End Sub
End Modulefn main() {
let numbers = vec![1, 2, 3];
// numbers[10] // panics: index out of bounds
// "oops".parse::<i32>().unwrap() // panics with a terse message
println!("{:?}", numbers.get(10));
println!("{}", "oops".parse::<i32>().unwrap_or(-1));
let value = numbers.first().expect("numbers must not be empty");
println!("{value}");
}A
panic! unwinds and normally ends the program — it is not catchable in the ordinary way, so it is for bugs, not for a missing file. unwrap() panics if the value is None or Err, and expect("...") does the same with a message you wrote. Both are fine in tests and prototypes and are a smell in production code: reach for unwrap_or, match, or ? instead. Where the anchor column catches an index error and carries on, the Rust equivalent is to use get() and never panic at all.Traits
Interfaces become traits
The same idea as an interface, implemented in a separate block — including for types you did not write.
Option Strict On
Imports System
Imports System.Collections.Generic
Public Interface IGreeter
Function Greet(name As String) As String
End Interface
Public Class Formal
Implements IGreeter
Public Function Greet(name As String) As String Implements IGreeter.Greet
Return $"Good day, {name}."
End Function
End Class
Module TraitDemo
Sub Main()
Dim greeters As New List(Of IGreeter) From {New Formal()}
For Each greeter As IGreeter In greeters
Console.WriteLine(greeter.Greet("Ada"))
Next
End Sub
End Moduletrait Greeter {
fn greet(&self, name: &str) -> String;
// A default method, like an interface default
fn greet_twice(&self, name: &str) -> String {
format!("{} {}", self.greet(name), self.greet(name))
}
}
struct Formal;
impl Greeter for Formal {
fn greet(&self, name: &str) -> String {
format!("Good day, {name}.")
}
}
fn main() {
let greeters: Vec<Box<dyn Greeter>> = vec![Box::new(Formal)];
for greeter in &greeters {
println!("{}", greeter.greet("Ada"));
println!("{}", greeter.greet_twice("Ada"));
}
}Interface becomes trait and Implements becomes a separate impl Trait for Type block, which means you can implement your trait for someone else's type — a capability .NET has no equivalent for. Traits may carry default methods, like a Java default or a Ruby mixin. Box<dyn Greeter> is the "any type implementing this" form used when the concrete type varies at runtime; impl Greeter in a signature is the compile-time version and costs nothing.Generics and trait bounds
The constraint syntax changes shape, and what it buys you is different.
Option Strict On
Imports System
Imports System.Collections.Generic
Module GenericDemo
Function Largest(Of T As IComparable(Of T))(items As List(Of T)) As T
Dim best As T = items(0)
For Each item As T In items
If item.CompareTo(best) > 0 Then best = item
Next
Return best
End Function
Sub Main()
Console.WriteLine(Largest(New List(Of Integer) From {3, 9, 2}))
Console.WriteLine(Largest(New List(Of String) From {"pear", "fig"}))
End Sub
End Modulefn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut best = items[0];
for &item in items {
if item > best {
best = item;
}
}
best
}
fn main() {
println!("{}", largest(&[3, 9, 2]));
println!("{}", largest(&["pear", "fig"]));
}(Of T As IComparable(Of T)) becomes <T: PartialOrd + Copy>, listing the traits T must implement, joined with +. The bound lets the compiler check the body against the constraint rather than trusting a cast. The deep difference: Rust generics are monomorphized — a separate specialized copy is compiled for each concrete type — so there is no boxing, no runtime type lookup, and largest(&[3, 9, 2]) is exactly as fast as a hand-written integer version.Cargo & Deployment
NuGet becomes cargo
The tooling story maps piece for piece, and the last three lines are much of why people pick Rust.
Option Strict On
Imports System
Imports System.Collections.Generic
Module CargoDemo
Sub Main()
Dim story As New Dictionary(Of String, String) From {
{"manifest", ".vbproj"},
{"registry", "NuGet"},
{"restore", "dotnet restore"},
{"test", "a separate test package"},
{"output", "dll plus a runtime"}
}
For Each entry In story
Console.WriteLine($"{entry.Key}: {entry.Value}")
Next
End Sub
End Modulefn main() {
let story = [
("manifest", "Cargo.toml"),
("registry", "crates.io"),
("fetch", "cargo build"),
("test", "cargo test — built in, tests live beside the code"),
("format", "cargo fmt, and cargo clippy for lints"),
("output", "one static binary, no runtime"),
];
for (key, value) in story {
println!("{key}: {value}");
}
}Cargo.toml is the .vbproj, crates.io is nuget.org, and Cargo.lock pins versions. What comes in the box: cargo test (tests written in the same file as the code, in a #[cfg(test)] module), cargo fmt (non-negotiable formatting, as with gofmt), cargo clippy (a very good linter), and cargo doc. The build produces one binary with no runtime to install — the same deployment story as Go, and a much smaller one than shipping .NET.Imports becomes use
Modules nest like namespaces, and everything is private until marked otherwise.
Option Strict On
Imports System
Imports System.Collections.Generic
Namespace Geometry
Public Module Area
Public Function Rectangle(width As Double, height As Double) As Double
Return width * height
End Function
End Module
End Namespace
Module UseDemo
Sub Main()
Console.WriteLine(Geometry.Area.Rectangle(3, 4))
End Sub
End Modulemod geometry {
pub mod area {
pub fn rectangle(width: f64, height: f64) -> f64 {
width * height
}
}
}
use geometry::area::rectangle;
fn main() {
println!("{}", rectangle(3.0, 4.0));
println!("{}", geometry::area::rectangle(2.0, 5.0));
}Namespace and Module both become mod, nested with :: rather than dots. Imports becomes use, which brings a name into scope — and unlike Go, it really does shorten the call. The default is private: pub is required to expose anything outside its module, which is the opposite of Visual Basic's default-public members. A file is a module and a directory is a module tree, so the layout on disk is the namespace hierarchy.⚠ Gotchas for Visual Basic Programmers
⚠ Working code the compiler refuses
The same mistake in both columns — caught at runtime on the left, at compile time on the right.
Option Strict On
Imports System
Imports System.Collections.Generic
Module BorrowGotcha
Sub Main()
Dim numbers As New List(Of Integer) From {1, 2, 3}
' Modifying while enumerating: compiles, throws at RUNTIME
Try
For Each value As Integer In numbers
If value = 2 Then numbers.Add(99)
Next
Catch error_ As InvalidOperationException
Console.WriteLine("collection was modified")
End Try
End Sub
End Modulefn main() {
let mut numbers = vec![1, 2, 3];
// This does not COMPILE — the loop borrows, push needs a mut borrow:
// for value in &numbers {
// if *value == 2 { numbers.push(99); }
// }
let additions: Vec<i32> = numbers
.iter()
.filter(|&&value| value == 2)
.map(|_| 99)
.collect();
numbers.extend(additions);
println!("{numbers:?}");
}Modifying a collection while iterating it throws
InvalidOperationException in .NET, after the program has shipped. In Rust it is a borrow-checker error: the loop holds an immutable borrow, push needs a mutable one, and both cannot exist at once. Expect to meet this constantly at first, and expect the fix to be a restructure — collect what you need, then apply it — rather than a workaround. The compiler is usually right, and the code it forces you toward is usually better.⚠ / truncates, and overflow panics
Two arithmetic surprises in one row — and on the second one Visual Basic is the safer language, which is worth knowing before you assume otherwise.
Option Strict On
Imports System
Module ArithmeticGotcha
Sub Main()
Dim average As Double = (3 + 4) / 2
Console.WriteLine(average)
Dim maximum As Integer = Integer.MaxValue
Try
Console.WriteLine(maximum + 1)
Catch error_ As OverflowException
Console.WriteLine("Visual Basic throws on overflow")
End Try
End Sub
End Modulefn main() {
let average = (3 + 4) / 2;
println!("{average}");
let correct = (3 + 4) as f64 / 2.0;
println!("{correct}");
let maximum = i32::MAX;
println!("{:?}", maximum.checked_add(1));
println!("{}", maximum.wrapping_add(1));
// println!("{}", maximum + 1); // panics in a debug build
}There is no
\, so / between two integers truncates and the fix is an explicit as f64 — Rust will not widen for you. Overflow is the more interesting half, and Visual Basic comes out of it well: it checks by default and throws OverflowException, which C# does not (C# wraps silently unless you write checked). Rust panics in a debug build and wraps in a release build, which is the worst of both unless you are explicit — so the standard library offers checked_add (returns Option), saturating_add (clamps at the limit) and wrapping_add (wraps on purpose), and production code should name the one it means rather than relying on the build profile.⚠ You cannot index a string
A Rust string is UTF-8 bytes, and the language refuses to pretend otherwise.
Option Strict On
Imports System
Module StringGotcha
Sub Main()
Dim text As String = "café"
Console.WriteLine(text.Length)
Console.WriteLine(text(3))
Console.WriteLine(text.Substring(0, 3))
End Sub
End Modulefn main() {
let text = "café";
println!("{}", text.len()); // BYTES, not characters
println!("{}", text.chars().count()); // characters
println!("{:?}", text.chars().nth(3));
// println!("{}", text[3]); // does not compile at all
println!("{}", &text[0..3]); // byte range — panics if it splits a char
}text[3] does not compile — a byte index into UTF-8 is meaningless, so Rust will not offer it. len() counts bytes, so "café".len() is 5 while chars().count() is 4. Slicing by byte range works but panics if it lands inside a character. This is genuinely more work than Mid and .Length, and it is the reason Rust programs do not have the class of bug where a Turkish name or an emoji corrupts a substring.⚠ No classes, no inheritance, no null
A whole family of .NET design habits has no direct translation — and one of them takes a real bug class with it.
Option Strict On
Imports System
Public MustInherit Class Report
Public MustOverride Function Title() As String
Public Function Header() As String
Return "== " & Title() & " =="
End Function
End Class
Public Class Quarterly
Inherits Report
Public Overrides Function Title() As String
Return "Quarterly"
End Function
End Class
Module InheritanceGotcha
Sub Main()
Dim report As Report = New Quarterly()
Console.WriteLine(report.Header())
End Sub
End Moduletrait Report {
fn title(&self) -> String;
fn header(&self) -> String {
format!("== {} ==", self.title())
}
}
struct Quarterly;
impl Report for Quarterly {
fn title(&self) -> String {
String::from("Quarterly")
}
}
fn main() {
let report: Box<dyn Report> = Box::new(Quarterly);
println!("{}", report.header());
}There is no class inheritance: an abstract base class with shared behaviour becomes a
trait with default methods, and "is-a" hierarchies become composition plus traits. There is no null, so the null-reference exception does not exist as a category. There is no Overridable and no method overloading either. Reaching for a base class is the most common way a ported .NET design fights Rust; designing around traits from the start is much less painful than translating a hierarchy.⚠ No My namespace, no runtime, no designer
Everything has a counterpart, but the standard library is deliberately small and dates are not in it.
Option Strict On
Imports System
Module PlatformGotcha
Sub Main()
Console.WriteLine(Environment.MachineName.Length > 0)
Console.WriteLine(IsNumeric("42"))
Console.WriteLine(Now.Year > 2000)
End Sub
End Moduleuse std::time::{SystemTime, UNIX_EPOCH};
fn main() {
println!("{}", std::env::var("HOME").is_ok());
println!("{}", "42".parse::<i32>().is_ok());
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before 1970")
.as_secs();
println!("{}", seconds > 1_000_000_000);
}My.Computer.FileSystem becomes std::fs, IsNumeric becomes parse::<i32>().is_ok(), and environment access is std::env. But Rust's standard library is small on purpose: there is no date/time type beyond raw durations (the chrono or time crate supplies one), no HTTP client, no JSON. Those come from crates.io, which is a healthier ecosystem than that sounds. There is no MsgBox and no designer — a desktop application means egui or Tauri, and most Rust work is services, tooling and systems code.