Cél és előfeltétel

Ebben a miniprojektben nincs fizetés, adatbázis vagy hálózat. Egy memóriában összeállított rendelés adatmodelljét készítjük el. Az eredménynek azt kell bizonyítania, hogy a tételek ellenőrzöttek, a rendelés nem üres, és a végösszeg ábrázolható. A newtype-ok, a konverziók és a builder már külön megoldották ennek egy-egy részét. Most a határaikat kell összekapcsolni úgy, hogy a kész objektum ne veszítse el a korábbi ellenőrzések eredményét.

Rögzítsd a tartományt számolás előtt

A darabszám egytől százig terjedhet, az egységár egész forintban érkezik, és a nulla ár megengedett. A modell szándékosan nem kezel áfát, devizát vagy kedvezménykerekítést. Ezek bevezetése további szerződést igényelne. Az egész forint használata itt egy szűk gyakorlófeladat választása, amelynek következményeit minden műveletben következetesen megtartjuk.

Két külön hibalehetőség marad akkor is, ha a mennyiség már érvényes. Az egységár és a darabszám szorzata túl nagy lehet; később az egyenként ábrázolható tételösszegek összege is túlcsordulhat. A checked_mul és a checked_add ezért más helyen őriz határt. Egy egyszeri bemeneti tartományvizsgálat nem váltja ki a későbbi aritmetikai művelet ellenőrzését.

Az ellenőrzés sorrendjét a felület részeként kezeljük. Először érvényes tételt kell létrehozni, majd a builder gyűjti ezeket, végül a build ellenőrzi a rendelés egészére vonatkozó feltételeket. A builder elutasítása nem módosít külső állapotot. Emiatt a hívó pontosan tudja, hogy sikertelen összeállításkor nem történt részleges vásárlás vagy mentés.

A publikus felület szándékosan kevesebb állapotot enged meg, mint amennyit a belső mezők önmagukban ábrázolnának. Egy u16 rengeteg tiltott mennyiséget is elbír, egy vektor pedig lehet üres. A konstruktorok és a lezáró ellenőrzés ezeket szűrik ki. A privát mezők azért fontosak, mert a hívó különben a sikeres ellenőrzés után közvetlenül visszaírhatná a tiltott állapotot. Ez nem csupán szervezési kérdés: az összeállítás után a program többi része erre az ígéretre épít.

A tesztadatok kis példákból és szélső értékekből állnak. A szokásos rendelés igazolja, hogy az út valóban használható; a szélső értékek pedig olyan szerződést vizsgálnak, amely hétköznapi számokkal alig látszana. Az utolsó feladat két, önmagában szabályos tétellel jut el az összegzési hibáig. Így a teszt pontosan azon a rétegen bukhatna el, amelyet ellenőrizni szeretnénk. Ha ehelyett már a tételkonstruktor elutasítaná a bemenetet, a builder túlcsordulás-kezeléséről semmit sem tudnánk meg.

1. feladat: csak ellenőrzött tétel

Készíts privát mezős tételt. Létrehozásakor utasítsd el a nulla és a száznál nagyobb mennyiséget, illetve a nem ábrázolható tételösszeget. A metódus a már kiszámolt összeget tárolja, így később nem kell megismételni a szorzást. Az ingyenes tétel és a felső mennyiséghatár is legyen külön ellenőrzött eset.

Megoldás
mod order {
    #[derive(Debug)]
    pub struct Line { total: u64 }
    impl Line {
        pub fn new(quantity: u16, unit: u64) -> Result<Self, &'static str> {
            if !(1..=100).contains(&quantity) { return Err("mennyiség"); }
            let total = unit.checked_mul(u64::from(quantity)).ok_or("túlcsordulás")?;
            Ok(Self { total })
        }
        pub fn total(&self) -> u64 { self.total }
    }
}
fn main() {
    use order::Line;
    assert_eq!(Line::new(3, 200).unwrap().total(), 600);
    assert_eq!(Line::new(100, 0).unwrap().total(), 0);
    assert!(matches!(Line::new(0, 1), Err("mennyiség")));
    assert!(matches!(Line::new(101, 1), Err("mennyiség")));
    assert!(matches!(Line::new(2, u64::MAX), Err("túlcsordulás")));
}

A hibákhoz legyen javítási lehetőség

