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
Mul
undAdd
der TraitCopy
auf. Typen, die den TraitCopy
implementieren, verhalten sich kopierbar. Das heißtself.x * self.x
multipliziert zwei Kopien vonself.x
. Es findet kein Move statt.Quiz
Was würde passieren, wenn
T
nichtCopy
implementierte? - Mehrere Traits, die durch ein Plus separiert werden wie
Mul<Output=T> + Add<Output=T> + Copy
, müssen allesamt implementiert worden sein. - Die Traits
Mul
undAdd
haben 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 trait
s 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ürLine
den TraitMeasureDistanceTo0
und verwendeLine
in der Funktionlongest_dist_to_0
. WennLine
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.