Typgrenzen mit Traits

Traits definieren Verhaltensweisen die mehrere Typen gemein haben. Für einen generischen Typen werden nur diejenigen konkreten Typen zugelassen, die das Verhalten der entsprechenden Traits implementieren.

Typen implementieren Traits

Für unser Point-Beispiel müssen wir dem generischen Typen eine Grenze mitteilen. Die entsprechenden Traits werden von der Rust Standardbibliothek bereit gestellt. Um sie zu verwenden, müssen wir sie importieren. In Rust verwendet man dazu das Schlüsselwort use gefolgt vom Pfad der Entität, die man importieren möchte. Teil des Pfads sind Module. In unserem Fall importieren wir Mul und Add aus dem Module std::ops. Wir werden uns in einem späteren Abschnitt genauer mit Modulen beschäftigen. Für den Moment nehmen wir hin, dass Module Funktionalität gruppieren und beschäftigen uns nun weiter mit Traits. Ein Typ, der den Trait Mul implementiert, lässt sich mit * multiplizieren. Für primitive numerische Typen hat die Standardbibliothek für uns die Implementierung des Traits übernommen. Syntaktisch wird die Typgrenze mit einem Doppelpunkt vom generischen Typen getrennt wie im folgenden Beispiel ersichtlich.

use std::ops::{Mul, Add};

struct Point<T: Mul<Output=T> + Add<Output=T> + Copy> {
    x: T,
    y: T,
}
impl<T: Mul<Output=T> + Add<Output=T> + Copy> Point<T> {
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y 
    }
}

fn main() {
    let v = Point{ x: 1.0, y: 1.0};
    v.squared_dist_to_0();

    let v: Point<i32> = Point{ x: 0, y: 1};
    v.squared_dist_to_0(); 
}

Wenn wir

Point<T: Mul<Output=T> + Add<Output=T> + Copy>

genauer betrachten, fallen drei Punkte auf.

  1. Es treten neben den Traits Mul und Add der Trait Copy auf. Typen, die den Trait Copy implementieren, verhalten sich kopierbar. Das heißt self.x * self.x multipliziert zwei Kopien von self.x. Es findet kein Move statt.

    Quiz

    Was würde passieren, wenn T nicht Copy implementierte?

  2. Mehrere Traits, die durch ein Plus separiert werden wie Mul<Output=T> + Add<Output=T> + Copy, müssen allesamt implementiert worden sein.
  3. Die Traits Mul und Add haben ein generisches Argument Output = T. Das ist ein assoziierter Typ, der den Rückgabewert der Addition festlegt. Oft verwendet man hier Self. Beispiele für andere Typen werden wir im Kapitel zur Fehlerbehandlung sehen.

Die Traits Mul und Add sowie auch Div und Sub haben eine spezielle Bedeutung in Rust. Typen, die diese Traits implementieren, können in Rust per *, +, / oder - multipliziert, addiert, dividert oder subtrahiert werden. Wir gehen nun einen Schritt weiter und implementieren Add für unseren Strukturtypen Point. Die Definition des Add-Traits in der Standarbibliothek sieht im Wesentlichen folgendermaßen aus.

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Der Kern ist die Funktionssignatur add. Diese muss von allen Typen, die Add implementieren möchten, mit Leben gefüllt werden. Das Schlüsselwort Self bezeichnet den Typen, der den Trait implementiert. Die Zeile type Output definiert den bereits erwähnten assoziierten Typen, der mit Self::Output referenziert werden kann. Rhs ist ein generischer Typ des Traits, der dazu verwendet werden kann, der rechten Seite der Addition einen anderen Typen zu verpassen als Self. Standardmäßig hat die rechte Seite den Typ Self was durch <Rhs=Self> festgelegt wird. Wenn wir also Add verwenden ohne Rhs zu spezifizieren, wird Self angenommen. Unser Typ Point kann Add nun folgendermaßen implementieren.

use std::ops::{Mul, Add};

