Cél és előfeltételek

Ez a lecke nem hoz új fogalmat — ehelyett összefűzi mindazt, amit a "Trait-ek és generikák" útvonalon idáig tanultál. Egy kis konfiguráció-elemző programot építünk, amely egyszerű kulcs = érték formátumú szöveget dolgoz fel, és minden hibás esetre saját, típusos hibát ad vissza — pontosan úgy, ahogy az előző, "Result, ? és thiserror" című leckében megalapoztuk.

A feladat megoldásához a következő korábbi ismeretekre lesz szükséged:

  • struct/enum definíció, impl blokkok
  • generikák (<T>), trait bound, where klóz
  • 'a lifetime-szintaxis, struct-lifetime, lifetime elision
  • Result/Option mélységben, a ? operátor
  • thiserror crate a hibatípusok deriválásához
  • Vec (new, push, iterálás), str::split_once
  • Iterator::find, Option::ok_or_else, turbofish szintaxis (::<T>())

Ha valamelyik pont bizonytalan, érdemes visszalapozni az előző leckére, mielőtt belevágsz.

Tipp

Ne siess a végmegoldáshoz! A lecke lényege, hogy minden lépésnél előbb magad próbáld megírni a kódot, és csak akkor nyisd ki a "Megoldás" blokkot, ha elakadtál.

Előkészítés

Hozz létre egy új, futtatható crate-et:

cargo new config-parser --bin
cd config-parser

A projekt egyetlen külső függősége a thiserror, amivel a hibatípusunkat fogjuk deriválni. Írd be a Cargo.toml-ba:

[package]
name = "config-parser"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"

Ennyi elég is — a program a src/main.rs-ben fog élni, egyetlen fájlban.

1. lépés — a hibatípus megtervezése

Először gondold át, milyen hibák történhetnek egy konfigurációfájl feldolgozása közben:

  1. egy sor nem kulcs = érték alakú (nincs benne =)
  2. hiányzik egy elvárt kulcs
  3. egy érték nem alakítható át a kívánt típusra (pl. "abc" nem egész szám)
  4. egy érték nem érvényes logikai érték

Írj egy ConfigError enumot, és deriváld rá a thiserror::Error-t úgy, hogy minden variánsnak legyen emberi olvasásra szánt #[error("...")] üzenete. A számot alakító hibát (ParseIntError) kapcsold össze #[from] attribútummal, hogy a ? operátor automatikusan konvertálja.

Megoldás
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("hibás formátumú sor ({0}. sor): {1:?}")]
    InvalidLine(usize, String),

    #[error("hiányzó kulcs: {0}")]
    MissingKey(String),

    #[error("nem sikerült egész számmá alakítani")]
    ParseInt(#[from] ParseIntError),

    #[error("nem sikerült logikai értékké alakítani: {0:?}")]
    ParseBool(String),
}
Megjegyzés

Az #[from] attribútum pontosan ugyanazt csinálja, mint az előző leckében: legenerálja a From<ParseIntError> for ConfigError implementációt, így egy parse::<i32>()? hívás automatikusan a megfelelő ConfigError-ba csomagolódik.

2. lépés — a Config struct és a parse-olás

Most jön a lifetime-os rész. A cél, hogy a Config struct ne foglaljon le új String-eket, hanem csak a bemeneti szövegre mutató szeleteket (&str) tároljon — ez pontosan az a minta, amit a struct-lifetime leckében gyakoroltunk.

Írj egy Config<'a> struct-ot, amely egy Vec<(&'a str, &'a str)>-ot tárol (kulcs–érték páronként), és egy parse asszociált függvényt, amely:

  • sorokra bontja a bemenetet (input.lines()),
  • kihagyja a kommenteket (#-tal kezdődő sorokat) és az üres sorokat,
  • minden megmaradt sort str::split_once('=')-vel bont kulcsra és értékre,
  • ha egy sor nem bontható, ConfigError::InvalidLine hibát ad vissza a ? operátorral.
Megoldás
// (részlet — a ConfigError definícióját lásd az 1. lépésben)
struct Config<'a> {
    pairs: Vec<(&'a str, &'a str)>,
}

