Kapitel 1. Typen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Im ersten Kapitel dieses Buches geht es um Ratschläge, die sich um das Typsystem von Rust drehen. Dieses Typensystem ist ausdrucksstärker als das anderer Mainstream-Sprachen; es hat mehr mit "akademischen" Sprachen wie OCaml oder Haskell gemein.

Ein zentraler Teil davon ist der enum Typ von Rust, der wesentlich ausdrucksstärker ist als die Aufzählungstypen in anderen Sprachen und der algebraische Datentypen ermöglicht.

In diesem Kapitel geht es um die grundlegenden Typen, die die Sprache bereitstellt, und darum, wie du sie zu Datenstrukturen kombinierst, die die Semantik deines Programms präzise ausdrücken. Dieses Konzept der Kodierung von Verhalten in das Typsystem trägt dazu bei, den Umfang des erforderlichen Prüf- und Fehlerpfadcodes zu verringern, da ungültige Zustände von der Toolchain zur Kompilierzeit und nicht vom Programm zur Laufzeit zurückgewiesen werden.

In diesem Kapitel werden auch einige der allgegenwärtigen Datenstrukturen beschrieben, die in der Standardbibliothek von Rust zur Verfügung stehen:Options, Results, Errors und Iterators. Die Vertrautheit mit diesen Standardwerkzeugen hilft dir, ein idiomatisches Rust zu schreiben, das effizient und kompakt ist - insbesondere ermöglichen sie die Verwendung des Fragezeichenoperators von Rust, der eine unauffällige, aber dennoch typsichere Fehlerbehandlung unterstützt.

Beachte, dass Items, die Rust-Traits beinhalten, im folgenden Kapitel behandelt werden, aber es gibt zwangsläufig eine gewisse Überschneidung mit den Items in diesem Kapitel, da Traits das Verhalten von Typen beschreiben.

Punkt 1: Verwende das Typensystem, umdeine Datenstrukturen auszudrücken

die sie Programmierer und nicht Schreibkräfte nannten

@thingskatedid

Dieser Artikel gibt einen kurzen Überblick über das Typensystem von Rust. Er beginnt mit den grundlegenden Typen, die der Compiler zur Verfügung stellt, und geht dann zu den verschiedenen Möglichkeiten über, wie Werte zu Datenstrukturen kombiniert werden können.

Der Typ enum von Rust spielt dabei die Hauptrolle. Obwohl die Grundversion dem entspricht, was andere Sprachen bieten, ermöglicht die Möglichkeit, enum Varianten mit Datenfeldern zu kombinieren, eine größere Flexibilität und Ausdruckskraft.

Grundlegende Typen

Die Grundlagen des Typsystems von Rust sind jedem, der aus einer anderen statisch typisierten Programmiersprache (wie C++, Go oder Java) kommt, ziemlich vertraut. Es gibt eine Sammlung von Ganzzahltypen mit bestimmten Größen, sowohl vorzeichenbehaftet (i8, i16, i32, i64, i128) und ohne Vorzeichen (u8, u16, u32, u64, u128).

Es gibt auch vorzeichenbehaftete (isize) und vorzeichenlose (usize) Ganzzahlen, deren Größe der Zeigergröße auf dem Zielsystem entspricht. Allerdings wirst du mit Rust nicht viel zwischen Zeigern und ganzen Zahlen konvertieren, sodass die Größengleichheit nicht wirklich relevant ist. Standard-Sammlungen geben ihre Größe jedoch als usize (von .len()) zurück, so dass die Indexierung von Sammlungen bedeutet, dass die Werte von usize recht häufig vorkommen - was aus Kapazitätssicht natürlich in Ordnung ist, da es nicht mehr Elemente in einer speicherinternen Sammlung geben kann, als es Speicheradressen auf dem System gibt.

Die ganzzahligen Typen geben uns den ersten Hinweis darauf, dass Rust eine strengere Welt ist als C++. In Rust führt der Versuch, einen größeren ganzzahligen Typ (i32) in einen kleineren ganzzahligen Typ (i16) einzufügen, zu einem Kompilierfehler:

error[E0308]: mismatched types
  --> src/main.rs:18:18
   |
18 |     let y: i16 = x;
   |            ---   ^ expected `i16`, found `i32`
   |            |
   |            expected due to this
   |
help: you can convert an `i32` to an `i16` and panic if the converted value
      doesn't fit
   |
18 |     let y: i16 = x.try_into().unwrap();
   |                   ++++++++++++++++++++

Das ist beruhigend: Rust wird nicht stillschweigend daneben sitzen, während der Programmierer Dinge tut, die riskant sind. Obwohl wir sehen können, dass die Werte in dieser speziellen Umwandlung in Ordnung sind, muss der Compiler die Möglichkeit von Werten berücksichtigen, bei denen die Umwandlung nicht in Ordnung ist:

Die Fehlerausgabe gibt auch einen ersten Hinweis darauf, dass Rust zwar strengere Regeln hat, aber auch hilfreiche Compiler-Meldungen, die den Weg zur Einhaltung der Regeln weisen. Die vorgeschlagene Lösung wirft die Frage auf, wie mit Situationen umzugehen ist, in denen die Konvertierung den Wert ändern müsste, damit er passt. Sowohl zur Fehlerbehandlung(Punkt 4) als auch zur Verwendung von panic! (Punkt 18) werden wir später mehr zu sagen haben.

Rust lässt auch einige Dinge nicht zu, die "sicher" erscheinen, wie z.B. das Einfügen eines Wertes von einem kleineren Ganzzahltyp in einen größeren Ganzzahltyp:

error[E0308]: mismatched types
  --> src/main.rs:36:18
   |
36 |     let y: i64 = x;
   |            ---   ^ expected `i64`, found `i32`
   |            |
   |            expected due to this
   |
help: you can convert an `i32` to an `i64`
   |
36 |     let y: i64 = x.into();
   |                   +++++++

In diesem Fall wirft die vorgeschlagene Lösung nicht das Schreckgespenst der Fehlerbehandlung auf, aber die Umwandlung muss trotzdem explizit sein. Wir werden die Typumwandlung später noch genauer besprechen(Punkt 5).