struct Point<T: Mul<Output=T> + Add<Output=T> + Copy> {
    x: T,
    y: T,
}
impl<T: Mul<Output=T> + Add<Output=T> + Copy> Point<T> {
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> {
    type Output = Self;
    fn add (self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let p = Point{ x: 1.0, y: 1.0};

    let p = p + p; // äquivalent zu v.add(v)
    
    p.squared_dist_to_0();
}

Wenn wir den obigen Schnipsel ausführen, bekommen wir den erwartbaren Fehler, dass v bereits verschoben wurde, da Point nicht direkt kopierbar ist. Im Kapitel über Ownership haben wir gelernt, dass alle primitive Typen direkt kopiert werden können und nicht verschoben werden, da sie keinen Speicher auf dem Heap belegen. An dieser Stelle wollen wir etwas genauer sein. Rust implementiert für alle primitiven Typen den Trait Copy. Der Trait Copy kann für alle Typen implementiert werden, die keine spezielle Aufräumoperation benötigen, wenn sie ihren Geltungsbereich verlassen. Der Typ Vec gibt beispielsweise den Heap-Speicher frei, den er belegt, wenn er seinen Geltungsbereich verlässt. Das wird durch Implementierung des Traits Drop bewerkstelligt. Desweiteren muss ein Typ, der Copy implementiert, dem Compiler mitteilen wie die eigentliche Kopie funktioniert. Das passiert durch Implementierung des Traits Clone. Der Typ Vec beispielsweise implementiert Clone aber nicht Copy. Wir können eine Instanz von Vec durch Aufruf der Methode clone klonen. Diese Aktion dupliziert den kompletten Speicher auf dem Stack und auf dem Heap für einen Vec.

#![allow(unused)]
fn main() {
let v1 = vec![1, 2, 3];
let v2 = v1.clone();
assert_eq!(v1, v2);
}

In Rust können also alle Typen Copy implementieren, die Clone implentieren Drop aber nicht. Unser Typ Point hat nur primitiv typisierte Felder. Kein primitiver Typ implementiert Drop und alle primitiven Typen implementieren Copy. Daher spricht nichts dagegen, dass Point ebenfalls Copy implementiert. In Rust können wir mit Hilfe des derive-Attributs die Implementierung eines Traits an seine Felder zu delegieren.

#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    x: T,
    y: T,
}
}

Die Zeile #[derive(Copy, Clone)] bewirkt, dass die Implementierung der Traits Copy und Clone des Typen Point sich aus den Implementierungen der Felder von Point ergibt. Des Weiteren haben wir die Typgrenzen der Übersicht wegen hinter das Schlüsselwort where gesetzt. Diese Schreibweise ist äquivalent zur bisher Verwendeten. Der folgende Schnipsel zeigt uns nun, wie ein addierbarer Point aussehen kann.

use std::ops::{Mul, Add};

#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    x: T,
    y: T,
}

impl<T> Point<T> 
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    fn dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> {
    type Output = Self;
    fn add (self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let v = Point{ x: 1.0, y: 1.0};
    let v = v + v;
    v.squared_dist_to_0();
}

Auch Aufzählungstypen können über generische Typparameter verfügen. Wir werden die beiden wichtigen Beispiele Option und Result demnächst kennenlernen.

Generische Funktionen

Wie Typen können auch Funktionen und Methoden über generische Typen verallgemeinert werden. Nehmen wir an, wir wollen zwei Instanzen des Typen Point bzgl. ihres Abstands zum Ursprung miteinander vergleichen.

use std::ops::{Mul, Add};

#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    x: T,
    y: T,
}

impl<T> Point<T> 
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> {
    type Output = Self;
    fn add (self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}
// kompiliert nicht
fn longest_dist_to_0<T>(p1: Point<T>, p2: Point<T>) -> T 
where
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    let d1 = p1.squared_dist_to_0();
    let d2 = p2.squared_dist_to_0();
    if  d1 > d2 {
        d1
    } else {
        d2
    }
}
fn main() {
    let p1 = Point{ x: 1.0, y: 1.0};
    let p2 = Point{ x: 2.0, y: 1.0};
    let dist = longest_dist_to_0(p1, p2);
    println!("{}", dist);
}

Das funktioniert so nicht, da wir von T nicht verlangt haben, per > verglichen werden zu können. Rust stellt zu diesem Zweck den Trait std::cmp::PartialOrd bereit, den alle primitiven numerischen Skalare implementieren. Es reicht, den generischen Typen T nur für die Funktion einzuschränken, denn in der Implementierung von Point verwenden wir PartialOrd nicht.

use std::cmp::PartialOrd;
use std::ops::{Mul, Add};

#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    x: T,
    y: T,
}

