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 egyHashMap::getvisszaadhatNone-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}"),
}
}
Ö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.
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.
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.
Ö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.
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.