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:Option
s, Result
s, Error
s und Iterator
s. 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
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, auchunsafe
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
);
enum
s
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 enum
s 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 enum
s 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 enum
vor fehlenden switch
Armen warnen können und dies auch tun).
enum
s 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 Job
im 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 move
vor 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
move
d 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 ausmove
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 einer
FnMut
), 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 T
gelten - 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 enum
sein -, 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 T
s 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 enum
s vorgestellt, die die Rust-Standardbibliothek bietet:
Option<T>
-
Um auszudrücken, dass ein Wert (vom Typ
T
) 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 TypE
) zurückgibt
In diesem Artikel werden Situationen untersucht, in denen du versuchen solltest, explizite match
Ausdrücke für diese speziellen enum
s 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 match
Ausdrü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.
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
undResult
und zieheResult
Option
vor. Verwende.as_ref()
bei Bedarf, wenn die Umwandlungen Referenzen beinhalten. -
Verwende diese Umwandlungen anstelle der expliziten
match
Operationen aufOption
undResult
. -
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 kannformat!
mit{}
-
Die
Debug
Eigenschaft, d.h., sie kannformat!
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, Error
zu 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 Error
s 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 Typsstd::io::Error
. -
format!
wandelt dies in einString
um, indem es dieDebug
Implementierung vonstd::io::Error
verwendet. -
?
bringt den Compiler dazu, nach einerFrom
Implementierung zu suchen und diese zu verwenden, die ihn vonString
aufMyError
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 thiserror
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.WrappedError
implementiert, 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
undInto
- 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 E
angibt, 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 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 derive
s 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 let
dem 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
=
0
u64
;
let
ref_x
=
&
x
;
let
ref_pt
=
&
pt
;
können auf dem Stapel liegen, wie in Abbildung 1-2 gezeigt.
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.
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 beiDeref
)
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
];
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
];
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 desRange<usize>
Typs um, der eine inklusive untere Grenze und eine exklusive obere Grenze enthält. -
Der Typ
Range
implementiertdie EigenschaftSliceIndex<T>
Trait, der Indexierungsoperationen auf Slices eines beliebigen TypsT
beschreibt (der TypOutput
ist also[T]
). -
Der Teil
vector[ ]
ist ein Indexierungsausdruck; der Compiler wandelt in einen Aufruf derIndex
Trait'sindex
Methode des Traits aufvector
, zusammen mit einer Dereferenz (d.h.*vector.index( )
).20 -
vector[1..3]
Deshalb ruftVec<T>
dieImplementierung vonIndex<I>
auf, was voraussetzt, dassI
eine Instanz vonSliceIndex<[u64]>
ist. Das funktioniert, weilRange<usize>
SliceIndex<[T]>
für jedesT
implementiert, einschließlichu64
. -
&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.
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::get
Borrow
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 Borrow
Trait 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 Clone
eine 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
);
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
();
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 austry_borrow[_mut]
-
Nutze die vermeintlich unfehlbaren Borrowing-Methoden
borrow[_mut]
, und nimm das Risiko einerpanic!
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: MutexGuard
Deref[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 Some
Elemente ausgibt, bis sie es nicht mehr tut (None
). Der Typ der ausgegebenen Items wird durch den zugehörigen Item
Typ 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)
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
DoubleEndedIterator
Trait implementieren, der eine zusätzlichenext_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 liegendeItem
TypClone
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 zugrundeliegendeItem
TypCopy
implementiert, aber es ist wahrscheinlich schneller alscloned()
.) 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 Item
s 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 flatten
in 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()
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 vonreduce
. 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)
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)
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)
try_find(f)
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()
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 Result
s.
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 voncollect()
, eine Sammlung vonResult
Werten in eineResult
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.