PONYλM2Modula-2

Visual Basic.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Visual Basic and Rust

Visual Basic (.NET 10) Rust 1.98.0
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 Module
fn 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 Module
fn 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 Module
fn 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 Module
const 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 Module
fn 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 Module
fn 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 Module
fn 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 Module
fn 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 Module
fn 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 Module
struct 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 Module
fn 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 Module
fn 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)); }
Shorti16, Integeri32, Longi64, Byteu8, Doublef64, Singlef32; 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 Module
enum 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 Module
fn 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 Module
fn 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 Module
use 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 Module
fn 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:?}"); }
Wherefilter, Selectmap, Anyany, Allall, FirstOrDefaultfind, Aggregatefold, ToListcollect. 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 Module
fn 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 Module
fn 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 Module
use 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 Module
fn 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 Module
fn 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 capturedFn (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 Module
fn 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 Module
use 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 Module
fn 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 Module
trait 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 Module
fn 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 Module
fn 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 Module
mod 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 Module
fn 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 Module
fn 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 Module
fn 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 Module
trait 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 Module
use 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.