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}"),
    }
}
Tipp

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), kicsomagolja v-t, és a kifejezés értéke v lesz,
  • ha Err(e), azonnal visszatér a befoglaló függvényből egy Err(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.

Megjegyzés

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 a Display implementációt,
  • a #[from] attribútumból generálja a From<std::io::Error> és From<ParseIntError> implementációkat, tehát a ? operátor magától tud konvertálni,
  • implementálja a std::error::Error trait-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.

Jó tudni

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)
Figyelem

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 anyhow gyorsabb fejlesztést enged, kevesebb boilerplate-tel, és a .context()-tel remek hibaüzeneteket kapsz main-ig visszafelé.
  • Ha egyik crate se elérhető (pl. minimalista, függőségmentes projekt): a kézzel írt enum + From implementációk kombinációja, esetleg Box<dyn std::error::Error> a main-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.rs pedig anyhow-ba csomagolja őket a ? operátorral – a kettő remekül összekombinálható, hiszen az anyhow::Error bármilyen std::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.