A Rust egyik legnagyobb erénye, hogy nincs benne exception-alapú hibakezelés. Minden, ami elromolhat, azt a típusrendszer explicit módon jelzi vissza – ez elsőre furcsa lehet más nyelvekből érkezve, de ha megszokod, sosem fogsz utána vágyni a try/catch blokkokra.

Option vs Result: mikor melyiket használjuk

A Option<T> és a Result<T, E> első ránézésre hasonlóak – mindkettő egy enum, ami "lehet, hogy van érték, lehet, hogy nincs" szituációt modellez. A különbség a szemantikában rejlik:

  • Option<T> akkor jó választás, ha egy érték hiánya nem hiba, hanem a normális működés része. Például egy HashMap::get visszaadhat None-t, ha a kulcs nincs benne – ez nem katasztrófa, csak egy lehetséges eredmény.
  • Result<T, E> akkor kell, ha valami elromolhat, és tudni akarod, miért. Fájl megnyitása, hálózati kérés, parse-olás – ezek mind olyan műveletek, ahol a hiba oka fontos információ.
fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some("Kovács Anna".to_string())
    } else {
        None
    }
}

fn parse_age(input: &str) -> Result<u8, std::num::ParseIntError> {
    input.parse::<u8>()
}

fn main() {
    match find_user(2) {
        Some(name) => println!("Megtaláltam: {name}"),
        None => println!("Nincs ilyen felhasználó."),
    }

    match parse_age("harminc") {
        Ok(age) => println!("Kor: {age}"),
        Err(e) => println!("Hiba a parse-olás közben: {e}"),
    }
}
Tipp

Ökölszabály: ha meg tudnád indokolni, miért nincs érték, akkor valószínűleg Result-ot akarsz, nem Option-t.

A ? operátor működése és hibapropagáció

A ? operátor a Rust hibakezelésének igazi ékszerdoboza. Ha egy függvényhívás Result-ot (vagy Option-t) ad vissza, a ? kicsomagolja a sikeres esetet, hiba esetén pedig azonnal visszatér a hívó függvényből az adott hibával.

use std::fs::File;
use std::io::{self, Read};

fn read_file_contents(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    match read_file_contents("config.txt") {
        Ok(text) => println!("Tartalom: {text}"),
        Err(e) => eprintln!("Nem sikerült beolvasni: {e}"),
    }
}

A fenti kód nélküle így nézne ki: minden sor után egy match, ami vagy visszaadja a hibát, vagy folytatja a végrehajtást. A ? ezt a boilerplate-et tünteti el.

Fontos, hogy a ? csak akkor működik, ha a hiba típusa konvertálható a függvény visszatérési típusában szereplő hibatípusra a From trait segítségével. Ez az, ami miatt gyakran találkozol saját hibatípusokkal, amik impl From<IoError> for MyError-t implementálnak.

Megjegyzés

A ? operátor Option<T>-n is működik, de csak akkor, ha a körülvevő függvény is Option-t ad vissza. let-else mintával kombinálva (ami az 1.65 óta stabil) sokszor még olvashatóbb kódot kapsz, mint a ?-tel.

fn first_word(s: &str) -> Option<&str> {
    let Some(word) = s.split_whitespace().next() else {
        return None;
    };
    Some(word)
}

Saját hibatípusok létrehozása thiserror-ral

Ha egy könyvtárat vagy egy nagyobb alkalmazást írsz, hamar szükséged lesz saját, jól strukturált hibatípusokra. Kézzel megírni egy enum-ot, ami implementálja a std::error::Error és Display traiteket, sok ismétlődő kóddal jár. Itt jön képbe a thiserror crate.

Add hozzá a Cargo.toml-hoz:

[dependencies]
thiserror = "1.0"

És máris deklarálhatsz egy szép, típusos hibát:

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("a fájl nem található: {0}")]
    NotFound(String),

    #[error("IO hiba történt")]
    Io(#[from] std::io::Error),

    #[error("hibás formátum a(z) {line}. sorban")]
    ParseError { line: usize },
}

fn load_config(path: &str) -> Result<String, ConfigError> {
    if !std::path::Path::new(path).exists() {
        return Err(ConfigError::NotFound(path.to_string()));
    }
    let content = std::fs::read_to_string(path)?; // az io::Error automatikusan ConfigError::Io lesz
    Ok(content)
}

A #[from] attribútum generálja a From<std::io::Error> for ConfigError implementációt, így a ? operátor a load_config-ban simán működik, hiába más a hiba típusa. Ez pontosan az a mechanizmus, amit az előző szakaszban emlegettem.

Jó tudni