A rövid példákban a hibatípus kis enum vagy statikus szöveg. A végső felületben a tartományokat jelentő enum előnyösebb, ha a hívó másképp reagál az üres rendelésre és a túl nagy összegre. A hibák neve a szerződést fejezze ki, ne az implementáció pillanatnyi lépésszámát. Egy belső átszervezés után is ugyanaz maradhat, hogy a rendelés összege nem ábrázolható.

Az üres és az ingyenes rendelés külön fogalom. Az előbbiben nincs tétel, az utóbbiban legalább egy érvényes tétel van nulla összeggel. Ha mindkettőt pusztán a végösszeg nullaságából próbálnád felismerni, elveszne ez a különbség. Ez jó példa arra, miért kell néha a gyűjtemény szerkezetét és a számított értékét külön vizsgálni.

2. feladat: ellenőrzött összegzés

Írj tiszta függvényt a már érvényes tételösszegek összesítésére. Az üres szelet eredménye ebben az általános segédfüggvényben nulla. A rendelés ürességének tiltása később, másik rétegben következik. Az utolsó két teszt ugyanazt a felső határt két oldalról közelíti meg, így a túlcsordulás nem marad véletlenül csak elméleti eset.

Megoldás
fn total(lines: &[u64]) -> Result<u64, &'static str> {
    lines.iter().try_fold(0_u64, |sum, &next| sum.checked_add(next).ok_or("összeg"))
}
fn main() {
    assert_eq!(total(&[200, 300, 0]), Ok(500));
    assert_eq!(total(&[]), Ok(0));
    assert_eq!(total(&[u64::MAX, 0]), Ok(u64::MAX));
    assert_eq!(total(&[u64::MAX, 1]), Err("összeg"));
}

3. feladat: builderből használható rendelés

A builder kizárólag érvényes Line értékeket fogadjon. A kész Order csak darabszámot és ellenőrzött végösszeget adjon ki; a mezői maradjanak privátak. A build fogyassza el az átmeneti objektumot. Ellenőrizd a normál összeállítást, az üres gyűjteményt, a nulla összegű valódi rendelést és a több tételből származó túlcsordulást.

Megoldás
mod domain {
    #[derive(Debug, PartialEq)]
    pub enum BuildError { Empty, Overflow }
    pub struct Line(u64);
    impl Line {
        pub fn new(q: u16, price: u64) -> Option<Self> {
            if !(1..=100).contains(&q) { return None; }
            price.checked_mul(u64::from(q)).map(Self)
        }
    }
    #[derive(Default)]
    pub struct Builder { lines: Vec<Line> }
    #[derive(Debug)]
    pub struct Order { count: usize, total: u64 }
    impl Builder {
        pub fn line(mut self, line: Line) -> Self { self.lines.push(line); self }
        pub fn build(self) -> Result<Order, BuildError> {
            if self.lines.is_empty() { return Err(BuildError::Empty); }
            let total = self.lines.iter().try_fold(0_u64, |sum, line|
                sum.checked_add(line.0).ok_or(BuildError::Overflow))?;
            Ok(Order { count: self.lines.len(), total })
        }
    }
    impl Order { pub fn summary(&self) -> (usize, u64) { (self.count, self.total) } }
}
fn main() {
    use domain::{BuildError, Builder, Line};
    let order = Builder::default().line(Line::new(2, 300).unwrap())
        .line(Line::new(1, 50).unwrap()).build().unwrap();
    assert_eq!(order.summary(), (2, 650));
    assert!(matches!(Builder::default().build(), Err(BuildError::Empty)));
    assert_eq!(Builder::default().line(Line::new(1, 0).unwrap())
        .build().unwrap().summary(), (1, 0));
    let big = Builder::default().line(Line::new(1, u64::MAX).unwrap())
        .line(Line::new(1, 1).unwrap()).build();
    assert!(matches!(big, Err(BuildError::Overflow)));
}

Elfogadási feltételek

  1. Érvénytelen mennyiségből és túlcsorduló szorzatból nem jöhet létre publikus tétel.
  2. A builder külön hibával jelzi az ürességet és az összeadási túlcsordulást, miközben elfogad egy ingyenes tételt.
  3. A kész rendelés összegét a hívó megvizsgálhatja, de ellenőrzés nélkül nem írhatja át.

Továbblépés

Ez a modell helyi adatok érvényességét bizonyítja, nem teljes rendelési rendszer. Bővítéskor először a szerződést és a határeseteket fogalmazd meg. A generikus API-k leckéje segít, ha később cserélhető, tiszta szabályt szeretnél átadni az összeállításhoz.