Bei den primitiven Typen gibt es in Rust einen bool Typ, Fließkommatypen (f32, f64), und einen Einheitstyp () (wie C's void).

Noch interessanter ist der char Zeichentyp, der einenUnicode-Wert enthält (ähnlich wie der rune Typ von Go). Obwohl er intern als vier Bytes gespeichert wird, gibt es auch hier keine stille Konvertierung in oder von einer 32-Bit-Ganzzahl.

Diese Präzision im Typensystem zwingt dich dazu, genau zu sagen, was du ausdrücken willst - ein u32 Wert unterscheidet sich von einem char, der sich wiederum von einer Folge von UTF-8 Bytes unterscheidet, die sich wiederum von einer Folge beliebiger Bytesunterscheidet.1 Joel Spolskys berühmter Blogbeitrag kann dir helfen zu verstehen, was du brauchst.

Natürlich gibt es Hilfsmethoden, mit denen du zwischen diesen verschiedenen Typen konvertieren kannst, aber ihre Signaturen zwingen dich dazu, die Möglichkeit eines Fehlers zu behandeln (oder explizit zu ignorieren). Zum Beispiel kann ein Unicode-Codepunkt immer in 32 Bit dargestellt werden,2'a' as u32 ist also zulässig, aber die andere Richtung ist schwieriger (da es einige u32 Werte gibt, die keine gültigen Unicode-Codepunkte sind):

char::from_u32

Gibt ein Option<char> zurück und zwingt den Aufrufer, den Fehlerfall zu behandeln.

char::from_u32_unchecked

geht von der Gültigkeit aus, kann aber zu einem undefinierten Verhalten führen, wenn sich diese Annahme als falsch herausstellt. Die Funktion wird deshalb als unsafe markiert und zwingt den Aufrufer, auch unsafe zu verwenden(Punkt 16).

Aggregat-Typen

Bei den Aggregatstypen gibt es in Rust eine Vielzahl von Möglichkeiten, zusammengehörige Werte zu kombinieren. Die meisten dieser Möglichkeiten entsprechen den Aggregationsmechanismen, die in anderen Sprachen verfügbar sind:

Arrays

Enthält mehrere Instanzen eines einzigen Typs, wobei die Anzahl der Instanzen zur Kompilierzeit bekannt ist. [u32; 4] zum Beispiel sind vier 4-Byte-Ganzzahlen in einer Reihe.

Tupel

Instanzen mehrerer heterogener Typen enthalten, bei denen die Anzahl der Elemente und ihre Typen zur Kompilierzeit bekannt sind, z. B.(WidgetOffset, WidgetSize, WidgetColor). Wenn die Typen in dem Tupel nicht unterscheidbar sind - zum Beispiel (i32, i32, &'static str, bool)- ist es besser, jedem Element einen Namen zu geben und eine Struktur zu verwenden.

Strukturen

Sie halten auch Instanzen heterogener Typen fest, die zur Kompilierzeit bekannt sind, erlauben aber, dass sowohl der Gesamttyp als auch die einzelnen Felder durch Namen referenziert werden können.

In Rust gibt es auch die Tupel-Struktur, die eine Kreuzung aus struct und Tupel ist: Es gibt einen Namen für den Gesamttyp, aber keine Namen für die einzelnen Felder - sie werden stattdessen mit Nummern bezeichnet: s.0, s.1, und so weiter:

/// Struct with two unnamed fields.
struct TextMatch(usize, String);

// Construct by providing the contents in order.
let m = TextMatch(12, "needle".to_owned());

// Access by field number.
assert_eq!(m.0, 12);

enums

Das bringt uns zu dem Juwel in der Krone des Typsystems von Rust, dem enum. Bei der Grundform eines enum ist es schwer zu erkennen, was es da zu entdecken gibt. Wie in anderen Sprachen kannst du mit enum eine Reihe von sich gegenseitig ausschließenden Werten angeben, die möglicherweise mit einem numerischen Wert verbunden sind:

enum HttpResultCode {
    Ok = 200,
    NotFound = 404,
    Teapot = 418,
}

let code = HttpResultCode::NotFound;
assert_eq!(code as i32, 404);

Da jede enum Definition einen eigenen Typ erzeugt, kann dies genutzt werden, um die Lesbarkeit und Wartbarkeit von Funktionen zu verbessern, die bool Argumente annehmen. Anstelle von:

print_page(/* both_sides= */ true, /* color= */ false);

eine Version, die ein Paar von enums verwendet:

pub enum Sides {
    Both,
    Single,
}

pub enum Output {
    BlackAndWhite,
    Color,
}

pub fn print_page(sides: Sides, color: Output) {
    // ...
}

ist typsicherer und einfacher zu lesen, wenn es aufgerufen wird:

print_page(Sides::Both, Output::BlackAndWhite);

Anders als bei der Version bool würde sich der Compiler sofort beschweren, wenn ein Bibliotheksbenutzer versehentlich die Reihenfolge der Argumente vertauschen würde:

error[E0308]: arguments to this function are incorrect
   --> src/main.rs:104:9
    |
104 | print_page(Output::BlackAndWhite, Sides::Single);
    | ^^^^^^^^^^ ---------------------  ------------- expected `enums::Output`,
    |            |                                    found `enums::Sides`
    |            |
    |            expected `enums::Sides`, found `enums::Output`
    |
note: function defined here
   --> src/main.rs:145:12
    |
145 |     pub fn print_page(sides: Sides, color: Output) {
    |            ^^^^^^^^^^ ------------  -------------
help: swap these arguments
    |
104 | print_page(Sides::Single, Output::BlackAndWhite);
    |             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Die Verwendung des newtype-Musters - siehe Punkt 6 - umeine bool zuverpacken, sorgt ebenfalls für Typsicherheit und Wartungsfreundlichkeit. Es ist im Allgemeinen am besten, das newtype-Muster zu verwenden, wenn die Semantik immer boolesch sein wird, und enum zu verwenden, wenn die Möglichkeit besteht, dass eine neue Alternative - z. B. Sides::BothAlternateOrientation- in der Zukunft entstehen könnte.

Die Typsicherheit von Rust's enums setzt sich mit dem match Ausdruck fort:

error[E0004]: non-exhaustive patterns: `HttpResultCode::Teapot` not covered
  --> src/main.rs:44:21
   |
44 |     let msg = match code {
   |                     ^^^^ pattern `HttpResultCode::Teapot` not covered
   |
note: `HttpResultCode` defined here
  --> src/main.rs:10:5
   |
7  | enum HttpResultCode {
   |      --------------
...
10 |     Teapot = 418,
   |     ^^^^^^ not covered
   = note: the matched value is of type `HttpResultCode`
help: ensure that all possible cases are being handled by adding a match arm
      with a wildcard pattern or an explicit pattern as shown
   |
46 ~         HttpResultCode::NotFound => "Not found",
47 ~         HttpResultCode::Teapot => todo!(),
   |

Der Compiler zwingt den Programmierer dazu, alle Möglichkeiten zu berücksichtigen, die durch die enum repräsentiert werden,3 zu berücksichtigen, selbst wenn das Ergebnis nur darin besteht, einen Standardarm _ => {} hinzuzufügen. (Beachte, dass moderne C++-Compiler auch bei enumvor fehlenden switch Armen warnen können und dies auch tun).

enums mit Feldern

Die wahre Stärke der enum Funktion von Rust liegt in der Tatsache, dass jede Variante Daten haben kann, die mit ihr einhergehen, was sie zu einem aggregierten Typ macht, der als algebraischer Datentyp (ADT) fungiert. Das ist Programmierern von Mainstream-Sprachen weniger vertraut; in C/C++ ausgedrückt, ist es wie eine Kombination aus enum und union-nur typsicher.

Das bedeutet, dass die Invarianten der Datenstrukturen des Programms im Typsystem von Rust kodiert werden können; Zustände, die diesen Invarianten nicht entsprechen, werden nicht kompiliert. Ein gut gestaltetes enum macht die Absicht des Erstellers sowohl für den Menschen als auch für den Compiler deutlich:

use std::collections::{HashMap, HashSet};

pub enum SchedulerState {
    Inert,
    Pending(HashSet<Job>),
    Running(HashMap<CpuId, Vec<Job>>),
}

Die Definition des Typs lässt vermuten, dass Jobim Status Pending in eine Warteschlange gestellt wird, bis das Zeitplannungsprogramm vollständig aktiv ist.

Das unterstreicht das zentrale Thema dieses Artikels, nämlich die Verwendung des Typensystems von Rust, um die Konzepte auszudrücken, die mit dem Design deiner Software verbunden sind.

Ein eindeutiges Indiz dafür, dass dies nicht der Fall ist, ist ein Kommentar, der erklärt, wann ein Feld oder ein Parameter gültig ist:

Dies ist ein erstklassiger Kandidat für den Austausch gegen eine enum mit Daten:

pub enum Color {
    Monochrome,
    Foreground(RgbColor),
}

pub struct DisplayProps {
    pub x: u32,
    pub y: u32,
    pub color: Color,
}

Dieses kleine Beispiel veranschaulicht einen wichtigen Ratschlag: Mach ungültige Zustände in deinen Typen unaussprechlich. Typen, die nur gültige Kombinationen von Werten unterstützen, bedeuten, dass ganze Klassen von Fehlern vom Compiler zurückgewiesen werden, was zu kleinerem und sichererem Code führt.

Allgegenwärtige enum Typen

Um auf die Leistungsfähigkeit von enum zurückzukommen, gibt es zwei Konzepte, die so verbreitet sind, dass die Standardbibliothek von Rust eingebaute enum Typen enthält, um sie auszudrücken; diese Typen sind in Rust-Code allgegenwärtig.

Option<T>

Das erste Konzept ist das eines Option: Entweder gibt es einen Wert eines bestimmten Typs (Some(T)) oder nicht (None). Verwende immerOption für Werte, die nicht vorhanden sein können; greife nie auf Sentinel-Werte (-1, nullptr, ...) zurück, um dasselbe Konzept in-band auszudrücken.

Es gibt jedoch einen feinen Punkt zu beachten. Wenn du es mit einer Sammlung von Dingen zu tun hast, musst du entscheiden, ob null Dinge in der Sammlung dasselbe sind wie keine Sammlung zu haben. In den meisten Situationen gibt es diesen Unterschied nicht und du kannst (z. B.) Vec<Thing> verwenden: Eine Anzahl von null Dingen bedeutet, dass es keine Dinge gibt.

Es gibt jedoch definitiv andere seltene Szenarien, in denen die beiden Fälle mitOption<Vec<Thing>>unterschieden werden müssen - zum Beispiel könnte ein kryptografisches System zwischen "separat transportierter Nutzlast" und "bereitgestellter leerer Nutzlast" unterscheiden müssen. (Dies hängt mit den Debatten um die NULL Markierung für Spalten in SQL zusammen).

Was ist die beste Wahl für ein String, das nicht vorhanden sein könnte? Macht "" oder None mehr Sinn, um das Fehlen eines Wertes anzuzeigen? Beides geht, aber Option<String> zeigt deutlich, dass dieser Wert fehlen könnte.

Result<T, E>

Das zweite gemeinsame Konzept ergibt sich aus der Fehlerverarbeitung: Wenn eine Funktion fehlschlägt, wie soll dieser Fehler gemeldet werden? In der Vergangenheit wurden spezielle Sentinel-Werte (z. B. -errno Rückgabewerte von Linux-Systemaufrufen) oder globale Variablen (errno für POSIX-Systeme) verwendet. In neueren Sprachen, die mehrere Rückgabewerte oder Tupel unterstützen (z. B. Go), kann es üblich sein, ein (result, error) Paar zurückzugeben, wobei vorausgesetzt wird, dass es einen geeigneten "Null"-Wert für result gibt, wenn error nicht "Null" ist.

In Rust gibt es enum für genau diesen Zweck: Kodiere das Ergebnis einer Operation, die fehlschlagen könnte, immer als Result<T, E>. Der Typ T enthält das erfolgreiche Ergebnis (in der Variante Ok ), und der Typ E enthält die Fehlerdetails (in der Variante Err ) bei einem Fehlschlag.

Die Verwendung des Standardtyps macht die Absicht des Entwurfs deutlich. Sie ermöglicht auch die Verwendung von Standardtransformationen(Punkt 3) und die Fehlerverarbeitung(Punkt 4), was wiederum die Rationalisierung der Fehlerverarbeitung mit dem Operator ? ermöglicht.

Punkt 2: Verwende das Typensystem, um gemeinsames Verhalten auszudrücken

In Punkt 1 wurde erörtert, wie Datenstrukturen im Typsystem ausgedrückt werden können; in diesem Punkt geht es nun um die Kodierung vonVerhalten im Typsystem von Rust.

Die in diesem Artikel beschriebenen Mechanismen werden dir in der Regel bekannt vorkommen, da sie alle direkte Entsprechungen in anderen Sprachen haben:

Funktionen

Der universelle Mechanismus, um ein Stück Code mit einem Namen und einer Parameterliste zu verknüpfen.

Methoden

Funktionen, die mit einer Instanz einer bestimmten Datenstruktur verbunden sind. Methoden sind in Programmiersprachen üblich, die nach dem Aufkommen der Objektorientierung als Programmierparadigma entstanden sind.

Funktionszeiger

Wird von den meisten Sprachen der C-Familie, einschließlich C++ und Go, als Mechanismus unterstützt, dereine zusätzliche Ebene der Indirektion beim Aufruf von anderem Code ermöglicht.

Schließungen

Ursprünglich waren sie in der Lisp-Sprachfamilie am weitesten verbreitet, wurden aber in viele beliebteProgrammiersprachen nachgerüstet, darunter C++ (seit C++11) und Java (seit Java 8).

Eigenschaften

Beschreiben Sammlungen verwandter Funktionen, die sich alle auf dasselbe zugrunde liegende Element beziehen. Traits haben grobe Entsprechungen in vielen anderen Sprachen, darunter abstrakte Klassen in C++ und Schnittstellen in Go und Java.

Natürlich haben alle diese Mechanismen Rust-spezifische Details, die wir in diesem Artikel behandeln werden.

Von der vorangegangenen Liste haben Traits die größte Bedeutung für dieses Buch, da sie einen Großteil des Verhaltens beschreiben, das der Rust-Compiler und die Standardbibliothek bieten. Kapitel 2 konzentriert sich auf die Artikel, die Ratschläge zum Entwurf und zur Implementierung von Traits geben, aber ihre Allgegenwärtigkeit bedeutet, dass sie auch in den anderen Artikeln dieses Kapitels häufig auftauchen.

Funktionen und Methoden

Wie jede andere Programmiersprache verwendet auch Rust Funktionen, um den Code in benannte Teile zu gliedern und wiederzuverwenden, wobei die Eingaben in den Code als Parameter ausgedrückt werden. Wie bei jeder anderen statisch typisierten Sprache werden die Typen der Parameter und des Rückgabewerts explizit angegeben:

/// Return `x` divided by `y`.
fn div(x: f64, y: f64) -> f64 {
    if y == 0.0 {
        // Terminate the function and return a value.
        return f64::NAN;
    }
    // The last expression in the function body is implicitly returned.
    x / y
}

/// Function called just for its side effects, with no return value.
/// Can also write the return value as `-> ()`.
fn show(x: f64) {
    println!("x = {x}");
}

Wenn eine Funktion eng mit einer bestimmten Datenstruktur verbunden ist, wird sie als Methode bezeichnet. Eine Methode wirkt auf ein Element dieses Typs, das durch self gekennzeichnet ist, und ist in einem impl DataStructure Block enthalten. Auf diese Weise werden zusammengehörige Daten und Code auf eine objektorientierte Weise gekapselt, die anderen Sprachen ähnelt. In Rust können Methoden jedoch sowohl zu enum als auch zu struct Typen hinzugefügt werden, was der allgegenwärtigen Natur von Rusts enum (Punkt 1) entspricht:

enum Shape {
    Rectangle { width: f64, height: f64 },
    Circle { radius: f64 },
}

impl Shape {
    pub fn area(&self) -> f64 {
        match self {
            Shape::Rectangle { width, height } => width * height,
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        }
    }
}

Der Name einer Methode bezeichnet das Verhalten, das sie kodiert, und die Methodensignatur enthält Typinformationen für ihre Ein- und Ausgaben. Die erste Eingabe für eine Methode ist eine Variante von self, die angibt, was die Methode mit der Datenstruktur machen kann:

  • Der Parameter &self gibt an, dass der Inhalt der Datenstruktur gelesen, aber nicht verändert werden darf.

  • Ein &mut self Parameter zeigt an, dass die Methode den Inhalt der Datenstruktur verändern kann.

  • Ein self Parameter zeigt an, dass die Methode die Datenstruktur verbraucht.

Funktion Zeiger

Im vorherigen Abschnitt wurde beschrieben, wie man einen Namen (und eine Parameterliste) mit einem Code verknüpft. Der Aufruf einer Funktion führt jedoch immer dazu, dass derselbe Code ausgeführt wird; das Einzige, was sich von Aufruf zu Aufruf ändert, sind die Daten, mit denen die Funktion arbeitet. Das deckt viele mögliche Szenarien ab, aber was ist, wenn sich der Code zur Laufzeit ändern muss?

Die einfachste Verhaltensabstraktion, die dies ermöglicht, ist der Funktionszeiger: ein Zeiger auf (nur) einen Code mit einem Typ, der die Signatur der Funktion widerspiegelt:

fn sum(x: i32, y: i32) -> i32 {
    x + y
}
// Explicit coercion to `fn` type is required...
let op: fn(i32, i32) -> i32 = sum;

Der Typ wird bei der Kompilierung überprüft. Wenn das Programm ausgeführt wird, ist der Wert also nur die Größe eines Zeigers. Funktionszeiger haben keine anderen Daten, die mit ihnen verbunden sind, sodass sie auf verschiedene Weise als Werte behandelt werden können:

// `fn` types implement `Copy`
let op1 = op;
let op2 = op;
// `fn` types implement `Eq`
assert!(op1 == op2);
// `fn` implements `std::fmt::Pointer`, used by the {:p} format specifier.
println!("op = {:p}", op);
// Example output: "op = 0x101e9aeb0"

Ein technisches Detail, auf das du achten musst: Es ist eine explizite Zwangsumwandlung in einen fn Typ erforderlich, da du durch die Verwendung des Funktionsnamens nicht einfach etwas vom Typ fn erhältst:

error[E0369]: binary operation `==` cannot be applied to type
              `fn(i32, i32) -> i32 {main::sum}`
   --> src/main.rs:102:17
    |
102 |     assert!(op1 == op2);
    |             --- ^^ --- fn(i32, i32) -> i32 {main::sum}
    |             |
    |             fn(i32, i32) -> i32 {main::sum}
    |
help: use parentheses to call these
    |
102 |     assert!(op1(/* i32 */, /* i32 */) == op2(/* i32 */, /* i32 */));
    |                ++++++++++++++++++++++       ++++++++++++++++++++++

Stattdessen zeigt der Compilerfehler an, dass es sich um einen Typ wie fn(i32, i32) -> i32 {main::sum} handelt, einen Typ, der ausschließlich intern vom Compiler verwendet wird (d.h. nicht im Benutzercode geschrieben werden kann) und der sowohl die spezifische Funktion als auch ihre Signatur identifiziert. Anders ausgedrückt: Der Typ sum kodiert aus Optimierungsgründen sowohl die Signatur der Funktion als auch ihren Speicherort. Dieser Typ kann automatisch in einen fn Typ umgewandelt werden(Punkt 5).

Schließungen

Die bloßen Funktionszeiger sind einschränkend, denn die einzigen Eingaben, die der aufgerufenen Funktion zur Verfügung stehen, sind die, die explizit als Parameterwerte übergeben werden. Betrachten wir zum Beispiel einen Code, der jedes Element eines Slice mit einem Funktionszeiger verändert:

// In real code, an `Iterator` method would be more appropriate.
pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) {
    for value in data {
        *value = mutator(*value);
    }
}

Das funktioniert bei einer einfachen Mutation des Slice:

fn add2(v: u32) -> u32 {
    v + 2
}
let mut data = vec![1, 2, 3];
modify_all(&mut data, add2);
assert_eq!(data, vec![3, 4, 5]);

Wenn die Änderung jedoch von einem zusätzlichen Zustand abhängt, ist es nicht möglich, diesen implizit an den Funktionszeiger zu übergeben:

error[E0434]: can't capture dynamic environment in a fn item
   --> src/main.rs:125:13
    |
125 |         v + amount_to_add
    |             ^^^^^^^^^^^^^
    |
    = help: use the `|| { ... }` closure form instead

Die Fehlermeldung verweist auf das richtige Werkzeug für die Aufgabe: eine Closure. Eine Closure ist ein Stück Code, das wie der Körper einer Funktionsdefinition (ein Lambda-Ausdruck) aussieht, mit folgenden Ausnahmen:

  • Sie kann als Teil eines Ausdrucks erstellt werden und muss daher nicht mit einem Namen versehen werden.

  • Die Eingabeparameter werden in vertikalen Balken angegeben |param1, param2| (ihre zugehörigen Typen können in der Regel automatisch vom Compiler abgeleitet werden).

  • Sie kann Teile der Umgebung einfangen:

    let amount_to_add = 3;
    let add_n = |y| {
        // a closure capturing `amount_to_add`
        y + amount_to_add
    };
    let z = add_n(5);
    assert_eq!(z, 8);

Um (grob) zu verstehen, wie die Erfassung funktioniert, stell dir vor, dass der Compiler einen einmaligen, internen Typ erstellt, der alle Teile der Umgebung enthält, die im Lambda-Ausdruck erwähnt werden. Wenn die Closure erstellt wird, wird eine Instanz dieses ephemeren Typs erstellt, um die relevanten Werte zu speichern:

let amount_to_add = 3;
// *Rough* equivalent to a capturing closure.
struct InternalContext<'a> {
    // references to captured variables
    amount_to_add: &'a u32,
}
impl<'a> InternalContext<'a> {
    fn internal_op(&self, y: u32) -> u32 {
        // body of the lambda expression
        y + *self.amount_to_add
    }
}
let add_n = InternalContext {
    amount_to_add: &amount_to_add,
};
let z = add_n.internal_op(5);
assert_eq!(z, 8);

Die Werte, die in diesem fiktiven Kontext gehalten werden, sind oft Referenzen(Punkt 8) wie hier, aber können auch veränderbare Referenzen auf Dinge in der Umgebung sein oder Werte, die ganz aus der Umgebung herausgeschoben werden (indem das Schlüsselwort movevor den Eingabeparametern verwendet wird).

Um auf das Beispiel von modify_all zurückzukommen: Eine Closure kann nicht verwendet werden, wenn ein Funktionszeiger erwartet wird:

error[E0308]: mismatched types
   --> src/main.rs:199:31
    |
199 |         modify_all(&mut data, |y| y + amount_to_add);
    |         ----------            ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer,
    |         |                                           found closure
    |         |
    |         arguments to this function are incorrect
    |
    = note: expected fn pointer `fn(u32) -> u32`
                  found closure `[closure@src/main.rs:199:31: 199:34]`
note: closures can only be coerced to `fn` types if they do not capture any
      variables
   --> src/main.rs:199:39
    |
199 |         modify_all(&mut data, |y| y + amount_to_add);
    |                                       ^^^^^^^^^^^^^ `amount_to_add`
    |                                                     captured here
note: function defined here
   --> src/main.rs:60:12
    |
60  |     pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) {
    |            ^^^^^^^^^^                   -----------------------

Stattdessen muss der Code, der die Schließung erhält, eine Instanz einer der Fn* Eigenschaften akzeptieren:

pub fn modify_all<F>(data: &mut [u32], mut mutator: F)
where
    F: FnMut(u32) -> u32,
{
    for value in data {
        *value = mutator(*value);
    }
}

Rust hat drei verschiedene Fn* Eigenschaften, die untereinander einige Unterscheidungen rund um dieses umweltbewusste Verhalten ausdrücken:

FnOnce

Beschreibt eine Closure, die nureinmal aufgerufen werden kann. Wenn ein Teil der Umgebung moved in den Kontext der Closure verschoben wird und der Body der Closure ihn anschließend aus dem Kontext der Closure verschiebt, kann diese Verschiebung nur einmal stattfinden - es gibt keine andere Kopie des Quellobjekts, von der aus move aufgerufen werden kann - und somit kann die Closure nur einmal aufgerufen werden.

FnMut

Beschreibt eine Schließung, die wiederholt aufgerufen werden kann und die Änderungen an ihrer Umgebung vornehmen kann, weil sie sich von der Umgebung leihen kann.

Fn

Beschreibt eine Closure, die wiederholt aufgerufen werden kann und die nur unveränderlich Werte aus der Umgebung entleiht.

Der Compiler implementiert automatisch die entsprechende Teilmenge dieser Fn* Eigenschaften für jeden Lambda-Ausdruck im Code; es ist nicht möglich, eine dieser Eigenschaften manuell zu implementieren (anders als bei der operator() Überladung von C++).4

Um zum vorangegangenen groben mentalen Modell von Closures zurückzukehren: Welche der Eigenschaften der Compiler automatisch implementiert, entspricht in etwader Tatsache, ob der erfasste Umgebungskontext diese Elemente aufweist:

FnOnce

Alle verschobenen Werte

FnMut

Alle veränderbaren Verweise auf Werte (&mut T)

Fn

Nur normale Verweise auf Werte (&T)

Die letzten beiden Eigenschaften in dieser Liste haben jeweils eine Eigenschaft, die an die vorhergehende Eigenschaft gebunden ist, was Sinn macht, wenn man bedenkt, welche Dinge die Schließungen verwenden:

  • Wenn etwas erwartet, eine Closure nur einmal aufzurufen (angezeigt durch FnOnce), ist es in Ordnung, ihr eine Closure zu übergeben, die wiederholt aufgerufen werden kann (FnMut).

  • Wenn etwas erwartet, dass es wiederholt eine Closure aufruft, die ihre Umgebung verändern könnte (angezeigt durch den Erhalt einerFnMut), ist es in Ordnung, ihm eine Closure zu übergeben, die ihre Umgebung nicht verändern muss (Fn).

Der bloße Funktionszeigertyp fn gehört fiktiv auch an das Ende dieser Liste; jeder (nichtunsafe) fn Typ implementiert automatisch alle Fn* Eigenschaften, weil er nichts von der Umgebung borgt.

Wenn du also Code schreibst, der Closures akzeptiert, solltest du den allgemeinsten Fn* Trait verwenden, der funktioniert, um dem Aufrufer die größtmögliche Flexibilität zu geben - akzeptiere zum Beispiel FnOnce für Closures, die nur einmal verwendet werden. Aus demselben Grund wird auch empfohlen, Fn* Trait Bounds gegenüber bloßen Funktionszeigern (fn) zu bevorzugen.

Eigenschaften

Die Fn* Traits sind flexibler als bloße Funktionszeiger, aber sie können immer noch nur das Verhalten einer einzelnen Funktion beschreiben, und selbst dann nur in Bezug auf die Signatur der Funktion.

Sie sind jedoch selbst Beispiele für einen anderen Mechanismus zur Beschreibung von Verhalten im Typsystem von Rust, den Trait. Ein Trait definiert eine Reihe von zusammenhängenden Funktionen, die ein zugrundeliegendes Element öffentlich zugänglich macht. Außerdem sind die Funktionen in der Regel (aber nicht zwangsläufig) Methoden, die eine Variante von self als ihr erstes Argument annehmen.

Jede Funktion in einem Trait hat auch einen Namen, der es dem Compiler ermöglicht, Funktionen mit derselben Signatur zu unterscheiden, und, was noch wichtiger ist, der es Programmierern ermöglicht, die Absicht der Funktion zu erkennen.

Ein Rust-Trait ist in etwa vergleichbar mit einer "Schnittstelle" in Go und Java oder einer "abstrakten Klasse" (alle virtuellen Methoden, keine Datenelemente) in C++. Die Implementierungen des Traits müssen alle Funktionen bereitstellen (aber beachte, dass die Trait-Definition eine Standard-Implementierung enthalten kann; Punkt 13) und können auch zugehörige Daten haben, die von diesen Implementierungen genutzt werden. Das bedeutet, dass Code und Daten zusammen in einer gemeinsamen Abstraktion gekapselt werden, und zwar auf eine objektorientierte (OO) Weise.

Code, der eine struct akzeptiert und Funktionen aufruft, ist darauf beschränkt, nur mit diesem bestimmten Typ zu arbeiten. Wenn es mehrere Typen gibt, die ein gemeinsames Verhalten implementieren, ist es flexibler, einen Trait zu definieren, der dieses gemeinsame Verhalten kapselt, und den Code die Funktionen des Traits nutzen zu lassen, anstatt Funktionen, die einen bestimmten struct verwenden.

Dies führt zu denselben Ratschlägen, die auch bei anderen OO-beeinflussten Sprachen auftauchen:5 Akzeptiere lieber Trait-Typen als konkrete Typen, wenn du in Zukunft flexibel seinwillst.

Manchmal gibt es ein Verhalten, das du im Typsystem unterscheiden möchtest, das aber nicht als spezifische Funktionssignatur in einer Trait-Definition ausgedrückt werden kann. Nehmen wir zum Beispiel einen Sort Trait zum Sortieren von Sammlungen. Eine Implementierung könnte stabil sein (Elemente, die gleich sind, erscheinen vor und nach der Sortierung in der gleichen Reihenfolge), aber es gibt keine Möglichkeit, dies in den sort Methodenargumenten auszudrücken.

In diesem Fall lohnt es sich trotzdem, das Typsystem zu verwenden, um diese Anforderung mit einer Marker-Eigenschaft zu verfolgen:

pub trait Sort {
    /// Rearrange contents into sorted order.
    fn sort(&mut self);
}

/// Marker trait to indicate that a [`Sort`] sorts stably.
pub trait StableSort: Sort {}

Ein Marker-Trait hat keine Funktionen, aber eine Implementierung muss trotzdem erklären, dass sie den Trait implementiert - was wie ein Versprechen des Implementierers wirkt: "Ich schwöre feierlich, dass meine Implementierung stabil sortiert." Code, der sich auf eine stabile Sortierung verlässt, kann dann die StableSort Trait-Bound angeben und sich auf das Ehrensystem verlassen, um seine Invarianten zu erhalten. Verwende Marker-Traits, um Verhaltensweisen zu unterscheiden, die nicht in den Funktionssignaturen der Traits ausgedrückt werden können.

Sobald ein Verhalten als Trait in das Typsystem von Rust gekapselt wurde, kann es auf zwei Arten verwendet werden:

  • Als Trait-Bound, der einschränkt, welche Typen für einen generischen Datentyp oder eine Funktion zur Kompilierzeit zulässig sind

  • Als Trait-Objekt, das einschränkt, welche Typen zur Laufzeit gespeichert oder an eine Funktion übergeben werden können

In den folgenden Abschnitten werden diese beiden Möglichkeiten beschrieben, und unter Punkt 12 werden die Kompromisse zwischen ihnen näher erläutert.

Merkmalsgrenzen

Eine Trait-Bindung zeigt an, dass generischer Code, der durch einen Typ T parametrisiert ist, nur dann verwendet werden kann, wenn dieser Typ T einen bestimmten Trait implementiert. Das Vorhandensein der Trait-Bindung bedeutet, dass die Implementierung der Generik die Funktionen dieser Eigenschaft verwenden kann, in der Gewissheit, dass der Compiler sicherstellt, dass jede T, die kompiliert wird, tatsächlich über diese Funktionen verfügt. Diese Überprüfung findet zur Kompilierzeit statt, wenn die Generik monomorphisiert wird - alsovom generischen Code, der einen beliebigen Typ T behandelt, in spezifischen Code umgewandelt wird, der einen bestimmten SomeType behandelt (was in C++ als Template-Instanziierung bezeichnet wird).

Diese Einschränkung des Zieltyps T ist explizit in den Trait-Grenzen kodiert: Der Trait kann nur von Typen implementiert werden, die die Trait-Grenzen erfüllen. Dies steht im Gegensatz zu der entsprechenden Situation in C++, wo die Beschränkungen für den Typ T, der in einertemplate<typename T>implizit sind:6 C++-Vorlagencode lässt sich nur dann kompilieren, wenn alle referenzierten Funktionen zur Kompilierzeit verfügbar sind, aber die Prüfungen basieren ausschließlich auf Funktionsnamen und Signaturen. (Dieses "Duck-Typing" kann zu Verwirrung führen; eine C++-Vorlage, die t.pop()verwendet, könnte für einen T Typ-Parameter von entweder Stack oderBalloon-kompiliert, was wahrscheinlich nicht erwünscht ist.)

Die Notwendigkeit von expliziten Trait Bounds bedeutet auch, dass ein großer Teil der Generika Trait Bounds verwendet. Um zu sehen, warum das so ist, drehst du die Beobachtung um und überlegst, was mit einer struct Thing<T> gemacht werden kann, wenn es keine Trait Bounds auf T gibt. Ohne Trait Bound kann Thing nur Operationen durchführen, die für jeden Typ Tgelten - im Grunde nur das Verschieben oder Fallenlassen des Wertes. Das wiederum ermöglicht generische Container, Collections und Smart Pointer, aber nicht viel mehr. Alles, was den Typ T verwendet, braucht einen Trait-Bound:

pub fn dump_sorted<T>(mut collection: T)
where
    T: Sort + IntoIterator,
    T::Item: std::fmt::Debug,
{
    // Next line requires `T: Sort` trait bound.
    collection.sort();
    // Next line requires `T: IntoIterator` trait bound.
    for item in collection {
        // Next line requires `T::Item : Debug` trait bound
        println!("{:?}", item);
    }
}

Der Ratschlag hier lautet also, Trait Bounds zu verwenden, um Anforderungen an die in Generics verwendeten Typen zu formulieren, aber es ist ein leicht zu befolgender Ratschlag - der Compiler wird dich zwingen, ihn trotzdem einzuhalten.

Trait-Objekte

Ein Trait-Objekt ist die andere Möglichkeit, die durch einen Trait definierte Kapselung zu nutzen, aber hier werden verschiedene mögliche Implementierungen des Traits zur Laufzeit und nicht zur Kompilierzeit ausgewählt. Dieses dynamische Dispatching ist vergleichbar mit der Verwendung von virtuellen Funktionen in C++, und Rust verfügt über "vtable"-Objekte, diein etwa denen in C++ entsprechen.

Dieser dynamische Aspekt von Trait-Objekten bedeutet auch, dass sie immer indirekt über eine Referenz (z. B. &dyn Trait) oder einen Zeiger (z. B. Box<dyn Trait>) behandelt werden müssen. Der Grund dafür ist, dass die Größe des Objekts, das den Trait implementiert, zum Zeitpunkt der Kompilierung nicht bekannt ist - es könnte ein riesiges struct oder ein winziges enumsein -, so dass es keine Möglichkeit gibt, die richtige Menge an Speicherplatz für ein reines Trait-Objekt zuzuweisen.

Die Größe des konkreten Objekts nicht zu kennen, bedeutet auch, dass Traits, die als Trait-Objekte verwendet werden, keine Funktionen haben können, die den Typ Self zurückgeben oder Argumente (außer dem Empfänger - demObjekt, auf dem die Methode aufgerufen wird), die Self verwenden. Der Grund dafür ist, dass der im Voraus kompilierte Code, der das Trait-Objekt verwendet, keine Ahnung hat, wie groß das Self sein könnte.

Ein Trait mit einer generischen Funktion fn some_fn<T>(t:T) lässt die Möglichkeit einer unendlichen Anzahl von implementierten Funktionen zu, für alle verschiedenen Typen T, die es geben könnte. Für einen Trait, der als Trait-Bound verwendet wird, ist das in Ordnung, denn die unendliche Menge der möglichen generischen Funktionen wird zur Kompilierzeit zu einer endlichen Menge der tatsächlich aufgerufenen generischen Funktionen. Für ein Trait-Objekt gilt das nicht: Der zur Kompilierzeit verfügbare Code muss mit allen möglichen Ts zurechtkommen, die zur Laufzeit auftreten können.

Diese beiden Einschränkungen - keine Verwendung von Self und keine generischen Funktionen - sind im Konzept der Objektsicherheit vereint. Nur objektsichere Traits können als Trait-Objekte verwendet werden.

Punkt 3: Bevorzuge Option und Result Transformationengegenüber expliziten match Ausdrücken

In Punkt 1 wurden die Vorzüge von enum erläutert und gezeigt, wie match Ausdrücke den Programmierer dazu zwingen, alle Möglichkeiten in Betracht zu ziehen. In Punkt 1 wurden auch die beiden allgegenwärtigen enums vorgestellt, die die Rust-Standardbibliothek bietet:

Option<T>

Um auszudrücken, dass ein Wert (vom TypT) vorhanden oder nicht vorhanden sein kann

Result<T, E>

Für den Fall, dass eine Operation zur Rückgabe eines Wertes (vom Typ T) nicht erfolgreich ist und stattdessen einen Fehler (vom Typ E) zurückgibt

In diesem Artikel werden Situationen untersucht, in denen du versuchen solltest, explizite match Ausdrücke für diese speziellen enums zu vermeiden und stattdessen verschiedene Transformationsmethoden zu verwenden, die die Standardbibliothek für diese Typen bereitstellt. Die Verwendung dieser Transformationsmethoden (die in der Regel selbst als match Ausdrücke implementiert sind) führt zu einem kompakteren und idiomatischeren Code, der eine klarere Absicht verfolgt.

Die erste Situation, in der eine match unnötig ist, ist, wenn nur der Wert relevant ist und das Fehlen des Wertes (und jeder damit verbundene Fehler) einfach ignoriert werden kann:

struct S {
    field: Option<i32>,
}

let s = S { field: Some(42) };
match &s.field {
    Some(i) => println!("field is {i}"),
    None => {}
}

Für diese Situation ist ein if let Ausdruck ist eine Zeile kürzer und vor allem klarer:

if let Some(i) = &s.field {
    println!("field is {i}");
}

Die meiste Zeit muss der Programmierer jedoch den entsprechenden else Arm bereitstellen: Das Fehlen eines Wertes (Option::None), möglicherweise mit einem damit verbundenen Fehler (Result::Err(e)), ist etwas, mit dem der Programmierer umgehen muss. Software so zu entwickeln, dass sie mit Fehlerpfaden zurechtkommt, ist schwierig, und das meiste davon ist grundlegende Komplexität, bei der keine noch so große syntaktische Unterstützung helfen kann - insbesondere die Entscheidung, was passieren soll, wenn eine Operation fehlschlägt.

In manchen Situationen ist es die richtige Entscheidung, den Kopf in den Sand zu stecken und explizit nicht mit Fehlern umzugehen. Du kannst den Fehlerarm nicht komplett ignorieren, denn Rust verlangt, dass der Code mit beiden Varianten des Error enum umgeht, aber du kannst dich dafür entscheiden, einen Fehler als fatal zu behandeln. Die Ausführung eines panic! bei einem Fehler bedeutet, dass das Programm beendet wird, aber der Rest des Codes kann dann mit der Annahme eines Erfolgs geschrieben werden.Das mit einem expliziten match zu tun, wäre unnötiglangatmig:

let result = std::fs::File::open("/etc/passwd");
let f = match result {
    Ok(f) => f,
    Err(_e) => panic!("Failed to open /etc/passwd!"),
};
// Assume `f` is a valid `std::fs::File` from here onward.

Sowohl Option als auch Result bieten ein Methodenpaar, das ihren inneren Wert extrahiert und panic!, wenn er nicht vorhanden ist: unwrap und expect. Letzteres ermöglicht es, die Fehlermeldung bei einem Fehler zu personalisieren, aber in jedem Fall ist der resultierende Code kürzer und einfacher - die Fehlerbehandlung wird an das Suffix .unwrap() delegiert (ist aber immer noch vorhanden):

let f = std::fs::File::open("/etc/passwd").unwrap();

Aber Achtung: Diese Hilfsfunktionen sind immer noch panic! und die Entscheidung, sie zu benutzen, ist die gleiche wie die Entscheidung für panic!(Punkt 18).

In vielen Situationen ist es jedoch die richtige Entscheidung, die Fehlerbehandlung auf eine andere Person zu übertragen. Das gilt vor allem, wenn du eine Bibliothek schreibst, deren Code in vielen verschiedenen Umgebungen verwendet werden kann, die der Autor der Bibliothek nicht vorhersehen kann. Um dieser anderen Person die Arbeit zu erleichtern, solltest du Result gegenüber Option bevorzugen, um Fehler auszudrücken, auch wenn dabei zwischen verschiedenen Fehlertypen umgewandelt werden muss(Punkt 4).

Das wirft natürlich die Frage auf: Was gilt als Fehler? In diesem Beispiel ist es definitiv ein Fehler, wenn eine Datei nicht geöffnet werden kann, und die Details dieses Fehlers (keine solche Datei? Berechtigung verweigert?) können dem Benutzer helfen zu entscheiden, was er als Nächstes tun soll. Fehlschlägt hingegen das Abrufen des first() Element eines Slice nicht abrufen zu können, weil das Slice leer ist, ist kein wirklicher Fehler und wird daher in der Standardbibliothek als Option Rückgabetyp ausgedrückt. Die Entscheidung zwischen den beiden Möglichkeiten erfordert Urteilsvermögen, aber wenn ein Fehler etwas Nützliches mitteilt, solltest du zu Result tendieren.

Result hat auch ein #[must_use]Attribut, um Bibliotheksbenutzer in die richtige Richtungzu lenken - wennder Code, der die zurückgegebene Result verwendet, sie ignoriert, erzeugt der Compiler eine Warnung:

warning: unused `Result` that must be used
  --> src/main.rs:63:5
   |
63 |     f.set_len(0); // Truncate the file
   |     ^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
63 |     let _ = f.set_len(0); // Truncate the file
   |     +++++++

Die explizite Verwendung von match ermöglicht die Ausbreitung eines Fehlers, aber auf Kosten von sichtbarem Boilerplate (erinnert an Go):

pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
    let f = match std::fs::File::open("/etc/passwd") {
        Ok(f) => f,
        Err(e) => return Err(From::from(e)),
    };
    // ...
}

Der Schlüssel zur Reduzierung von Boilerplate ist der Fragezeichenoperator von Rust , ?. Dieser syntaktische Zucker sorgt für den Abgleich mit dem Err Arm, die Umwandlung des Fehlertyps, falls nötig, und die Erstellung des return Err(...) Ausdrucks - alles in einem einzigenZeichen:

pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
    let f = std::fs::File::open("/etc/passwd")?;
    // ...
}

Für Rust-Neulinge ist das manchmal verwirrend: Das Fragezeichen ist auf den ersten Blick schwer zu erkennen und führt zu Unbehagen darüber, wie der Code überhaupt funktionieren kann. Aber selbst mit einem einzigen Zeichen ist das Typsystem immer noch am Werk und stellt sicher, dass alle Möglichkeiten, die in den relevanten Typen(Punkt 1) ausgedrückt werden, abgedeckt sind - so dass sich der Programmierer ohne Ablenkung auf den Hauptcodepfad konzentrieren kann.

Außerdem verursachen diese scheinbaren Methodenaufrufe in der Regel keine Kosten: Es handelt sich um generische Funktionen, die als #[inline]gekennzeichnet, so dass der generierte Code in der Regel zu Maschinencode kompiliert wird, der identisch mit der manuellen Version ist.

Diese beiden Faktoren zusammengenommen bedeuten, dass du Option und Result Transformationen gegenüber expliziten matchAusdrücken vorziehen solltest.

Im vorherigen Beispiel waren die Fehlertypen identisch: Sowohl die innere als auch die äußere Methode drückten Fehler alsstd::io::Error. Das ist oft nicht der Fall: Eine Funktion kann Fehler aus einer Vielzahl verschiedener Unterbibliotheken ansammeln, die jeweils unterschiedliche Fehlertypen verwenden.

Die Fehlerzuordnung im Allgemeinen wird in Punkt 4 besprochen, aber für den Moment solltest du dir bewusst sein, dass ein manuelles Mapping:

pub fn find_user(username: &str) -> Result<UserId, String> {
    let f = match std::fs::File::open("/etc/passwd") {
        Ok(f) => f,
        Err(e) => {
            return Err(format!("Failed to open password file: {:?}", e))
        }
    };
    // ...
}

könnte knapper und idiomatischer ausgedrückt werden mit der folgenden .map_err() Umwandlung:

pub fn find_user(username: &str) -> Result<UserId, String> {
    let f = std::fs::File::open("/etc/passwd")
        .map_err(|e| format!("Failed to open password file: {:?}", e))?;
    // ...
}

Noch besser ist, dass selbst dies nicht notwendig ist - wenn der äußere Fehlertyp aus dem inneren Fehlertyp über eine Implementierung des Standardtraits From (Punkt 10) erstellt werden kann, dann führt der Compiler die Konvertierung automatisch durch, ohne dass ein Aufruf von .map_err() erforderlich ist.

Diese Arten von Transformationen lassen sich noch weiter verallgemeinern. Der Fragezeichenoperator ist ein großer Hammer; verwende Transformationsmethoden für die Typen Option und Result, um sie in eine Position zu manövrieren, in der sie ein Nagel sein können.

Die Standardbibliothek bietet eine Vielzahl dieser Transformationsmethoden, um dies zu ermöglichen. Abbildung 1-1zeigt einige der gängigsten Methoden (helle Rechtecke), die zwischen den entsprechenden Typen (dunkle Rechtecke) transformieren. In Übereinstimmung mit Punkt 18 sind die Methoden, die panic! können, mit einem Sternchen gekennzeichnet.

The diagram shows mappings between Result, Option and related types.  Gray boxes show types, and white rounded boxes show methods that transform between types.  Methods that can panic are marked with an asterisk. In the middle are the Result<T, E> and Option<T> types, with methods ok, ok_or and ok_or_else that convert between them. To one side of Result<T, E> are the or and or_else methods that transform back to the same type. To one side of Option<T> are various methods that transform back to the same type: filter, xor, or, or_else and replace. Across the top and bottom of the diagram are various related types that can covert to or from Result and Option. For Result<T, E>, the map method reaches Result<T, F>, the map, and and and_then methods reach Result<U, E>, and the map_or and map_or_else methods reach U, with all of the destinations at the bottom of the diagram. At the top of the diagram, Result<T, E> maps to Option<E> via err, to E via unwrap_err and expect_err (both of which can panic), and to T via a collection of methods: unwrap, expect, unwrap_or, unwrap_or_else, unwrap_or_default (where unwrap and expect might panic).  The E and T types map back to Result<T, E> via the Err(e) and Ok(t) enum variants.  For Option<T>, the map, and and and_then methods reach Option<U>, and the map_or and map_or_else methods reach U at the bottom of the diagram. At the top of the diagram, Option<T> maps to T via the same collection of methods as for Result: unwrap, expect, unwrap_or, unwrap_or_else, unwrap_or_default (where unwrap and expect might panic).  The T type maps back to Option<T> via the Some(t) enum; the () type also maps to Option<T> via None.
Abbildung 1-1. Option und Result Transformationen7

Eine häufige Situation, die das Diagramm nicht abdeckt, betrifft Referenzen. Nehmen wir zum Beispiel eine Struktur, die optional einige Daten enthält:

struct InputData {
    payload: Option<Vec<u8>>,
}

Eine Methode auf dieser struct, die versucht, die Nutzlast an eine Verschlüsselungsfunktion mit der Signatur (&[u8]) -> Vec<u8>zu übergeben, schlägt fehl, wenn ein naiver Versuch unternommen wird, eine Referenz zu nehmen:

error[E0507]: cannot move out of `self.payload` which is behind a shared
              reference
  --> src/main.rs:15:18
   |
15 |     encrypt(&self.payload.unwrap_or(vec![]))
   |              ^^^^^^^^^^^^ move occurs because `self.payload` has type
   |                           `Option<Vec<u8>>`, which does not implement the
   |                           `Copy` trait

Das richtige Werkzeug dafür ist die as_ref() Methode auf Option.8 Diese Methode wandelt einen Verweis auf einenOption in einen Option-of-a-Verweis um:

pub fn encrypted(&self) -> Vec<u8> {
    encrypt(self.payload.as_ref().unwrap_or(&vec![]))
}

Dinge zum Erinnern

  • Gewöhne dich an die Umwandlungen von Option und Result und ziehe Result Option vor. Verwende .as_ref() bei Bedarf, wenn die Umwandlungen Referenzen beinhalten.

  • Verwende diese Umwandlungen anstelle der expliziten match Operationen auf Option und Result.

  • Verwende diese Umwandlungen insbesondere, um Ergebnistypen in eine Form umzuwandeln, auf die der ? Operator anwendbar ist.

Punkt 4: Bevorzuge idiomatische Error Typen

In Punkt 3 wurde beschrieben, wie die Transformationen, die die Standardbibliothek für die Typen Option und Result bereitstellt, genutzt werden können, um eine prägnante, idiomatische Handhabung von Ergebnistypen mit dem Operator ? zu ermöglichen. Es wurde nicht erörtert, wie man am besten mit den verschiedenen Fehlertypen E umgeht, die als zweites Argument des Typs Result<T, E> auftreten; das ist das Thema dieses Artikels.

Das ist nur dann wichtig, wenn eine Vielzahl verschiedener Fehlertypen im Spiel ist. Wenn alle Fehler, auf die eine Funktion stößt, vom gleichen Typ sind, kann sie nur diesen Typ zurückgeben. Wenn es verschiedene Fehlertypen gibt, muss entschieden werden, ob die Information über den Unterfehlertyp erhalten bleiben soll.

Die Error Eigenschaft

Es ist immer gut zu verstehen, was die Standardmerkmale(Punkt 10) beinhalten, und das relevante Merkmal ist hier std::error::Error. Der Typ-Parameter E fürResult muss nicht unbedingt ein Typ sein, der Error implementiert, aber es ist eine gängige Konvention, die es Wrappern ermöglicht, geeignete Trait-Grenzen auszudrücken - implementiere also lieber Error für deine Fehlertypen.

Als erstes fällt auf, dass die einzige harte Anforderung für Error Typen die Traits sind: Jeder Typ, der Error implementiert, muss auch die folgenden Traits implementieren:

  • Die Display Eigenschaft, d.h., sie kann format!mit {}

  • Die Debug Eigenschaft, d.h., sie kann format!mit {:?}

Mit anderen Worten: Es sollte möglich sein, Error Typen sowohl dem Benutzer als auch dem Programmierer anzuzeigen.

Die einzige Methode in dem Trait istsource(),9 die es dem Typ Error ermöglicht, einen inneren, verschachtelten Fehler aufzudecken. Diese Methode ist optional - sie wird mit einer Standardimplementierung(Punkt 13) geliefert, die None zurückgibt und damit anzeigt, dass keine inneren Fehlerinformationen verfügbar sind.

Ein letzter Hinweis: Wenn du Code für eine no_std Umgebung(Punkt 33) schreibst, kann es sein, dass es nicht möglich ist, Errorzu implementieren - die Error Eigenschaft ist derzeit instd implementiert, nicht in core, und ist daher nicht verfügbar.10

Minimale Fehler

Wenn keine verschachtelten Fehlerinformationen benötigt werden, muss eine Implementierung des Typs Error nicht viel mehr sein als eineString- eine seltene Gelegenheit, in der eine "stringly typed" Variable angemessen sein könnte. Sie muss allerdingsetwas mehr als String sein; es ist zwar möglich, String als E Typparameter zu verwenden:

pub fn find_user(username: &str) -> Result<UserId, String> {
    let f = std::fs::File::open("/etc/passwd")
        .map_err(|e| format!("Failed to open password file: {:?}", e))?;
    // ...
}

a String implementiert Error nicht, was wir bevorzugen würden, damit andere Bereiche des Codes mit Errors umgehen können. Es ist nicht möglich, impl Error für String zu implementieren, weil weder der Trait noch der Typ zu uns gehören (die sogenannte Orphan-Regel):

error[E0117]: only traits defined in the current crate can be implemented for
              types defined outside of the crate
  --> src/main.rs:18:5
   |
18 |     impl std::error::Error for String {}
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^------
   |     |                          |
   |     |                          `String` is not defined in the current crate
   |     impl doesn't use only types from inside the current crate
   |
   = note: define and implement a trait or new type instead

Ein Typ-Alias hilft auch nicht, da er keinen neuen Typ erstellt und somit die Fehlermeldung nicht ändert :

error[E0117]: only traits defined in the current crate can be implemented for
              types defined outside of the crate
  --> src/main.rs:41:5
   |
41 |     impl std::error::Error for MyError {}
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^-------
   |     |                          |
   |     |                          `String` is not defined in the current crate
   |     impl doesn't use only types from inside the current crate
   |
   = note: define and implement a trait or new type instead

Wie üblich gibt die Fehlermeldung des Compilers einen Hinweis auf die Lösung des Problems. Wenn du eine Tupel-Struktur definierst, die um den TypString wickelt (das "newtype pattern", Punkt 6), kann die Eigenschaft Error implementiert werden, vorausgesetzt, dass auch Debug und Display implementiert werden:

#[derive(Debug)]
pub struct MyError(String);

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl std::error::Error for MyError {}

pub fn find_user(username: &str) -> Result<UserId, MyError> {
    let f = std::fs::File::open("/etc/passwd").map_err(|e| {
        MyError(format!("Failed to open password file: {:?}", e))
    })?;
    // ...
}

Für kann es sinnvoll sein, die Eigenschaft From<String> zu implementieren, damit String-Werte einfach in MyError Instanzen umgewandelt werden können(Punkt 5):

impl From<String> for MyError {
    fn from(msg: String) -> Self {
        Self(msg)
    }
}

Wenn er auf den Fragezeichenoperator (?) stößt, wendet der Compiler automatisch alle relevanten From Trait-Implementierungen an, die erforderlich sind, um den Ziel-Fehlerrückgabetyp zu erreichen. Dies ermöglicht eine weitere Minimierung:

pub fn find_user(username: &str) -> Result<UserId, MyError> {
    let f = std::fs::File::open("/etc/passwd")
        .map_err(|e| format!("Failed to open password file: {:?}", e))?;
    // ...
}

Der Fehlerpfad umfasst dabei die folgenden Schritte:

  • File::open gibt einen Fehler des Typs std::io::Error.

  • format! wandelt dies in ein String um, indem es die Debug Implementierung von std::io::Error verwendet.

  • ? bringt den Compiler dazu, nach einer From Implementierung zu suchen und diese zu verwenden, die ihn von String auf MyError bringen kann.

Verschachtelte Fehler

Das alternative Szenario ist, dass der Inhalt von verschachtelten Fehlern so wichtig ist, dass er erhalten bleiben und dem Aufrufer zur Verfügung gestellt werden sollte.

Betrachte eine Bibliotheksfunktion, die versucht, die erste Zeile einer Datei als String zurückzugeben, sofern die Zeile nicht zu lang ist. Ein kurzer Moment des Nachdenkens zeigt (mindestens) drei verschiedene Arten von Fehlern, die auftreten können:

  • Die Datei existiert möglicherweise nicht oder kann nicht gelesen werden.

  • Die Datei kann Daten enthalten, die nicht in UTF-8 gültig sind und daher nicht in String konvertiert werden können.

  • Die erste Zeile der Datei ist möglicherweise zu lang.

In Übereinstimmung mit Punkt 1 kannst du das Typensystem verwenden, um all diese Möglichkeiten als enum auszudrücken und zu erfassen:

#[derive(Debug)]
pub enum MyError {
    Io(std::io::Error),
    Utf8(std::string::FromUtf8Error),
    General(String),
}

Diese enum Definition enthält eine derive(Debug), aber um die Error Eigenschaft zu erfüllen, ist auch eineDisplay Implementierung erforderlich:

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MyError::Io(e) => write!(f, "IO error: {}", e),
            MyError::Utf8(e) => write!(f, "UTF-8 error: {}", e),
            MyError::General(s) => write!(f, "General error: {}", s),
        }
    }
}

