A Rust egyik legnagyobb erénye, hogy a hibákat nem lehet csendben elnyelni – a fordító rákényszerít, hogy foglalkozz velük. Ez elsőre plusz munkának tűnik, aztán rájössz, hogy pont ez mentett meg egy csomó production incidenstől. Nézzük sorban, hogyan épül fel ez a rendszer.
A Result<T, E> alapjai és a panic! elkerülése
A Rust standard könyvtárában két fő eszköz van a hibák jelzésére: az Option<T> (amikor egy érték hiányozhat) és a Result<T, E> (amikor egy művelet hibával térhet vissza). A Result egy egyszerű enum:
enum Result<T, E> {
Ok(T),
Err(E),
}
Ha egy függvényed hibázhat, ne panic!-kolj, hanem adj vissza egy Result-ot. A panic! ugyanis leállítja a szálat (vagy az egész programot, ha panic = "abort" van beállítva), és a hívó félnek semmi esélye reagálni rá. Kivétel persze van: fejlesztés közbeni gyors prototípusozásnál, tesztekben vagy olyan invariánsok megsértésénél, amik tényleg programozói hibát jeleznek, teljesen oké a panic!, unwrap() vagy expect().
use std::fs::File;
use std::io::Read;
fn read_config(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
fn main() {
match read_config("config.toml") {
Ok(content) => println!("Beolvasva: {} bájt", content.len()),
Err(e) => eprintln!("Hiba a konfiguráció olvasásakor: {e}"),
}
}
Ha egy függvény visszatérési típusa Result<T, E>, a fordító figyelmeztet (vagy hibát dob #[must_use] miatt), ha nem kezeled a visszaadott értéket. Ez az egyik legjobb védőháló, amit egy nyelv adhat.
A match kifejezés a Result kezelésének legexplicitebb módja, de hosszú láncolt hívásoknál gyorsan olvashatatlanná válik. Itt jön a képbe a ? operátor.
A ? operátor és a hibaterjesztés egyszerűsítése
A ? operátor a Rust egyik legjobb ergonómiai döntése. Ha egy Result-ot ad vissza egy kifejezés, és arra teszed a ?-et, akkor:
- ha
Ok(v), kicsomagoljav-t, és a kifejezés értékevlesz, - ha
Err(e), azonnal visszatér a befoglaló függvényből egyErr(e.into())-vel.
Ez a .into() hívás kulcsfontosságú: a ? automatikusan konvertálja a hibát a célfüggvény hibatípusára, feltéve, hogy létezik From implementáció a kettő között. Ez teszi lehetővé, hogy egy függvényen belül több különböző hibaforrásból (I/O, parse, hálózat) is egyszerűen tudj hibát terjeszteni, ha megfelelő From implementációkat írsz, vagy – mint később látjuk – ha thiserror-ral generáltatod le azokat.
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Parse(e)
}
}
fn read_port(path: &str) -> Result<u16, ConfigError> {
let content = std::fs::read_to_string(path)?; // io::Error -> ConfigError
let port: u16 = content.trim().parse()?; // ParseIntError -> ConfigError
Ok(port)
}
Ez a minta – kézzel megírt From implementációk – működik, de sok boilerplate-tel jár, ha 5-6 különböző hibatípust kell összefogni. Pontosan ezt a fájdalmat oldja meg a thiserror.
A main függvény is visszaadhat Result-ot: fn main() -> Result<(), Box<dyn std::error::Error>>. Ez remek gyors megoldás kisebb programoknál, mert a ?-et rögtön használhatod a main-ben is, anélkül hogy külön match-elned kellene.
Egyedi hibatípusok készítése a thiserror crate-tel
A thiserror egy derive makrót ad, amivel könnyen felépíthetsz saját, jól tipizált hibaenum-okat, anélkül hogy kézzel kelljen megírnod a Display és Error trait implementációkat, valamint a From konverziókat.
Add hozzá a Cargo.toml-hoz:
[dependencies]
thiserror = "1.0"
És íme egy tipikus hibaenum:
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("nem sikerült megnyitni a konfigurációs fájlt")]
Io(#[from] std::io::Error),
#[error("érvénytelen port szám: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("hiányzó kulcs a konfigurációban: {key}")]
MissingKey { key: String },
}
fn read_port(path: &str) -> Result<u16, ConfigError> {
let content = std::fs::read_to_string(path)?;
let port: u16 = content.trim().parse()?;
Ok(port)
}
Amit a thiserror itt megspórolt neked:
- a
#[error("...")]attribútumból generálja aDisplayimplementációt, - a
#[from]attribútumból generálja aFrom<std::io::Error>ésFrom<ParseIntError>implementációkat, tehát a?operátor magától tud konvertálni, - implementálja a
std::error::Errortrait-et is, tehát a típusod kompatibilis lesz minden olyan API-val, ami hibaobjektumokat vár.
Ez a megközelítés akkor éri meg igazán, ha egy könyvtárat írsz, amit mások fognak használni: a hívóknak lehetőségük van match-elni a konkrét hibaesetekre, mert a te enum-od pontosan leírja, mi mehet félre.
A thiserror a fordítási időben generál kódot, nincs runtime overhead-je. Ha egy library crate-et írsz, ez majdnem mindig jobb választás, mint az anyhow, mert a felhasználóid látják és kezelhetik a konkrét hibavariánsokat.
Gyors prototípusozás az anyhow segítségével
Amikor egy alkalmazást (nem library-t) írsz – például egy CLI eszközt vagy egy szervizt –, gyakran nem érdekel, pontosan milyen típusú hiba történt, csak az, hogy legyen egy jó hibaüzenet és egy stack-szerű kontextus, amiből kiderül, hol romlott el a dolog. Erre való az anyhow.
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("nem sikerült beolvasni: {path}"))?;
let port: u16 = content
.trim()
.parse()
.context("a port értéke nem szám")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("config.txt")?;
println!("A szerver a {port} porton fut.");
Ok(())
}
Az anyhow::Result<T> valójában Result<T, anyhow::Error>, ahol az anyhow::Error egy típustörölt (type-erased) hibaobjektum – bármilyen std::error::Error-t implementáló típusból tud ?-tel konvertálni, minden extra kód nélkül. A .context() és .with_context() metódusok pedig lehetővé teszik, hogy emberi olvasásra alkalmas kontextust fűzz a hibalánchoz, ami hibakeresésnél aranyat érhet.
Ha kiíratod a hibát (eprintln!("{:?}", err) vagy println!("{:#}", err)), az anyhow szépen egymás alá rendezi a teljes kontextuslistát:
Error: nem sikerült beolvasni: config.txt
Caused by:
No such file or directory (os error 2)
Az anyhow::Error type-erased jellege miatt a hívó fél nem tud egyszerűen match-elni a konkrét hibavariánsra (bár van downcast_ref erre a célra). Ezért library crate-ekben inkább kerüld – az anyhow-t az alkalmazás szintjén (bináris crate-ekben) érdemes használni.
Mikor melyik megoldást érdemes választani a projektedben
Összegezve egy egyszerű döntési szabály:
- Ha library crate-et írsz, amit más csomagok fognak importálni: használj saját,
thiserror-ral generált hibaenum-okat. A felhasználóid megkapják a pontos hibavariánsokat, és eldönthetik, hogyan reagáljanak rájuk. - Ha alkalmazást vagy CLI eszközt írsz: az
anyhowgyorsabb fejlesztést enged, kevesebb boilerplate-tel, és a.context()-tel remek hibaüzeneteket kapszmain-ig visszafelé. - Ha egyik crate se elérhető (pl. minimalista, függőségmentes projekt): a kézzel írt
enum+Fromimplementációk kombinációja, esetlegBox<dyn std::error::Error>amain-ben, tökéletesen elég. - Gyakori mintázat nagyobb projekteknél: a belső modulok
thiserror-os hibatípusokat használnak, a legfelső, alkalmazás szintűmain.rspediganyhow-ba csomagolja őket a?operátorral – a kettő remekül összekombinálható, hiszen azanyhow::Errorbármilyenstd::error::Error-t implementáló típusból tud konvertálni.
// belső modul: pontos hibatípus
mod config {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O hiba")]
Io(#[from] std::io::Error),
}
pub fn load() -> Result<String, ConfigError> {
Ok(std::fs::read_to_string("app.toml")?)
}
}
// alkalmazás szint: anyhow összefogja mindet
fn main() -> anyhow::Result<()> {
let cfg = config::load()?; // ConfigError -> anyhow::Error automatikusan
println!("{cfg}");
Ok(())
}
Összefoglalás
A Rust hibakezelése első ránézésre bürokratikusnak tűnhet, de valójában egy nagyon tudatos design: a Result<T, E> explicitté teszi a hibalehetőségeket, a ? operátor pedig kellemesen tömörré a terjesztésüket. Ha library-t írsz, a thiserror segít pontos, típusos hibákat készíteni minimális boilerplate-tel; ha alkalmazást fejlesztesz, az anyhow gyorsít és jó kontextust ad a hibaüzenetekhez. A kettő nem versenyzik egymással – sőt, a leggyakoribb valós projekteknél pontosan együtt, egymást kiegészítve él a kódbázisban.