Programmieren in Rust

Traits und generische Programmierung

Inhaltsverzeichnis

  1. Arithmetik
  2. Monkey patching

Arithmetik

Bei der Programmierung von generischen arithmetischen Funktionen ist zu beachten, dass jede Operation einen bestimmten Trait benötigt, welcher die Operation implementiert.

Als erstes Beispiel soll die Funktion range(a,b) dienen, die für einen beliebigen Ganzzahltyp das Feld [aa+1, a+2, …, b] erzeugt.

use std::ops::Add;

trait Cast {fn cast(n: i32) -> Self;}
impl Cast for  i8 {fn cast(n: i32) -> Self {n as  i8}}
impl Cast for i16 {fn cast(n: i32) -> Self {n as i16}}
impl Cast for i32 {fn cast(n: i32) -> Self {n as i32}}
impl Cast for i64 {fn cast(n: i32) -> Self {n as i64}}
impl Cast for  u8 {fn cast(n: i32) -> Self {n as  u8}}
impl Cast for u16 {fn cast(n: i32) -> Self {n as u16}}
impl Cast for u32 {fn cast(n: i32) -> Self {n as u32}}
impl Cast for u64 {fn cast(n: i32) -> Self {n as u64}}

fn range<T>(a: T, b: T) -> Vec<T>
    where T: Copy + Cast + Add<Output=T> + PartialOrd
{
    let mut v: Vec<T> = Vec::new();
    let mut k = a;
    let one = T::cast(1);
    while k<=b {
        v.push(k);
        k = k+one;
    }
    return v;
}

fn main() {
    let v = range::<i32>(1,10);
    println!("{:?}",v);
}

Das hätten wir natürlich auch einfacher haben können:

fn main() {
    let v: Vec<i32> = (1..11).collect();
    println!("{:?}",v);
}

Bei der Formulierung von Cast wurde dasselbe Muster immer wiederholt. Oft lassen sich solche Wiederholungen durch where-Klauseln faktorisieren. Fast dasselbe Verhalten bekommen wir, wenn as durch from ersetzt wird. Nur die potentiell verlustbehaftete Typumwandlung von u8 nach i8 ist nicht erlaubt.

trait Cast {fn cast(n: u8) -> Self;}
impl<T> Cast for T where T: From<u8> {
    fn cast(n: u8) -> T {T::from(n)}
}

Man kann auch einfach Cast gegen From<u8> austauschen und T::cast(1) gegen T::from(1).

Nun schaut es etwas ungeschickt aus wenn jede Funktion lange where-Klauseln bekommt. Um dies zu vermeiden, lässt sich ein Trait als Zusammenfassung der verwendeten Traits formulieren. Man spricht auch von einem Alias-Trait. Hierzu wird ein Trait mit Trait-Bounds beschränkt, wobei die Liste der Methoden aber leer bleiben kann. Wir führen nun den neuen Trait Int ein, der mehr oder weniger die ganzen Zahlen repräsentieren soll.

trait Int: Copy + From<u8> + Add<Output=Self> + PartialOrd {}

impl<T> Int for T
where T: Copy + From<u8> + Add<Output=T> + PartialOrd {}

fn range<T: Int>(a: T, b: T) -> Vec<T> {
    let mut v: Vec<T> = Vec::new();
    let mut k = a;
    let one = T::from(1);
    while k<=b {
        v.push(k);
        k = k+one;
    }
    return v;
}

Wie man sieht lassen sich auf diese Weise recht hübsch generische numerische Algorithmen schreiben. Für vordefinierte Traits und Datentypen für numerische Zwecke steht auch das Crate num[1] zur Verfügung. Da Algorithmen aber durch unnötige Trait-Bounds eventuell zu stark beschränkt werden, ist es in manchen Fällen eleganter, zusätzliche Zwischen-Traits einzuführen, die nur einige der Trait-Bounds enthalten.

Verzichtet man auf den Trait-Bound Copy und führt ggf. die Trait-Bounds Sized und 'static ein, lassen sich auch Algorithmen schreiben, die mit größeren Datenstrukturen umgehen können.

Monkey patching

In manchen Programmiersprachen besteht die Möglichkeit, nachträglich weitere Methoden zu einem Datentyp hinzuzufügen. Man spricht hier von Monkey patching oder Extension methods. Diese Technik ist eigentlich verpönt, da sie zu Bezeichnerkonflikten führen kann. Implementieren zwei Module nämlich denselben Bezeichner, liegt Zweideutigkeit vor.

Aus didaktischen Gründen soll trotzdem einmal gezeigt werden, wie sich das Trait-System dafür »missbrauchen« lässt. Als Beispiel soll zum Typ Vec eine Methode sq hinzugefügt werden, die alle Elemente quadriert und das Ergebnis als neues Array zurückgibt.

use std::ops::Mul;
use std::iter::FromIterator;

trait Sq<T> {
    fn sq(&self) -> Vec<T>;
}

impl<T: Copy + Mul> Sq<T> for Vec<T>
    where Vec<T>: FromIterator<<T as Mul>::Output>
{
    fn sq(&self) -> Vec<T> {
        self.iter().map(|&x| x*x).collect()
    }
}

fn main() {
    println!("{:?}", vec![1, 2, 3, 4].sq());
}

Die Trait-Bounds T: Copy+Mul und in der where-Klausel können erst einmal ausgeblendet werden. Diese sind dafür da, dass sich ein Wert x vom Typ T auch multiplizieren und ein Iterator über die Ergebnisse auch zu einem neuen Array sammeln lässt.

Glücklicherweise meckert der Compiler bei Bezeichnerkonflikten sofort. Außerdem lassen sie sich auf zwei Arten verhindern:

Es kann später aber trotzdem zu ein paar Konflikten kommen, wenn zum Typ oder zum Trait neue Methoden hinzugefügt werden. Aus diesem Grund würde ich eher dazu neigen, solche Erweiterungsmethoden zu vermeiden.

Eine elegante Alternative zur Wucherung von Erweiterungsmethoden bietet die allgemeine Beschreibung des Postfix-Aufrufs durch eine einzige Erweiterungsmethode pipe:

trait Pipe {
    fn pipe<Y>(self, f: impl FnOnce(Self) -> Y) -> Y
    where Self: Sized
        {f(self)}
    fn pipe_ref<Y>(&self, f: impl FnOnce(&Self) -> Y) -> Y {f(self)}
}
impl<T> Pipe for T {}

Man schreibt nun freistehende Funktionen anstelle von Erweiterungsmethoden:

fn sq<T: Copy + Mul>(a: &Vec<T>) -> Vec<T>
where Vec<T>: FromIterator<<T as Mul>::Output>
{
    a.iter().map(|&x| x*x).collect()
}

fn main() {
    println!("{:?}", vec![1, 2, 3, 4].pipe_ref(sq));
}

Bei mehrstelligen Funktionen ist nun partielle Applikation erforderlich. Eine solche formuliert man üblicherweise als anonyme Funktion. Kurzes Beispiel:

fn add(x: i32, y: i32) -> i32 {x + y}

let a = 0;
let b = a.pipe(|x| add(x, 1));

Eine vollständige Implementierung von Pipe ist im Crate tap [2] zu finden.

Links