impl<'a> Config<'a> {
    fn parse(input: &'a str) -> Result<Config<'a>, ConfigError> {
        let mut pairs = Vec::new();

        for (line_number, raw_line) in input.lines().enumerate() {
            let line = raw_line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            let (key, value) = line.split_once('=').ok_or_else(|| {
                ConfigError::InvalidLine(line_number + 1, line.to_string())
            })?;

            pairs.push((key.trim(), value.trim()));
        }

        Ok(Config { pairs })
    }
}
Jó tudni

Figyeld meg, hogy a pairs-ben tárolt szeletek pontosan az input-ra mutatnak, tehát a Config<'a> élettartama nem lehet hosszabb, mint az input szövegé. Ezt fejezi ki a 'a lifetime paraméter — a fordító ezt önállóan ellenőrzi majd, neked semmi extra dolgod nincs vele.

3. lépés — kulcs lekérése szövegként

Írj egy get metódust a Config-ra, amely egy kulcs alapján visszaadja a hozzá tartozó értéket &str-ként, vagy ConfigError::MissingKey-t, ha a kulcs nincs a listában. Használd az Iterator::find-ot és az Option::ok_or_else-t.

Megoldás
// (részlet — a Config<'a> struct definícióját lásd a 2. lépésben)
impl<'a> Config<'a> {
    fn get(&self, key: &str) -> Result<&str, ConfigError> {
        self.pairs
            .iter()
            .find(|(k, _)| *k == key)
            .map(|(_, value)| *value)
            .ok_or_else(|| ConfigError::MissingKey(key.to_string()))
    }
}

Figyelj a visszatérési típusra: Result<&str, ConfigError>, holott self.pairs tulajdonképpen &'a str szeleteket tartalmaz. Ez a lifetime elision jó példája — a fordító a &self élettartamához köti a kimenetet, ami mindig legalább annyi ideig érvényes, mint amíg a metódushívás tart, hiszen 'a biztosan hosszabb vagy egyenlő ezzel.

4. lépés — generikus konverzió trait-tel

Eddig csak szöveget tudunk kiolvasni. A cél, hogy tetszőleges típusra tudjunk konvertálni — pontosan úgy, mint amikor egy generikus függvényt írtunk trait bounddal. Definiálj egy FromConfigValue trait-et egyetlen metódussal, amely egy &str-ből próbál Self-et építeni, és adjon vissza ConfigError-t hiba esetén. Majd írj egy generikus get_as<T: FromConfigValue> metódust a Config-ra, amely a get eredményét adja tovább a trait metódusának.

Megoldás
trait FromConfigValue {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError>
    where
        Self: Sized;
}

impl<'a> Config<'a> {
    fn get_as<T: FromConfigValue>(&self, key: &str) -> Result<T, ConfigError> {
        let raw = self.get(key)?;
        T::from_config_value(raw)
    }
}
Tipp

A get_as hívásakor kétféleképp is megadhatod a típust: config.get_as::<i32>("port") turbofish-sel, vagy let port: i32 = config.get_as("port")?; a let-kötés típusából kikövetkeztetve. Mindkettő ugyanahhoz a monomorfizált kódhoz vezet — a fordító mindkét esetben egy konkrét get_as::<i32> függvényt generál.

5. lépés — konkrét típusok bekötése

Implementáld a FromConfigValue trait-et i32-re (a str::parse::<i32>()-t használva, turbofish-sel) és bool-ra (ahol saját magad döntöd el, milyen szövegeket fogadsz el igaznak/hamisnak).

Megoldás
// (részlet — a FromConfigValue trait definícióját lásd a 4. lépésben)
impl FromConfigValue for i32 {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError> {
        let value = raw.parse::<i32>()?;
        Ok(value)
    }
}

impl FromConfigValue for bool {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError> {
        match raw {
            "true" | "1" | "yes" => Ok(true),
            "false" | "0" | "no" => Ok(false),
            other => Err(ConfigError::ParseBool(other.to_string())),
        }
    }
}

Figyeld meg, hogy az i32-es implementációban a raw.parse::<i32>()? egy Result<i32, ParseIntError>-t ad, amit a ? operátor automatikusan ConfigError-rá alakít — az 1. lépésben megírt #[from] attribútum jóvoltából. Ez a hibaláncolás lényege: az alacsonyabb szintű hiba (ParseIntError) egy magasabb szintű, saját hibatípusunkba (ConfigError) csomagolódik, anélkül hogy nekünk kellene kézzel map_err-ezni.

6. lépés — mindent összefűzni egy run függvénnyel

Az utolsó lépésben írj egy run() -> Result<(), ConfigError> függvényt, amely:

  1. definiál egy beágyazott konfiguráció-szöveget (&str literál),
  2. lefuttatja a Config::parse-ot,
  3. kiolvas belőle legalább két értéket különböző típusokkal (get_as),
  4. kiírja az eredményt.

A main függvényben hívd meg a run-t, és ha hibát kapsz, írd ki eprintln!-nel, majd lépj ki nem-nulla kilépési kóddal (std::process::exit(1)).

Megoldás
// (részlet — a teljes, összefüggő kódot lásd a következő szakaszban)
fn run() -> Result<(), ConfigError> {
    let input = "\
# ez egy komment
name = rust.hu
port = 8080
debug = true
";

    let config = Config::parse(input)?;
    let port: i32 = config.get_as("port")?;
    let debug = config.get_as::<bool>("debug")?;

    println!("port: {port}, debug: {debug}");
    Ok(())
}

fn main() {
    if let Err(error) = run() {
        eprintln!("hiba: {error}");
        std::process::exit(1);
    }
}
Figyelem

Próbáld ki szándékosan hibás bemenettel is (pl. port = nem_szam), hogy lásd, a ConfigError::ParseInt valóban megjelenik a hibaüzenetben. Ez jó gyakorlat arra, hogy a hibakezelésed valóban minden ágat lefedjen, nem csak a "happy path"-t.

Teljes referencia-megoldás

Az alábbi kód az összes lépés eredményét egyetlen, önmagában fordítható programba fűzi össze:

use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("hibás formátumú sor ({0}. sor): {1:?}")]
    InvalidLine(usize, String),

    #[error("hiányzó kulcs: {0}")]
    MissingKey(String),

    #[error("nem sikerült egész számmá alakítani")]
    ParseInt(#[from] ParseIntError),

    #[error("nem sikerült logikai értékké alakítani: {0:?}")]
    ParseBool(String),
}

struct Config<'a> {
    pairs: Vec<(&'a str, &'a str)>,
}

impl<'a> Config<'a> {
    fn parse(input: &'a str) -> Result<Config<'a>, ConfigError> {
        let mut pairs = Vec::new();

        for (line_number, raw_line) in input.lines().enumerate() {
            let line = raw_line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            let (key, value) = line.split_once('=').ok_or_else(|| {
                ConfigError::InvalidLine(line_number + 1, line.to_string())
            })?;

            pairs.push((key.trim(), value.trim()));
        }

        Ok(Config { pairs })
    }

    fn get(&self, key: &str) -> Result<&str, ConfigError> {
        self.pairs
            .iter()
            .find(|(k, _)| *k == key)
            .map(|(_, value)| *value)
            .ok_or_else(|| ConfigError::MissingKey(key.to_string()))
    }

    fn get_as<T: FromConfigValue>(&self, key: &str) -> Result<T, ConfigError> {
        let raw = self.get(key)?;
        T::from_config_value(raw)
    }
}

trait FromConfigValue {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError>
    where
        Self: Sized;
}

impl FromConfigValue for i32 {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError> {
        let value = raw.parse::<i32>()?;
        Ok(value)
    }
}

impl FromConfigValue for bool {
    fn from_config_value(raw: &str) -> Result<Self, ConfigError> {
        match raw {
            "true" | "1" | "yes" => Ok(true),
            "false" | "0" | "no" => Ok(false),
            other => Err(ConfigError::ParseBool(other.to_string())),
        }
    }
}

fn run() -> Result<(), ConfigError> {
    let input = "\
# ez egy komment
name = rust.hu
port = 8080
debug = true
";

    let config = Config::parse(input)?;
    let port: i32 = config.get_as("port")?;
    let debug = config.get_as::<bool>("debug")?;

    println!("port: {port}, debug: {debug}");
    Ok(())
}

fn main() {
    if let Err(error) = run() {
        eprintln!("hiba: {error}");
        std::process::exit(1);
    }
}

Futtasd le cargo run-nal — a kimenetnek port: 8080, debug: true kell lennie. Próbálj ki néhány hibás bemenetet is (hiányzó kulcs, hibás szám, hibás sor), és figyeld meg, ahogy a ConfigError variánsai pontosan visszaadják, mi ment félre.

Bónusz-kihívások

Ha végeztél, és szeretnéd tovább gyakorolni az itt tanultakat, próbáld ki ezeket saját erőből (megoldás nélkül):

  1. Alapértelmezett érték támogatása. Írj egy get_as_or<T: FromConfigValue>(&self, key: &str, default: T) -> T metódust, amely hiányzó kulcs esetén nem hibát ad, hanem a megadott alapértéket — de egy rossz típusú érték esetén (pl. port = nem_szam) továbbra is hibát dobjon. Gondold át, hogy ez miért nem ugyanaz a két eset.
  2. String mint FromConfigValue. Implementáld a trait-et String-re is, hogy get_as::<String>("name") is működjön — figyelj rá, hogy ez a variáns miért nem hibázhat soha.

Összefoglalás

Ebben a leckében nem tanultál új szintaxist, de valószínűleg most érezted először igazán, hogy a lifetime-ok, a Result/thiserror páros és a generikus trait-ek együtt hogyan adnak ki egy valódi, típusbiztos programot. A Config<'a> struct nem foglal le felesleges memóriát, a ConfigError pontosan leírja, mi mehet félre, és a FromConfigValue trait révén a hívó kód a típusrendszer segítségével, futásidejű match-elgetés nélkül kap típusos értékeket.

A "Trait-ek és generikák" útvonal utolsó leckéje egy záró kvíz lesz, amely az egész útvonal — trait-ek, generikák, iterátorok, lifetime-ok és a hibakezelés — anyagát méri végig. Ha ezt a projektet magad is végig tudtad írni, készen állsz rá.