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.
- Es treten neben den Traits
MulundAddder TraitCopyauf. Typen, die den TraitCopyimplementieren, verhalten sich kopierbar. Das heißtself.x * self.xmultipliziert zwei Kopien vonself.x. Es findet kein Move statt.Quiz
Was würde passieren, wenn
TnichtCopyimplementierte? - Mehrere Traits, die durch ein Plus separiert werden wie
Mul<Output=T> + Add<Output=T> + Copy, müssen allesamt implementiert worden sein. - Die Traits
MulundAddhaben ein generisches ArgumentOutput = T. Das ist ein assoziierter Typ, der den Rückgabewert der Addition festlegt. Oft verwendet man hierSelf. 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
Dropzuständig?Unter welchen Bedingungen kann ein Typ in Rust den Trait
Copyimplementieren und was muss man tun damit er das auch wirklich tut?Erstelle einen Strukturtypen
Lineder eine beliebige Gerade im \( \mathbb R^2 \) repräsentiert. Implementiere fürLineden TraitMeasureDistanceTo0und verwendeLinein der Funktionlongest_dist_to_0. WennLinedurch 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.