A thiserror nem futásidejű könyvtár, hanem csak egy derive makró – nincs runtime overhead, a generált kód ugyanaz, mintha kézzel írtad volna meg a Display és Error implementációkat.

Rugalmas hibakezelés anyhow segítségével

Míg a thiserror könyvtárak és API-k esetén ideális, ahol a hívónak tudnia kell, pontosan milyen hiba történt, alkalmazások (bináris crate-ek) esetén gyakran elég annyi, hogy "valami elromlott, itt a hibaüzenet". Erre való az anyhow crate.

[dependencies]
anyhow = "1.0"

Az anyhow::Result<T> valójában Result<T, anyhow::Error>, ahol az anyhow::Error képes bármilyen std::error::Error-t implementáló típust magába csomagolni, plusz kontextust is tudsz hozzáadni a context metódussal.

use anyhow::{Context, Result};

fn read_and_parse(path: &str) -> Result<i32> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("nem sikerült beolvasni: {path}"))?;

    let number: i32 = content.trim().parse()
        .context("a fájl tartalma nem szám")?;

    Ok(number)
}

fn main() -> Result<()> {
    let value = read_and_parse("number.txt")?;
    println!("Beolvasott érték: {value}");
    Ok(())
}

Ha a read_and_parse hibát dob, az anyhow láncolja a kontextusokat, így egy jól olvasható hibaüzenetet kapsz a végén, ami elárulja, hol romlott el pontosan a folyamat.

Tipp

Ökölszabály: könyvtárban thiserror, binárisban anyhow. A könyvtár API-ja legyen típusos és pontos, az alkalmazás felszínén viszont elég a rugalmas, kontextusos hibaüzenet.

Kis gyakorlati projekt: fájlbeolvasás hibakezeléssel

Rakjuk össze a fentieket egy apró, de valósághű példában. Készítünk egy programot, ami beolvas egy konfigurációs fájlt, feldolgozza a sorokat, és minden lehetséges hibát értelmesen kezel.

use std::fs;
use thiserror::Error;
use anyhow::{Context, Result};

#[derive(Error, Debug)]
enum ConfigError {
    #[error("üres a konfigurációs fájl")]
    Empty,

    #[error("érvénytelen kulcs-érték pár a(z) {0}. sorban")]
    InvalidLine(usize),
}

fn parse_config(content: &str) -> Result<Vec<(String, String)>, ConfigError> {
    if content.trim().is_empty() {
        return Err(ConfigError::Empty);
    }

    let mut pairs = Vec::new();
    for (i, line) in content.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }
        let mut parts = line.splitn(2, '=');
        match (parts.next(), parts.next()) {
            (Some(key), Some(value)) => {
                pairs.push((key.trim().to_string(), value.trim().to_string()));
            }
            _ => return Err(ConfigError::InvalidLine(i + 1)),
        }
    }
    Ok(pairs)
}

fn load_and_parse(path: &str) -> Result<Vec<(String, String)>> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("nem sikerült megnyitni a fájlt: {path}"))?;

    let pairs = parse_config(&content)
        .with_context(|| format!("hibás konfiguráció ebben a fájlban: {path}"))?;

    Ok(pairs)
}

fn main() -> Result<()> {
    let config = load_and_parse("app.conf")?;
    for (key, value) in config {
        println!("{key} = {value}");
    }
    Ok(())
}

Figyeld meg, hogy a parse_config egy szigorúan típusos ConfigError-t ad vissza – ez a belső logika, aminek a hívói pontosan tudják, milyen hibák jöhetnek. A load_and_parse viszont már anyhow::Result-ot használ, mert ez az alkalmazás "felszíne", ahol elég a kontextusos hibaüzenet a felhasználónak.

Figyelem

Ha main() Result-ot ad vissza, és hiba történik, a Rust a Debug reprezentációt írja ki a stderr-re, és az exit code 1 lesz. Az anyhow::Error Debug implementációja szépen, láncolva írja ki a kontextusokat – pont ezért érdemes main-ben anyhow::Result-ot használni.

Összefoglalás

A Rust hibakezelése első blikkre több boilerplate-nek tűnhet, mint egy exception-alapú nyelvben, de cserébe a fordító garantálja, hogy minden hibaesetet lekezeltél – nincs elfelejtett catch blokk, nincs meglepetés futásidőben. A ? operátor eltünteti a felesleges ismétlődést, a thiserror típusos, jól dokumentált hibákat ad könyvtárakhoz, az anyhow pedig gyors, kontextusos hibakezelést biztosít alkalmazásokban. Ha ezt a két eszközt együtt használod a megfelelő helyen, sokkal karbantarthatóbb és megbízhatóbb kódot fogsz írni – anélkül, hogy feleslegesen szenvednél a hibakezeléssel.