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/enumdefiníció,implblokkok- generikák (
<T>), trait bound,whereklóz 'alifetime-szintaxis, struct-lifetime, lifetime elisionResult/Optionmélységben, a?operátorthiserrorcrate a hibatípusok deriválásáhozVec(new,push, iterálás),str::split_onceIterator::find,Option::ok_or_else, turbofish szintaxis (::<T>())
Ha valamelyik pont bizonytalan, érdemes visszalapozni az előző leckére, mielőtt belevágsz.
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:
- egy sor nem
kulcs = értékalakú (nincs benne=) - hiányzik egy elvárt kulcs
- egy érték nem alakítható át a kívánt típusra (pl.
"abc"nem egész szám) - 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),
}
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::InvalidLinehibá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 })
}
}
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)
}
}
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:
- definiál egy beágyazott konfiguráció-szöveget (
&strliterál), - lefuttatja a
Config::parse-ot, - kiolvas belőle legalább két értéket különböző típusokkal (
get_as), - 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);
}
}
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):
- Alapértelmezett érték támogatása. Írj egy
get_as_or<T: FromConfigValue>(&self, key: &str, default: T) -> Tmetó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. StringmintFromConfigValue. Implementáld a trait-etString-re is, hogyget_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á.