Es ist auch sinnvoll, die Standardimplementierung von source() zu überschreiben, um einfachen Zugriff auf verschachtelte Fehler zu haben:

use std::error::Error;

impl Error for MyError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            MyError::Io(e) => Some(e),
            MyError::Utf8(e) => Some(e),
            MyError::General(_) => None,
        }
    }
}

Durch die Verwendung von enum kann die Fehlerbehandlung kurz und bündig sein, während alle Typinformationen über verschiedene Fehlerklassen hinweg erhalten bleiben:

use std::io::BufRead; // for `.read_until()`

/// Maximum supported line length.
const MAX_LEN: usize = 1024;

/// Return the first line of the given file.
pub fn first_line(filename: &str) -> Result<String, MyError> {
    let file = std::fs::File::open(filename).map_err(MyError::Io)?;
    let mut reader = std::io::BufReader::new(file);

    // (A real implementation could just use `reader.read_line()`)
    let mut buf = vec![];
    let len = reader.read_until(b'\n', &mut buf).map_err(MyError::Io)?;
    let result = String::from_utf8(buf).map_err(MyError::Utf8)?;
    if result.len() > MAX_LEN {
        return Err(MyError::General(format!("Line too long: {}", len)));
    }
    Ok(result)
}

Es ist auch eine gute Idee, die From Eigenschaft für alle Unterfehlertypen zu implementieren(Punkt 5):

impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(e)
    }
}
impl From<std::string::FromUtf8Error> for MyError {
    fn from(e: std::string::FromUtf8Error) -> Self {
        Self::Utf8(e)
    }
}

Dadurch wird verhindert, dass Bibliotheksbenutzer selbst unter den Waisenregeln leiden: Sie dürfenFrom nicht auf MyError implementieren, weil sowohl der Trait als auch die Struktur für sie extern sind.

Noch besser ist, dass die Implementierung von From noch mehr Prägnanz ermöglicht, da der Fragezeichenoperator automatisch alle notwendigen From Konvertierungen durchführt, wodurch .map_err() überflüssig wird:

use std::io::BufRead; // for `.read_until()`

/// Maximum supported line length.
pub const MAX_LEN: usize = 1024;
/// Return the first line of the given file.
pub fn first_line(filename: &str) -> Result<String, MyError> {
    let file = std::fs::File::open(filename)?; // `From<std::io::Error>`
    let mut reader = std::io::BufReader::new(file);
    let mut buf = vec![];
    let len = reader.read_until(b'\n', &mut buf)?; // `From<std::io::Error>`
    let result = String::from_utf8(buf)?; // `From<string::FromUtf8Error>`
    if result.len() > MAX_LEN {
        return Err(MyError::General(format!("Line too long: {}", len)));
    }
    Ok(result)
}

Das Schreiben eines vollständigen Fehlertyps kann eine ganze Menge Boilerplate beinhalten und ist daher ein guter Kandidat für die Automatisierung durch ein derive Makro(Punkt 28). Es ist jedoch nicht nötig, ein solches Makro selbst zu schreiben:Du kannst die thiserror Kiste von David Tolnayverwenden, die eine hochwertige und weit verbreitete Implementierung eines solchen Makros bietet. Der vonthiserror erzeugte Code vermeidet außerdem, dass this​er⁠ror Typen in der erzeugten API sichtbar werden, was wiederum bedeutet, dass die Bedenken, die mit Punkt 24 verbunden sind, nicht zutreffen.

Trait-Objekte

Beim ersten Ansatz für verschachtelte Fehler wurden alle Details zu den Unterfehlern weggeworfen und nur einige String-Ausgaben beibehalten (format!("{:?}", err)). Der zweite Ansatz behielt die vollständigen Typinformationen für alle möglichen Unterfehler bei, erforderte aber eine vollständige Aufzählung aller möglichen Arten von Unterfehlern.

Das wirft die Frage auf: Gibt es einen Mittelweg zwischen diesen beiden Ansätzen, bei dem die Informationen zu den Unterfehlern erhalten bleiben, ohne dass jede mögliche Fehlerart manuell erfasst werden muss?

Durch die Kodierung der Suberror-Informationen als Trait-Objekt wird die Notwendigkeit einer enum Variante für jede Möglichkeit vermieden, aber die Details der spezifischen zugrundeliegenden Fehlertypen werden gelöscht. Der Empfänger eines solchen Objekts hätte Zugriff auf die Methoden des Traits Error und seine Trait-Bounds -source(), Display::fmt() und Debug::fmt()-, würde aber den ursprünglichen statischen Typ des Subfehlers nicht kennen:

Es stellt sich heraus, dass dies möglich ist, aber es ist erstaunlich subtil. Ein Teil der Schwierigkeit ergibt sich aus den Objektsicherheitsbeschränkungen für Trait-Objekte(Punkt 12), aber auch die Kohärenzregeln von Rust kommen ins Spiel, die (grob) besagen, dass es höchstens eine Implementierung eines Traits für einen Typ geben kann.

Von einem vermeintlichen WrappedError Typ würde man naiverweise erwarten, dass er beide derfolgenden Funktionen erfüllt:

  • Die Error Eigenschaft, weil sie selbst ein Fehler ist.

  • Die From<Error> Eigenschaft, mit der Unterfehler einfach umbrochen werden können.

Das bedeutet, dass ein WrappedError from ein inneres erstellt werden kann.WrappedErrorimplementiert, da WrappedError Error implementiert, und das kollidiert mit der pauschalen reflexiven Implementierung von From:

error[E0119]: conflicting implementations of trait `From<WrappedError>` for
              type `WrappedError`
   --> src/main.rs:279:5
    |
279 |     impl<E: 'static + Error> From<E> for WrappedError {
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |
    = note: conflicting implementation in crate `core`:
            - impl<T> From<T> for T;

David Tolnay's anyhow ist eine Kiste, die diese Probleme bereits gelöst hat (indem sie eine zusätzliche Ebene der Indirektion über Box hinzugefügt hat) und darüber hinaus weitere hilfreiche Funktionen (wie z. B. Stack Traces) bietet. Dadurch wird sie schnell zur Standardempfehlung für die Fehlerbehandlung - eine Empfehlung, die hier noch einmal aufgegriffen wird: Erwäge die Verwendung der anyhow crate für die Fehlerbehandlung in Anwendungen.

Bibliotheken vs. Anwendungen

Der letzte Ratschlag aus dem vorigen Abschnitt enthielt die Einschränkung "...für die Fehlerbehandlung in Anwendungen", denn es gibt oft einen Unterschied zwischen Code, der für die Wiederverwendung in einer Bibliothek geschrieben wird, und Code, der eine Anwendung auf höchster Ebene bildet.11

Code, der für eine Bibliothek geschrieben wurde, kann die Umgebung, in der der Code verwendet wird, nicht vorhersagen. Daher ist es besser, konkrete, detaillierte Fehlerinformationen auszugeben und es dem Aufrufer zu überlassen, herauszufinden, wie er diese Informationen verwenden soll. Dies kommt den zuvor beschriebenen verschachtelten Fehlern im Stil von enum entgegen (und vermeidet außerdem eine Abhängigkeit von anyhow in der öffentlichen API der Bibliothek, siehe Punkt 24).

Allerdings muss sich der Anwendungscode in der Regel mehr darauf konzentrieren, wie er dem Benutzer Fehler anzeigt. Außerdem muss er potenziell mit allen verschiedenen Fehlertypen fertig werden, die von allen Bibliotheken in seinem Abhängigkeitsgraphen ausgegeben werden(Punkt 25). Ein dynamischerer Fehlertyp (z. B.anyhow::Error) macht die Fehlerbehandlung einfacher und konsistenter für die gesamte Anwendung.

Dinge zum Erinnern

  • Die Standard-Eigenschaft Error erfordert wenig von dir, also implementiere sie lieber für deine Fehlertypen.

  • Wenn du mit heterogenen zugrundeliegenden Fehlertypen zu tun hast, entscheide, ob es notwendig ist, diese Typen zu erhalten.

    • Wenn nicht, solltest du anyhow verwenden, um Suberrors in den Anwendungscode einzuschließen.

    • Wenn ja, kodiere sie in einer enum und biete Umrechnungen an. Erwäge die Verwendung vonthiserror zur Hilfe zu nehmen.

  • Ziehe in Erwägung, die anyhow crate für eine bequeme idiomatische Fehlerbehandlung im Anwendungscode zu verwenden.

  • Es ist deine Entscheidung, aber wie auch immer du dich entscheidest, kodiere es im Typensystem(Punkt 1).

Punkt 5: Typumwandlungen verstehen

Die Umwandlung von Rosttypen fällt in drei Kategorien:

Handbuch

Benutzerdefinierte Typkonvertierungen durch Implementierung der Traits From und Into

Halbautomatisch

Explizite Übertragungen zwischen Werten mit dem Schlüsselwort as

Automatisch

Impliziter Zwang zu einem neuen Typ

Der Großteil dieses Artikels konzentriert sich auf die erste dieser beiden Möglichkeiten, die manuelle Konvertierung von Typen, da die beiden letzteren meist nicht auf die Konvertierung von benutzerdefinierten Typen zutreffen. Da es einige Ausnahmen gibt, wird am Ende des Artikels auf Casting und Coercion eingegangen - einschließlich der Frage, wie sie auf einen benutzerdefinierten Typ angewendet werden können.

Beachte, dass Rust im Gegensatz zu vielen älteren Sprachen keine automatische Umwandlung zwischen numerischen Typen durchführt. Das gilt sogar für "sichere" Umwandlungen von ganzzahligen Typen:

error[E0308]: mismatched types
  --> src/main.rs:70:18
   |
70 |     let y: u64 = x;
   |            ---   ^ expected `u64`, found `u32`
   |            |
   |            expected due to this
   |
help: you can convert a `u32` to a `u64`
   |
70 |     let y: u64 = x.into();
   |                   +++++++

Benutzerdefinierte Typkonvertierungen

Wie bei anderen Merkmalen der Sprache(Punkt 10) ist die Fähigkeit, Konvertierungen zwischen Werten verschiedener benutzerdefinierter Typen durchzuführen, als Standardmerkmal gekapselt - oder besser gesagt, als eine Reihe verwandter generischer Merkmale.

Die vier relevanten Eigenschaften, die die Fähigkeit ausdrücken, Werte eines Typs umzuwandeln, sind diefolgenden:

From<T>

Gegenstände dieses Typs können aus Gegenständen des Typs T gebildet werden, und die Umwandlung ist immer erfolgreich.

TryFrom<T>

Gegenstände dieses Typs können aus Gegenständen des Typs T gebaut werden, aber die Umwandlung gelingt möglicherweise nicht.

Into<T>

Gegenstände dieses Typs können in Gegenstände des Typs T umgewandelt werden, und die Umwandlung ist immer erfolgreich.

TryInto<T>

Artikel dieses Typs können in Artikel des Typs T umgewandelt werden, aber die Umwandlung gelingt möglicherweise nicht.

Angesichts der Diskussion in Punkt 1 über das Ausdrücken von Dingen im Typsystem ist es keine Überraschung, dass der Unterschied zu den Try... Varianten darin besteht, dass die einzige Trait-Methode ein Result zurückgibt und nicht ein garantiert neues Element. Die Try... Trait-Definitionen erfordern außerdem einen zugehörigen Typ, der den Typ des Fehlers Eangibt, der in Fehlersituationen ausgegeben wird.

Der erste Ratschlag lautet daher, (nur) die Eigenschaft Try... zu implementieren, wenn eine Konvertierung fehlschlagen kann, wie in Punkt 4 beschrieben. Die Alternative ist, die Möglichkeit eines Fehlers zu ignorieren (z. B. mit.unwrap()), aber das muss eine bewusste Entscheidung sein, und in den meisten Fällen ist es am besten, diese Entscheidung dem Aufrufer zu überlassen.

into Die Eigenschaften der Typumwandlung haben eine offensichtliche Symmetrie: Wenn ein Typ T in einen Typ U (über Into<U>) umgewandelt werden kann, ist es dann nicht genauso möglich, einen Artikel des Typs U zu erstellen, indem man from in einen Artikel des Typs T (überFrom<T>) umwandelt?

Das ist tatsächlich der Fall und führt zum zweiten Ratschlag: Implementiere die From Eigenschaft für Konvertierungen. Die Rust-Standardbibliothek musste sich für eine der beiden Möglichkeiten entscheiden, um zu verhindern, dass sich das System in schwindelerregenden Kreisen dreht,12 und sie entschied sich für die automatische Bereitstellung von Into aus einer From Implementierung.

Wenn du einen dieser beiden Traits als Trait-Bound für eine eigene neue Generic verwendest, ist der Ratschlag umgekehrt:Verwende den Into Trait für Trait-Bounds. Auf diese Weise wird die Grenze sowohl von Dingen erfüllt, die Into direkt implementieren , als auch von Dingen, die nur From direkt implementieren.

Diese automatische Konvertierung wird in der Dokumentation für From und Into hervorgehoben, aber es lohnt sich auch, den entsprechenden Teil des Codes der Standardbibliothek zu lesen, der eine pauschale Trait-Implementierung ist:

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

Eine Merkmalsbeschreibung in Worte zu fassen, kann helfen, komplexere Merkmalsbeschränkungen zu verstehen. In diesem Fall ist es ziemlich einfach: "Ich kann Into<U> für einen Typ T implementieren, wenn U bereits From<T> implementiert."

Die Standardbibliothek enthält auch verschiedene Implementierungen dieser Konvertierungseigenschaften für Standardbibliothekstypen. Wie zu erwarten, gibt es From Implementierungen für ganzzahlige Konvertierungen, bei denen der Zieltyp alle möglichen Werte des Quelltyps enthält (From<u32> for u64), und TryFrom Implementierungen, wenn der Quelltyp möglicherweise nicht in den Zieltyp passt (TryFrom<u64> for u32).

Neben der zuvor gezeigten Into Version gibt es noch verschiedene andere Implementierungen von Blanket Traits; diese sind meist für Smart Pointer-Typen, die es ermöglichen, dass der Smart Pointer automatisch aus einer Instanz des Typs konstruiert wird, den er enthält. Das bedeutet, dass generische Methoden, die Smart-Pointer-Parameter akzeptieren, auch mit einfachen alten Items aufgerufen werden können; mehr dazu später und in Punkt 8.

Die TryFrom Eigenschaft hat auch eine pauschale Implementierung für jeden Typ, der bereits die Into Eigenschaft in derentgegengesetzten Richtung implementiert - was automatisch (wie zuvor gezeigt) jeden Typ einschließt, der From in der gleichen Richtung implementiert. Mit anderen Worten: Wenn du eine T unfehlbar in eine U umwandeln kannst, kannst du auch unfehlbar eine U aus einerT erhalten; da diese Umwandlung immer gelingt, ist der zugehörige Fehlertyp der hilfreich benannte Infallible.13

Es gibt auch eine sehr spezifische generische Implementierung von From, die heraussticht: die reflexive Implementierung:

impl<T> From<T> for T {
    fn from(t: T) -> T {
        t
    }
}

In Worten ausgedrückt heißt das: "Wenn ich ein T habe, kann ich ein T bekommen." Das ist so offensichtlich, dass es sich lohnt, einmal innezuhalten und zu verstehen, warum das nützlich ist.

Betrachte einen einfachen newtype struct (Punkt 6) und eine Funktion, die darauf operiert (ignoriere, dass diese Funktion besser als Methode ausgedrückt werden sollte):

/// Integer value from an IANA-controlled range.
#[derive(Clone, Copy, Debug)]
pub struct IanaAllocated(pub u64);

/// Indicate whether value is reserved.
pub fn is_iana_reserved(s: IanaAllocated) -> bool {
    s.0 == 0 || s.0 == 65535
}

Diese Funktion kann mit Instanzen der struct aufgerufen werden:

let s = IanaAllocated(1);
println!("{:?} reserved? {}", s, is_iana_reserved(s));
// output: "IanaAllocated(1) reserved? false"

aber auch wenn From<u64> für den newtype Wrapper implementiert ist:

impl From<u64> for IanaAllocated {
    fn from(v: u64) -> Self {
        Self(v)
    }
}

kann die Funktion nicht direkt für u64 Werte aufgerufen werden:

error[E0308]: mismatched types
  --> src/main.rs:77:25
   |
