An den Fingern: zugeordnete Typen in Rust und was ist ihr Unterschied zu Typargumenten

Warum sind Rust Typen zugeordnet, und was ist der Unterschied zwischen ihnen und Typargumenten, auch Generika genannt, weil sie so ähnlich sind? Ist es nicht genug, nur letzteres, wie in allen normalen Sprachen? Für diejenigen, die gerade erst anfangen, Rust zu lernen, und insbesondere für Leute, die aus anderen Sprachen kommen ("Das sind Generika!" - der seit Jahren weise Javist wird sagen), stellt sich regelmäßig eine solche Frage. Lass es uns richtig machen.


TL; DR Ersteres steuert den aufgerufenen Code, letzteres den Anrufer.


Generika gegen zugehörige Typen


Wir haben also bereits Typargumente oder die beliebtesten Generika aller. Es sieht ungefähr so ​​aus:


trait Foo<T> { fn bar(self, x: T); } 

Hier ist T genau das Typargument. Es scheint, dass dies für alle ausreichen sollte (wie 640 Kilobyte Speicher). In Rust gibt es aber auch zugehörige Typen, etwa so:


 trait Foo { type Bar; //    fn bar(self, x: Self::Bar); } 

Auf den ersten Blick die gleichen Eier, aber aus einem anderen Blickwinkel. Warum mussten Sie eine andere Entität in die Sprache einführen? (Was übrigens nicht in den frühen Versionen der Sprache war.)


Typargumente sind genau Argumente . Dies bedeutet, dass sie an das Merkmal am Ort des Aufrufs übergeben werden und die Kontrolle darüber, welcher Typ anstelle von T , dem Aufrufer gehört. Auch wenn wir T am Aufrufort nicht explizit angeben, T der Compiler dies für uns mithilfe der Typinferenz. Das heißt, implizit wird dieser Typ vom Aufrufer abgeleitet und als Argument übergeben. (Natürlich geschieht dies alles während der Kompilierung, nicht zur Laufzeit.)


Betrachten Sie ein Beispiel. Die Standardbibliothek verfügt über ein AsRef- AsRef , mit dem ein Typ für eine Weile vorgeben kann, ein anderer Typ zu sein, und einen Link zu sich selbst in einen Link zu etwas anderem konvertiert. Vereinfacht ausgedrückt sieht dieses Merkmal so aus (in Wirklichkeit ist es etwas komplizierter, ich habe absichtlich alles Unnötige entfernt und nur das für das Verständnis notwendige Minimum übrig gelassen):


 trait AsRef<T> { fn as_ref(&self) -> &T; } 

Hier wird der Typ T vom Aufrufer als Argument übergeben, auch wenn dies implizit geschieht (wenn der Compiler diesen Typ für Sie ableitet). Mit anderen Worten, es ist der Anrufer, der entscheidet, welcher neue Typ T vorgibt, unser Typ zu sein, der dieses Merkmal implementiert:


 let foo = Foo::new(); let bar: &Bar = foo.as_ref(); 

Hier verwendet der Compiler unter Verwendung der Kenntnisse von bar: &Bar die AsRef<Bar> , um die as_ref() -Methode as_ref() , da dies der vom Aufrufer benötigte as_ref() ist. Es versteht sich von selbst, dass der Foo Typ das AsRef<Bar> AsRef AsRef<Bar> implementieren muss. Außerdem kann er so viele andere AsRef<T> implementieren, unter denen der Aufrufer die gewünschte auswählt.


Beim zugehörigen Typ ist alles genau umgekehrt. Der zugehörige Typ wird vollständig von denjenigen gesteuert, die dieses Merkmal implementieren, und nicht vom Anrufer.


Ein häufiges Beispiel ist ein Iterator. Angenommen, wir haben eine Sammlung und möchten einen Iterator daraus erhalten. Welche Art von Werten sollte der Iterator zurückgeben? Genau die in dieser Sammlung enthaltene! Es ist nicht Sache des Anrufers, zu entscheiden, was der Iterator zurückgibt, und der Iterator selbst weiß besser, was er genau zurückgeben kann. Hier ist der abgekürzte Code aus der Standardbibliothek:


 trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } 

Beachten Sie, dass der Iterator keinen Typparameter hat, mit dem der Aufrufer auswählen kann, was der Iterator zurückgeben soll. Stattdessen wird der Typ des von der next() -Methode zurückgegebenen Werts vom Iterator selbst unter Verwendung des zugehörigen Typs bestimmt, aber er bleibt nicht bei Nägeln hängen, d. H. Jede Iterator-Implementierung kann ihren Typ auswählen.


Hör auf Na und? Trotzdem ist nicht klar, warum dies besser ist als ein Generikum. Stellen Sie sich für einen Moment vor, wir verwenden das übliche Generikum anstelle des zugehörigen Typs. Das Merkmal des Iterators sieht dann ungefähr so ​​aus:


 trait GenericIterator<T> { fn next(&mut self) -> Option<T>; } 