impl<T> Point<T> 
where 
    T: Mul<Output=T> + Add<Output=T> + Copy 
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> {
    type Output = Self;
    fn add (self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}
fn longest_dist_to_0<T>(p1: Point<T>, p2: Point<T>) -> T
where
    T: Mul<Output=T> + Add<Output=T> + Copy + PartialOrd
    //                                            |
    //                                            |
    //                         hier fordern wir, dass T partiell geordnet ist
{
    let d1 = p1.squared_dist_to_0();
    let d2 = p2.squared_dist_to_0();
    if  d1 > d2 {
        d1
    } else {
        d2
    }
}
fn main() {
    let p1 = Point{ x: 1.0, y: 1.0};
    let p2 = Point{ x: 2.0, y: 1.0};
    let dist = longest_dist_to_0(p1, p2);
    println!("{dist}");
}

Traits zusammenfassen

Unsere Typgrenze T: Mul<Output=T> + Add<Output=T> + Copy ist etwas länglich. Wir können sie in einem neuen Trait zusammenfassen und packen Substraktion dazu.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
    + Add<Output=Self> 
    + Sub<Output=Self> 
    + Copy {}
}

Wir müssen zusätzlich noch dem Compiler mitteilen, dass alle Typen, die alle Bestandteile des kombinierten Traits implementieren, auch den kombinierten Trait selbst implementieren. Dazu erstellen wir die Implementierung von Calculate für alle T, die unsere Auswahl an Berechnungstraits implementieren.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T> Calculate for T 
where 
    T: Mul<Output=Self> 
        + Add<Output=Self> 
        + Sub<Output=Self> 
        + Copy 
{}
}

Der Trait Calculate erbt1 das Verhalten der Traits Mul, Add und Copy. Wir werden im Abschnitt über objektorientierte Programmierung genauer auf die Vererbung von Traits eingehen.

Benutzerdefinierte Traits

Man kann nicht nur bereitgestellte Traits verwenden, man kann auch selbst welche definieren. Wir werden dafür im Folgenden zwei Beispiele sehen.

Auch für einen Strukturtypen, der einen Kreis repräsentiert, kann man den Abstand zum Ursprung bestimmen. Um den quadratischen Abstand des Kreisrands zur 0 zu bestimmen, benötigen wir allerdings die Wurzel. Die Rust Standardbibliothek stellt keinen Wurzel-Trait bereit. Daher implementieren wir einen entsprechenden Trait Sqrt selbst. Da nur f32 und f64 das Wurzelziehen unterstützen, implementieren wir Sqrt nur für f32 und f64.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Calculate
{
    x: T,
    y: T,
}

impl<T> Point<T> 
where 
    T: Calculate
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> where T: Calculate {
    type Output = Self;
    fn add (self, rhs: Self) -> Self {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}
trait Sqrt {
    fn sqrt(self) -> Self;
}
impl Sqrt for f64 
{
    fn sqrt(self) -> f64 {
        self.sqrt()
    }
}
impl Sqrt for f32 
{
    fn sqrt(self) -> f32 {
        self.sqrt()
    }
}

struct Circle<T: Calculate> {
    center: Point<T>,
    r: T
}
impl<T> Circle<T> 
where 
    T: Calculate + Sqrt
{
    fn squared_dist_to_0(&self) -> T {
        let d = self.center.squared_dist_to_0().sqrt() - self.r;
        d * d
    }
}
}

Um unsere Funktion longest_dist_to_0 auch auf Kreise anwenden zu können, brauchen wir einen weiteren Trait.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
trait MeasureDistanceTo0<T: Calculate> {
    fn squared_dist_to_0(&self) -> T; 
}
}

Nun ändern wir Point und Circle so, dass sie squared_dist_to_0 als Funktion von MeasureDistanceTo0 implementieren.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
trait MeasureDistanceTo0<T: Calculate> {
   fn squared_dist_to_0(&self) -> T; 
}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Calculate
{
    x: T,
    y: T,
}
trait Sqrt {
    fn sqrt(self) -> Self;
}
impl Sqrt for f64 
{
    fn sqrt(self) -> f64 {
        self.sqrt()
    }
}
impl Sqrt for f32 
{
    fn sqrt(self) -> f32 {
        self.sqrt()
    }
}
impl<T> MeasureDistanceTo0<T> for Point<T> 
where 
    T: Calculate 
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}
struct Circle<T: Calculate> {
   center: Point<T>,
   r: T
}
impl<T> MeasureDistanceTo0<T> for Circle<T> 
where 
    T: Calculate + Sqrt
{
    fn squared_dist_to_0(&self) -> T {
        self.center.squared_dist_to_0().sqrt() - self.r
    }
}
}

Damit können wir longest_dist_to_0 so anpassen, dass sie Punkte mit Kreisen bzgl. ihres Abstands zum Ursprung vergleichen kann.