77 |     if is_iana_reserved(42) {
   |        ---------------- ^^ expected `IanaAllocated`, found integer
   |        |
   |        arguments to this function are incorrect
   |
note: function defined here
  --> src/main.rs:7:8
   |
7  | pub fn is_iana_reserved(s: IanaAllocated) -> bool {
   |        ^^^^^^^^^^^^^^^^ ----------------
help: try wrapping the expression in `IanaAllocated`
   |
77 |     if is_iana_reserved(IanaAllocated(42)) {
   |                         ++++++++++++++  +

Eine allgemeine Version der Funktion, die alles akzeptiert (und explizit umwandelt), was Into<IanaAllocated> erfüllt, ist jedoch möglich:

pub fn is_iana_reserved<T>(s: T) -> bool
where
    T: Into<IanaAllocated>,
{
    let s = s.into();
    s.0 == 0 || s.0 == 65535
}

erlaubt diese Verwendung:

if is_iana_reserved(42) {
    // ...
}

Mit dieser Trait-Bindung macht die reflexive Trait-Implementierung von From<T> mehr Sinn: Sie bedeutet, dass die generische Funktion mit Elementen zurechtkommt, die bereits IanaAllocated Instanzen sind, ohne dass eine Umwandlung erforderlich ist.

Dieses Muster erklärt auch, warum (und wie) Rust-Code manchmal den Anschein erweckt, dass er implizite Umwandlungen zwischen Typen vornimmt: Die Kombination aus From<T> Implementierungen und Into<T> Trait-Grenzen führt zu Code, der an der Aufrufstelle wie von Zauberhand konvertiert (aber im Verborgenen immer noch sichere, explizite Umwandlungen vornimmt). Dieses Muster wird noch mächtiger, wenn es mit Referenztypen und den zugehörigen Konvertierungstraits kombiniert wird; mehr dazu in Punkt 8.

Besetzungen

Rust enthält das Schlüsselwort as, um explizite Übertragungen zwischen einigen Typenpaaren durchzuführen.

Die Paare von Typen, die auf diese Weise umgewandelt werden können, sind ziemlich begrenzt, und die einzigen benutzerdefinierten Typen, die enthalten sind, sind "C-ähnliche" enum(solche, die nur einen Ganzzahlwert haben). Allgemeine Integralumwandlungen sind jedoch enthalten und bieten eine Alternative zu into():

let x: u32 = 9;
let y = x as u64;
let z: u64 = x.into();

Die Version as ermöglicht auch verlustbehaftete Konvertierungen:14

let x: u32 = 9;
let y = x as u16;

die von den Versionen from/into abgelehnt werden würden:

error[E0277]: the trait bound `u16: From<u32>` is not satisfied
   --> src/main.rs:136:20
    |
136 |     let y: u16 = x.into();
    |                    ^^^^ the trait `From<u32>` is not implemented for `u16`
    |
    = help: the following other types implement trait `From<T>`:
              <u16 as From<NonZeroU16>>
              <u16 as From<bool>>
              <u16 as From<u8>>
    = note: required for `u32` to implement `Into<u16>`

Aus Gründen der Konsistenz und Sicherheit solltest du from/ into Konvertierungen gegenüber as Casts bevorzugen, es sei denn, du verstehst und brauchst die genaue Casting-Semantik (z.B. für die C-Interoperabilität). Dieser Ratschlag kann durch Clippy(Punkt 29) verstärkt werden, das mehrere Lints über as Konvertierungen enthält; diese Lints sind jedoch standardmäßig deaktiviert.

Nötigung

Die expliziten as Umwandlungen, die im vorherigen Abschnitt beschrieben wurden, sind eine Obermenge der implizitenUmwandlungen, die der Compiler stillschweigend durchführt: Jede Umwandlung kann mit einer expliziten as erzwungen werden, aber der umgekehrte Fall ist nicht wahr. Insbesondere die im vorherigen Abschnitt beschriebenen Integralumwandlungen sind keine Zwangsumwandlungen und erfordern daher immer as.

Die meisten Zwänge beinhalten stille Konvertierungen von Zeiger- und Referenztypen auf eine Art und Weise, die für den Programmierer sinnvoll und bequem ist, wie z.B. die Konvertierung der folgenden:

  • Ein veränderbarer Verweis auf einen unveränderbaren Verweis (so kannst du eine &mut T als Argument für eine Funktionverwenden, die eine &T nimmt)

  • Ein Verweis auf einen rohen Zeiger (das ist nicht unsafe- die Unsicherheit entsteht an dem Punkt, an dem du dumm genug bist, einen rohen Zeiger zu derefenzieren )

  • Eine Schließung, die zufällig keine Variablen erfasst, wird zu einem bloßen Funktionszeiger(Punkt 2)

  • Ein Array zu einemSlice

  • Ein konkretes Element zu einem Trait-Objekt, für einen Trait, den das konkrete Element implementiert

  • Ein Artikel auf Lebenszeit zu einem "kürzeren"(Artikel 14)15

Es gibt nur zwei Zwänge, deren Verhalten durch benutzerdefinierte Typen beeinflusst werden kann. Das erste passiert, wenn ein benutzerdefinierter Typ die Deref oder die DerefMut Eigenschaft implementiert. Diese Eigenschaften zeigen an, dass der benutzerdefinierte Typ als eine Art Smart Pointer fungiert(Punkt 8), und in diesem Fall zwingt der Compiler einen Verweis auf den Smart Pointer in einen Verweis auf ein Element des Typs, den der Smart Pointer enthält (angezeigt durch seine Target).

Die zweite Zwangsbehandlung eines benutzerdefinierten Typs findet statt, wenn ein konkreter Gegenstand in ein Trait-Objekt umgewandelt wird. Dieser Vorgang erzeugt einen Fat Pointer auf das Objekt. Dieser Zeiger ist Fat, weil er sowohl einen Zeiger auf den Speicherplatz des Objekts als auch einen Zeiger auf die vtable für die Implementierung des Traits durch den konkreten Typ enthält - siehe Punkt 8.

Punkt 6: Mach dir das newtype-Muster zu eigen

In Punkt 1 wurden Tupel-Strukturen beschrieben, bei denen die Felder einer struct keine Namen haben und stattdessen mit einer Nummer bezeichnet werden (self.0). In diesem Artikel geht es um Tupel-Strukturen, die einen einzigen Eintrag eines bestehenden Typs enthalten und so einen neuen Typ schaffen, der genau den gleichen Wertebereich wie der eingeschlossene Typ enthalten kann. Dieses Muster ist in Rust so weit verbreitet, dass es einen eigenen Artikel verdient und einen eigenen Namen hat: das newtype-Muster.

Die einfachste Anwendung des newtype-Musters ist die Angabe zusätzlicher semantischer Eigenschaften für einen Typ, die über sein normales Verhalten hinausgehen. Um das zu veranschaulichen, stell dir ein Projekt vor, bei dem ein Satellit zum Mars geschickt werden soll.16 Da es sich um ein großes Projekt handelt, haben verschiedene Gruppen unterschiedliche Teile des Projekts entwickelt. Eine Gruppe hat sich um den Code für die Raketentriebwerke gekümmert:

/// Fire the thrusters. Returns generated impulse in pound-force seconds.
pub fn thruster_impulse(direction: Direction) -> f64 {
    // ...
    return 42.0;
}

während eine andere Gruppe sich um das Trägheitsleitsystem kümmert:

/// Update trajectory model for impulse, provided in Newton seconds.
pub fn update_trajectory(force: f64) {
    // ...
}

Irgendwann müssen diese verschiedenen Teile zusammengefügt werden:

let thruster_force: f64 = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force);

Ruhig-roh.17

Rust enthält eine Typ-Alias-Funktion, mit der die verschiedenen Gruppen ihre Absichten deutlicher machen können:

/// Units for force.
pub type PoundForceSeconds = f64;

/// Fire the thrusters. Returns generated impulse.
pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds {
    // ...
    return 42.0;
}
/// Units for force.
pub type NewtonSeconds = f64;

/// Update trajectory model for impulse.
pub fn update_trajectory(force: NewtonSeconds) {
    // ...
}

Die Typ-Aliase sind jedoch nur eine Dokumentation; sie sind ein besserer Hinweis als die Doc-Kommentare der vorherigen Version, aber nichts verhindert, dass ein PoundForceSeconds Wert verwendet wird, wo einNewtonSeconds Wert erwartet wird:

let thruster_force: PoundForceSeconds = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force);

Ruh-roh noch einmal.

Das ist der Punkt, an dem das newtype-Muster hilft:

/// Units for force.
pub struct PoundForceSeconds(pub f64);

/// Fire the thrusters. Returns generated impulse.
pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds {
    // ...
    return PoundForceSeconds(42.0);
}
/// Units for force.
pub struct NewtonSeconds(pub f64);

/// Update trajectory model for impulse.
pub fn update_trajectory(force: NewtonSeconds) {
    // ...
}

Wie der Name schon sagt, ist ein newtype ein neuer Typ, und als solcher wird er vom Compiler beanstandet, wenn die Typen nicht übereinstimmen - hier beim Versuch, einen PoundForceSeconds Wert an etwas zu übergeben, das einen NewtonSeconds Wert erwartet:

error[E0308]: mismatched types
  --> src/main.rs:76:43
   |
76 |     let new_direction = update_trajectory(thruster_force);
   |                         ----------------- ^^^^^^^^^^^^^^ expected
   |                         |        `NewtonSeconds`, found `PoundForceSeconds`
   |                         |
   |                         arguments to this function are incorrect
   |
note: function defined here
  --> src/main.rs:66:8
   |
66 | pub fn update_trajectory(force: NewtonSeconds) {
   |        ^^^^^^^^^^^^^^^^^ --------------------
help: call `Into::into` on this expression to convert `PoundForceSeconds` into
      `NewtonSeconds`
   |
76 |     let new_direction = update_trajectory(thruster_force.into());
   |                                                         +++++++

Wie in Punkt 5 beschrieben, wird eine Implementierung der Standard-Eigenschaft From hinzugefügt:

impl From<PoundForceSeconds> for NewtonSeconds {
    fn from(val: PoundForceSeconds) -> NewtonSeconds {
        NewtonSeconds(4.448222 * val.0)
    }
}

ermöglicht es, die notwendige Einheiten- und Typenumwandlung mit .into() durchzuführen:

let thruster_force: PoundForceSeconds = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force.into());

Das gleiche Muster der Verwendung eines neuen Typs, um zusätzliche "Einheits"-Semantik für einen Typ zu kennzeichnen, kann auch dazu beitragen, rein boolesche Argumente weniger mehrdeutig zu machen. Um auf das Beispiel aus Punkt 1 zurückzukommen: Die Verwendung von newtypes macht die Bedeutung von Argumenten klar:

struct DoubleSided(pub bool);

struct ColorOutput(pub bool);

fn print_page(sides: DoubleSided, color: ColorOutput) {
    // ...
}
print_page(DoubleSided(true), ColorOutput(false));

Wenn Größeneffizienz oder Binärkompatibilität eine Rolle spielen, stellt das Attribut#[repr(transparent)] sicher, dass ein neuer Typ die gleiche Darstellung im Speicher hat wie der innere Typ.

Das ist die einfache Verwendung von newtype und ein spezielles Beispiel für Item 1, das dieSemantik in das Typsystem kodiert, so dass der Compiler sich um die Überwachung dieser Semantik kümmert.

Umgehung der Orphan Rule für Traits

Das anderehäufige, aber subtilere Szenario, das das newtype-Muster erfordert, dreht sich um die Orphan-Regel von Rust. Grob gesagt besagt diese, dass eine Kiste nur dann einen Trait für einen Typ implementieren kann, wenn eine der folgenden Bedingungen erfüllt ist:

  • Die Kiste hat die Eigenschaft

  • Die Kiste hat den Typ definiert

Versuch, einen fremden Trait für einen fremden Typ zu implementieren:

führt zu einem Compilerfehler (der wiederum den Weg zurück zu newtypes weist):

error[E0117]: only traits defined in the current crate can be implemented for
              types defined outside of the crate
   --> src/main.rs:146:1
    |
146 | impl fmt::Display for rand::rngs::StdRng {
    | ^^^^^^^^^^^^^^^^^^^^^^------------------
    | |                     |
    | |                     `StdRng` is not defined in the current crate
    | impl doesn't use only types from inside the current crate
    |
    = note: define and implement a trait or new type instead

Der Grund für diese Einschränkung liegt in der Gefahr der Mehrdeutigkeit: Wenn zwei verschiedene Kisten im Abhängigkeitsgraphen(Punkt 25) beide zu (sagen wir) impl std::fmt::Display for rand::rngs::StdRng gehören, dann hat der Compiler/Linker keine Möglichkeit, zwischen ihnen zu wählen.

Dies kann häufig zu Frustration führen: Wenn du zum Beispiel versuchst, Daten zu serialisieren, die einen Typ aus einer anderen Kiste enthalten, hindert dich die Orphan-Regel daran, impl serde::Serialize for somecrate::SomeType zu schreiben.18

Aber das newtype-Muster bedeutet, dass du einen neuen Typ definierst, der Teil der aktuellen Kiste ist, und daher gilt der zweite Teil der Orphan-Trait-Regel. Die Implementierung eines fremden Traits ist jetzt möglich:

struct MyRng(rand::rngs::StdRng);

impl fmt::Display for MyRng {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "<MyRng instance>")
    }
}

Newtype-Einschränkungen

Das newtype-Muster löst diese beiden Problemklassen - es verhindert Einheitenumwandlungen und umgeht die Orphan-Rule - aber es bringt einige Unannehmlichkeiten mit sich: Jede Operation, die den newtype betrifft, muss an den inneren Typ weitergeleitet werden.

Auf trivialer Ebene bedeutet das, dass der Code durchgängig thing.0 und nicht nur thing verwenden muss, aber das ist einfach, und der Compiler wird dir sagen, wo es gebraucht wird.

Die größere Unannehmlichkeit besteht darin, dass alle Trait-Implementierungen des inneren Typs verloren gehen: Da der newtype ein neuer Typ ist, gilt die bestehende innere Implementierung nicht.

Für ableitbare Traits bedeutet das, dass die newtype-Deklaration mit vielen derives endet:

#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub struct NewType(InnerType);

Für anspruchsvollere Traits ist jedoch eine Weiterleitungs-Boilerplate erforderlich, um die Implementierung des inneren Typs wiederherzustellen, zum Beispiel :

use std::fmt;
impl fmt::Display for NewType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        self.0.fmt(f)
    }
}

Punkt 7: Bauherren für komplexe Typen verwenden

In diesem Artikel wird das Builder-Muster beschrieben, bei dem komplexe Datenstrukturen einen zugehörigen Builder-Typ haben, der es den Benutzern erleichtert, Instanzen derDatenstruktur zu erstellen.

Rust besteht darauf, dass alle Felder in einer struct ausgefüllt werden müssen, wenn eine neue Instanz dieser struct erstellt wird. Das macht den Code sicher, weil es keine uninitialisierten Werte gibt, führt aber zu ausführlicherem Boilerplate-Code als ideal wäre.

Zum Beispiel müssen alle optionalen Felder explizit mit None als fehlend markiert werden:

/// Phone number in E164 format.
#[derive(Debug, Clone)]
pub struct PhoneNumberE164(pub String);

#[derive(Debug, Default)]
pub struct Details {
    pub given_name: String,
    pub preferred_name: Option<String>,
    pub middle_name: Option<String>,
    pub family_name: String,
    pub mobile_phone: Option<PhoneNumberE164>,
}

// ...

let dizzy = Details {
    given_name: "Dizzy".to_owned(),
    preferred_name: None,
    middle_name: None,
    family_name: "Mixer".to_owned(),
    mobile_phone: None,
};

Dieser Boilerplate-Code ist auch insofern spröde, als dass eine zukünftige Änderung, die ein neues Feld zu struct hinzufügt, eine Aktualisierung an jeder Stelle erfordert, die die Struktur aufbaut.

Der Boilerplate kann durch die Implementierung und Verwendung derDefault Trait, wie inPunkt 10 beschrieben:

let dizzy = Details {
    given_name: "Dizzy".to_owned(),
    family_name: "Mixer".to_owned(),
    ..Default::default()
};

Die Verwendung von Default trägt auch dazu bei, dass weniger Änderungen erforderlich sind, wenn ein neues Feld hinzugefügt wird, vorausgesetzt, das neue Feld ist selbst von einem Typ, der Default implementiert.

Das ist ein allgemeineres Problem: Die automatisch von abgeleitete Implementierung von Default funktioniert nur, wenn alle Feldtypen die Eigenschaft Default implementieren. Wenn es ein Feld gibt, das nicht mitspielt, funktioniert der derive Schritt nicht:

error[E0277]: the trait bound `Date: Default` is not satisfied
  --> src/main.rs:48:9
   |
41 |     #[derive(Debug, Default)]
   |                     ------- in this derive macro expansion