Aber erstens muss der Typ T an jeder Stelle, an der der Iterator erwähnt wird, immer wieder angegeben werden, und zweitens ist es jetzt möglich geworden, dieses Merkmal mehrmals mit verschiedenen Typen zu implementieren, was für den Iterator irgendwie seltsam aussieht. Hier ist ein Beispiel:


 struct MyIterator; impl GenericIterator<i32> for MyIterator { fn next(&mut self) -> Option<i32> { unimplemented!() } } impl GenericIterator<String> for MyIterator { fn next(&mut self) -> Option<String> { unimplemented!() } } fn test() { let mut iter = MyIterator; let lolwhat: Option<_> = iter.next(); // Error! Which impl of GenericIterator to use? } 

Sehen Sie den Haken? Wir können iter.next() nicht einfach ohne Kniebeugen nehmen und aufrufen - wir müssen dem Compiler explizit oder implizit iter.next() , welcher Typ zurückgegeben wird. Und es sieht unangenehm aus: Warum sollten wir auf der Aufrufseite wissen (und dem Compiler mitteilen!), Welchen Typ der Iterator zurückgeben wird, während dieser Iterator besser wissen sollte, welchen Typ er zurückgibt ?! Und das alles, weil wir das GenericIterator- GenericIterator zweimal mit einem anderen Parameter für denselben MyIterator , was aus Sicht der Iteratorsemantik auch lächerlich aussieht: Warum kann derselbe Iterator Werte unterschiedlichen Typs zurückgeben?


Wenn wir zur Variante mit dem zugehörigen Typ zurückkehren, können all diese Probleme vermieden werden:


 struct MyIter; impl Iterator for MyIter { type Item = String; fn next(&mut self) -> Option<Self::Item> { unimplemented!() } } fn test() { let mut iter = MyIter; let value = iter.next(); } 

Hier gibt der Compiler zum einen den value: Option<String> korrekt aus value: Option<String> ohne unnötige Wörter, und zum anderen funktioniert es nicht, das Iterator MyIter für MyIter zweites Mal mit einem anderen Rückgabetyp zu implementieren und damit alles zu ruinieren.


Zur Befestigung. Eine Sammlung kann ein solches Merkmal implementieren, um sich selbst in einen Iterator verwandeln zu können:


 trait IntoIterator { type Item; type IntoIter: Iterator<Item=Self::Item>; fn into_iter(self) -> Self::IntoIter; } 

Und wieder ist es hier die Sammlung, die entscheidet, was Iterator sein wird, nämlich: ein Iterator, dessen Rückgabetyp mit dem Typ der Elemente in der Sammlung selbst übereinstimmt, und kein anderer.


Mehr an den Fingern


Wenn die obigen Beispiele immer noch unverständlich sind, finden Sie hier eine noch weniger wissenschaftliche, aber verständlichere Erklärung. Typargumente können als Eingabeinformationen betrachtet werden, die wir bereitstellen, damit das Merkmal funktioniert. Zugehörige Typen können als "Ausgabe" -Informationen betrachtet werden, die uns das Merkmal liefert, damit wir die Ergebnisse seiner Arbeit verwenden können.


Die Standardbibliothek kann mathematische Operatoren für ihre Typen (Addition, Subtraktion, Multiplikation, Division und dergleichen) überladen. Dazu müssen Sie eines der entsprechenden Merkmale aus der Standardbibliothek implementieren. Hier zum Beispiel, wie dieses Merkmal für die Additionsoperation aussieht (wieder vereinfacht):


 trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output; } 

Hier haben wir das RHS Argument "input" - dies ist der Typ, auf den wir die Additionsoperation mit unserem Typ anwenden werden. Und es gibt ein "Ausgabe" -Argument Add::Output - dies ist der Typ, der sich aus der Addition ergibt. Im allgemeinen Fall kann es sich von der Art der Begriffe unterscheiden, die wiederum auch von unterschiedlicher Art sein können (fügen Sie dem Blau Leckeres hinzu und werden Sie weich - aber was, ich mache das die ganze Zeit). Der erste wird mit dem Argument type angegeben, der zweite mit dem zugehörigen Typ.


Sie können eine beliebige Anzahl von Hinzufügungen mit unterschiedlichen Typen des zweiten Arguments implementieren, aber jedes Mal gibt es nur einen Ergebnistyp, der durch die Implementierung dieses Zusatzes bestimmt wird.


Versuchen wir, dieses Merkmal zu implementieren:


 use std::ops::Add; struct Foo(&'static str); #[derive(PartialEq, Debug)] struct Bar(&'static str, i32); impl Add<i32> for Foo { type Output = Bar; fn add(self, rhs: i32) -> Bar { Bar(self.0, rhs) } } fn test() { let x = Foo("test"); let y = x + 42; //      <Foo as Add>::add(42)  x assert_eq!(y, Bar("test", 42)); } 

In diesem Beispiel wird der Typ der Variablen y durch den Additionsalgorithmus bestimmt, nicht durch den aufrufenden Code. Es wäre sehr seltsam, wenn es möglich wäre, etwas wie let y: Baz = x + 42 zu schreiben, dh die Additionsoperation zu zwingen, ein Ergebnis eines fremden Typs zurückzugeben. Aus solchen Add::Output versichert uns der zugehörige Typ Add::Output .


Insgesamt


Wir verwenden Generika, bei denen es uns nichts ausmacht, mehrere Trait-Implementierungen für denselben Typ zu haben, und bei denen es akzeptabel ist, auf der Aufrufseite eine bestimmte Implementierung anzugeben. Wir verwenden zugehörige Typen, bei denen wir eine "kanonische" Implementierung haben möchten, die selbst die Typen steuert. Kombinieren und mischen Sie in den richtigen Proportionen, wie im letzten Beispiel.


Ist die Münze ausgefallen? Töte mich mit Kommentaren.

Source: https://habr.com/ru/post/de441444/


All Articles