Makroen jeg skal vise dere i dag er den andre jeg har skrevet i Rust så langt, så jeg er på ingen måte noen ekspert på temaet.

Jeg synes den var så kraftig, men på samme tid så enkel at jeg bare måtte dele den med dere.

Problemstilling

Jeg jobber til daglig med ett system som har med kameraer å gjøre, og disse kameraene leverer data i ett råformat som må konverteres til f.eks jpeg. Ved konvertering fra rådata ønsker vi å kunne angi hvor mye kompressjon som skal benyttes når vi skal opprette jpeg filene.

Dess mer kompressjon, dess dårligere kvalitet på bildet. Graden av kompressjon styres av et parameter som kalles jpeg_quality, hvor 0 gir dårligst kvalitet, og 100 som gir best kvalitet.

For å konvertere til jpeg, så har vi følgende funksjon og feiltype.

pub enum JpegConversionError {
    QualityOutOfRange,
    ConversionFailed,
    // andre varianter
}


pub fn to_jpeg(self, jpeg_quality: i8) ->  Result<Vec<u8>, JpegConversionError> {
    ...
    ...
}

Dersom jpeg_quality er utenfor gyldig område, så vil valideringen inne i metoden feile, og returnere JpegConversionError::QualityOutOfRange.

Hadde det ikke vært bedre om vi kunne garantere at jpeg_quality aldri kunne inneholde en ugyldig verdi? Vi kan da forenkle metoden ved å fjerne valideringen. Enklere er bedre.

JpegQuality

Vi oppretter JpegQuality som garanterer at den kun kan opprettes med gyldige verdier.

pub struct JpegQuality(i8);

impl JpegQuality {
    pub fn new(value: i8) -> Result<Self, &'static str> {
        (0..=100)
            .contains(&value)
            .then_some(JpegQuality(value))
            .ok_or("Value is out of range")
    }

    pub fn get_value(self) -> i8 {
        self.0
    }
}

Vi endrer signaturen til to_jpeg til å bruke JpegQuality fremfor i8, og vi har dermed ikke behov for JpegConversionError::QualityOutOfRange lenger.

pub fn to_jpeg(self, jpeg_quality: JpegQuality) -> Result<Vec<u8>, JpegConversionError> {
    ...
    ...
}

Bruk av JpegQuality kan se slik ut:

fn main() {
    let quality = let quality = JpegQuality::new(95);
    match quality {
        Ok(v) => println!("JPEG Quality: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }
}

Dette er jo vel og bra, men la oss si at jeg får behov for en annen type som har ett gyldighetsområde fra 0.0 til 1.0, som f.eks LearningRate.

Løsningen er jo enkel. Kopier litt kode, justerer litt på typene og vipps så har vi LearningRate.

pub struct LearningRate(f64);

impl LearningRate {
    pub fn new(value: f64) -> Result<Self, &'static str> {
        (0.0..=1.0)
            .contains(&value)
            .then_some(LearningRate(value))
            .ok_or("Value is out of range")
    }

    pub fn get_value(self) -> f64 {
        self.0
    }
}

…og det fungerer til og med 🤯

fn main() {
    let lr = LearningRate::new(0.1);
    match lr {
        Ok(v) => println!("Learning Rate: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }
}

Jada, det fungerer, men jeg er nok ikke alene med å kjenne på at dette må kunne løses på en bedre måte? Hva med å lage en type som er generisk over verdi?

pub struct BoundedValue<T>(T);

impl<T> BoundedValue<T>
where
    T: PartialOrd,
{
    pub fn new(value: T, min: T, max: T) -> Result<Self, &'static str> {
        if value >= min && value <= max {
            Ok(BoundedValue(value))
        } else {
            Err("Value is out of range")
        }
    }

    pub fn get_value(self) -> T {
        self.0
    }
}

Siden contains ikke er generisk, så endrer jeg koden til å bruker en god gammel if/else, men funksjonaliteten er den samme.

let lr = BoundedValue::new(0.5f64, 0.0f64, 1.0f64);
    match lr {
        Ok(v) => println!("Learning Rate: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }

    let quality = BoundedValue::new(50i8, 0i8, 100i8);
    match quality {
        Ok(v) => println!("JPEG Quality: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }

Det fungerer, men spesielt pent er det ikke, og det blir ikke noe penere når man ser på typen.

let lr: Result<BoundedValue<f64>, &str> = BoundedValue::new(0.5f64, 0.0f64, 1.0f64);
let quality: Result<BoundedValue<i8>, &str> = BoundedValue::new(50i8, 0i8, 100i8);

macros to the rescue

Tittelen på denne blogposten avslører jo at den har noe med makroer å gjøre, så overraskelsen er vel ikke så stor når “løsningen” baserer seg på det 😄.

Rust krabbe

Her har vi en makro, og dersom du myser på koden så vil du dra kjensel på det som skjer her. Samtidig så ser du også at noe har kommet til.

#[macro_export]
macro_rules! bounded_value {
    ($name:ident, $t:ty, $min:expr, $max:expr) => {
        pub struct $name($t);

        impl $name {
            pub fn new(value: $t) -> Result<Self, &'static str>
            where $t: PartialOrd {
                if value >= $min && value <= $max {
                    Ok($name(value))
                } else {
                    Err("Value is out of range")
                }
            }

            pub fn get_value(&self) -> $t {
                self.0
            }
        }
    };
}

La meg forsøke å gi en liten forklaring.

#[macro_export]: Dette attributtet gjør at makroen kan brukes utenfor den gjeldende modulen.

macro_rules! bounded_value: Definerer en ny makro som vi kaller bounded_value.

($name:ident, $t:ty, $min:expr, $max:expr): Dette er parameterlisten for makroen.

  • $name:ident er en “identifikator”, som er navnet på den nye strukturen.
  • $t:ty er en “type”, som er den underliggende typen for den nye strukturen.
  • $min:expr og $max:expr er uttrykk som representerer minimums- og maksimumsverdier for strukturen.

Når vi har makroen på plass, så kan vi definere typene vi ønsker:

bounded_value!(JpegQuality, i8, 0, 100);
bounded_value!(LearningRate, f64, 0.0, 0.1);

…og la oss bruke de:

fn main() {
    let lr = LearningRate::new(0.1);
    match lr {
        Ok(v) => println!("Learning Rate: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }

    let quality = let quality = JpegQuality::new(95);;
    match quality {
        Ok(v) => println!("JPEG Quality: {}", v.get_value()),
        Err(e) => println!("Error: {}", e),
    }
}

Som du ser så er bruken av LearningRate og JpegQuality identisk med de spesifikke typene vi definerte tidligere.

Tar vi med typene for variablene så ser vi at de er identiske også.

let lr: Result<LearningRate, &str> = LearningRate::new(0.1);
let quality: Result<JpegQuality, &str> = JpegQuality::new(95);

Konklusjon?

Jeg startet denne bloggposten med påstanden at makroer er kryptiske, vanskelige å forstå og at det er lett å misbruke de. Det vi har sett nå viser at makroer ikke nødvendigvis trenger å være så kryptiske eller vanskelige å forstå, og at de kan være ett veldig nyttig verktøy å ha i verktøykassen.

Når det kommer til spørsmålet om de er lette å misbruke, så er det som med verktøy ellers: "If all you have is a hammer, everything looks like a nail

Golden Hammer

PS!

Makroen som vi har sett påher er det man kaller en Declarative Macro. Det finnes også en type som heter Procedural Macros. Disse er mye kraftigere og fleksible, og med det også mere komplekse. Skulle du ønske å dykke dypere ned i materien rundt makroer så anbefales denne boken som er del av Rust sin samling av gratis tilgjengelige bøker.