...
48 |         pub date_of_birth: time::Date,
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Default` is not
   |                                       implemented for `Date`
   |
   = note: this error originates in the derive macro `Default`

Wegen der Waisenregel kann der Code nicht Default für chrono::Utc implementieren. Aber selbst wenn er es könnte, wäre es nicht hilfreich - die Verwendung eines Standardwerts für das Geburtsdatum wird fast immer falsch sein.

Das Fehlen von Default bedeutet, dass alle Felder manuell ausgefüllt werden müssen:

let bob = Details {
    given_name: "Robert".to_owned(),
    preferred_name: Some("Bob".to_owned()),
    middle_name: Some("the".to_owned()),
    family_name: "Builder".to_owned(),
    mobile_phone: None,
    date_of_birth: time::Date::from_calendar_date(
        1998,
        time::Month::November,
        28,
    )
    .unwrap(),
    last_seen: None,
};

Diese Ergonomie kann verbessert werden, wenn du das Builder-Muster für komplexe Datenstrukturen einsetzt.

Die einfachste Variante des Builder-Patterns ist eine separate struct, die die Informationen enthält, die für die Konstruktion des Gegenstands benötigt werden. Der Einfachheit halber enthält das Beispiel eine Instanz des Gegenstands selbst:

pub struct DetailsBuilder(Details);

impl DetailsBuilder {
    /// Start building a new [`Details`] object.
    pub fn new(
        given_name: &str,
        family_name: &str,
        date_of_birth: time::Date,
    ) -> Self {
        DetailsBuilder(Details {
            given_name: given_name.to_owned(),
            preferred_name: None,
            middle_name: None,
            family_name: family_name.to_owned(),
            mobile_phone: None,
            date_of_birth,
            last_seen: None,
        })
    }
}

Der Builder-Typ kann dann mit Hilfsmethoden ausgestattet werden, die die Felder des entstehenden Objekts ausfüllen. Jede dieser Methoden verbraucht self, gibt aber eine neue Self aus, so dass verschiedene Baumethoden miteinander verkettet werden können:

/// Set the preferred name.
pub fn preferred_name(mut self, preferred_name: &str) -> Self {
    self.0.preferred_name = Some(preferred_name.to_owned());
    self
}

/// Set the middle name.
pub fn middle_name(mut self, middle_name: &str) -> Self {
    self.0.middle_name = Some(middle_name.to_owned());
    self
}

Diese Hilfsmethoden können hilfreicher sein als einfache Setzer:

/// Update the `last_seen` field to the current date/time.
pub fn just_seen(mut self) -> Self {
    self.0.last_seen = Some(time::OffsetDateTime::now_utc());
    self
}

Die letzte Methode, die für den Builder aufgerufen wird, verbraucht den Builder und gibt das erstellte Element aus:

/// Consume the builder object and return a fully built [`Details`]
/// object.
pub fn build(self) -> Details {
    self.0
}

Insgesamt ermöglicht dies den Kunden des Bauunternehmens einergonomischeres Bauen:

let also_bob = DetailsBuilder::new(
    "Robert",
    "Builder",
    time::Date::from_calendar_date(1998, time::Month::November, 28)
        .unwrap(),
)
.middle_name("the")
.preferred_name("Bob")
.just_seen()
.build();

Die allumfassende Natur dieses Bauherrenstils führt zu einer Reihe von Problemen. Die erste ist, dass es nicht möglich ist, die einzelnen Phasen des Bauprozesses voneinander zu trennen:

error[E0382]: use of moved value: `builder`
   --> src/main.rs:256:15
    |
247 |     let builder = DetailsBuilder::new(
    |         ------- move occurs because `builder` has type `DetailsBuilder`,
    |                 which does not implement the `Copy` trait
...
254 |         builder.preferred_name("Bob");
    |                 --------------------- `builder` moved due to this method
    |                                       call
255 |     }
256 |     let bob = builder.build();
    |               ^^^^^^^ value used here after move
    |
note: `DetailsBuilder::preferred_name` takes ownership of the receiver `self`,
      which moves `builder`
   --> src/main.rs:60:35
    |
27  |     pub fn preferred_name(mut self, preferred_name: &str) -> Self {
    |                               ^^^^

Das lässt sich umgehen, indem du den verbrauchten Builder wieder derselbenVariablen zuweist:

let mut builder = DetailsBuilder::new(
    "Robert",
    "Builder",
    time::Date::from_calendar_date(1998, time::Month::November, 28)
        .unwrap(),
);
if informal {
    builder = builder.preferred_name("Bob");
}
let bob = builder.build();

Der andere Nachteil dieses Builders ist, dass nur ein Element gebaut werden kann. Der Versuch, mehrere Instanzen durch wiederholtes Aufrufen von build() auf demselben Builder zu erstellen, führt erwartungsgemäß zu Problemen mit dem Compiler:

error[E0382]: use of moved value: `smithy`
   --> src/main.rs:159:39
    |
154 |   let smithy = DetailsBuilder::new(
    |       ------ move occurs because `smithy` has type `base::DetailsBuilder`,
    |              which does not implement the `Copy` trait
...
159 |   let clones = vec![smithy.build(), smithy.build(), smithy.build()];
    |                            -------  ^^^^^^ value used here after move
    |                            |
    |                            `smithy` moved due to this method call

Ein alternativer Ansatz ist, dass die Methoden des Erbauers eine &mut self nehmen und eine &mut Self ausgeben:

/// Update the `last_seen` field to the current date/time.
pub fn just_seen(&mut self) -> &mut Self {
    self.0.last_seen = Some(time::OffsetDateTime::now_utc());
    self
}

Damit entfällt die Notwendigkeit der Selbstzuweisung in separaten Bauphasen:

let mut builder = DetailsBuilder::new(
    "Robert",
    "Builder",
    time::Date::from_calendar_date(1998, time::Month::November, 28)
        .unwrap(),
);
if informal {
    builder.preferred_name("Bob"); // no `builder = ...`
}
let bob = builder.build();

Diese Version macht es jedoch unmöglich, die Konstruktion des Builders mit dem Aufruf seiner Setter-Methoden miteinander zu verknüpfen:

error[E0716]: temporary value dropped while borrowed
   --> src/main.rs:265:19
    |
265 |       let builder = DetailsBuilder::new(
    |  ___________________^
266 | |         "Robert",
267 | |         "Builder",
268 | |         time::Date::from_calendar_date(1998, time::Month::November, 28)
269 | |             .unwrap(),
270 | |     )
    | |_____^ creates a temporary value which is freed while still in use
271 |       .middle_name("the")
272 |       .just_seen();
    |                   - temporary value is freed at the end of this statement
273 |       let bob = builder.build();
    |                 --------------- borrow later used here
    |
    = note: consider using a `let` binding to create a longer lived value

Wie der Compiler-Fehler zeigt, kannst du das Problem umgehen, indem du letdem Bauelement einen Namen gibst:

let mut builder = DetailsBuilder::new(
    "Robert",
    "Builder",
    time::Date::from_calendar_date(1998, time::Month::November, 28)
        .unwrap(),
);
builder.middle_name("the").just_seen();
if informal {
    builder.preferred_name("Bob");
}
let bob = builder.build();

Diese Variante des mutierenden Builders ermöglicht es auch, mehrere Elemente zu bauen. Die Signatur der Methode build() darf nicht self verbrauchen und muss daher wie folgt lauten:

/// Construct a fully built [`Details`] object.
pub fn build(&self) -> Details {
    // ...
}

Die Implementierung dieser wiederholbaren build() Methode muss dann bei jedem Aufruf ein neues Element erstellen. Wenn das zugrundeliegende Element Clone implementiert, ist dies einfach: Der Builder kann eine Vorlage speichern und sie für jeden Build auf clone() speichern. Wenn der zugrundeliegende Gegenstand nicht Clone implementiert, muss der Builder über genügend Status verfügen, um bei jedem Aufruf von build() eine Instanz des zugrundeliegenden Gegenstands manuell zu erstellen.

Bei jeder Art von Builder-Muster ist der Boilerplate-Code jetzt auf eine Stelle beschränkt - den Builder - und wird nicht mehr an jeder Stelle benötigt, die den zugrunde liegenden Typ verwendet.

Die verbleibende Boilerplate kann möglicherweise durch die Verwendung eines Makros noch weiter reduziert werden(Punkt 28), aber wenn du diesen Weg gehst, solltest du auch prüfen, ob es eine bestehende Crate gibt (wie z. B. die derive_builder crate) gibt, die genau das bietet, was du brauchst - vorausgesetzt, du bist bereit, eine Abhängigkeit von ihr zu akzeptieren(Punkt 25).

Punkt 8: Mache dich mitReferenz- und Zeigertypen vertraut

Bei der Programmierung im Allgemeinen ist eine Referenz eine Möglichkeit, indirekt auf eine Datenstruktur zuzugreifen, unabhängig davon, welche Variable diese Datenstruktur besitzt. In der Praxis wird dies meist als Zeiger implementiert: eine Zahl, deren Wert die Adresse der Datenstruktur im Speicher ist.

Eine moderne CPU hat in der Regel einige Einschränkungen für Zeiger: Die Speicheradresse sollte in einem gültigen Speicherbereich liegen (egal ob virtuell oder physisch) und muss eventuell ausgerichtet sein (z. B. kann auf einen 4-Byte-Ganzzahlwert nur zugegriffen werden, wenn seine Adresse ein Vielfaches von 4 ist).

Höhere Programmiersprachen kodieren jedoch normalerweise mehr Informationen über Zeiger in ihren Typsystemen. In von C abgeleiteten Sprachen, einschließlich Rust, haben Zeiger einen Typ, der angibt, welche Art von Datenstruktur an der Speicheradresse, auf die gezeigt wird, zu erwarten ist. Dies ermöglicht es dem Code, den Inhalt des Speichers an dieser Adresse und im Speicher nach dieser Adresse zu interpretieren.

Diese grundlegende Ebene der Zeigerinformation - die relative Speicherposition und das erwartete Layout der Datenstruktur - wird in Rust als Raw Pointer dargestellt. Sicherer Rust-Code verwendet jedoch keine rohen Zeiger, da Rust reichhaltigere Referenz- und Zeigertypen bietet, die zusätzliche Sicherheitsgarantien und Beschränkungen bieten. Diese Referenz- und Zeigertypen sind das Thema dieses Artikels. Rohe Zeiger werden in Artikel 16 behandelt ( unsafe ).

Rost-Referenzen

Der allgegenwärtigste zeigerähnliche Typ in Rust ist die Referenz, mit einem Typ, der als &T für einen TypT geschrieben wird. Obwohl es sich hierbei um einen Zeigerwert handelt, stellt der Compiler sicher, dass verschiedene Regeln für seine Verwendung eingehalten werden: Er muss immer auf eine gültige, korrekt ausgerichtete Instanz des entsprechenden Typs T zeigen, dessen Lebensdauer(Punkt 14) über seine Verwendung hinausgeht, und er muss die Regeln für die Ausleihkontrolle(Punkt 15) erfüllen. Diese zusätzlichen Einschränkungen werden in Rust immer durch den Begriff Referenz impliziert, so dass der bloße Begriff Zeiger im Allgemeinen selten ist.

Die Einschränkung, dass eine Rust-Referenz auf ein gültiges, korrekt ausgerichtetes Element zeigen muss, gilt auch für die Referenztypen von C++. Allerdings hat C++ kein Konzept für Lebenszeiten und erlaubt daher Fußangeln mit baumelnden Referenzen:19

Dank der Borrowing- und Lifetime-Prüfungen von Rust lässt sich der entsprechende Code von nicht einmal kompilieren:

error[E0515]: cannot return reference to local variable `x`
   --> src/main.rs:477:5
    |
477 |     &x
    |     ^^ returns a reference to data owned by the current function

Eine Rust-Referenz &T erlaubt einen Nur-Lese-Zugriff auf das zugrunde liegende Element (entspricht in etwa der const T&in C++ ). Eine veränderbare Referenz, die auch die Änderung des zugrundeliegenden Objekts erlaubt, wird als &mut T geschrieben und unterliegt auchden in Punkt 15 besprochenen Regeln zur Überprüfung von Ausleihen. Dieses Benennungsmuster spiegelt eine leicht unterschiedliche Denkweise zwischen Rust und C++ wider:

  • In Rust ist die Standardvariante schreibgeschützt, und beschreibbare Typen werden speziell markiert (mit mut).

  • In C++ ist die Standardvariante beschreibbar, und schreibgeschützte Typen sind besonders gekennzeichnet (mit const).

Der Compiler wandelt Rust-Code, der Referenzen verwendet, in Maschinencode um, der einfache Zeiger verwendet, die auf einer 64-Bit-Plattform acht Byte groß sind (wovon dieser Artikel durchgehend ausgeht). Zum Beispiel ein Paar lokaler Variablen zusammen mit Referenzen auf sie:

pub struct Point {
    pub x: u32,
    pub y: u32,
}

let pt = Point { x: 1, y: 2 };
let x = 0u64;
let ref_x = &x;
let ref_pt = &pt;

können auf dem Stapel liegen, wie in Abbildung 1-2 gezeigt.

Representation of a stack with 4 entries, each shown as a rectangle representing 8 bytes. Starting from the bottom, the first entry is labelled pt, and the 8 bytes it represents is split into two 4-byte values, 1 and 2. Above that, the next entry is labelled x and holds the value 0.  Above that is an entry labelled ref_x, whose contents are just an arrow that points to the x entry below it on the stack. At the top is a ref_pt entry, whose contents are an arrow that points to the pt entry at the bottom of the stack.
Abbildung 1-2. Stack-Layout mit Zeigern auf lokale Variablen

Eine Rust-Referenz kann sich auf Elemente beziehen, die sich entweder auf dem Stack oder auf dem Heap befinden.Rust weist Elemente standardmäßig auf dem Stack zu, aber der Zeigertyp Box<T> (entspricht in etwa dem von C++ std::unique_ptr<T>) erzwingt die Zuweisung auf dem Heap, was wiederum bedeutet, dass das zugewiesene Element den Gültigkeitsbereich des aktuellen Blocks überschreiten kann. Unter der Haube ist Box<T> ebenfalls ein einfacher Acht-Byte-Zeigerwert:

    let box_pt = Box::new(Point { x: 10, y: 20 });

Dies ist in Abbildung 1-3 dargestellt.

The figure shows a representation of the stack on the left, with a single entry labelled box_pt. The contents of this entry is the start of an arrow that points to a rectangle on the right hand side, inside a cloud labelled 'Heap'. The rectangle on the right hand side is split into two 4-byte components, holding the values 10 and 20.
Abbildung 1-3. Stack Box Zeiger auf struct auf dem Heap

Zeiger-Eigenschaften

Eine Methode, die ein Referenzargument wie &Point erwartet, kann auch ein &Box<Point> gefüttert werden:

fn show(pt: &Point) {
    println!("({}, {})", pt.x, pt.y);
}
show(ref_pt);
show(&box_pt);
(1, 2)
(10, 20)

Dies ist möglich, weil Box<T> den Deref Trait mit Target = T implementiert. Eine Implementierung dieses Traits für einen Typ bedeutet, dass die Methode des Traitsderef() Methode des Traits verwendet werden kann, um einen Verweis auf den Typ Target zu erstellen. Es gibt auch einen entsprechenden DerefMut Trait, der eine veränderbare Referenz auf den Typ Target ausgibt.

Die Deref/DerefMut Traits sind etwas Besonderes, weil der Rust-Compiler ein bestimmtes Verhalten an den Tag legt, wenn es um Typen geht, die sie implementieren. Wenn der Compiler auf einen dereferenzierenden Ausdruck trifft (z. B., *x), sucht er nach einer Implementierung eines dieser Traits und verwendet sie, je nachdem, ob die Dereferenzierung einen veränderlichen Zugriff erfordert oder nicht.Diese Deref Zwangskonstruktion ermöglicht es verschiedenen Smart Pointer-Typen, sich wie normale Referenzen zu verhalten, und ist einer der wenigen Mechanismen, die eine implizite Typumwandlung in Rust ermöglichen (wie in Punkt 5 beschrieben).

Am Rande sei erwähnt, dass es sich lohnt zu verstehen, warum die Deref Eigenschaften nicht generisch (Deref<Target>) für den Zieltyp sein können. Wäre dies der Fall, wäre es möglich, dass ein Typ ConfusedPtr sowohl Deref<TypeA>als auch Deref<TypeB> implementiert, so dass der Compiler nicht in der Lage wäre, einen einzigen eindeutigen Typ für einen Ausdruck wie *x abzuleiten. Stattdessen wird der Zieltyp als der zugehörige Typ namensTarget.

Diese technische Randbemerkung steht im Gegensatz zu zwei anderen Standard-Zeigermerkmalen, den AsRef und AsMut Traits. Diese Traits bewirken kein spezielles Verhalten im Compiler, sondern ermöglichen die Umwandlung in eine Referenz oder eine veränderbare Referenz durch einen expliziten Aufruf ihrer Trait-Funktionen (as_ref() undas_mut(), bzw.). Der Zieltyp für diese Konvertierungen wird als Typparameter kodiert (z. B. AsRef<Point>), was bedeutet, dass ein einziger Containertyp mehrere Ziele unterstützen kann.

Zum Beispiel implementiert der Standard String Typ implementiert die EigenschaftDeref mit Target = str, was bedeutet, dass ein Ausdruck wie &my_string zum Typ &str gezwungen werden kann. Aber er implementiert auch das Folgende:

  • AsRef<[u8]>und ermöglicht die Umwandlung in eine Byte-Slice &[u8]

  • AsRef<OsStr>und ermöglicht die Umwandlung in einen OS-String

  • AsRef<Path>und ermöglicht die Umwandlung in einen Dateisystempfad

  • AsRef<str>und ermöglicht die Umwandlung in ein String-Slice &str (wie bei Deref)

Fette Zeigertypen

Rust hat zwei eingebaute fette Zeigertypen: Slices und Trait-Objekte. Das sind Typen, die wie Zeiger funktionieren, aber zusätzliche Informationen über das Objekt enthalten, auf das sie zeigen.

Slices

Der erste fette Zeigertyp ist der Slice: ein Verweis auf eine Teilmenge einer zusammenhängenden Sammlung von Werten. Er wird aus einem (nicht-besitzenden) einfachen Zeiger und einem Längenfeld gebildet, wodurch er doppelt so groß wie ein einfacher Zeiger ist (16 Byte auf einer 64-Bit-Plattform). Der Typ eines Slice wird als &[T]geschrieben - ein Verweis auf [T], der der fiktive Typ für eine zusammenhängende Sammlung von Werten des Typs T ist.

Der fiktive Typ [T] kann nicht instanziiert werden, aber es gibt zwei gängige Container, die ihn verkörpern. Der erste ist das Array: eine zusammenhängende Sammlung von Werten mit einer Größe, die zum Zeitpunkt der Kompilierung bekannt ist - ein Array mit fünf Werten hat immer fünf Werte. Ein Slice kann sich daher auf eine Teilmenge eines Arrays beziehen (wie in Abbildung 1-4 dargestellt):

let array: [u64; 5] = [0, 1, 2, 3, 4];
let slice = &array[1..3];
Representation of a stack holding seven 8-byte quantities, divided into two groups.  The bottom group is labelled array and covers the top 5 entries in the stack, which hold the values 0 to 4.  The top group is labelled slice and covers the bottom 2 entries in the stack. Of these 2 entries, the top one holds an arrow that points to the second element in the array chunk, counting from the bottom; the bottom entry holds a value labelled len=2.
Abbildung 1-4. Stack-Slice, die in ein Stack-Array zeigt

Der andere gängige Container für zusammenhängende Werte ist ein Vec<T>. Dieser enthält eine zusammenhängende Sammlung von Werten wie ein Array, aber im Gegensatz zu einem Array kann die Anzahl der Werte im Vec wachsen (z. B. mit push(value)) oder schrumpfen (z. B. mit pop()).

Die Inhalte von Vec werden auf dem Heap gehalten (was diese Größenvariationen ermöglicht), sind aber immer zusammenhängend, so dass sich ein Slice auf eine Teilmenge eines Vektors beziehen kann, wie in Abbildung 1-5 gezeigt:

let mut vector = Vec::<u64>::with_capacity(8);
for i in 0..5 {
    vector.push(i);
}
let vslice = &vector[1..3];
The diagram shows a stack on the left, and a heap on the right, both arranged as vertically stacked rectangles where each rectangle represents an 8-byte quantity.  The heap on the right has 8 entries within it: from bottom to top the first 5 contain values from 0 to 4 consecutively; the top 3 entries are all labelled (uninit).  The stack holds five 8-byte quantities, divided into two groups.  The top group is labelled slice and holds two entries.  Of these 2 entries, the top one holds an arrow that points to the second element in the heap chunk, counting from the bottom; the bottom entry holds a value labelled len=2. The bottom group of the stack is labelled vec and hols three entries. The top entry holds an arrow that points to the bottom element of the heap chunk; the middle entry has a value capacity=8; the bottom entry has a value len=5.
Abbildung 1-5. Stack-Slice, die auf den Inhalt von Vec auf dem Heap zeigt

Hinter dem Ausdruck &vector[1..3] verbirgt sich eine ganze Menge, deshalb lohnt es sich, ihn in seine Bestandteile zu zerlegen:

  • Der 1..3 Teil ist ein Bereichsausdruck; der Compiler wandelt diesen in eine Instanz des Range<usize> Typs um, der eine inklusive untere Grenze und eine exklusive obere Grenze enthält.

  • Der Typ Rangeimplementiertdie Eigenschaft SliceIndex<T> Trait, der Indexierungsoperationen auf Slices eines beliebigen Typs T beschreibt (der Typ Output ist also [T]).

  • Der Teil vector[ ] ist ein Indexierungsausdruck; der Compiler wandelt in einen Aufruf der Index Trait's index Methode des Traits auf vector, zusammen mit einer Dereferenz (d.h. *vector.index( )).20

  • vector[1..3] Deshalb ruft Vec<T>dieImplementierung von Index<I> auf, was voraussetzt, dass I eine Instanz von SliceIndex<[u64]> ist. Das funktioniert, weil Range<usize>SliceIndex<[T]> für jedes T implementiert, einschließlich u64.

  • &vector[1..3] macht die Dereferenzierung rückgängig, so dass der endgültige Ausdruckstyp &[u64] ist.

Trait-Objekte

Der zweite eingebaute Fat Pointer Typ ist ein Trait-Objekt: ein Verweis auf ein Element, das einen bestimmten Trait implementiert. Es besteht aus einem einfachen Zeiger auf den Gegenstand und einem internen Zeiger auf die vtable des Typs, was eine Größe von 16 Byte ergibt (auf einer 64-Bit-Plattform). Die vtable für die Implementierung eines Traits eines Typs enthält Funktionszeiger für jede der Methodenimplementierungen und ermöglicht so eine dynamische Verteilung zur Laufzeit(Punkt 12).21

Also eine einfache Eigenschaft:

trait Calculate {
    fn add(&self, l: u64, r: u64) -> u64;
    fn mul(&self, l: u64, r: u64) -> u64;
}

mit einer struct, die es umsetzt:

struct Modulo(pub u64);

impl Calculate for Modulo {
    fn add(&self, l: u64, r: u64) -> u64 {
        (l + r) % self.0
    }
    fn mul(&self, l: u64, r: u64) -> u64 {
        (l * r) % self.0
    }
}

let mod3 = Modulo(3);

kann in ein Trait-Objekt des Typs &dyn Trait umgewandelt werden. Das Schlüsselwortdyn hebt die Tatsache hervor, dass es sich um dynamischen Versand handelt:

// Need an explicit type to force dynamic dispatch.
let tobj: &dyn Calculate = &mod3;
let result = tobj.add(2, 2);
assert_eq!(result, 1);

Das äquivalente Speicherlayout ist in Abbildung 1-6 dargestellt.

The diagram shows a stack layout on the left, with a single entry labelled mod3 with value 3 at the top, and below that a pair of entries jointly labelled tobj.  The top entry in tobj holds an arrow that points to the mod3 entry on the stack; the bottom entry in tobj points to a composite rectangle on the right hand side of the diagram labelled Calculate for Modulo vtable.  This representable of a vtable contains two entries, labelled add and mul. The first of these holds an arrow that leads to a box representing the code of Modulo::add(); the second holds an arrow that leads to a box representing the code of Modulo::mul().
Abbildung 1-6. Trait-Objekt mit Zeigern auf konkretes Element und vtable

Code, der ein Trait-Objekt enthält, kann die Methoden des Traits über die Funktionszeiger in der vtable aufrufen, indem er den Item Pointer als &self Parameter übergibt; siehe Punkt 12 für weitere Informationen und Ratschläge.

Weitere Zeiger-Eigenschaften

In "Pointer Traits" wurden zwei Trait-Paare (Deref/DerefMut, AsRef/AsMut) beschrieben, die beim Umgang mit Typen verwendet werden, die leicht in Referenzen umgewandelt werden können. Es gibt noch ein paar weitere Standard-Traits, die bei der Arbeit mit zeigerähnlichen Typen zum Einsatz kommen können, egal ob sie aus der Standardbibliothek stammen oder benutzerdefiniert sind.

Die einfachste davon ist die Pointer Trait, der einen Zeigerwert für die Ausgabe formatiert. Dies kann bei der Fehlersuche auf niedriger Ebene hilfreich sein, und der Compiler greift automatisch auf diese Eigenschaft zurück, wenn er auf den {:p} format specifier stößt.

Noch faszinierender sind die Borrow und BorrowMut Traits, die jeweils eine einzige Methode haben (borrow undborrow_mut, bzw.). Diese Methodehat die gleiche Signatur wie die entsprechenden Methoden der Traits AsRef/AsMut.

Der Hauptunterschied in der Absicht zwischen diesen Merkmalen wird durch die pauschalen Implementierungen sichtbar, die die Standardbibliothek bereitstellt. Für eine beliebige Rust-Referenz &T gibt es eine pauschale Implementierung von AsRef undBorrow; für eine veränderbare Referenz &mut T gibt es ebenfalls eine pauschale Implementierung von AsMut und BorrowMut.

Borrow hat jedoch auch eine pauschale Implementierung für (Nicht-Referenz-)Typen: impl<T> Borrow<T> for T.

Das bedeutet, dass eine Methode, die die Eigenschaft Borrow akzeptiert, sowohl mit Instanzen von T als auch mit Referenzen zuT umgehen kann:

fn add_four<T: std::borrow::Borrow<i32>>(v: T) -> i32 {
    v.borrow() + 4
}
assert_eq!(add_four(&2), 6);
assert_eq!(add_four(2), 6);

Die Containertypen der Standardbibliothek haben realistischere Verwendungen von Borrow. Zum Beispiel,HashMap::getBorrow verwendet, um das bequeme Abrufen von Einträgen zu ermöglichen, unabhängig davon, ob sie als Wert oder als Referenz angegeben sind.

Der ToOwned Trait baut auf dem BorrowTrait auf und fügt eine to_owned() Methode hinzu, die ein neues eigenes Element des zugrunde liegenden Typs erzeugt. Es handelt sich um eine Verallgemeinerung des Clone Traits: Während Cloneeine Rust-Referenz &T benötigt, kann ToOwned stattdessen mit Dingen umgehen, die Borrow implementieren.

So gibt es mehrere Möglichkeiten, sowohl Referenzen als auch verschobene Objekte auf einheitliche Weise zu behandeln:

  • Eine Funktion, die mit Referenzen auf einen Typ arbeitet, kann Borrow akzeptieren, so dass sie auch mit verschobenen Elementen sowie Referenzen aufgerufen werden kann.

  • Eine Funktion, die mit besessenen Gegenständen eines bestimmten Typs arbeitet, kann ToOwned akzeptieren, so dass sie auch mit Referenzen auf Gegenstände sowie mit verschobenen Gegenständen aufgerufen werden kann; alle Referenzen, die ihr übergeben werden, werden in einen lokal besessenen Gegenstand repliziert.

Obwohl es sich nicht um einen Zeigertyp handelt, ist der Cow Typ ist an dieser Stelle erwähnenswert, weil er eine alternative Möglichkeit bietet, mit der gleichen Art von Situation umzugehen. Cow ist ein enum, der entweder eigene Daten oder eine Referenz auf geliehene Daten enthalten kann. Der eigentümliche Name steht für "clone-on-write": Eine Eingabe von Cow kann bis zu dem Punkt, an dem sie geändert werden muss, als geliehene Daten bestehen bleiben, wird aber an dem Punkt, an dem die Daten geändert werden müssen, zu einer eigenen Kopie.

Intelligente Zeigertypen

Die Standardbibliothek von Rust enthält eine Reihe von Typen, die sich in gewissem Maße wie Zeiger verhalten, vermittelt durch die zuvor beschriebenen Eigenschaften der Standardbibliothek. Diese intelligenten Zeigertypen haben jeweils eine bestimmte Semantik und Garantie, was den Vorteil hat, dass die richtige Kombination von ihnen eine feinkörnige Kontrolle über das Verhalten des Zeigers ermöglicht, aber auch den Nachteil, dass die daraus resultierenden Typen zunächst überwältigend erscheinen können (Rc<RefCell<Vec<T>>>, anyone?).

Der erste intelligente Zeigertyp ist Rc<T>der ein referenzgezählter Zeiger auf ein Element ist (ungefähr analog zu C++'s std::shared_ptr<T>). Er implementiert alle Zeiger-Eigenschaften und verhält sich daher in vielerlei Hinsicht wie Box<T>.

Das ist nützlich für Datenstrukturen, in denen dasselbe Element auf verschiedene Weise erreicht werden kann, aber es hebt eine der wichtigsten Regeln von Rust auf, nämlich dass jedes Element nur einen Besitzer hat. Die Lockerung dieser Regel bedeutet, dass es nun möglich ist, Daten zu verlieren: Wenn Element A einen Rc Zeiger auf Element B hat und Element B einen Rc Zeiger auf A, dann wird das Paar niemals gelöscht.22 Anders ausgedrückt: Du brauchst Rc, um zyklische Datenstrukturen zu unterstützen, aber der Nachteil ist, dass es jetzt Zyklen in deinen Datenstrukturen gibt.

Das Risiko von Lecks kann in einigen Fällen durch den verwandten Typ gemindert werden. Weak<T> Typ gemildert werden, der eine nicht-besitzende Referenz auf das zugrunde liegende Element enthält (ungefähr analog zu C++'s std::weak_ptr<T>). Das Halten einer schwachen Referenz verhindert nicht, dass das zugrundeliegende Element gelöscht wird (wenn alle starken Referenzen entfernt werden), so dass die Nutzung von Weak<T>ein Upgrade auf Rc<T>erfordert, das fehlschlagen kann.

Unter der Haube ist Rc (derzeit) als ein Paar von Referenzwerten zusammen mit dem referenzierten Element implementiert, die alle auf dem Heap gespeichert werden (wie in Abbildung 1-7 dargestellt):

use std::rc::Rc;
let rc1: Rc<u64> = Rc::new(42);
let rc2 = rc1.clone();
let wk = Rc::downgrade(&rc1);
The diagram shows a stack on the left and a heap on the right. The stack holds three entries, labelled rc1, rc2 and wk.  All three of these entries hold arrows that point to an object in the heap, however the arrow from the wk entry is dashed rather than solid.  The object on the heap is a composite rectangle holding three component values: an entry labelled strong=2, and entry labelled weak=1 and an entry labelled 42.
Abbildung 1-7. Rc und Weak Zeiger, die alle auf das gleiche Heap-Element verweisen

Das zugrundeliegende Element wird gelöscht, wenn die Anzahl der starken Referenzen auf Null sinkt, aber die Buchhaltungsstruktur wird nur gelöscht, wenn die Anzahl der schwachen Referenzen ebenfalls auf Null sinkt.

Ein Rc gibt dir die Möglichkeit, ein Objekt auf verschiedene Arten zu erreichen, aber wenn du dieses Objekt erreichst, kannst du es (über get_mut) nur dann ändern, wenn es keine anderen Möglichkeiten gibt, den Gegenstand zu erreichen, d.h. wenn es keine anderen Rc oder Weak Verweise auf denselben Gegenstand gibt. Das ist schwer zu arrangieren, deshalb wird Rc oft mit RefCell kombiniert.

Der nächste intelligente Zeigertyp, RefCell<T>lockert die Regel(Punkt 15), dass ein Element nur von seinem Besitzer oder von dem Code verändert werden kann, der die (einzige) veränderbare Referenz auf das Element hält. Diese interne Veränderbarkeit ermöglicht eine größere Flexibilität - zum Beispiel Trait-Implementierungen, die Interna verändern, auch wenn die Methodensignatur nur &self zulässt. Allerdings entstehen dadurch auch Kosten: Neben der zusätzlichen Speicherung (ein zusätzliches isize zur Verfolgung aktueller Borgen, wie in Abbildung 1-8 gezeigt) werden die normalen Borgen-Prüfungen von der Kompilierzeit auf die Laufzeit verlagert:

use std::cell::RefCell;
let rc: RefCell<u64> = RefCell::new(42);
let b1 = rc.borrow();
let b2 = rc.borrow();
The diagram shows a representation of a stack, with three entries in it, each containing two 8-byte values. The top entry is labelled rc, and holds the value borrow=2 above a value 42. The middle entry is labelled b1, and holds two values with arrows: the top arrow leads to the 42 value in rc, the bottom arrow leads to the rc entry as a whole. The bottom entry is labelled b2 and holds the same contents as b1: a top arrow to 42 and a bottom arrow to rc.
Abbildung 1-8. Ref borrows verweist auf einen RefCell Container

Die Laufzeit dieser Prüfungen bedeutet, dass der RefCell Benutzer zwischen zwei Optionen wählen muss, die beide nicht angenehm sind:

  • Akzeptiere, dass das Ausleihen ein Vorgang ist, der fehlschlagen kann, und gehe mit Result Werten aus try_borrow[_mut]

  • Nutze die vermeintlich unfehlbaren Borrowing-Methoden borrow[_mut], und nimm das Risiko einer panic! zur Laufzeit(Punkt 18) in Kauf, wenn die Borrow-Regeln nicht eingehalten wurden

In jedem Fall bedeutet diese Laufzeitprüfung, dass RefCell selbst keine der Standard-Zeiger-Eigenschaften implementiert; stattdessen geben seine Zugriffsoperationen eine Ref<T> oder RefMut<T> einen intelligenten Zeigertyp zurück, der diese Eigenschaften implementiert.

Wenn der zugrunde liegende Typ T die Eigenschaft Copy implementiert (was bedeutet, dass eine schnelle Bit-für-Bit-Kopie ein gültiges Element erzeugt; siehe Punkt 10), dann ermöglicht der Typ Cell<T> eine interne Mutation mit weniger Overhead - die Methode get(&self) kopiert den aktuellen Wert heraus und die Methode set(&self, val) kopiert einen neuen Wert hinein. Der Typ Cell wird intern von den Implementierungen Rc undRefCell verwendet, um Zähler gemeinsam zu verfolgen, die ohne &mut self verändert werden können.

Die bisher beschriebenen intelligenten Zeigertypen sind nur für den Single-Thread-Einsatz geeignet; ihre Implementierungen gehen davon aus, dass es keinen gleichzeitigen Zugriff auf ihre Interna gibt. Wenn dies nicht der Fall ist, werden Smart Pointer benötigt, die zusätzlichen Synchronisations-Overhead enthalten.

Das thread-sichere Äquivalent von Rc<T> ist Arc<T>das atomare Zähler verwendet, um sicherzustellen, dass die Referenzzahlen genau bleiben. Wie Rc implementiert auch Arc die verschiedenen Eigenschaften von Zeigern.

Allerdings erlaubt Arc allein keinen veränderbaren Zugriff auf das zugrunde liegende Objekt. Dies wird durch den Mutex Typ abgedeckt, der sicherstellt, dass nur ein ThreadZugriff aufdas zugrundeliegende Objekt hat- sei esveränderlich oder unveränderlich. Wie bei RefCell implementiert Mutex selbst keineZeiger-Eigenschaften, aber seine lock() Operation gibt einen Wert eines Typs zurück, der dies tut: MutexGuardDeref[Mut] implementiert.

Wenn es voraussichtlich mehr Leser als Schreiber geben wird, ist der RwLock Typ vorzuziehen, da er es mehreren Lesern ermöglicht, parallel auf das zugrunde liegende Objekt zuzugreifen, vorausgesetzt, es gibt nicht gerade einen (einzigen) Schreiber.

In jedem Fall erzwingen die Rust-Regeln für Borrowing und Threading die Verwendung eines dieser Synchronisationscontainer in Multithreading-Code (dies schützt jedoch nur vor einigen der Probleme von Shared-State Concurrency; siehe Punkt 17).

Die gleiche Strategie - zu sehen, was der Compiler ablehnt und was er stattdessen vorschlägt - kann manchmal auch bei den anderen Smart Pointer-Typen angewendet werden. Es ist jedoch schneller und weniger frustrierend, wenn du verstehst, was das Verhalten der verschiedenen Smart Pointer impliziert. Um ein Beispiel aus der ersten Ausgabe des Rust-Buches aufzugreifen (Wortspiel beabsichtigt):

  • Rc<RefCell<Vec<T>>> hält einen Vektor (Vec) mit geteiltem Eigentum (Rc), wobei der Vektor mutiert werden kann - aber nur als ganzer Vektor.

  • Rc<Vec<RefCell<T>>> enthält ebenfalls einen Vektor mit geteiltem Eigentum, aber hier kann jeder einzelne Eintrag im Vektor unabhängig von den anderen mutiert werden.

Die beteiligten Typen beschreiben diese Verhaltensweisen genau.

Punkt 9: Erwäge die Verwendung von Iterator-Transformationenanstelle von expliziten Schleifen

Die bescheidene Schleife hat einen langen Weg der zunehmenden Bequemlichkeit und Abstraktion hinter sich. In der Sprache B (dem Vorläufer von C) gab es nur while (condition) { ... }, aber mit der Einführung von C wurde das übliche Szenario, durch die Indizes eines Arrays zu iterieren, mit der for Schleife bequemer:

// C code
int i;
for (i = 0; i < len; i++) {
  Item item = collection[i];
  // body
}

Die frühen Versionen von C++ verbesserten die Bequemlichkeit und das Scoping noch weiter, indem sie es ermöglichten, die Deklaration der Schleifenvariablen unterin die Anweisung for einzubetten (dies wurde auch von C in C99 übernommen):

// C++98 code
for (int i = 0; i < len; i++) {
  Item item = collection[i];
  // ...
}

Die meisten modernen Sprachen abstrahieren die Idee der Schleife weiter: Die Kernfunktion einer Schleife besteht oft darin, zum nächsten Element eines Containers zu gelangen. Die Verfolgung der Logistik, die erforderlich ist, um dieses Element zu erreichen (index++ oder ++it), ist meist ein irrelevantes Detail. Diese Erkenntnis führte zu zwei Kernkonzepten:

Iteratoren

Ein Typ, dessen Zweck es ist, wiederholt das nächste Element eines Containers auszusenden, bis er erschöpft ist23

For-each-Schleifen

Ein kompakter Schleifenausdruck für die Iteration über alle Elemente in einem Container, wobei eine Schleifenvariable an das Element und nicht an die Details zum Erreichen dieses Elements gebunden wird

Diese Konzepte ermöglichen einen kürzeren und (noch wichtiger) klareren Code für Schleifen:

// C++11 code
for (Item& item : collection) {
  // ...
}

Sobald diese Konzepte zur Verfügung standen, waren sie so leistungsfähig, dass sie schnell in Sprachen nachgerüstet wurden, die sie noch nicht hatten (z. B. wurden for-each-Schleifen in Java 1.5 und C++11 hinzugefügt).

Rust enthält Iteratoren und for-each-Schleifen, aber auch den nächsten Schritt in der Abstraktion: Die gesamte Schleife kann als Iterator-Transformation ausgedrückt werden (manchmal auch als Iterator-Adapter bezeichnet). Wie bei der Diskussion von Option und Result in Artikel 3wird in diesem Artikel gezeigt, wie diese Iterator-Transformationen anstelle von expliziten Schleifen verwendet werden können, und es werden Hinweise gegeben, wann dies eine gute Idee ist. Insbesondere können Iterator-Transformationen effizienter sein als eine explizite Schleife, weil der Compiler die Bound Checks, die er sonst durchführen müsste, überspringen kann.

Am Ende dieses Artikels findest du eine C-ähnliche explizite Schleife, um die Quadrate der ersten fünf geraden Elemente eines Vektors zu summieren:

let values: Vec<u64> = vec![1, 1, 2, 3, 5 /* ... */];

let mut even_sum_squares = 0;
let mut even_count = 0;
for i in 0..values.len() {
    if values[i] % 2 != 0 {
        continue;
    }
    even_sum_squares += values[i] * values[i];
    even_count += 1;
    if even_count == 5 {
        break;
    }
}

sollte sich natürlicher anfühlen, als ein Ausdruck des funktionalen Stils:

let even_sum_squares: u64 = values
    .iter()
    .filter(|x| *x % 2 == 0)
    .take(5)
    .map(|x| x * x)
    .sum();

Iterator-Transformationsausdrücke wie dieser lassen sich grob in drei Teile unterteilen:

  • Ein anfänglicher Quell-Iterator, der von einer Instanz eines Typs stammt, der eine der Iterator-Traits von Rust implementiert

  • Eine Folge von Iterator-Transformationen

  • Eine Endverbrauchermethode, um die Ergebnisse der Iteration zu einem Endwert zu kombinieren

Die ersten beiden Teile verlagern die Funktionalität aus dem Schleifenkörper in den for Ausdruck; der letzte Teil macht die for Anweisung komplett überflüssig.

Iterator-Eigenschaften

Der Core Iterator Trait hat eine sehr einfache Schnittstelle: eine einzige Methode next die SomeElemente ausgibt, bis sie es nicht mehr tut (None). Der Typ der ausgegebenen Items wird durch den zugehörigen ItemTyp des Traits bestimmt.

Sammlungen, die eine Iteration über ihren Inhalt erlauben - in anderen Sprachen würde man sie als iterable bezeichnen - implementieren die IntoIterator Trait; die into_iter Methode dieses Traits verbraucht Self und gibt stattdessen eine Iterator aus. Der Compiler verwendet diesen Trait automatisch für Ausdrücke der Form:

for item in collection {
    // body
}

sie in einen Code umzuwandeln, der ungefähr so aussieht:

let mut iter = collection.into_iter();
loop {
    let item: Thing = match iter.next() {
        Some(item) => item,
        None => break,
    };
    // body
}

oder prägnanter und idiomatischer:

let mut iter = collection.into_iter();
while let Some(item) = iter.next() {
    // body
}

Damit alles reibungslos funktioniert, gibt es auch eine Implementierung von IntoIterator für jede Iterator, die einfach self zurückgibt; schließlich ist es einfach, eine Iterator in eine Iterator umzuwandeln!

Diese anfängliche Form ist ein verbrauchender Iterator, der die Sammlung aufbraucht, während sie erstellt wird:

let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)];
for item in collection {
    println!("Consumed item {item:?}");
}

Jeder Versuch, die Sammlung zu verwenden, nachdem sie durchlaufen wurde, schlägt fehl:

println!("Collection = {collection:?}");
error[E0382]: borrow of moved value: `collection`
   --> src/main.rs:171:28
    |
163 |   let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)];
    |       ---------- move occurs because `collection` has type `Vec<Thing>`,
    |                  which does not implement the `Copy` trait
164 |   for item in collection {
    |               ---------- `collection` moved due to this implicit call to
    |                           `.into_iter()`
...
171 |   println!("Collection = {collection:?}");
    |                          ^^^^^^^^^^^^^^ value borrowed here after move
    |
note: `into_iter` takes ownership of the receiver `self`, which moves
      `collection`

Dieses Verhalten ist zwar einfach zu verstehen, aber oft unerwünscht; eine Art Ausleihe der iterierten Elemente ist notwendig.

Um sicherzustellen, dass das Verhalten klar ist, wird in den Beispielen hier ein Typ Thing verwendet, der Copy (Punkt 10) nicht implementiert, da dies die Eigentumsfrage(Punkt 15) verbergen würde - der Compiler würde überall stillschweigend Kopien erstellen:

// Deliberately not `Copy`
#[derive(Clone, Debug, Eq, PartialEq)]
struct Thing(u64);

let collection = vec![Thing(0), Thing(1), Thing(2), Thing(3)];

Wenn der Sammlung, über die iteriert wird, das Präfix & vorangestellt ist:

for item in &collection {
    println!("{}", item.0);
}
println!("collection still around {collection:?}");

dann sucht der Rust-Compiler nach einer Implementierung vonIntoIterator für den Typ &Collection. Ordnungsgemäß entworfene Sammlungstypen bieten eine solche Implementierung; diese Implementierung verbraucht immer noch Self, aber Self ist jetzt &Collection und nicht mehr Collection, und der zugehörige Typ Item ist eine Referenz &Thing.

Dadurch bleibt die Sammlung nach der Iteration intakt, und der entsprechende erweiterte Code lautet wie folgt:

let mut iter = (&collection).into_iter();
while let Some(item) = iter.next() {
    println!("{}", item.0);
}

Wenn es sinnvoll ist, Iteration über veränderliche Referenzen anzubieten,24 dann gilt ein ähnliches Muster für for item in &mut collection: Der Compiler sucht und verwendet eine Implementierung von IntoIterator für &mut Collection, wobei jede Item vom Typ &mut Thing ist.

Standardcontainer bieten per Konvention auch eine iter() Methode, die einen Iterator über Referenzen auf das zugrundeliegende Element zurückgibt, sowie gegebenenfalls eine äquivalente iter_mut() Methode mit dem gleichen Verhalten wie gerade beschrieben. Diese Methoden können in for Schleifen verwendet werden, haben aber einen offensichtlichen Vorteil, wenn sie am Anfang einer Iterator-Transformation verwendet werden:

let result: u64 = (&collection).into_iter().map(|thing| thing.0).sum();

wird:

let result: u64 = collection.iter().map(|thing| thing.0).sum();

Iterator-Transformationen

Die Iterator Trait hat eine einzige erforderliche Methode (next), bietet aber auch Standardimplementierungen(Punkt 13) einer großen Anzahl anderer Methoden, die Transformationen an einem Iterator durchführen.

Einige dieser Umwandlungen beeinflussen den gesamten Iterationsprozess:

take(n)

Schränkt einen Iterator auf die Ausgabe von höchstens n ein.

skip(n)

Überspringt die ersten nElemente des Iterators.

step_by(n)

Wandelt einen Iterator so um, dass er nur jedes n-te Element ausgibt.

chain(other)

Fügt zwei Iteratoren zusammen, um einen kombinierten Iterator zu bilden, der erst den einen und dann den anderen durchläuft.

cycle()

Konvertiert einen Iterator, der endet, in einen Iterator, der immer wieder von vorne beginnt, wenn er das Ende erreicht. (Der Iterator muss Clone unterstützen, um dies zu ermöglichen.)

rev()

Kehrt die Richtung eines Iterators um. (Der Iterator muss die Eigenschaft Double​En⁠ded​Iterator Trait implementieren, der eine zusätzliche next_back erforderliche Methode hat.)

Andere Transformationen beeinflussen die Art der Item, die das Thema der Iterator ist:

map(|item| {...})

Wendet wiederholt eine Schließung an, um jedes Element der Reihe nach zu transformieren. Dies ist die allgemeinste Version; mehrere der folgenden Einträge in dieser Liste sind Bequemlichkeitsvarianten, die gleichwertig als map implementiert werden könnten.

cloned()

Erzeugt einen Klon aller Elemente des ursprünglichen Iterators; ist besonders nützlich bei Iteratoren über &Item Referenzen. (Dies erfordert natürlich, dass der zugrunde liegende Item Typ Clone implementiert).

copied()

Erzeugt eine Kopie aller Elemente im ursprünglichen Iterator; dies ist besonders nützlich bei Iteratoren über &Item Referenzen. (Dies erfordert natürlich, dass der zugrundeliegende Item Typ Copy implementiert, aber es ist wahrscheinlich schneller als cloned().)

enumerate()

Wandelt einen Iterator über Elemente in einen Iterator über (usize, Item) Paare um und stellt einen Index für die Elemente im Iterator bereit.

zip(it)

Verbindet einen Iterator mit einem zweiten Iterator, um einen kombinierten Iterator zu erzeugen, der Paare von Elementen ausgibt, eines von jedem der ursprünglichen Iteratoren, bis der kürzere der beiden Iteratoren fertig ist.

Andere Transformationen führen eine Filterung der Items durch, die von derIterator:

filter(|item| {...})

Wendet eine bool-Rückgabe-Schließung auf jede Artikelreferenz an, um zu bestimmen, ob sie durchgereicht werden soll.

take_while()

Gibt einen anfänglichen Teilbereich des Iterators aus, der auf einem Prädikat basiert. Spiegelbild von skip_while.

skip_while()

Gibt einen endgültigen Teilbereich des Iterators aus, der auf einem Prädikat basiert. Spiegelbild von take_while.

Die flatten() Methode geht mit einem Iterator um, dessen Elemente selbst Iteratoren sind, und glättet das Ergebnis. Für sich genommen scheint das nicht sehr hilfreich zu sein, aber es wird viel nützlicher, wenn man es mit der Beobachtung kombiniert, dass sowohl Option und Result als Iteratoren fungieren: Sie produzieren entweder null (für None, Err(e)) oder eins (für Some(v), Ok(v)) Elemente. Das bedeutet, dass flattenin einem Strom vonOption/Result Werten eine einfache Möglichkeit ist, nur die gültigen Werte zu extrahieren und den Rest zu ignorieren.

Mit diesen Methoden können Iteratoren so umgewandelt werden, dass sie genau die Abfolge von Elementen erzeugen, die in den meisten Situationen benötigt wird.

Iterator-Verbraucher

In den beiden vorherigen Abschnitten wurde beschrieben, wie man einen Iterator erhält und wie man ihn in genau die richtige Form für eine präzise Iteration bringt. Diese zielgenaue Iteration könnte als explizite for-each-Schleife erfolgen:

let mut even_sum_squares = 0;
for value in values.iter().filter(|x| *x % 2 == 0).take(5) {
    even_sum_squares += value * value;
}

Die große Sammlung von Iterator Methoden enthält jedoch viele, die es ermöglichen, eine Iteration in einem einzigen Methodenaufruf zu konsumieren, wodurch die Notwendigkeit einer expliziten for Schleife entfällt.

Die allgemeinste dieser Methoden ist for_each(|item| {...})die für jedes Element, das von Iterator erzeugt wird, eine Closure ausführt. Diese Methode kann die meisten Dinge tun, die eine explizite for Schleife tun kann (die Ausnahmen werden in einem späteren Abschnitt beschrieben), aber ihre Allgemeingültigkeit macht sie auch etwas umständlich in der Anwendung - die Closure muss veränderbare Verweise auf externe Zustände verwenden, um etwas auszugeben:

let mut even_sum_squares = 0;
values
    .iter()
    .filter(|x| *x % 2 == 0)
    .take(5)
    .for_each(|value| {
        // closure needs a mutable reference to state elsewhere
        even_sum_squares += value * value;
    });

Wenn der Rumpf der for Schleife jedoch einem der üblichen Muster entspricht, gibt es spezifischere Iterator-verbrauchende Methoden, die klarer, kürzer und idiomatischer sind.

Diese Muster enthalten Abkürzungen, um einen einzelnen Wert aus der Sammlung zu erstellen:

sum()

Summiert eine Sammlung von numerischen Werten (Ganzzahlen oder Gleitkommazahlen).

product()

Multipliziert eine Sammlung von numerischen Werten.

min()

Findet den Mindestwert einer Sammlung, bezogen auf die Implementierung des Items Ord (siehe Item 10).

max()

Findet den maximalen Wert einer Sammlung, bezogen auf die Implementierung des Items Ord (siehe Item 10).

min_by(f)

Findet den Minimalwert einer Sammlung, relativ zu einer benutzerdefinierten Vergleichsfunktion f.

max_by(f)

Findet den Maximalwert einer Sammlung, relativ zu einer benutzerdefinierten Vergleichsfunktion f.

reduce(f)

Erzeugt einen akkumulierten Wert des Typs Item, indem bei jedem Schritt eine Closure ausgeführt wird, die den bisher akkumulierten Wert und das aktuelle Element aufnimmt. Dies ist eine allgemeinere Operation, die die vorherigen Methoden umfasst.

fold(f)

Erzeugt einen akkumulierten Wert eines beliebigen Typs (nicht nur des Typs Iterator::Item ), indem bei jedem Schritt eine Schließung ausgeführt wird, die den bisher akkumulierten Wert und das aktuelle Element aufnimmt. Dies ist eine Verallgemeinerung von reduce.

scan(init, f)

Baut einen akkumulierten Wert eines beliebigen Typs auf, indem bei jedem Schritt eine Closure ausgeführt wird, die eine veränderbare Referenz auf einen internen Zustand und das aktuelle Element aufnimmt. Dies ist eine etwas andere Verallgemeinerung von reduce.

Es gibt auch Methoden, um einen einzelnen Wert aus der Sammlung auszuwählen:

find(p)

Findet das erste Element, das ein Prädikat erfüllt.

position(p)

Findet auch das erste Element, das ein Prädikat erfüllt, aber diesmal gibt es den Index des Elements zurück.

nth(n)

Gibt das nth Element des Iterators zurück, falls vorhanden.

Es gibt Methoden, mit denen du jedes Element in der Sammlung prüfen kannst:

any(p)

Zeigt an, ob ein Prädikat für ein Element in der Sammlung true ist.

all(p)

Gibt an, ob ein Prädikat für alle Elemente in der Sammlung true ist.

In beiden Fällen wird die Iteration frühzeitig beendet, wenn das entsprechende Gegenbeispiel gefunden wird.

Es gibt Methoden, die die Möglichkeit des Scheiterns in den für jedes Element verwendeten Closures berücksichtigen. In jedem Fall wird die Iteration abgebrochen, wenn eine Closure für ein Element einen Fehler zurückgibt, und die Operation als Ganzes gibt den ersten Fehler zurück:

try_for_each(f)

Verhält sich wie for_each, aber der Verschluss kann fehlschlagen

try_fold(f)

Verhält sich wiefold, aber der Verschluss kann fehlschlagen

try_find(f)

Verhält sich wiefind, aber der Verschluss kann fehlschlagen

Schließlich gibt es noch Methoden, die alle iterierten Elemente in einer neuen Sammlung zusammenfassen. Die wichtigste davon ist collect()die verwendet werden kann, um eine neue Instanz eines beliebigen Sammlungstyps zu erstellen, der dieFromIterator Eigenschaft implementiert.

Die FromIterator Eigenschaft ist für alle Standard-Sammlungstypen der Bibliothek implementiert (Vec, HashMap, BTreeSet, usw.) implementiert, aber diese Allgegenwärtigkeit bedeutet auch dass du oft explizite Typen verwenden musst, weil der Compiler sonst nicht herausfinden kann, ob du versuchst, (z.B.) eine Vec<i32> oder HashSet<i32> zu assemblieren:

use std::collections::HashSet;

// Build collections of even numbers.  Type must be specified, because
// the expression is the same for either type.
let myvec: Vec<i32> = (0..10).into_iter().filter(|x| x % 2 == 0).collect();
let h: HashSet<i32> = (0..10).into_iter().filter(|x| x % 2 == 0).collect();

Dieses Beispiel veranschaulicht auch die Verwendung von Bereichsausdrücken, um die Ausgangsdaten zu erzeugen, über die iteriert werden soll.

Zu den anderen (eher obskuren) Methoden zur Sammlung gehören die folgenden:

unzip()

Teilt einen Iterator von Paaren in zwei Sammlungen

partition(p)

Teilt einen Iterator in zwei Sammlungen auf der Grundlage eines Prädikats, das auf jedes Element angewendet wird

In diesem Artikel wurde eine große Auswahl an Methoden von Iterator vorgestellt, aber das ist nur eine Teilmenge der verfügbaren Methoden. Weitere Informationen findest du in der Iterator-Dokumentation oder in Kapitel 15 von Programming Rust, 2nd edition (O'Reilly), in dem die Möglichkeiten ausführlich beschrieben sind.

Diese umfangreiche Sammlung von Iterator-Transformationen ist dazu da, um genutzt zu werden. Dadurch wird der Code idiomatischer, kompakter und klarer.

Wenn Schleifen als Iterator-Transformationen ausgedrückt werden, kann der Code auch effizienter sein. Im Interesse der Sicherheit prüft Rust den Zugriff auf zusammenhängende Container wie Vektoren und Slices. Der Versuch, auf einen Wert zuzugreifen, der die Grenzen der Sammlung überschreitet, löst eher eine Panik aus als ein Zugriff auf ungültige Daten. Eine Schleife im alten Stil, die auf Containerwerte (z. B. values[i]) zugreift, könnte diesen Laufzeitprüfungen unterliegen, während bei einem Iterator, der einen Wert nach dem anderen erzeugt, bereits bekannt ist, dass er innerhalb des Bereichs liegt.

Es ist aber auch möglich, dass eine Schleife im alten Stil im Vergleich zur entsprechenden Iterator-Transformation keine zusätzlichen Grenzprüfungen durchlaufen muss. Der Rust-Compiler und -Optimierer ist sehr gut darin, den Code zu analysieren, der einen Slice-Zugriff umgibt, um festzustellen, ob es sicher ist, die Bound Checks zu überspringen; Sergey "Shnatsel" Davidoffs Artikel aus dem Jahr 2023 befasst sich mit diesen Feinheiten.

Aufbau von Sammlungen aus Result Werten

Im vorherigen Abschnitt wurde die Verwendung von collect() beschrieben, um Sammlungen aus Iteratoren zu erstellen, aber collect() hat auch eine besonders hilfreiche Funktion, wenn es um Result Werte geht.

Betrachte den Versuch, einen Vektor von i64 Werten in Bytes zu konvertieren (u8), mit der optimistischen Erwartung , dass sie alle passen werden:

Das funktioniert so lange, bis ein unerwarteter Input kommt:

let inputs: Vec<i64> = vec![0, 1, 2, 3, 4, 512];

und verursacht einen Laufzeitfehler:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value:
TryFromIntError(())', iterators/src/main.rs:266:36
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Wie in Punkt 3 beschrieben, wollen wir den Typ Result beibehalten und den Operator ? verwenden, um jeden Fehler zum Problem des aufrufenden Codes zu machen. Die offensichtliche Änderung, die Result auszusenden, hilft nicht wirklich weiter:

let result: Vec<Result<u8, _>> =
    inputs.into_iter().map(|v| <u8>::try_from(v)).collect();
// Now what?  Still need to iterate to extract results and detect errors.

Es gibt jedoch eine alternative Version von collect(), bei der ein Result mit einem Vec zusammensetzen kann, anstatt einVec mit einem Results.

Um die Verwendung dieser Version zu erzwingen, brauchst du den Turbofish (::<Result<Vec<_>, _>>):

let result: Vec<u8> = inputs
    .into_iter()
    .map(|v| <u8>::try_from(v))
    .collect::<Result<Vec<_>, _>>()?;

Wenn du mit dem Fragezeichenoperator kombinierst, erhältst du ein nützliches Verhalten:

  • Wenn die Iteration auf einen Fehlerwert stößt, wird dieser Fehlerwert an den Aufrufer ausgegeben und die Iteration wird abgebrochen.

  • Wenn keine Fehler auftreten, kann der Rest des Codes mit einer sinnvollen Sammlung von Werten des richtigen Typs umgehen.

Schleife Transformation

Ziel dieses Artikels ist es, dich davon zu überzeugen, dass viele explizite Schleifen als etwas betrachtet werden können, das in Iterator-Transformationen umgewandelt werden kann. Das kann sich für Programmierer, die nicht daran gewöhnt sind, etwas unnatürlich anfühlen, also gehen wir eine Umwandlung Schritt für Schritt durch.

Wir beginnen mit einer sehr C-ähnlichen expliziten Schleife, um die Quadrate der ersten fünf geraden Elemente eines Vektors zu summieren:

let mut even_sum_squares = 0;
let mut even_count = 0;
for i in 0..values.len() {
    if values[i] % 2 != 0 {
        continue;
    }
    even_sum_squares += values[i] * values[i];
    even_count += 1;
    if even_count == 5 {
        break;
    }
}

Der erste Schritt besteht darin, die Vektorindizierung durch die direkte Verwendung eines Iterators in einer for-each-Schleife zu ersetzen:

let mut even_sum_squares = 0;
let mut even_count = 0;
for value in values.iter() {
    if value % 2 != 0 {
        continue;
    }
    even_sum_squares += value * value;
    even_count += 1;
    if even_count == 5 {
        break;
    }
}

Ein Anfangsarm der Schleife, der continue verwendet, um über einige Elemente zu überspringen, wird natürlich als filter() ausgedrückt:

let mut even_sum_squares = 0;
let mut even_count = 0;
for value in values.iter().filter(|x| *x % 2 == 0) {
    even_sum_squares += value * value;
    even_count += 1;
    if even_count == 5 {
        break;
    }
}

Der vorzeitige Ausstieg aus der Schleife , sobald fünf gerade Gegenstände entdeckt wurden, wird auf take(5) abgebildet:

let mut even_sum_squares = 0;
for value in values.iter().filter(|x| *x % 2 == 0).take(5) {
    even_sum_squares += value * value;
}

Bei jeder Iteration der Schleife wird nur das Element im Quadrat verwendet, in der Kombination value * value, was es zu einem idealen Ziel für eine map() macht:

let mut even_sum_squares = 0;
for val_sqr in values.iter().filter(|x| *x % 2 == 0).take(5).map(|x| x * x)
{
    even_sum_squares += val_sqr;
}

Diese Umgestaltung der ursprünglichen Schleife führt zu einem Schleifenkörper, der perfekt unter den Hammer der sum() Methode passt:

let even_sum_squares: u64 = values
    .iter()
    .filter(|x| *x % 2 == 0)
    .take(5)
    .map(|x| x * x)
    .sum();

Wenn Explizitheit besser ist

In diesem Artikel wurden die Vorteile von Iterator-Transformationen hervorgehoben, insbesondere im Hinblick auf Prägnanz und Klarheit. Wann sind Iterator-Transformationen also nicht angemessen oder idiomatisch?

  • Wenn der Schleifenkörper groß und/oder multifunktional ist, ist es sinnvoll, ihn als expliziten Körper beizubehalten, anstatt ihn in einen Abschluss zu quetschen.

  • Wenn der Schleifenkörper Fehlerbedingungen enthält, die zu einem vorzeitigen Abbruch der umgebenden Funktion führen, sind diese oft am besten explizit zu halten - die try_..() Methoden helfen nur wenig. Die Fähigkeit von collect(), eine Sammlung von Result Werten in eine Result zu konvertieren, die eine Sammlung von Werten enthält, ermöglicht es jedoch oft, Fehlerbedingungen mit dem ? Operator zu behandeln.

  • Wenn die Leistung entscheidend ist, sollte eine Iterator-Transformation, die eine Closure beinhaltet , so optimiert werden, dass sie genauso schnell ist wie der entsprechende explizite Code. Aber wenn die Leistung einer Kernschleife so wichtig ist, solltest du verschiedene Varianten messen und entsprechend optimieren:

    • Achte darauf, dass deine Messungen die reale Leistung widerspiegeln - der Optimierer des Compilers kann bei Testdaten zu optimistische Ergebnisse liefern (wie in Punkt 30 beschrieben).

    • Der Godbolt Compiler-Explorer ist ein tolles Werkzeug, um zu untersuchen, was der Compiler ausspuckt.

Am wichtigsten ist, dass du eine Schleife nicht in eine Iterationstransformation umwandelst, wenn die Umwandlung erzwungen oder umständlich ist. Das ist natürlich Geschmackssache - aber sei dir bewusst, dass sich dein Geschmack wahrscheinlich ändern wird, wenn du dich mit dem funktionalen Stil besser auskennst.

1 Die Situation wird noch unübersichtlicher, wenn das Dateisystem involviert ist, da Dateinamen auf gängigen Plattformen irgendwo zwischen willkürlichen Bytes und UTF-8-Sequenzen liegen: siehe die std::ffi::OsString Dokumentation.

2 Technisch gesehen ein Unicode-Skalarwert und kein Codepunkt.

3 Die Notwendigkeit, alle Möglichkeiten zu berücksichtigen, bedeutet auch, dass das Hinzufügen einer neuen Variante zu einer bestehenden enum in einer Bibliothek eine bahnbrechende Änderung ist(Punkt 21): Die Bibliothekskunden müssen ihren Code ändern, um mit der neuen Variante zurechtzukommen. Wenn eine enum wirklich nur eine C-ähnliche Liste zusammengehöriger Zahlenwerte ist, kann dieses Verhalten vermieden werden, indem sie als eine non_exhaustive enum; siehe Punkt 21.

4 Zumindest nicht im stabilen Rust zum Zeitpunkt der Erstellung dieses Artikels. Die unboxed_closures und fn_traits experimentellen Funktionen können dies in Zukunft ändern.

5 In Joshua Blochs Effective Java (3. Auflage, Addison-Wesley) steht zum Beispiel Punkt 64: Bezeichne Objekte durch ihre Schnittstellen.

6 Die Hinzufügung von Konzepten in C++20 ermöglicht die explizite Angabe von Beschränkungen für Vorlagentypen, aber die Prüfungen werden immer noch nur bei der Instanziierung der Vorlage durchgeführt, nicht bei ihrer Deklaration.

7 Die Online-Version dieses Diagramms ist anklickbar; jedes Kästchen verweist auf die entsprechende Dokumentation.

8 Beachte, dass diese Methode von der AsRef Eigenschaft getrennt ist, auch wenn der Methodenname derselbe ist.

9 Oder zumindest die einzige nicht veraltete, stabile Methode.

10 Zum Zeitpunkt der Erstellung dieses Artikels wurde Error nach core verschoben, ist aber noch nicht in Stable Rust verfügbar.

11 Dieser Abschnitt ist inspiriert von Nick Groenens Artikel "Rust: Strukturierung und Umgang mit Fehlern im Jahr 2020" inspiriert.

12 Besser bekannt als die Regeln der Merkmalskohärenz.

13 Im Moment wird dies wahrscheinlich in einer zukünftigen Version von Rust durch den Typ! "never" ersetzt.

14 Die verlustbehafteten Konvertierungen in Rust zuzulassen, war wahrscheinlich ein Fehler, und es gab Diskussionen darüber, dieses Verhalten zu entfernen.

15 Rust bezeichnet diese Umwandlungen als "Subtyping", aber das ist etwas ganz anderes als die Definition von "Subtyping", die in objektorientierten Sprachen verwendet wird.

16 Genauer gesagt, der Mars Climate Orbiter.

17 Siehe "Mars Climate Orbiter" auf Wikipedia für mehr über die Ursache des Scheiterns.

18 Dieses Problem ist auf serde so häufig, dass es einen Mechanismus zur Abhilfe gibt.

19 Allerdings mit einer Warnung von modernen Compilern.

20 Der entsprechende Trait für veränderbare Ausdrücke ist IndexMut.

21 Dies ist etwas vereinfacht; eine vollständige vtable enthält auch Informationen über die Größe und Ausrichtung des Typs sowie einen drop() Funktionszeiger, damit das zugrunde liegende Objekt sicher abgelegt werden kann.

22 Beachte, dass dies keine Auswirkungen auf die Speichersicherheitsgarantien von Rust hat: Die Elemente sind immer noch sicher, nur unzugänglich.

23 Der Iterator kann sogar noch allgemeiner sein - die Idee, die nächsten Elemente bis zur Fertigstellung auszugeben, muss nicht mit einem Container verbunden sein.

24 Diese Methode kann nicht verwendet werden, wenn eine Veränderung des Objekts die internen Garantien des Containers ungültig machen könnte. Zum Beispiel würde eine Änderung des Inhalts des Elements, die seinen Hash Wert verändert, würde die internen Datenstrukturen eines HashMap ungültig machen.

Get Effektiver Rost now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.