Programmieren in Rust

Konstanten als Typparameter

Inhaltsverzeichnis

  1. Beispiel: Der Typ einer Restklasse
  2. Beispiel: Zerklüftungsfreie Vektoren
  3. Beispiel: Einheiten
  4. Beispiel: Beschränkter Stapelspeicher
  5. Beispiel: Ausschnitte fester Länge
  6. Einschränkung auf einzelne Konstanten
  7. Einschränkung durch Prädikate
  8. Zusicherungen an Konstanten
  9. Konstanten zur Schnittstelle hinzufügen

Beispiel: Der Typ einer Restklasse

Das Rechnen mit Restklassen ist in der Algebra zentral und kommt auch in manchen Bereichen der Informatik vor. Möchte man nun die Arithmetik mit Restklassen auf ordentliche Art implementieren, muss man einen Datentyp für den Restklassenring Z/mZ formulieren. Dieser ist nun allerdings vom Modul m abhängig. Das bereitet uns zwei Probleme:

  1. müsste der Modul zur Laufzeit mit dem Wert mitgeschleppt werden,
  2. dürften bei strenger Typisierung Restklassen mit unterschiedlichem Modul nicht addiert und multipliziert werden.

Hat man 1., ließe sich 2. durch eine Zusicherung zur Laufzeit prüfen. Eigentlich möchten wir dies alles aber schon zur Kompilierzeit haben. Die nähere Betrachtung dieser Komplikation bringt uns zu einer mysteriösen Einsicht: Der Typ muss vom Modul, einem Wert, abhängig sein. Einen solchen Typ nennt man abhängigen Typ, ein Typsystem das mit diesen umgehen kann abhängiges Typsystem.

Rust enthält eine recht eingeschränkte Form von abhängigen Typen, die sich Const Generics nennen. Eine wesentliche Notwendigkeit für die Einführung dieser ist der Umgang mit Arrays vom Typ [T;N], wo N eine schon zur Kompilierzeit bekannte Konstante ist. Man kam dabei zu der Einsicht, dass sich einige Algorithmen ohne Const Generics nicht vernünftig formulieren ließen. Das ist deshalb problematisch, weil solche Arrays Bestandteil vieler laufzeitkritischer Programme sind.

Soll ein Typ abhängig von einem Wert sein bzw. polymorph über Werte, wird der Typvariable bei der Definition und Implementation das Schlüsselwort const vorangestellt. Den Restklassenring zum Modul M schreiben wir Z<M>. Die Umsetzung gestaltet sich nun wie folgt.

use std::fmt;

#[derive(Clone, Copy)]
struct Z<const M: u32> (u32);

impl<const M: u32> fmt::Display for Z<M> {
    fn fmt(&self, f: &mut fmt::Formatter) ->fmt::Result {
        write!(f, "{}", self.0 % M)
    }
}

impl<const M: u32> std::ops::Add for Z<M> {
    type Output = Self;
    fn add(self, y: Self) -> Self {
        Z((self.0 + y.0) % M)
    }
}

impl<const M: u32> std::ops::Sub for Z<M> {
    type Output = Self;
    fn sub(self, y: Self) -> Self {
        Z((self.0 - y.0) % M)
    }
}

impl<const M: u32> std::ops::Add<u32> for Z<M> {
    type Output = Self;
    fn add(self, y: u32) -> Self {
        Z((self.0 + y) % M)
    }
}

impl<const M: u32> std::ops::Sub<u32> for Z<M> {
    type Output = Self;
    fn sub(self, y: u32) -> Self {
        Z((self.0 - y) % M)
    }
}

impl<const M: u32> std::ops::Mul for Z<M> {
    type Output = Self;
    fn mul(self, y: Self) -> Self {
        Z((self.0*y.0) % M)
    }
}

impl<const M: u32> std::ops::Mul<Z<M>> for u32 {
    type Output = Z<M>;
    fn mul(self, y: Z<M>) -> Z<M> {
        Z((self*y.0) % M)
    }
}

fn main() {
    let x: Z<5> = Z(4);
    let y: Z<5> = Z(3);
    println!("{}", x*x + 2*y + 1);
}

Beispiel: Zerklüftungsfreie Vektoren

Die Implementierung der Vektorrechnung aus dem Abschnitt ›Effiziente Programmierung: Nochmals Verkettung‹ ist in der Hinsicht makelhaft, dass intern der Typ Vec<f64> zur Anwendung kommt, womit zumindest für die Initialisierung der Puffervariablen eine Heap-Allokation stattfinden muss. Somit kommt es außerdem zu einer Zeigerindirektion und einer Cache-unfreundlichen Fragmentierung der Speicherbereichs-Nutzung.

