Zum Inhalt springen

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.

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

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.

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.

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
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
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: 150
Kontostand ist 150
▶ Selbst ausprobieren

Die 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.14
Gesamtfläche: 9.14
▶ Selbst ausprobieren

Explizit 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.

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 Ada
Ada berichtet an niemanden
Eve: Benutzer nicht gefunden
▶ Selbst ausprobieren

Dieselbe 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:8080
Fehler: ungültiger Port "abc": invalid digit found in string
Fehler: ungültiger Port "70000": number too large to fit in target type
Fehler: Port 0 ist reserviert
▶ Selbst ausprobieren

Die 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: 14
▶ Selbst ausprobieren

Ein 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: 14
▶ Selbst ausprobieren

Ganz 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.

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 öffnen
Mutex sperren
arbeite
Mutex entsperren
Datei schließen
fertig
▶ Selbst ausprobieren

In 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.

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` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    Der Compiler sagt auf Englisch: prices wird 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 mit total(&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 wie Rc<RefCell<T>> oder Arc<Mutex<T>>. Rechne mit ein paar frustrierenden Wochen. Danach wird es viel leichter.

  • Keine Nullwerte. let x: i32; gefolgt von println!("{x}") ist ein Compilerfehler, und jedes Feld muss gesetzt werden, wenn du ein Struct erzeugst. Wenn du Standardwerte wie in Go willst, leite Default per derive ab und schreib Config { port: 8080, ..Default::default() }.

  • Einen Trait zu implementieren ist explizit. Eine Methode area macht deinen Typ noch nicht zu einer Shape. Du brauchst impl Shape for Circle. Der Vorteil: Du findest jede Implementierung, indem du danach suchst.

  • String vs. &str. Go hat einen einzigen string-Typ. Rust hat zwei: String besitzt seinen Text, &str leiht ihn aus. Funktionen nehmen meist &str entgegen, Structs speichern meist String. Und du kannst einen String nicht wie in Go mit s[0] indizieren; nutze s.chars() oder s.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_add oder saturating_add, wenn ein Überlauf möglich ist. Ganzzahl-Literale sind standardmäßig i32, und Indizes sind usize. Siehe Integer-Überlauf.

  • Eine kleinere Standardbibliothek. Kein HTTP-Server, kein JSON, keine async-Runtime in std. Die holst du mit cargo 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 fn tut nichts, bis sie in einer Runtime wie tokio awaited wird, und nur async-Code kann .await aufrufen. 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 build funktioniert einfach. In Rust fügst du das Ziel mit rustup hinzu und brauchst oft auch einen Linker dafür. Tools wie cross erledigen das für dich.

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.

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:

  1. Erste Schritte mit Rust und Rust-Werkzeuge und Einrichtung: Rust installieren, rust-analyzer einrichten und cargo new ausführen.
  2. Variablen und Datentypen: nur überfliegen, vor allem für let mut und die Zahlentypen.
  3. Ownership, dann Borrowing, dann Slices: die wichtigsten Seiten für dich. Lass dir Zeit.
  4. Strings: String vs. &str.
  5. Structs und Enums, dann Match und Pattern Matching. Enums mit Daten ersetzen in Rust viele Type Switches aus Go.
  6. Option und Result, dann Eigene Fehlertypen und anyhow und thiserror.
  7. Traits, Generics, Wichtige Traits und Trait-Objekte.
  8. Closures und Iteratoren: Rust setzt viel stärker auf Iterator-Ketten als Go.
  9. Vektoren, HashMap und HashSet.
  10. Module, Cargo und Crates und Testen.
  11. Lifetimes und Smart Pointer.
  12. Nebenläufigkeit, Channels, Geteilter Zustand, dann Async/Await.
  13. JSON mit Serde und Logging, danach baust du das Web-API-Projekt.

Beide Übungen geben dir ein Stück Go-Code. Portiere es nach Rust.

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: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Selbst ausprobieren

Der 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.

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 70
Fehler: ungültiger Betrag 0
abgelehnt: Guthaben reicht nicht aus
▶ Selbst ausprobieren

Der 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.

  • 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, Option statt nil und Result + ? statt if err != nil.
  • Aus Interfaces werden Traits, und einen zu implementieren ist explizit (impl Trait for Type). Aus defer wird das automatische Drop am Ende eines Gültigkeitsbereichs.
  • Aus Goroutines und Channels werden thread::spawn + mpsc für ein paar Worker oder tokio-Tasks für viele; der Compiler schließt Data Races aus.
  • Cargo ersetzt go build, go test und go mod; clippy und rustfmt ersetzen go vet und gofmt. 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

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

  • 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.