Rust für Go-Entwickler: ein praktischer Leitfaden
Wenn du Go schreibst, wirst du dich in Rust mehr zu Hause fühlen, als du vielleicht erwartest. Beide Sprachen kompilieren zu einer einzigen schnellen Binärdatei. Beide haben Structs mit Methoden statt Klassen, keine Vererbung, Fehler als Rückgabewerte statt Exceptions, einen Standard-Formatter, den alle nutzen, und ein Kommandozeilen-Tool, das baut, testet und Pakete holt. Ein struct aus Go mit ein paar Methoden lässt sich fast Zeile für Zeile nach Rust übertragen.
Neu ist vor allem der Umgang mit Speicher und wie explizit du sein musst. Rust hat keinen Garbage Collector: Jeder Wert hat genau einen Besitzer, und der Compiler prüft Ownership (Eigentum) und Borrowing (Ausleihen), bevor dein Programm läuft. Es gibt kein nil (du nutzt Option), und aus if err != nil werden Result und der Operator ?. Interfaces gibt es weiterhin (sie heißen Traits), aber ein Typ muss ausdrücklich sagen, dass er eins implementiert. Rechne damit, die meiste Lernzeit in Ownership zu stecken. Der Rest fühlt sich an wie ein strengeres, detaillierteres Go.
Warum lernen Go-Entwickler Rust? Meist aus einem dieser Gründe: ein Dienst, bei dem Pausen der Garbage Collection oder der Speicherverbrauch ins Gewicht fallen, rechenintensive Arbeit, Code, der ohne Betriebssystem (Embedded) oder im Browser (WebAssembly) läuft, oder einfach der Wunsch, dass der Compiler Data Races und Abstürze im Stil von nil abfängt, bevor sie in Produktion landen. Einen allgemeineren Vergleich von Rust mit Go, C++ und Python findest du unter Rust vs. C++, Go und Python. Diese Seite geht tiefer und ist für dich geschrieben, als Go-Entwickler.
Sprachkonzepte: von Go zu Rust
Abschnitt betitelt „Sprachkonzepte: von Go zu Rust“Nutze diese Tabelle wie einen Sprachführer. Die letzte Spalte verlinkt die Lektion auf dieser Seite, die die Rust-Seite erklärt.
| Go | Rust | Mehr dazu |
|---|---|---|
| Garbage Collector | Ownership und Borrowing, geprüft beim Kompilieren; Speicher wird freigegeben, wenn der Besitzer seinen Gültigkeitsbereich verlässt | Ownership, Borrowing |
struct + Methoden |
struct für die Daten + ein impl-Block für die Methoden |
Structs |
Value Receiver func (a Account) |
&self (liest, ohne Kopie) oder self (übernimmt das Ownership) |
Structs |
Pointer Receiver func (a *Account) |
&mut self (darf den Wert ändern) |
Structs |
Konstruktorfunktion NewAccount(...) |
Eine assoziierte Funktion, per Konvention Account::new(...) |
Structs |
| Großbuchstabe = exportiert | Das Schlüsselwort pub |
Module |
Nullwerte (var c Config) |
Keine impliziten Nullwerte; jedes Feld muss gesetzt werden. #[derive(Default)] + Config::default() |
Structs, Wichtige Traits |
| Struct Embedding | Kein Embedding. Leg das Struct in ein Feld und schreib kleine Weiterleitungsmethoden, oder teile Verhalten über Traits | Traits, Entwurfsmuster |
interface (implizit erfüllt) |
trait (explizit implementiert mit impl Trait for Type) |
Traits |
[]Shape mit verschiedenen Typen |
Vec<Box<dyn Shape>> (ein Trait-Objekt) |
Trait-Objekte |
any / interface{} + Type Switch |
Ein enum mit einer Variante pro Fall, dann match |
Enums, Match |
Generics func Max[T cmp.Ordered](a, b T) T |
Generics mit Trait Bounds: fn max<T: PartialOrd>(a: T, b: T) -> T |
Generics, Fortgeschrittene Generics |
nil-Pointer, nil-Map, nil-Interface |
Option<T>: entweder Some(value) oder None |
Option |
v, ok := m[k] |
m.get(&k) gibt eine Option zurück |
HashMap |
(T, error) + if err != nil { return err } |
Result<T, E> + der Operator ? |
Result |
errors.New, Sentinel Errors, errors.Is / errors.As |
Ein Fehler-enum, auf das du match anwendest (das Crate thiserror hilft) |
Eigene Fehlertypen, anyhow und thiserror |
fmt.Errorf("...: %w", err) |
anyhow mit .context("...") oder map_err |
anyhow und thiserror |
panic / recover |
panic! für echte Bugs; Abfangen ist selten (std::panic::catch_unwind) |
Panic |
defer |
Drop, das läuft, wenn ein Wert seinen Gültigkeitsbereich verlässt (RAII) |
Gültigkeitsbereich, Smart Pointer |
go f() |
std::thread::spawn(...) oder tokio::spawn(...) für async-Tasks |
Nebenläufigkeit, Async/Await |
chan T, make(chan T, n) |
std::sync::mpsc::channel() (unbegrenzt) oder sync_channel(n); in async-Code tokio::sync::mpsc |
Channels |
select |
tokio::select! in async-Code oder das select! aus crossbeam-channel |
Async im Detail |
sync.Mutex neben den Daten |
Mutex<T> umschließt die Daten; er entsperrt sich selbst, wenn der Guard gedroppt wird |
Geteilter Zustand |
sync.WaitGroup |
JoinHandle::join() oder std::thread::scope |
Nebenläufigkeit |
sync.Once |
std::sync::OnceLock / LazyLock |
Kniffe der Standardbibliothek |
context.Context |
Keine einzelne Entsprechung: ein Future droppen, um es abzubrechen, tokio::time::timeout oder CancellationToken aus tokio-util |
Async im Detail |
func-Werte und Closures |
Closures, beschrieben durch die Traits Fn, FnMut, FnOnce |
Closures, Fn, FnMut und FnOnce |
for i, v := range s |
for (i, v) in s.iter().enumerate() |
For-Schleifen, Iteratoren |
Die eine for-Schleife |
loop, while und for |
Schleifen |
Range-over-func-Iteratoren (iter.Seq) |
Der Trait Iterator, mit Adaptern wie filter, map, sum, collect |
Iteratoren, Eigene Iteratoren |
switch |
match (der Compiler prüft, ob du jeden Fall abgedeckt hast) |
Match, Pattern Matching |
const + iota |
Ein enum (auf Wunsch mit expliziten Zahlen) |
Enums |
:= |
let (standardmäßig unveränderbar; mit let mut erlaubst du Änderungen) |
Variablen |
string (Bytes, meist UTF-8) |
String (besessen) und &str (geliehen), immer gültiges UTF-8 |
Strings |
rune, for _, r := range s |
char, for c in s.chars() |
Strings |
[]byte |
Vec<u8> oder &[u8] |
Slices |
fmt.Println, %v, %+v |
println!, {} (Display), {:?} und {:#?} (Debug, mit #[derive(Debug)]) |
Ausgabe |
String() string (das Interface Stringer) |
Display implementieren (dann funktioniert .to_string() gratis) |
Wichtige Traits |
Struct Tags `json:"user_name"` |
Attribute: #[serde(rename = "user_name")] |
JSON mit Serde |
Umwandlungen wie int(x), float64(n) |
as für Zahlen, From/Into und TryFrom, wenn es fehlschlagen kann |
Typumwandlung, Konvertierungen |
| Integer-Überlauf läuft still über | Panic in Debug-Builds, Überlauf in Release-Builds; checked_add, wrapping_add, saturating_add sagen, was du willst |
Integer-Überlauf |
*T und &x |
Referenzen &T und &mut T (nie null, können den Wert nicht überleben); Box<T> für einen besessenen Wert auf dem Heap |
Borrowing, Smart Pointer |
| Packages | Module (mod) innerhalb eines Crates; use statt import |
Module |
go generate |
Makros und Build-Skripte build.rs |
Makros, Prozedurale Makros |
Package unsafe |
unsafe-Blöcke und Raw Pointer |
Unsafe Rust |
| cgo | extern "C" und FFI (bindgen erzeugt Bindings aus C-Headern) |
FFI |
Build Tags //go:build linux |
#[cfg(target_os = "linux")] und Cargo-Features |
Cargo-Features und cfg |
Collections
Abschnitt betitelt „Collections“Go hat Slices und Maps direkt in die Sprache eingebaut. Rust hat sie in der Standardbibliothek, dazu noch ein paar mehr.
| Go | Rust | Mehr dazu |
|---|---|---|
[]T, das dir gehört und an das du anhängst |
Vec<T> |
Vektoren |
[]T, das an eine Funktion geht, die es nur liest |
&[T] (ein Slice, geliehen von einem Vec oder Array) |
Slices |
[N]T |
[T; N] |
Arrays |
append(s, x) / append(s, other...) |
v.push(x) / v.extend(other) |
Vektoren |
s[a:b] |
&v[a..b] |
Slices |
len(s), cap(s), make([]T, 0, n) |
v.len(), v.capacity(), Vec::with_capacity(n) |
Vektoren |
map[K]V |
HashMap<K, V> |
HashMap |
map[K]struct{} oder map[K]bool als Set |
HashSet<T> |
HashSet |
| Map-Schlüssel sammeln und vor der Ausgabe sortieren | BTreeMap<K, V> (Schlüssel bleiben sortiert) |
HashMap |
container/list |
VecDeque<T> (push_back / pop_front) |
VecDeque und BinaryHeap |
container/heap (5 Methoden implementieren) |
BinaryHeap<T>: einfach push und pop; größtes Element zuerst, mit Reverse umschlossen das kleinste zuerst |
VecDeque und BinaryHeap |
slices.Sort, sort.Slice |
v.sort(), v.sort_by(...), v.sort_by_key(...) |
Sortieren und Suchen |
slices.Contains, slices.Index |
v.contains(&x), v.iter().position(...) |
Vektoren |
strings.Builder |
Ein String mit push_str oder write! |
Strings |
sync.Map |
Mutex<HashMap<K, V>>, RwLock oder das Crate dashmap |
Geteilter Zustand |
Zwei Dinge solltest du wissen. Erstens: Wie Maps in Go hat auch eine HashMap in Rust keine feste Reihenfolge. Sortiere also vor der Ausgabe oder nimm eine BTreeMap. Zweitens: Die berühmte Slice-Überraschung aus Go (zwei Slices teilen sich ein Backing Array, und ein append verändert das andere) kann in Rust nicht passieren. Solange ein Teil deines Codes einen &mut-Borrow auf einen Vec hält, kann kein anderer Teil ihn ansehen.
Werkzeuge: von go zu cargo
Abschnitt betitelt „Werkzeuge: von go zu cargo“Gute Nachricht: Du bist ein einziges Tool gewohnt, das alles erledigt, und Rust hat auch eins. Cargo deckt go build, go run, go test, go mod und go install ab.
| Go | Rust | Hinweise |
|---|---|---|
Befehl go |
cargo |
Baut, startet, testet, fügt Pakete hinzu, veröffentlicht. Siehe Cargo und Crates |
go mod init |
cargo new my_app (cargo new --lib für eine Bibliothek) |
Legt den Ordner, die Cargo.toml, src/main.rs und ein Git-Repository an |
go.mod |
Cargo.toml |
|
go.sum |
Cargo.lock |
Wird für dich erzeugt und aktualisiert |
go get github.com/gin-gonic/gin |
cargo add axum |
|
go get -u |
cargo update |
Aktualisiert innerhalb der Versionsbereiche in der Cargo.toml |
| pkg.go.dev | crates.io für Pakete, docs.rs für ihre API-Doku | |
go build / go run / go test |
cargo build / cargo run / cargo test |
Mit --release bekommst du einen optimierten Build; Debug-Builds laufen deutlich langsamer |
go vet, staticcheck, golangci-lint |
cargo clippy |
Offiziell, mit Hunderten hilfreicher Lints |
gofmt / goimports |
cargo fmt (rustfmt) |
Ein einheitlicher Stil, genau wie bei gofmt |
go doc |
cargo doc --open |
Code-Beispiele in Doc-Kommentaren laufen als Tests. Siehe Dokumentation |
go test -bench |
criterion (ausgeführt mit cargo bench) |
Siehe Performance |
go test -cover |
cargo llvm-cov oder cargo tarpaulin |
|
go test -race |
Für sicheren Code nicht nötig: Data Races sind Compilerfehler. Miri prüft unsafe-Code |
|
go test -fuzz |
cargo fuzz |
Installation mit cargo install cargo-fuzz |
| testify | Eingebautes assert! und assert_eq!; pretty_assertions für schönere Diffs |
Siehe Testen und Testen für Fortgeschrittene |
| gomock / mockery | mockall oder handgeschriebene Fakes, die deinen Trait implementieren |
|
go install pkg@latest |
cargo install name |
|
go work (Workspaces) |
Ein Cargo-Workspace | Eine Cargo.toml ganz oben listet die zugehörigen Crates auf |
Zeile toolchain in der go.mod |
rustup und eine Datei rust-toolchain.toml |
Siehe Rust-Werkzeuge und Einrichtung |
GOOS=linux GOARCH=arm64 go build |
rustup target add aarch64-unknown-linux-gnu + cargo build --target ... |
Braucht oft einen Linker für das Ziel; cross oder cargo-zigbuild machen es leichter |
| govulncheck | cargo audit (RustSec-Advisories), cargo deny |
|
| gopls | rust-analyzer | Funktioniert in VS Code, Neovim, Helix, Zed und mehr |
| GoLand | JetBrains RustRover | |
| Delve | rust-gdb / rust-lldb oder CodeLLDB in VS Code |
|
| pprof | cargo flamegraph, perf oder ein beliebiger nativer Profiler |
|
| air (Live Reload) | bacon oder cargo watch |
Eine kleine Cargo.toml neben der go.mod, die du kennst:
module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }Einen Unterschied bemerkst du sofort: Die Standardbibliothek von Go enthält einen HTTP-Server, JSON, Templates und Kryptografie. Die Standardbibliothek von Rust ist mit Absicht kleiner. Dinge wie JSON, HTTP, async und Zufallszahlen kommen deshalb aus Crates. Eins hinzuzufügen ist ein einziges cargo add, und eine Handvoll Crates (serde, tokio, clap, regex, rand) nutzt fast jeder.
Bibliotheken und Frameworks: „Was nehmen wir statt …?“
Abschnitt betitelt „Bibliotheken und Frameworks: „Was nehmen wir statt …?““Das ist die erste praktische Frage: „Wir nutzen Gin und GORM. Was gibt es dafür in Rust?“ Hier sind die nächsten Entsprechungen. Wie bei Go gibt es in Rust meist zwei oder drei gute Optionen aus der Community statt einer offiziellen Antwort.
Web und Netzwerk
Abschnitt betitelt „Web und Netzwerk“| Go | Rust | Hinweise |
|---|---|---|
| Gin, Echo, Chi, Fiber | axum (wir bauen eine API in Projekt: Web-API mit Axum), actix-web, Rocket | axum und actix-web sind in Produktion weit verbreitet |
net/http-Server |
hyper (niedrige Ebene), meist über axum genutzt |
Kein HTTP-Server in der Standardbibliothek |
Middleware für http.Handler |
tower-Layer und tower-http (CORS, Kompression, Tracing, Timeouts) |
axum nutzt tower direkt |
net/http-Client |
reqwest |
Standardmäßig async, mit optionalem blockierendem Client. Siehe Netzwerkprogrammierung |
| grpc-go | tonic (mit prost für Protobuf) |
|
| gorilla/websocket | tokio-tungstenite oder das Feature ws von axum |
|
html/template, text/template |
askama (beim Kompilieren geprüft), tera, minijinja |
|
net (TCP/UDP) |
std::net oder tokio::net für async |
Siehe Netzwerkprogrammierung |
| errgroup | tokio::task::JoinSet |
| Go | Rust | Hinweise |
|---|---|---|
encoding/json + Struct Tags |
serde + serde_json |
Kann auch TOML (Crate toml) und andere Formate. Siehe JSON mit Serde |
database/sql + pgx |
sqlx (async, einfaches SQL, beim Kompilieren gegen deine Datenbank geprüft), tokio-postgres |
Trotz des Namens nicht verwandt mit jmoiron/sqlx aus Go |
| sqlc | Die query!-Makros von sqlx |
Beide starten mit SQL, das du schreibst, und geben dir typisierte Ergebnisse |
| GORM | SeaORM (async, im Entity-Stil), Diesel (typgeprüfter Query Builder) | Siehe Datenbanken |
| go-sqlite3 | rusqlite |
|
| go-redis | redis |
CLI, Konfiguration, Logging und Alltagswerkzeuge
Abschnitt betitelt „CLI, Konfiguration, Logging und Alltagswerkzeuge“| Go | Rust | Hinweise |
|---|---|---|
cobra, urfave/cli, flag |
clap (leitet deine Argumente aus einem Struct ab) |
Siehe Kommandozeilenargumente und Projekt: CLI-Tool mit clap |
| viper | config oder figment |
Dateien und Umgebungsvariablen übereinanderlegen, dann mit serde deserialisieren |
log/slog, zap, zerolog |
tracing + tracing-subscriber oder log + env_logger |
Strukturiertes Logging. Siehe Logging |
regexp |
regex |
Dieselbe Idee wie RE2 in Go: Matching in linearer Zeit, keine Rückverweise. Siehe Reguläre Ausdrücke |
time |
std::time für Zeitdauern; chrono oder jiff für Datum und Zeitzonen |
Siehe Datum und Uhrzeit |
embed |
include_str! / include_bytes! oder rust-embed für ganze Ordner |
|
| bubbletea | ratatui |
Terminal-UIs |
| Prometheus client_golang | prometheus oder metrics |
|
sync/atomic |
std::sync::atomic |
Desktop, Spiele und Embedded
Abschnitt betitelt „Desktop, Spiele und Embedded“| Go | Rust | Hinweise |
|---|---|---|
| Wails | Tauri (Web-UI + Rust-Backend) | Sehr ähnliche Idee. Siehe GUI-Apps |
| Fyne | egui, Iced, Slint |
Kein offizielles GUI-Toolkit |
| Ebitengine | macroquad, Bevy |
Bevy ist noch vor 1.0 und ändert seine API zwischen Releases. Siehe Spieleentwicklung |
| TinyGo | embedded-hal, Embassy |
Rust läuft ohne Betriebssystem und ohne Heap. Siehe Embedded Rust |
GOOS=js GOARCH=wasm |
wasm-bindgen, Leptos, Yew, Dioxus |
Kleine Binärdateien, kein GC, der mitgeliefert werden muss. Siehe WebAssembly |
Im direkten Vergleich: Structs und Methoden (fast gleich)
Abschnitt betitelt „Im direkten Vergleich: Structs und Methoden (fast gleich)“Hier fühlen sich Go-Entwickler sofort zu Hause. Das ist ein kleiner Kontotyp in Go:
type Account struct { Owner string // exportiert: Großbuchstabe balance int // nicht exportiert}
func NewAccount(owner string, balance int) *Account { return &Account{Owner: owner, balance: balance}}
func (a *Account) Deposit(amount int) { a.balance += amount }func (a *Account) Balance() int { return a.balance }func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() { account := NewAccount("Ada", 100) account.Deposit(50) fmt.Println(account) fmt.Println("Kontostand ist", account.Balance())}Und in Rust:
use std::fmt;
// `pub` übernimmt die Aufgabe des Großbuchstabens in Go.pub struct Account { pub owner: String, balance: i64, // privat, wie ein kleingeschriebenes Feld in Go}
impl Account { // Wie NewAccount in Go: eine ganz normale Funktion. `new` ist nur eine Konvention. pub fn new(owner: &str, balance: i64) -> Self { Account { owner: owner.to_string(), balance } }
// Wie ein Pointer Receiver: Diese Methode verändert das Konto. pub fn deposit(&mut self, amount: i64) { self.balance += amount; }
// Liest nur. Anders als bei einem Value Receiver in Go wird nichts kopiert. pub fn balance(&self) -> i64 { self.balance }}
// Die Methode String() aus Go (das Interface Stringer) wird zu Display.impl fmt::Display for Account { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.owner, self.balance) }}
fn main() { let mut account = Account::new("Ada", 100); account.deposit(50); println!("{account}"); println!("Kontostand ist {}", account.balance());}Ausgabe:
Ada: 150Kontostand ist 150Die Form ist dieselbe: Daten in einem Struct, Methoden daran, ein Konstruktor, der einfach eine Funktion ist. Die Unterschiede sind klein, aber wichtig. Methoden stehen gemeinsam in einem impl-Block. Der Receiver sagt genau, was die Methode tut: &self liest, &mut self verändert, und ein einfaches self nimmt den Wert und verbraucht ihn. Und weil deposit das Konto verändert, muss die Variable mit let mut angelegt sein. In Go kann jeder, der einen *Account hält, ihn ändern. In Rust siehst du das Recht, etwas zu ändern, direkt im Code.
Im direkten Vergleich: aus impliziten Interfaces werden explizite Traits
Abschnitt betitelt „Im direkten Vergleich: aus impliziten Interfaces werden explizite Traits“In Go erfüllt ein Typ ein Interface allein dadurch, dass er die richtigen Methoden hat:
type Shape interface { Area() float64 Name() string}
type Circle struct{ R float64 }type Rect struct{ W, H float64 }
// Nirgends steht "implements": Die Methoden zu haben reicht.func (c Circle) Area() float64 { return math.Pi * c.R * c.R }func (c Circle) Name() string { return "Kreis" }func (r Rect) Area() float64 { return r.W * r.H }func (r Rect) Name() string { return "Rechteck" }
func describe[T Shape](s T) { fmt.Printf("%s: %.2f\n", s.Name(), s.Area())}
func main() { describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}} total := 0.0 for _, s := range shapes { total += s.Area() } fmt.Printf("Gesamtfläche: %.2f\n", total)}In Rust schreibst du die Verbindung mit impl Shape for Circle ausdrücklich hin:
use std::f64::consts::PI;
trait Shape { fn area(&self) -> f64; fn name(&self) -> &str;}
struct Circle { r: f64,}
struct Rect { w: f64, h: f64,}
// Diese Zeile ist der Teil, den Go weglässt.impl Shape for Circle { fn area(&self) -> f64 { PI * self.r * self.r } fn name(&self) -> &str { "Kreis" }}
impl Shape for Rect { fn area(&self) -> f64 { self.w * self.h } fn name(&self) -> &str { "Rechteck" }}
// Generisch, wie describe[T Shape](s T) in Go.fn describe<T: Shape>(s: &T) { println!("{}: {:.2}", s.name(), s.area());}
fn main() { describe(&Circle { r: 1.0 });
// Wie []Shape in Go: verschiedene Typen hinter einem Trait. let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Rect { w: 2.0, h: 3.0 }), ]; let total: f64 = shapes.iter().map(|s| s.area()).sum(); println!("Gesamtfläche: {total:.2}");}Ausgabe:
Kreis: 3.14Gesamtfläche: 9.14Explizit zu sein kostet eine Zeile pro Typ und bringt dir Dinge, die Go nicht kann. Ein Trait kann Standardmethoden haben, sodass Typen gemeinsames Verhalten gratis bekommen. Du kannst deinen eigenen Trait auch für Typen implementieren, die du nicht geschrieben hast, sogar für i32 oder String (in Go darfst du Methoden nur an Typen im eigenen Package hängen). Und das generische describe wird für jeden konkreten Typ einzeln kompiliert, der Aufruf braucht also kein Nachschlagen zur Laufzeit. Box<dyn Shape> ist der Stil der Go-Interfaces: ein Wert, der auf „irgendeinen Typ, der Shape implementiert“ zeigt, wobei die Methode zur Laufzeit nachgeschlagen wird. Siehe Traits und Trait-Objekte.
Im direkten Vergleich: aus nil wird Option
Abschnitt betitelt „Im direkten Vergleich: aus nil wird Option“In Go ist ein Pointer, der fehlen könnte, nil, und du musst daran denken, das zu prüfen:
type User struct { Name string Manager *User // nil heißt "kein Manager"}
func findUser(users []*User, name string) *User { for _, u := range users { if u.Name == name { return u } } return nil}
func main() { ada := &User{Name: "Ada"} users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} { u := findUser(users, name) if u == nil { fmt.Println(name + ": Benutzer nicht gefunden") continue } boss := "niemanden" if u.Manager != nil { boss = u.Manager.Name } fmt.Println(name, "berichtet an", boss) }}In Rust ist „könnte fehlen“ Teil des Typs. Option<&User> ist entweder Some(user) oder None, und der Compiler lässt dich den User erst benutzen, wenn du None behandelt hast:
struct User { name: String, manager: Option<String>, // Go: Manager *User, das nil sein kann}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> { users.iter().find(|u| u.name == name)}
fn main() { let users = vec![ User { name: "Ada".to_string(), manager: None }, User { name: "Bob".to_string(), manager: Some("Ada".to_string()) }, ];
for name in ["Bob", "Ada", "Eve"] { match find_user(&users, name) { Some(user) => { let boss = user.manager.as_deref().unwrap_or("niemanden"); println!("{name} berichtet an {boss}"); } None => println!("{name}: Benutzer nicht gefunden"), } }}Ausgabe:
Bob berichtet an AdaAda berichtet an niemandenEve: Benutzer nicht gefundenDieselbe Idee ersetzt das Comma-ok-Nachschlagen aus Go. Aus if email, ok := emails[name]; ok { ... } wird if let Some(email) = emails.get(name) { ... }. Ein Punkt, auf den du achten solltest: In Go liefert dir emails["missing"] still den Nullwert "". In Rust löst emails["missing"] eine Panic aus. Nutze also .get(), wann immer der Schlüssel fehlen könnte. Das 'a in find_user ist eine Lifetime: Es sagt dem Compiler, dass der zurückgegebene &User in users zeigt. Denen begegnest du bald.
Im direkten Vergleich: aus if err != nil werden Result und ?
Abschnitt betitelt „Im direkten Vergleich: aus if err != nil werden Result und ?“Die Fehlerbehandlung von Go ist berühmt für ihr Dreizeilen-Muster:
func parsePort(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("ungültiger Port %q: %w", s, err) } if n < 1 || n > 65535 { return 0, fmt.Errorf("Port %d außerhalb des gültigen Bereichs", n) } return n, nil}
func address(host, port string) (string, error) { p, err := parsePort(port) if err != nil { return "", err } return fmt.Sprintf("%s:%d", host, p), nil}
func main() { for _, port := range []string{"8080", "abc", "70000", "0"} { addr, err := address("localhost", port) if err != nil { fmt.Println("Fehler:", err) continue } fmt.Println("lausche auf", addr) }}Rust behält die Idee (Fehler sind Werte, die du zurückgibst), faltet das Muster aber in ein einziges Zeichen: ?.
fn parse_port(s: &str) -> Result<u16, String> { // Ein u16 kann 70000 nicht speichern, also fängt parse() „außerhalb des Bereichs“ für uns ab. let port: u16 = s.parse().map_err(|e| format!("ungültiger Port {s:?}: {e}"))?; if port == 0 { return Err("Port 0 ist reserviert".to_string()); } Ok(port)}
fn address(host: &str, port: &str) -> Result<String, String> { // `?` bedeutet: Wenn das ein Fehler ist, gib ihn sofort zurück. let port = parse_port(port)?; Ok(format!("{host}:{port}"))}
fn main() { for port in ["8080", "abc", "70000", "0"] { match address("localhost", port) { Ok(addr) => println!("lausche auf {addr}"), Err(e) => println!("Fehler: {e}"), } }}Ausgabe:
lausche auf localhost:8080Fehler: ungültiger Port "abc": invalid digit found in stringFehler: ungültiger Port "70000": number too large to fit in target typeFehler: Port 0 ist reserviertDie beiden englischen Meldungen kommen von Rust selbst: „invalid digit found in string“ heißt „ungültige Ziffer im String gefunden“, und „number too large to fit in target type“ heißt „Zahl zu groß für den Zieltyp“.
Der Unterschied geht tiefer als ein paar gesparte Zeilen. In Go gibt dir n, err := strconv.Atoi(s) beide Werte, und nichts hindert dich daran, n zu benutzen, wenn err nicht nil ist. Ein Result ist entweder der Wert oder der Fehler. Du kommst also nicht an den Wert, ohne dich vorher um den Fehler zu kümmern. Und wenn du ein Result komplett ignorierst, warnt dich der Compiler.
In echten Projekten werden String-Fehler durch etwas Reichhaltigeres ersetzt. Ein Fehler-enum (oft mit dem Crate thiserror geschrieben) übernimmt die Rolle von Sentinel Errors und errors.Is: Aufrufer wenden match auf die Variante an. Das Crate anyhow gibt dir .context("loading config")?, die Rust-Version von fmt.Errorf("loading config: %w", err). Siehe Eigene Fehlertypen und anyhow und thiserror.
Im direkten Vergleich: aus Goroutines und Channels werden Threads, mpsc und Tasks
Abschnitt betitelt „Im direkten Vergleich: aus Goroutines und Channels werden Threads, mpsc und Tasks“Hier ist das klassische Fan-in aus Go: ein paar Goroutines starten, Ergebnisse über einen Channel schicken und ihn schließen, wenn alle fertig sind:
func main() { results := make(chan int) var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} { wg.Add(1) go func(n int) { defer wg.Done() results <- n * n }(n) }
go func() { wg.Wait() close(results) }()
total := 0 for r := range results { total += r } fmt.Println("Summe:", total)}Die Rust-Version mit Threads und einem Channel aus der Standardbibliothek ist sogar etwas kürzer:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] { let tx = tx.clone(); // jeder Thread bekommt seinen eigenen Sender thread::spawn(move || { tx.send(n * n).unwrap(); }); }
// Unseren eigenen Sender droppen. Wenn alle Sender weg sind, ist der Channel // geschlossen, und die Schleife unten endet: keine WaitGroup nötig. drop(tx);
let total: i32 = rx.iter().sum(); println!("Summe: {total}");}Ausgabe:
Summe: 14Ein paar Dinge funktionieren anders. mpsc steht für „multi-producer, single-consumer“ (mehrere Sender, ein Empfänger): Du kannst den Sender klonen, aber nicht den Empfänger (für Channels mit vielen Lesern wie in Go nimm crossbeam-channel). mpsc::channel() ist unbegrenzt, so etwas hat Go nicht. mpsc::sync_channel(n) entspricht make(chan T, n) in Go, und sync_channel(0) ist ein ungepufferter Channel. Niemand ruft close auf: Der Channel schließt sich, wenn der letzte Sender gedroppt wird. move gibt jedem Thread seine eigene Kopie von n und sein eigenes tx. So stellt Rust sicher, dass Threads Daten nie auf unsichere Weise teilen. Siehe Channels und Nebenläufigkeit.
thread::spawn startet einen echten OS-Thread, der schwerer ist als eine Goroutine. Für eine Handvoll Worker ist das in Ordnung. Für Tausende gleichzeitige Verbindungen nutzt Rust async-Tasks auf einer Runtime wie tokio, und die kommen Goroutines viel näher:
#[tokio::main]async fn main() { let mut handles = Vec::new();
for n in [1, 2, 3] { // Wie `go func() { ... }()`, nur bekommst du ein Handle zurück. handles.push(tokio::spawn(async move { n * n })); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("Summe: {total}");}Ausgabe:
Summe: 14Ganz ehrlich: Hier ist Go einfacher. In Go kann jede Funktion blockieren, und die Runtime kümmert sich darum. In Rust markierst du Funktionen mit async, rufst sie mit .await auf und wählst eine Runtime. Der Gewinn: Der Compiler weigert sich, Code zu bauen, in dem zwei Threads gleichzeitig dieselben Daten ändern könnten. go test -race hätte also nichts zu finden. Siehe Async/Await, Async im Detail und Geteilter Zustand für Arc<Mutex<T>>, die Rust-Version eines Structs mit einem sync.Mutex neben seinen Daten.
Im direkten Vergleich: aus defer wird Drop
Abschnitt betitelt „Im direkten Vergleich: aus defer wird Drop“In Go planst du das Aufräumen mit defer, und aufgeschobene Aufrufe laufen in umgekehrter Reihenfolge, wenn die Funktion zurückkehrt:
func process() { fmt.Println("Datei öffnen") defer fmt.Println("Datei schließen")
fmt.Println("Mutex sperren") defer fmt.Println("Mutex entsperren")
fmt.Println("arbeite")}
func main() { process() fmt.Println("fertig")}In Rust gehört das Aufräumen zum Wert selbst. Wenn ein Wert seinen Gültigkeitsbereich (Scope) verlässt, läuft seine Drop-Implementierung, und Werte werden in umgekehrter Reihenfolge gedroppt, genau wie deine Defers:
struct Guard(&'static str);
impl Drop for Guard { fn drop(&mut self) { println!("{}", self.0); }}
fn process() { println!("Datei öffnen"); let _file = Guard("Datei schließen");
println!("Mutex sperren"); let _lock = Guard("Mutex entsperren");
println!("arbeite");} // hier wird _lock gedroppt, danach _file
fn main() { process(); println!("fertig");}Ausgabe:
Datei öffnenMutex sperrenarbeiteMutex entsperrenDatei schließenfertigIn echtem Code schreibst du Drop selten selbst: Eine File schließt sich selbst, ein MutexGuard entsperrt sich selbst, ein TcpStream trennt die Verbindung selbst. Da gibt es nichts zu vergessen. Zwei Unterschiede zu defer solltest du kennen. Drop läuft am Ende des Blocks, nicht der Funktion. Die Go-Falle, dass sich defer in einer Schleife anhäuft, gibt es also nicht: Jeder Schleifendurchlauf räumt hinter sich auf. Und Vorsicht bei let _ = Guard(...): Das nackte _ behält den Wert überhaupt nicht, er wird also sofort gedroppt. Nimm stattdessen einen Namen wie _lock.
Stolpersteine für Go-Entwickler
Abschnitt betitelt „Stolpersteine für Go-Entwickler“Alle, die von Go kommen, stolpern über dieselben Dinge. Wenn du sie vorher kennst, hilft das.
-
Einen Wert zu übergeben, kann ihn verschieben. Wenn du in Go einen Slice an eine Funktion übergibst, wird ein kleiner Header kopiert, und beide Seiten benutzen ihn weiter. Wenn du in Rust einen
Vecübergibst, wird er in die Funktion verschoben (ein Move), und du kannst ihn danach nicht mehr benutzen:fn total(prices: Vec<u32>) -> u32 {prices.iter().sum()}fn main() {let prices = vec![3, 4, 5];let sum = total(prices);println!("{sum} from {} items", prices.len());}error[E0382]: borrow of moved value: `prices`--> src/main.rs:8:37|6 | let prices = vec![3, 4, 5];| ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveDer Compiler sagt auf Englisch:
priceswird ausgeliehen, nachdem sein Wert schon verschoben wurde. Die Lösung ist die Go-Gewohnheit in Rust-Form: Lass die Funktion einen Slice ausleihen,fn total(prices: &[u32]), und ruf sie mittotal(&prices)auf. Lies zuerst Ownership; dort wird diese Regel erklärt. E0382 behandelt genau diesen Fehler im Detail. -
Der Borrow Checker. Du kannst viele Leser (
&T) oder genau einen Schreiber (&mut T) gleichzeitig haben, nie beides. Go-Code, in dem viele Structs Pointer auf ein gemeinsames Struct halten und es verändern, braucht in Rust ein anderes Design oder Smart Pointer wieRc<RefCell<T>>oderArc<Mutex<T>>. Rechne mit ein paar frustrierenden Wochen. Danach wird es viel leichter. -
Keine Nullwerte.
let x: i32;gefolgt vonprintln!("{x}")ist ein Compilerfehler, und jedes Feld muss gesetzt werden, wenn du ein Struct erzeugst. Wenn du Standardwerte wie in Go willst, leiteDefaultper derive ab und schreibConfig { port: 8080, ..Default::default() }. -
Einen Trait zu implementieren ist explizit. Eine Methode
areamacht deinen Typ noch nicht zu einerShape. Du brauchstimpl Shape for Circle. Der Vorteil: Du findest jede Implementierung, indem du danach suchst. -
Stringvs.&str. Go hat einen einzigenstring-Typ. Rust hat zwei:Stringbesitzt seinen Text,&strleiht ihn aus. Funktionen nehmen meist&strentgegen, Structs speichern meistString. Und du kannst einen String nicht wie in Go mits[0]indizieren; nutzes.chars()oders.as_bytes()[0]. Siehe Strings. -
Unbenutztes ist eine Warnung, kein Fehler. Go kompiliert nicht, wenn ein Import oder eine Variable unbenutzt ist. Rust warnt nur, und mit einem
_vor dem Namen sagst du „absichtlich unbenutzt“. -
Integer-Überlauf. Go läuft still über. Rust löst in Debug-Builds eine Panic aus und läuft in Release-Builds über. Wähle also
checked_add,wrapping_addodersaturating_add, wenn ein Überlauf möglich ist. Ganzzahl-Literale sind standardmäßigi32, und Indizes sindusize. Siehe Integer-Überlauf. -
Eine kleinere Standardbibliothek. Kein HTTP-Server, kein JSON, keine async-Runtime in
std. Die holst du mitcargo add. Das fühlt sich anfangs seltsam an, aber die beliebten Crates sind stabil und gut dokumentiert. -
Async braucht eine Runtime und breitet sich aus. Eine
async fntut nichts, bis sie in einer Runtime wie tokio awaited wird, und nur async-Code kann.awaitaufrufen. Go kennt diese Trennung nicht. Fang mit einfachen Threads an und greif zu async, wenn du viele gleichzeitige Verbindungen brauchst. -
Kompilierzeiten. Go wurde gebaut, um sehr schnell zu kompilieren. Rust kompiliert langsamer, vor allem komplette Builds mit vielen Crates. Nutze beim Bearbeiten
cargo check; es überspringt die Codeerzeugung und ist viel schneller. -
Cross-Kompilieren braucht etwas Einrichtung.
GOOS=linux go buildfunktioniert einfach. In Rust fügst du das Ziel mit rustup hinzu und brauchst oft auch einen Linker dafür. Tools wiecrosserledigen das für dich.
Go oder Rust? Wann du was wählst
Abschnitt betitelt „Go oder Rust? Wann du was wählst“Beide sind gute Wahlen, und viele Teams nutzen beide. Hier ist eine faire Faustregel.
Wähle Go, wenn du Netzwerkdienste und interne Tools schnell ausliefern willst, wenn deinem Team Code wichtig ist, den jeder nach einer Woche lesen kann, wenn schnelle Builds viel zählen oder wenn du in der Cloud-native-Welt arbeitest (Kubernetes, Docker, Terraform), in der Go die gemeinsame Sprache ist. Mit der Standardbibliothek von Go und der einfachen Nebenläufigkeit lassen sich viele Serverprogramme leicht schreiben.
Wähle Rust, wenn du vorhersehbare Latenz ohne Pausen durch Garbage Collection brauchst, wenig Speicherverbrauch oder jedes bisschen CPU (Parser, Datenbanken, Game-Engines, Video, Kompression). Rust passt auch besser für Code ohne Betriebssystem, für WebAssembly und für Bibliotheken, die du aus anderen Sprachen aufrufen willst. Und es lohnt sich, wenn ein Absturz durch ein Data Race oder eine fehlende nil-Prüfung teuer ist. Discord hat einen bekannten Beitrag darüber geschrieben, warum sie einen Dienst von Go auf Rust umgestellt haben (auf Englisch): Latenzspitzen durch die Garbage Collection.
Der Preis von Rust sind Lernzeit und langsamere Builds. Der Preis von Go sind ein Garbage Collector und Bugs, die erst zur Laufzeit statt beim Kompilieren auftauchen. Welcher Preis mehr wehtut, hängt von deinem Projekt ab.
Ein Lernpfad für Go-Entwickler
Abschnitt betitelt „Ein Lernpfad für Go-Entwickler“Du musst diese Seite nicht der Reihe nach lesen. Als Go-Entwickler überspringst du auf diesem Weg, was du schon kannst, und nimmst dir Zeit für das, was in Rust anders ist:
- Erste Schritte mit Rust und Rust-Werkzeuge und Einrichtung: Rust installieren, rust-analyzer einrichten und
cargo newausführen. - Variablen und Datentypen: nur überfliegen, vor allem für
let mutund die Zahlentypen. - Ownership, dann Borrowing, dann Slices: die wichtigsten Seiten für dich. Lass dir Zeit.
- Strings:
Stringvs.&str. - Structs und Enums, dann Match und Pattern Matching. Enums mit Daten ersetzen in Rust viele Type Switches aus Go.
- Option und Result, dann Eigene Fehlertypen und anyhow und thiserror.
- Traits, Generics, Wichtige Traits und Trait-Objekte.
- Closures und Iteratoren: Rust setzt viel stärker auf Iterator-Ketten als Go.
- Vektoren, HashMap und HashSet.
- Module, Cargo und Crates und Testen.
- Lifetimes und Smart Pointer.
- Nebenläufigkeit, Channels, Geteilter Zustand, dann Async/Await.
- JSON mit Serde und Logging, danach baust du das Web-API-Projekt.
Übungen
Abschnitt betitelt „Übungen“Beide Übungen geben dir ein Stück Go-Code. Portiere es nach Rust.
1. Einen Wortzähler portieren
Abschnitt betitelt „1. Einen Wortzähler portieren“Dieses Go-Programm zählt Wörter und gibt sie alphabetisch sortiert aus. Portiere es nach Rust. Tipp: Nutze eine HashMap<&str, u32> und *counts.entry(word).or_insert(0) += 1 für counts[w]++ aus Go. text.split_whitespace() entspricht strings.Fields.
func main() { text := "the cat and the dog and the bird"
counts := map[string]int{} for _, w := range strings.Fields(text) { counts[w]++ }
keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys)
for _, k := range keys { fmt.Printf("%s: %d\n", k, counts[k]) }}Lösung anzeigen
use std::collections::HashMap;
fn main() { let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
let mut keys: Vec<&str> = counts.keys().copied().collect(); keys.sort();
for key in keys { println!("{key}: {}", counts[key]); }}Ausgabe:
and: 2bird: 1cat: 1dog: 1the: 3Der Beispielsatz bleibt auf Englisch, weil die Ausgabe alphabetisch sortiert wird. Einen Nullwert, auf den man sich stützen kann, gibt es hier nicht. Deshalb sagt entry(word).or_insert(0): „Fang bei 0 an, wenn das Wort neu ist.“ Das Indizieren mit counts[key] ist sicher, weil jeder Schlüssel aus der Map stammt. Noch kürzer: Nimm eine BTreeMap statt einer HashMap. Dann kommen die Schlüssel schon sortiert heraus, und du kannst den Vektor keys weglassen.
2. Einen Sentinel Error portieren
Abschnitt betitelt „2. Einen Sentinel Error portieren“Dieser Go-Code gibt einen Sentinel Error zurück und prüft ihn mit errors.Is. Portiere ihn so, dass withdraw ein Result<(), WithdrawError> zurückgibt, wobei WithdrawError ein enum mit zwei Varianten ist. Tipp: Implementiere fmt::Display für das Enum, damit du es ausgeben kannst, und nutze match auf die Variante dort, wo Go errors.Is verwendet.
var ErrInsufficientFunds = errors.New("Guthaben reicht nicht aus")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error { if n <= 0 { return fmt.Errorf("ungültiger Betrag %d", n) } if n > a.Balance { return ErrInsufficientFunds } a.Balance -= n return nil}
func main() { acc := &Account{Balance: 100} for _, n := range []int{30, 0, 500} { if err := acc.Withdraw(n); err != nil { if errors.Is(err, ErrInsufficientFunds) { fmt.Println("abgelehnt:", err) } else { fmt.Println("Fehler:", err) } continue } fmt.Println("ok, Kontostand", acc.Balance) }}Lösung anzeigen
use std::fmt;
enum WithdrawError { InvalidAmount(i64), InsufficientFunds,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InvalidAmount(n) => write!(f, "ungültiger Betrag {n}"), WithdrawError::InsufficientFunds => write!(f, "Guthaben reicht nicht aus"), } }}
struct Account { balance: i64,}
impl Account { fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> { if n <= 0 { return Err(WithdrawError::InvalidAmount(n)); } if n > self.balance { return Err(WithdrawError::InsufficientFunds); } self.balance -= n; Ok(()) }}
fn main() { let mut acc = Account { balance: 100 }; for n in [30, 0, 500] { match acc.withdraw(n) { Ok(()) => println!("ok, Kontostand {}", acc.balance), Err(e @ WithdrawError::InsufficientFunds) => println!("abgelehnt: {e}"), Err(e) => println!("Fehler: {e}"), } }}Ausgabe:
ok, Kontostand 70Fehler: ungültiger Betrag 0abgelehnt: Guthaben reicht nicht ausDer Sentinel Error und der Fehler aus fmt.Errorf werden zu zwei Varianten eines einzigen Enums, und aus errors.Is wird ein Zweig im match. e @ WithdrawError::InsufficientFunds passt auf die Variante und gibt dir gleichzeitig den ganzen Wert als e zum Ausgeben. Ein schöner Bonus: Wenn du später eine dritte Variante hinzufügst, zeigt dir der Compiler jedes match, das angepasst werden muss. Das Crate thiserror kann die Display-Implementierung für dich schreiben.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Vieles aus Go nimmst du mit: Structs mit Methoden, keine Vererbung, Fehler als Werte, ein Standard-Formatter und ein einziges Tool zum Bauen und Testen.
- Die großen Umstellungen sind Ownership statt Garbage Collector,
OptionstattnilundResult+?stattif err != nil. - Aus Interfaces werden Traits, und einen zu implementieren ist explizit (
impl Trait for Type). Ausdeferwird das automatischeDropam Ende eines Gültigkeitsbereichs. - Aus Goroutines und Channels werden
thread::spawn+mpscfür ein paar Worker oder tokio-Tasks für viele; der Compiler schließt Data Races aus. - Cargo ersetzt
go build,go testundgo mod; clippy und rustfmt ersetzengo vetundgofmt. Statt Gin schau dir axum oder actix-web an, statt GORM SeaORM oder Diesel, statt pgx und sqlc sqlx und statt cobra clap.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. In Go erfüllt ein Typ ein Interface allein dadurch, dass er die richtigen Methoden hat. Wie implementiert ein Typ in Rust einen Trait?
2. Was macht ? nach einem Aufruf, der ein Result zurückgibt?
3. Wann endet bei std::sync::mpsc die Schleife for msg in rx (oder rx.iter())?
4. Dein Go-Team baut seine HTTP-APIs mit Gin. Welches Rust-Crate passt am besten?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Die Programmiersprache Rust – Das Rust-Buch: die deutsche Übersetzung des offiziellen Buchs. Für Go-Entwickler sind die Kapitel 4 (Ownership), 9 (Fehlerbehandlung), 10 (Generics und Traits) und 16 (Nebenläufigkeit) am wichtigsten.
- Tokio Tutorial (auf Englisch): die async-Runtime, die die meisten Rust-Server nutzen – das, was dem Goroutine-Scheduler von Go am nächsten kommt.
- axum-Dokumentation (auf Englisch): das Web-Framework, das du dir zuerst ansehen solltest, wenn du von Gin, Echo oder Chi kommst.
- Why Discord is switching from Go to Rust (auf Englisch): wie ein echtes Team einen latenzkritischen Dienst von Go auf Rust umgestellt hat.
- Are We Web Yet? (auf Englisch): eine Übersicht aus der Community über die Web-Bibliotheken und -Frameworks von Rust.