Man kann das Programm von diesem Makel befreien, indem Vec<f64> gegen [f64; N] ausgetauscht wird. Nämlich beobachten wir, dass die Array-Länge bei der Vektorrechnung bereits zur Kompilierzeit feststeht. Die Anpassung des Programms fällt erfreulicherweise fast trivial aus, – man muss an den entsprechenden Stellen lediglich N einfügen.

pub mod vector {
    use std::ops::{Add, Sub, Mul};

    pub struct Vector<const N: usize>(pub [f64; N]);
    pub struct Addition<A: Get, B: Get>(A, B);
    pub struct ScalarMul<A: Get>(f64, A);

    impl<const N: usize> Vector<N> {
        pub fn flush<A: Get>(&mut self, a: A) {
            for i in 0..N {
                self.0[i] = a.get(i);
            }
        }
    }
    
    impl<const N: usize> std::fmt::Display for Vector<N> {
        fn fmt(&self, f: &mut std::fmt::Formatter)
        -> std::fmt::Result
        {
            write!(f, "{:?}", self.0)
        }
    }

    pub trait Get {
        fn len(&self) -> usize;
        fn get(&self, i: usize) -> f64;
    }
    impl<const N: usize> Get for &Vector<N> {
        fn len(&self) -> usize {self.0.len()}
        fn get(&self, i: usize) -> f64 {self.0[i]}
    }
    impl<A: Get, B: Get> Get for Addition<A, B> {
        fn len(&self) -> usize {self.0.len()}
        fn get(&self, i: usize) -> f64 {
            self.0.get(i) + self.1.get(i)
        }
    }
    impl<A: Get> Get for ScalarMul<A> {
        fn len(&self) -> usize {self.1.len()}
        fn get(&self, i: usize) -> f64 {
            self.0*self.1.get(i)
        }
    }
    
    impl<'a, B: Get, const N: usize> Add<B> for &'a Vector<N> {
        type Output = Addition<&'a Vector<N>, B>;
        fn add(self, b: B) -> Self::Output {
            Addition(self, b)
        }
    }
    impl<'a, B: Get, const N: usize> Sub<B> for &'a Vector<N> {
        type Output = Addition<&'a Vector<N>, ScalarMul<B>>;
        fn sub(self, b: B) -> Self::Output {
            Addition(self, ScalarMul(-1.0, b))
        }
    }
    impl<A0: Get, A1: Get, B: Get> Add<B> for Addition<A0, A1> {
        type Output = Addition<Self, B>;
        fn add(self, b: B) -> Self::Output {
            Addition(self, b)
        }
    }
    impl<A0: Get, A1: Get, B: Get> Sub<B> for Addition<A0, A1> {
        type Output = Addition<Self, ScalarMul<B>>;
        fn sub(self, b: B) -> Self::Output {
            Addition(self, ScalarMul(-1.0, b))
        }
    }
    impl<A: Get, B: Get> Add<B> for ScalarMul<A> {
        type Output = Addition<Self, B>;
        fn add(self, b: B) -> Self::Output {
            Addition(self, b)
        }
    }
    impl<A: Get, B: Get> Sub<B> for ScalarMul<A> {
        type Output = Addition<Self, ScalarMul<B>>;
        fn sub(self, b: B) -> Self::Output {
            Addition(self, ScalarMul(-1.0, b))
        }
    }
    impl<'a, const N: usize> Mul<&'a Vector<N>> for f64 {
        type Output = ScalarMul<&'a Vector<N>>;
        fn mul(self, a: &'a Vector<N>) -> Self::Output {
            ScalarMul(self, a)
        }
    }
    impl<A: Get, B: Get> Mul<Addition<A, B>> for f64 {
        type Output = ScalarMul<Addition<A, B>>;
        fn mul(self, a: Addition<A, B>) -> Self::Output {
            ScalarMul(self, a)
        }
    }
    impl<B: Get, const N: usize> Mul<B> for &Vector<N> {
        type Output = f64;
        fn mul(self, b: B) -> f64 {
            let a = &self.0;
            let mut acc = 0.0;
            for i in 0..a.len() {
                acc += a[i]*b.get(i);
            }
            acc
        }
    }
    impl<A0: Get, A1: Get, B: Get> Mul<B> for Addition<A0, A1> {
        type Output = f64;
        fn mul(self, b: B) -> f64 {
            let mut acc = 0.0;
            for i in 0..self.len() {
                acc += self.get(i)*b.get(i);
            }
            acc
        }
    }
    pub fn norm<A: Get>(v: A) -> f64 {
        let mut s = 0.0;
        for i in 0..v.len() {
            let a = v.get(i);
            s += a*a;
        }
        s.sqrt()
    }
}