use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
trait MeasureDistanceTo0<T: Calculate> {
   fn squared_dist_to_0(&self) -> T; 
}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Calculate
{
    x: T,
    y: T,
}
impl<T> MeasureDistanceTo0<T> for Point<T> 
where 
    T: Calculate 
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}
struct Circle<T: Calculate> {
   center: Point<T>,
   r: T
}
trait Sqrt {
    fn sqrt(self) -> Self;
}
impl Sqrt for f64 
{
    fn sqrt(self) -> f64 {
        self.sqrt()
    }
}
impl Sqrt for f32 
{
    fn sqrt(self) -> f32 {
        self.sqrt()
    }
}
impl<T> MeasureDistanceTo0<T> for Circle<T> 
where 
    T: Calculate + Sqrt
{
    fn squared_dist_to_0(&self) -> T {
        self.center.squared_dist_to_0().sqrt() - self.r
    }
}
fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T
where
    T: Calculate + PartialOrd,
    M1: MeasureDistanceTo0<T>,
    M2: MeasureDistanceTo0<T>
{
    let d1 = p1.squared_dist_to_0();
    let d2 = p2.squared_dist_to_0();
    if  d1 > p2.squared_dist_to_0() {
        d1
    } else {
        d2
    }
}
fn main() {
    let p = Point{ x: 1.0, y: 1.0 };
    let r = 0.5;
    let c = Circle{ center: p, r };
    let dist = longest_dist_to_0(p, c);
    
    println!("{}", dist);
}

Zur Ausgabe auf dem Bildschirm gibt es die traits std::fmt::Display und std::fmt::Debug. Im Falle von Point können wir sie mittels #[derive(Display, Debug)] herleiten. Display definiert, was in einem println!-Makro innerhalb von {} angezeigt wird, während Debug für hilfreiche Debugging-Informationen bei Verwendung von {:?} oder der pretty-print-Version {:#?} sorgt.

Quiz

  • Wofür ist der Trait Drop zuständig?

  • Unter welchen Bedingungen kann ein Typ in Rust den Trait Copy implementieren und was muss man tun damit er das auch wirklich tut?

  • Erstelle einen Strukturtypen Line der eine beliebige Gerade im \( \mathbb R^2 \) repräsentiert. Implementiere für Line den Trait MeasureDistanceTo0 und verwende Line in der Funktion longest_dist_to_0. Wenn Line durch ihren Normalenvektor \( n \in \mathbb R^2 \) und einen beliebigen Punkt auf der Geraden \( p \in \mathbb R^2 \) gegeben ist, lässt sich der Abstand \( d \) zum Ursprung durch \( d=\underbrace{<\frac{n}{ |n| }, p>}_{\text{Skalarprodukt}} \) berechnen, wobei \( | \cdot |: \mathbb R^2 \rightarrow \mathbb R \) die euklidische Norm in \( \mathbb R^2 \) bezeichnet. Dementsprechend gilt \[ |n| = \sqrt{n_1^2 + n_2^2}. \]

Default-Implementierungen

Traits können Default-Implementierungen von Methoden bereit stellen wie im folgenden Beispiel.

use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
trait Sqrt {
    fn sqrt(self) -> Self;
}
impl Sqrt for f64 
{
    fn sqrt(self) -> f64 {
        self.sqrt()
    }
}
impl Sqrt for f32 
{
    fn sqrt(self) -> f32 {
        self.sqrt()
    }
}
trait MeasureDistanceTo0<T: Calculate + Sqrt> {
    fn squared_dist_to_0(&self) -> T; 
    
    fn dist_to_0(&self) -> T {
        self.squared_dist_to_0().sqrt()
    }
}
#[derive(Copy, Clone)]
struct Point<T>
where 
    T: Calculate
{
    x: T,
    y: T,
}

impl<T> MeasureDistanceTo0<T> for Point<T> 
where 
    T: Calculate + Sqrt
{
    fn squared_dist_to_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

fn main() {
    let p = Point{ x: 1.0, y: 1.0 };
    let squared_dist = p.squared_dist_to_0();
    let dist = p.dist_to_0();
    println!("{squared_dist}, {dist}");
}

Default-Implementierungen können über self nur auf andere Methoden des Traits zugreifen, aber nicht auf Methoden oder Felder der implementierenden Strukturtypen. Typen können auch eigene Implementierungen bereitstellen und die Default-Implementierung überschreiben. Die Syntax ist unabhängig von der Existenz einer Default-Implementierung.


1: Das ist übrigens die einzige Art Vererbung, die in Rust existiert. Es können nur Traits voneinander erben. Strukturtypen können das glücklicherweise nicht, wie man es von bedauernswerten objektorientierten Programmiersprachen kennt.