use vector::Vector;

fn main() {
    let v = &Vector([1.0, 2.0]);
    let w = &Vector([3.0, 4.0]);
    let mut buf = Vector([0.0, 0.0]);
    buf.flush(2.0*v + 4.0*w + 5.0*(v - 2.0*w));
    println!("{}", buf);
    println!("{}", (v + w)*(v - w));
    println!("{}", v*v - w*w);
}

Beispiel: Einheiten

Betrachten wir nochmals die Schaffung von Einheiten aus dem Abschnitt ›Sichere Programmierung: Einheiten‹. Ein über Konstanten generischer Typ eröffnet uns die Möglichkeit einer etwas eleganteren Umsetzung.

Jede Größenart besitzt im MKS-System (Meter-Kilogramm-Sekunde) ein Tripel (L,M,T) zur Kodierung der Dimension

LängeL × MasseM × ZeitT.

Es liegt in der Natur der Sache, dieses Tripel nun als über Konstanten generischen Typ Quantity zu konstruieren. Dies bietet den Vorteil, die arithmetischen Operationen generisch formulieren zu können. Wie früher trägt Quantity nur Werte in kohärenten Einheiten. Bei nicht-kohärenten Einheiten findet intern eine Umrechnung in kohärente Einheiten statt.

pub mod quantities {
    use std::ops::{Add, Mul};

    #[derive(Clone, Copy)]
    pub struct Quantity<const L: i32, const M: i32, const T: i32> {
        value: f64
    }

    impl<const L: i32, const M: i32, const T: i32>
    Add<Quantity<L, M, T>> for Quantity<L, M, T>
    {
        type Output = Quantity<L, M, T>;
        fn add(self, y: Quantity<L, M, T>) -> Quantity<L, M, T> {
            Quantity {value: self.value + y.value}
        }
    }

    impl<const L: i32, const M: i32, const T: i32>
    Mul<Quantity<L,M,T>> for f64
    {
        type Output = Quantity<L, M, T>;
        fn mul(self, y: Quantity<L, M, T>) -> Quantity<L, M, T> {
            Quantity {value: self*y.value}
        }
    }

    // Leider ist der Compiler bisher (Stand Januar 2021)
    // nicht fortgeschritten genug, um die generische
    // Multiplikation von Größen kompilieren zu können.
    // Wir implementieren die benötigten Multiplikationen
    // im Folgenden daher einzeln.

    // Geschwindigkeit*Geschwindigkeit
    impl Mul<Quantity<0, 1, -1>> for Quantity<0, 1, -1> {
        type Output = Quantity<0, 2, -2>;
        fn mul(self, y: Quantity<0, 1, -1>) -> Self::Output {
            Quantity {value: self.value*y.value}
        }
    }

    // Masse*Geschwindigkeitsquadrat
    impl Mul<Quantity<0, 2, -2>> for Quantity<1, 0, 0> {
        type Output = Quantity<1, 2, -2>;
        fn mul(self, y: Quantity<0, 2, -2>) -> Self::Output {
            Quantity {value: self.value*y.value}
        }
    }

    // Impuls*Geschwindigkeit
    impl Mul<Quantity<0, 1, -1>> for Quantity<1, 1, -1> {
        type Output = Quantity<1, 2, -2>;
        fn mul(self, y: Quantity<0, 1, -1>) -> Self::Output {
            Quantity {value: self.value*y.value}
        }
    }

    // Masse*Geschwindigkeit
    impl Mul<Quantity<0, 1, -1>> for Quantity<1, 0, 0> {
        type Output = Quantity<1, 1, -1>;
        fn mul(self, y: Quantity<0, 1, -1>) -> Self::Output {
            Quantity {value: self.value*y.value}
        }
    }

    pub type Mass = Quantity<1, 0, 0>;
    pub type Speed = Quantity<0, 1, -1>;
    pub type Energy = Quantity<1, 2, -2>;

    use std::fmt;
    impl fmt::Display for Energy {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "{} Joule", self.value)
        }
    }

    pub mod unit {
        use std::ops::Mul;
        use super::{Mass, Speed};

        pub struct Kilogramm;
        pub struct MeterPerSecond;

        impl Mul<Kilogramm> for f64 {
            type Output = Mass;
            fn mul(self, _y: Kilogramm) -> Self::Output {
                Mass {value: self}
            }
        }
        impl Mul<MeterPerSecond> for f64 {
            type Output = Speed;
            fn mul(self, _y: MeterPerSecond) -> Self::Output {
                Speed {value: self}
            }
        }
    }
}

macro_rules! quantity {
    ($v:tt kg) => {$v*unit::Kilogramm};
    ($v:tt m/s) => {$v*unit::MeterPerSecond}
}

use quantities::{unit, Mass, Speed, Energy};

fn kinetic_energy(m: Mass, v: Speed) -> Energy {
    0.5*m*v*v
}

fn main() {
    let m = quantity!{1.0 kg};
    let v = quantity!{2.0 m/s};
    println!("{}", kinetic_energy(m, v));
}

Beispiel: Beschränkter Stapelspeicher

Die Nutzung von Vec zur Erstellung von Stapelspeichern ist mit dem Nachteil der Haldenspeicher-Allokation behaftet. Ein Ausweg besteht darin, Stapelspeicher stattdessen mit Feldern fester Länge zu erstellen, sofern eine Beschränkung der Kapazität tolerierbar erscheint.

Für diesen Stapel wünscht man sich darüber hinaus einen eigenen abstrakten Datentyp. Weil dieser Typ allerdings über seine Kapazität zu parametrisieren ist, benötigen wir eine Konstante als Typparameter. Die folgende Darstellung zeigt einen Prototyp, bei dem der Elementtyp zur Vereinfachung durch die Traits Copy und Default beschränkt wurde.

struct Stack<T, const N: usize> {
    sp: usize, buff: [T; N]
}

impl<T: Copy + Default, const N: usize> Stack<T, N> {
    fn new() -> Self {
        Self {sp: 0, buff: [T::default(); N]}
    }
    fn try_push(&mut self, value: T) -> Result<(), ()> {
        match self.buff.get_mut(self.sp) {
            Some(p) => {*p = value; self.sp += 1; Ok(())},
            None => Err(())
        }
    }
    fn push(&mut self, value: T) {
        self.try_push(value).unwrap()
    }
    fn pop(&mut self) -> Option<T> {
        match self.buff.get(self.sp - 1) {
            Some(value) => {self.sp -= 1; Some(*value)},
            None => None
        }
    }
}

fn main() {
    let mut stack = Stack::<i32, 10>::new();
    stack.push(1);
    stack.push(2);
    println!("{}", stack.pop().unwrap());
    println!("{}", stack.pop().unwrap());
}

Der Typ ArrayVec aus dem Paket »arrayvec« bietet eine vollständige Implementierung der benötigten Funktionalität. Im Wesentlichen handelt es sich hierbei um ein Analogon zu Vec.

Beispiel: Ausschnitte fester Länge

Angenommen, es besteht die Situation wo ein Programm Bytes aus gegebenen Daten herauslesen soll. Das kann beispielsweise so aussehen:

let a: &[u8] = &data[i..i+8];
let y1 = f(a[0], a[1], a[2], a[3]);
let y2 = f(a[4], a[5], a[6], a[7]);

Wenn der Compiler smart genug ist, erzeugt er vor den Zugriffen einmal die Prüfung

assert!(a.len() == 8);

und erspart dem Programm infolge die Bounds checks. Wir können die Entfernung der Bounds checks erzwingen ohne uns auf den Compiler zu verlassen, indem der Ausschnitt als Array begriffen wird. Das ist deshalb möglich, weil der Ausschnitt von der festen Länge acht ist. Über die Länge parametrisiert ergibt sich dafür:

trait SubArray<T> {
    fn slice<const N: usize>(&self, i: usize) -> &[T; N];
}

impl<T> SubArray<T> for [T] {
    fn slice<const N: usize>(&self, i: usize) -> &[T; N] {
        self[i..i+N].try_into().unwrap()
    }
}

Der Ausschnitt lässt sich nun durch

let a: &[u8; 8] = data.slice::<8>(i);

ersetzen. Freilich sollte der Compiler überdies die Implementierung von slice optimieren, oder man kümmert sich selbst darum. Entscheidend ist aber, dass diese Implementierung vom Rest des Programms abgekapselt vorliegt.

Nebenbei bemerkt ist es wohl noch besser, die acht Bytes gleich in einer Operation in ein Register zu schieben, um aufwändige Speicherzugriffe zu sparen. Gehen wir davon aus, der Compiler ist smart genug und behandelt [u8;8] wie u64, dann dürfte es kein Nachteil sein, den Ausschnitt per

let a: [u8; 8] = *data.slice::<8>(i);

als Wert zu handhaben. Es mag wohl auch günstig sein, wenn man erzwingen kann, dass die Adresse von data und der Index i ein ganzzahliges Vielfaches der Wortgröße sind. Dann fügt sich der Ausschnitt in die Speicherausrichtung ein und kann als ein oder zwei Worte gelesen werden. Ich will an dieser Stelle nicht näher darauf eingehen.

Einschränkung auf einzelne Konstanten

Manchmal möchte man den Bereich einer Parametrisierung auf einzelne Konstanten einschränken. Das ist erreichbar, indem für diese konkreten Konstanten ein Trait implementiert wird. Man stößt hierbei jedoch auf die Komplikation, dass Traits lediglich für reguläre Typen implementierbar sind, nicht aber für Konstanten. Dieses Problem lässt sich allerdings durch den folgenden Trick umgehen. Wir definieren einen Typkonstruktor U32, der eine Konstante N des Typs u32 in einen gewöhnlichen Typ U32<N> umwandelt. Der Trait wird dann stattdessen für diesen Typ implementiert.

Das folgende Beispiel verdeutlicht das Vorgehen anhand eines Linksshifts, der nur für solche Distanzen definiert sein soll, welche nichtnegative ganzzahlige Vielfache von acht sind.

struct U32<const N: u32>;

macro_rules! implement {
    ($t:ty, for ($tc:ident) [$($value:expr),*]) => {
        $(impl $t for $tc<$value> {})*
    }
}

trait ShiftAmount {}
implement!(ShiftAmount, for (U32) [0, 8, 16, 24]);

fn shift_left<const N: u32>(x: u32) -> u32
where U32<N>: ShiftAmount
{
    x << N
}

fn main() {
    println!("{}", shift_left::<8>(1));
}

Einschränkung durch Prädikate

Es tut sich die Frage auf, ob sich eine Parametrisierung allgemeiner durch logische Prädikate einschränken lässt. Eine Vorgehensweise dazu geht wie folgt. Der boolesche Wert des Prädikates ist ebenfalls eine Konstante, da das Prädikat auf eine Konstante angewendet wird. Analog wie zuvor muss ein Typkonstruktor Bool den booleschen Wert in einen gewöhnlichen Typ umwandeln. Um das Prädikat nun als erfüllt voraussetzen zu können, definiert man einen Trait True, der ausschließlich für den Wert true implementiert ist.

struct Bool<const B: bool>;

trait True {}
impl True for Bool<true> {}

fn shift_left<const N: u32>(x: u32) -> u32
where Bool<{N%8 == 0}>: True
{
    x << N
}

Zu bemerken ist, dass dafür Ausdrücke mit Parametern auswertbar sein müssen. Im Jahr 2021 war das noch experimentell gewesen.

Zusicherungen an Konstanten

Die Umwandlung einer booleschen Konstante in einen gewöhnlichen Typ befähigt uns darüber hinaus, eine bereits zur Kompilierzeit ausgeführte Zusicherung an eine Konstante konstruieren zu können. Es gibt zwar auch Tricks, wie man das ohne Konstanten als Typparameter erreichen kann, aber die sind nicht sonderlich ästhetisch.

Als Beispiel wird an ein Feld zugesichert, sortiert zu sein. Fügt man zusätzliche Elemente hinzu, können diese dann nicht versehentlich an der falschen Stelle eingefügt werden. Das ist beispielsweise wichtig, damit eine binäre Suche, die ein sortiertes Feld voraussetzt, korrekt abläuft.

macro_rules! const_assert {
    ($cond:expr) => {
        #[allow(dead_code)]
        const _: () = {
            struct Bool<const B: bool>;
            const _: Bool<true> = Bool::<{$cond}>;
        };
    }
}

const fn is_sorted(a: &[i32]) -> bool {
    let mut i = 1;
    while i < a.len() {
        if a[i-1] >= a[i] {return false;}
        i += 1;
    }
    true
}

const A: [i32; 4] = [1, 2, 3, 4];

const_assert!(is_sorted(&A));

Alternativ ginge es auch so:

macro_rules! const_assert {
    ($cond:expr) => {
        #[allow(dead_code)]
        const _: () = {
            if !$cond {panic!("Compile time assertion failed");}
        };
    }
}

Konstanten zur Schnittstelle hinzufügen

Manchmal möchte man konstante Parameter oder aus ihnen berechnete Konstanten in der öffentlichen Schnittstelle verfügbar machen. Obzwar diese Funktionalität entbehrlich erscheint, bietet sie unter Umständen ein wenig Komfort. Erreichbar ist dies, indem die gewünschten Konstanten als assoziierte Konstanten definiert werden.

pub struct Array<const L: usize>([u32; L]);

impl<const L: usize> Array<L> {
    pub const LEN: usize = L;
}