Das Programmieren eines Raten-Spiels

RustRustIntermediate
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Programmieren eines Ratespiels. Dieser Lab ist Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab werden wir ein Ratespiel in Rust implementieren, bei dem das Programm eine Zufallszahl generiert und den Spieler auffordert, sie zu erraten, indem es Feedback gibt, ob die Vermutung zu niedrig oder zu hoch ist, und gratuliert dem Spieler, wenn er richtig erraten hat.

Dies ist ein Guided Lab, das schrittweise Anweisungen bietet, um Ihnen beim Lernen und Üben zu helfen. Befolgen Sie die Anweisungen sorgfältig, um jeden Schritt abzuschließen und praktische Erfahrungen zu sammeln. Historische Daten zeigen, dass dies ein Labor der Stufe Fortgeschrittener mit einer Abschlussquote von 65% ist. Es hat eine positive Bewertungsrate von 100% von den Lernenden erhalten.

Programmieren eines Ratespiels

Lassen Sie uns gemeinsam an einem praxisorientierten Projekt in Rust starten! In diesem Kapitel werden Ihnen einige häufige Rust-Konzepte vorgestellt, indem Sie erfahren, wie Sie sie in einem echten Programm verwenden. Sie werden über let, match, Methoden, assoziierte Funktionen, externe Crates und vieles mehr lernen! In den folgenden Kapiteln werden wir diese Ideen im Detail erkunden. In diesem Kapitel werden Sie nur die Grundlagen üben.

Wir werden ein klassisches Programmierungsproblem für Einsteiger implementieren: ein Ratespiel. So funktioniert es: Das Programm generiert eine Zufallszahl zwischen 1 und 100. Anschließend wird der Spieler aufgefordert, eine Vermutung einzugeben. Nachdem eine Vermutung eingegeben wurde, wird das Programm anzeigen, ob die Vermutung zu niedrig oder zu hoch ist. Wenn die Vermutung richtig ist, wird das Spiel eine Glückwunschmeldung ausgeben und beenden.

Ein neues Projekt einrichten

Um ein neues Projekt einzurichten, gehe in das project-Verzeichnis, das du im ersten Kapitel erstellt hast, und erstelle ein neues Projekt mit Cargo wie folgt:

cargo new guessing_game
cd guessing_game

Der erste Befehl, cargo new, nimmt den Namen des Projekts (guessing_game) als erstes Argument. Der zweite Befehl wechselt in das Verzeichnis des neuen Projekts.

Schau dir die generierte Cargo.toml-Datei an:

Dateiname: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

## Weitere Schlüssel und ihre Definitionen findest du unter
https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Wie du im ersten Kapitel gesehen hast, generiert cargo new ein "Hello, world!"-Programm für dich. Schau dir die src/main.rs-Datei an:

Dateiname: src/main.rs

fn main() {
    println!("Hello, world!");
}

Lassen Sie uns dieses "Hello, world!"-Programm jetzt kompilieren und in einem Schritt mit dem Befehl cargo run ausführen:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

Der Befehl run ist praktisch, wenn Sie schnell iterieren müssen, wie wir es in diesem Spiel tun werden, indem Sie jede Iteration schnell testen, bevor Sie zur nächsten übergehen.

Öffnen Sie die src/main.rs-Datei erneut. In dieser Datei werden Sie den gesamten Code schreiben.

Einen Tipp verarbeiten

Der erste Teil des Ratespielprogramms wird den Benutzer nach Eingaben fragen, diese verarbeiten und überprüfen, ob die Eingabe in der erwarteten Form vorliegt. Zunächst erlauben wir es dem Spieler, einen Tipp abzugeben. Geben Sie den Code in Listing 2-1 in src/main.rs ein.

Dateiname: src/main.rs

use std::io;

fn main() {
    println!("Rate die Zahl!");

    println!("Bitte geben Sie Ihren Tipp ein.");

    let mut guess = String::new();

    io::stdin()
     .read_line(&mut guess)
     .expect("Fehler beim Lesen der Zeile");

    println!("Ihr Tipp war: {guess}");
}

Listing 2-1: Code, der einen Tipp vom Benutzer erhält und ihn ausgibt

Dieser Code enthält eine Menge Informationen, also gehen wir Zeile für Zeile durch ihn. Um Benutzer-Eingaben zu erhalten und das Ergebnis dann als Ausgabe auszugeben, müssen wir die io-Eingabe/Ausgabe-Bibliothek in den Gültigkeitsbereich bringen. Die io-Bibliothek stammt aus der Standardbibliothek, die als std bekannt ist:

use std::io;

Standardmäßig hat Rust eine Reihe von Elementen, die in der Standardbibliothek definiert sind und die in den Gültigkeitsbereich jedes Programms gebracht werden. Diese Menge wird als Prelude bezeichnet, und Sie können alles darin unter https://doc.rust-lang.org/std/prelude/index.html sehen.

Wenn ein Typ, den Sie verwenden möchten, nicht im Prelude vorhanden ist, müssen Sie diesen Typ mit einem use-Statement explizit in den Gültigkeitsbereich bringen. Die Verwendung der std::io-Bibliothek bietet Ihnen eine Reihe nützlicher Funktionen, einschließlich der Möglichkeit, Benutzer-Eingaben zu akzeptieren.

Wie Sie im ersten Kapitel gesehen haben, ist die main-Funktion der Einstiegspunkt in das Programm:

fn main() {

Die fn-Syntax deklariert eine neue Funktion; die Klammern () geben an, dass keine Parameter vorhanden sind; und die geschweifte Klammer { startet den Funktionskörper.

Wie Sie auch im ersten Kapitel gelernt haben, ist println! eine Makro, das einen String auf dem Bildschirm ausgibt:

println!("Rate die Zahl!");

println!("Bitte geben Sie Ihren Tipp ein.");

Dieser Code gibt eine Aufforderung aus, die angibt, was das Spiel ist, und fordert die Benutzer-Eingabe an.

Werte mit Variablen speichern

Als nächstes erstellen wir eine Variable, um die Benutzer-Eingabe zu speichern, wie folgt:

let mut guess = String::new();

Jetzt wird das Programm interessant! In dieser kleinen Zeile passiert viel. Wir verwenden die let-Anweisung, um die Variable zu erstellen. Hier ist ein weiteres Beispiel:

let apples = 5;

Diese Zeile erstellt eine neue Variable namens apples und bindet sie an den Wert 5. In Rust sind Variablen standardmäßig unveränderlich, was bedeutet, dass der Wert, nachdem wir der Variable einen Wert zugewiesen haben, nicht mehr geändert werden kann. Wir werden dieses Konzept im Abschnitt "Variablen und Veränderbarkeit" im Detail diskutieren. Um eine Variable veränderlich zu machen, fügen wir mut vor den Variablennamen hinzu:

let apples = 5; // unveränderlich
let mut bananas = 5; // veränderlich

Hinweis: Die //-Syntax startet einen Kommentar, der bis zum Ende der Zeile fortgesetzt wird. Rust ignoriert alles in Kommentaren. Wir werden Kommentare im dritten Kapitel im Detail diskutieren.

Wenn wir nun zurück zum Ratespielprogramm kommen, wissen Sie jetzt, dass let mut guess eine veränderliche Variable namens guess einführen wird. Das Gleichheitszeichen (=) sagt Rust, dass wir jetzt etwas an die Variable binden möchten. Rechts vom Gleichheitszeichen ist der Wert, an den guess gebunden ist, nämlich das Ergebnis des Aufrufs von String::new, einer Funktion, die eine neue Instanz eines String zurückgibt. String ist ein String-Typ, der von der Standardbibliothek bereitgestellt wird und der ein wächstes, UTF-8-kodiertes Textstück ist.

Die ::-Syntax in der Zeile ::new zeigt an, dass new eine assoziierte Funktion des String-Typs ist. Eine assoziierte Funktion ist eine Funktion, die auf einem Typ implementiert ist, in diesem Fall String. Diese new-Funktion erstellt einen neuen, leeren String. Sie werden auf vielen Typen eine new-Funktion finden, da es ein üblicher Name für eine Funktion ist, die einen neuen Wert eines bestimmten Typs erstellt.

Insgesamt hat die Zeile let mut guess = String::new(); eine veränderliche Variable erstellt, die derzeit an eine neue, leere Instanz eines String gebunden ist. Puh!

Benutzer-Eingaben empfangen

Denken Sie daran, dass wir die Eingabe/Ausgabe-Funktionalität aus der Standardbibliothek mit use std::io; in der ersten Zeile des Programms eingeschlossen haben. Jetzt rufen wir die stdin-Funktion aus dem io-Modul auf, was uns ermöglicht, Benutzer-Eingaben zu verarbeiten:

io::stdin()
 .read_line(&mut guess)

Wenn wir die io-Bibliothek am Anfang des Programms nicht mit use std::io; importiert hätten, könnten wir die Funktion auch weiterhin verwenden, indem wir diesen Funktionsaufruf als std::io::stdin schreiben. Die stdin-Funktion gibt eine Instanz von std::io::Stdin zurück, die ein Typ ist, der einen Zugangspunkt für die Standardeingabe Ihres Terminals darstellt.

Als nächstes ruft die Zeile .read_line(&mut guess) die read_line-Methode auf dem Standardeingabe-Zugangspunkt auf, um Eingaben vom Benutzer zu erhalten. Wir übergeben auch &mut guess als Argument an read_line, um zu sagen, in welche Zeichenfolge die Benutzer-Eingabe gespeichert werden soll. Die volle Aufgabe von read_line besteht darin, alles, was der Benutzer in die Standardeingabe eingibt, anzuhängen und das an eine Zeichenfolge anzuhängen (ohne deren Inhalt zu überschreiben), weshalb wir diese Zeichenfolge als Argument übergeben. Das Zeichenfolgenargument muss veränderlich sein, damit die Methode den Inhalt der Zeichenfolge ändern kann.

Das & zeigt an, dass dieses Argument eine Referenz ist, die Ihnen eine Möglichkeit gibt, mehreren Teilen Ihres Codes zu ermöglichen, auf ein Stück Daten zuzugreifen, ohne dass Sie das Daten in den Speicher mehrfach kopieren müssen. Referenzen sind ein komplexes Feature, und ein großer Vorteil von Rust ist, wie sicher und einfach es ist, Referenzen zu verwenden. Sie müssen nicht viele Details kennen, um dieses Programm abzuschließen. Im Moment müssen Sie nur wissen, dass, wie Variablen, Referenzen standardmäßig unveränderlich sind. Daher müssen Sie &mut guess schreiben, anstatt &guess, um sie veränderlich zu machen. (Kapitel 4 wird Referenzen ausführlicher erklären.)

Fehlerbehandlung mit Result

Wir beschäftigen uns immer noch mit dieser Codezeile. Wir diskutieren jetzt eine dritte Zeile des Codes, beachten Sie jedoch, dass es immer noch Teil einer einzelnen logischen Codezeile ist. Der nächste Teil ist diese Methode:

.expect("Fehler beim Lesen der Zeile");

Wir hätten diesen Code auch so schreiben können:

io::stdin().read_line(&mut guess).expect("Fehler beim Lesen der Zeile");

Allerdings ist eine lange Zeile schwer lesbar, daher ist es am besten, sie aufzuteilen. Es ist oft ratsam, einen Zeilenumbruch und andere Leerzeichen einzufügen, um lange Zeilen aufzubrechen, wenn Sie eine Methode mit der .method_name()-Syntax aufrufen. Jetzt besprechen wir, was diese Zeile macht.

Wie bereits erwähnt, schreibt read_line alles, was der Benutzer eingibt, in die Zeichenfolge, die wir an sie übergeben, aber es gibt auch einen Result-Wert zurück. Result ist eine Enumeration, oft auch als Enum bezeichnet, das ein Typ ist, der in einem von mehreren möglichen Zuständen sein kann. Wir nennen jeden möglichen Zustand einen Variant.

Kapitel 6 wird Enums im Detail behandeln. Der Zweck dieser Result-Typen ist es, Fehlerbehandlungsinformationen zu codieren.

Die Varianten von Result sind Ok und Err. Die Ok-Variant zeigt an, dass die Operation erfolgreich war, und innerhalb von Ok ist der erfolgreich generierte Wert. Die Err-Variant bedeutet, dass die Operation fehlgeschlagen ist, und Err enthält Informationen darüber, wie oder warum die Operation fehlgeschlagen ist.

Werte des Result-Typs haben wie Werte jedes Typs Methoden, die auf ihnen definiert sind. Eine Instanz von Result hat eine expect-Methode, die Sie aufrufen können. Wenn diese Result-Instanz ein Err-Wert ist, wird expect das Programm abstürzen lassen und die Nachricht anzeigen, die Sie als Argument an expect übergeben haben. Wenn die read_line-Methode einen Err zurückgibt, ist dies wahrscheinlich das Ergebnis eines Fehlers, der von dem zugrunde liegenden Betriebssystem stammt. Wenn diese Result-Instanz ein Ok-Wert ist, nimmt expect den Rückgabewert, den Ok enthält, und gibt Ihnen genau diesen Wert zurück, damit Sie ihn verwenden können. In diesem Fall ist dieser Wert die Anzahl der Bytes in der Benutzer-Eingabe.

Wenn Sie expect nicht aufrufen, wird das Programm kompilieren, aber Sie erhalten eine Warnung:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Rust warnt, dass Sie den von read_line zurückgegebenen Result-Wert nicht verwendet haben, was darauf hinweist, dass das Programm einen möglichen Fehler nicht behandelt hat.

Der richtige Weg, die Warnung zu unterdrücken, ist es, tatsächlich Fehlerbehandlungs-Code zu schreiben, aber in unserem Fall möchten wir einfach das Programm abstürzen lassen, wenn ein Problem auftritt, daher können wir expect verwenden. Sie werden in Kapitel 9 lernen, wie man Fehler behebt.

Ausgabe von Werten mit println! Platzhaltern

Abgesehen von der schließenden geschweiften Klammer gibt es bisher nur noch eine Zeile im Code, die wir besprechen müssen:

println!("You guessed: {guess}");

Diese Zeile druckt die Zeichenfolge aus, die jetzt die Benutzer-Eingabe enthält. Die geschweiften Klammern {} sind ein Platzhalter: denken Sie an {} wie an kleine Krabbenklauen, die einen Wert an einem bestimmten Ort halten. Wenn Sie den Wert einer Variable ausgeben möchten, kann der Variablenname innerhalb der geschweiften Klammern stehen. Wenn Sie das Ergebnis der Auswertung eines Ausdrucks ausgeben möchten, legen Sie leere geschweifte Klammern im Formatstring an, und folgen Sie dann den Formatstring mit einer komma-getrennten Liste von Ausdrücken, die in jeder leeren geschweiften Klammer-Platzhalter in derselben Reihenfolge ausgegeben werden sollen. Ein Aufruf von println!, um eine Variable und das Ergebnis eines Ausdrucks auszugeben, würde so aussehen:

let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);

Dieser Code würde ausgeben: x = 5 and y = 12.

Testen des ersten Teils

Lassen Sie uns den ersten Teil des Ratespiels testen. Führen Sie es mit cargo run aus:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Rate die Zahl!
Bitte geben Sie Ihre Vermutung ein.
6
You guessed: 6

An diesem Punkt ist der erste Teil des Spiels abgeschlossen: Wir erhalten die Eingabe von der Tastatur und geben sie dann aus.

Generieren einer Geheimzahl

Als nächstes müssen wir eine Geheimzahl generieren, die der Benutzer erraten wird. Die Geheimzahl sollte jedes Mal unterschiedlich sein, damit das Spiel mehrmals amüsant zu spielen ist. Wir werden eine Zufallszahl zwischen 1 und 100 verwenden, damit das Spiel nicht zu schwierig ist. Rust enthält noch keine Zufallszahl-Funktionalität in seiner Standardbibliothek. Der Rust-Team bietet jedoch eine rand-Kiste auf https://crates.io/crates/rand mit der genannten Funktionalität an.

Verwendung einer Kiste, um weitere Funktionalität zu erhalten

Denken Sie daran, dass eine Kiste eine Sammlung von Rust-Quellcode-Dateien ist. Das Projekt, das wir bisher gebaut haben, ist eine Binärkiste, das heißt, es ist ein ausführbares Programm. Die rand-Kiste ist eine Bibliothekskiste, die Code enthält, der für andere Programme verwendet werden soll und nicht eigenständig ausgeführt werden kann.

Die Koordination von externen Kisten durch Cargo ist der Bereich, in dem Cargo wirklich aufleuchtet. Bevor wir Code schreiben können, der rand verwendet, müssen wir die Cargo.toml-Datei ändern, um die rand-Kiste als Abhängigkeit hinzuzufügen. Öffnen Sie jetzt diese Datei und fügen Sie die folgende Zeile am Ende unterhalb der [dependencies]-Abschnittshöhe hinzu, die Cargo für Sie erstellt hat. Stellen Sie sicher, dass Sie rand genau so angeben wie hier, mit dieser Versionsnummer, sonst können die Codebeispiele in diesem Tutorial möglicherweise nicht funktionieren:

Dateiname: Cargo.toml

[dependencies]
rand = "0.8.5"

In der Cargo.toml-Datei ist alles, was einem Abschnittshöhe folgt, Teil dieses Abschnitts, der bis zu einem neuen Abschnitt anhält. In [dependencies] sagen Sie Cargo, welche externen Kisten Ihr Projekt von sich abhängt und welche Versionen dieser Kisten Sie benötigen. In diesem Fall geben wir die rand-Kiste mit dem semantischen Versionsbezeichner 0.8.5 an. Cargo versteht Semantic Versioning (manchmal auch als SemVer bezeichnet), das eine Standard für das Schreiben von Versionsnummern ist. Der Bezeichner 0.8.5 ist eigentlich eine Abkürzung für ^0.8.5, was bedeutet, dass jede Version mindestens 0.8.5, aber unter 0.9.0 ist.

Cargo betrachtet diese Versionen als mit einer öffentlichen API kompatibel mit Version 0.8.5, und diese Spezifikation stellt sicher, dass Sie die neueste Patchversion erhalten, die noch mit dem Code in diesem Kapitel kompilieren wird. Keine Version 0.9.0 oder höher ist gewährleistet, die gleiche API zu haben wie diejenige, die die folgenden Beispiele verwenden.

Lassen Sie uns jetzt das Projekt erstellen, ohne den Code zu ändern, wie in Listing 2-2 gezeigt.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
  Downloaded libc v0.2.127
  Downloaded getrandom v0.2.7
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.16
  Downloaded rand_chacha v0.3.1
  Downloaded rand_core v0.6.3
   Compiling rand_core v0.6.3
   Compiling libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Listing 2-2: Die Ausgabe von cargo build nach Hinzufügen der rand-Kiste als Abhängigkeit

Sie können andere Versionsnummern sehen (aber alle werden mit dem Code kompatibel sein, dank SemVer!) und andere Zeilen (abhängig von dem Betriebssystem), und die Zeilen können in einer anderen Reihenfolge sein.

Wenn wir eine externe Abhängigkeit hinzufügen, holt Cargo die neuesten Versionen von allem, was diese Abhängigkeit benötigt, aus dem Registrierungsverzeichnis, das eine Kopie der Daten von Crates.io auf https://crates.io ist. Crates.io ist der Ort, an dem Menschen in der Rust-Ekosystem ihre Open-Source-Rust-Projekte für andere zur Verwendung stellen.

Nachdem das Registrierungsverzeichnis aktualisiert wurde, überprüft Cargo den [dependencies]-Abschnitt und lädt alle aufgelisteten Kisten herunter, die noch nicht heruntergeladen wurden. In diesem Fall hat Cargo auch andere Kisten abgerufen, auf die rand angewiesen ist, um zu funktionieren, obwohl wir nur rand als Abhängigkeit aufgelistet haben. Nachdem die Kisten heruntergeladen wurden, kompiliert Rust sie und kompiliert dann das Projekt mit den verfügbaren Abhängigkeiten.

Wenn Sie sofort erneut cargo build ausführen, ohne Änderungen vorzunehmen, erhalten Sie keine Ausgabe außer der Finished-Zeile. Cargo weiß, dass es die Abhängigkeiten bereits heruntergeladen und kompiliert hat, und Sie haben nichts in Ihrer Cargo.toml-Datei geändert. Cargo weiß auch, dass Sie nichts an Ihrem Code geändert haben, daher kompiliert es auch nicht erneut. Da es nichts zu tun hat, beendet es sich einfach.

Wenn Sie die src/main.rs-Datei öffnen, eine unbedeutende Änderung vornehmen, speichern Sie sie und bauen erneut, sehen Sie nur zwei Ausgabelines:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs

Diese Zeilen zeigen, dass Cargo nur die Build mit Ihrer kleinen Änderung an der src/main.rs-Datei aktualisiert. Ihre Abhängigkeiten haben sich nicht geändert, daher weiß Cargo, dass es das Reusen kann, was es bereits heruntergeladen und kompiliert hat für diese.

Sicherstellung von reproduzierbaren Builds mit der Cargo.lock-Datei

Cargo hat einen Mechanismus, der sicherstellt, dass Sie jedes Mal, wenn Sie oder jemand anders Ihren Code bauen, dasselbe Artefakt neu bauen können: Cargo wird nur die von Ihnen angegebenen Versionen der Abhängigkeiten verwenden, bis Sie dies anders angeben. Nehmen wir an, dass nächste Woche die Version 0.8.6 der rand-Kiste veröffentlicht wird und diese Version einen wichtigen Bugfix enthält, aber auch eine Regression, die Ihren Code brechen wird. Um dies zu behandeln, erstellt Rust die Cargo.lock-Datei, wenn Sie das erste Mal cargo build ausführen, sodass wir jetzt diese in das guessing_game-Verzeichnis haben.

Wenn Sie ein Projekt zum ersten Mal bauen, ermittelt Cargo alle Versionen der Abhängigkeiten, die den Kriterien entsprechen, und schreibt sie dann in die Cargo.lock-Datei. Wenn Sie in Zukunft Ihr Projekt bauen, wird Cargo erkennen, dass die Cargo.lock-Datei existiert und wird die dort angegebenen Versionen verwenden, anstatt erneut alle Arbeiten zur Versionenermittlung durchzuführen. Dadurch können Sie automatisch einen reproduzierbaren Build haben. Mit anderen Worten, Ihr Projekt bleibt bei 0.8.5, bis Sie es explizit aktualisieren, dank der Cargo.lock-Datei. Da die Cargo.lock-Datei für reproduzierbare Builds wichtig ist, wird sie oft zusammen mit dem Rest des Codes Ihres Projekts in die Quellcodeverwaltung aufgenommen.

Aktualisierung einer Kiste, um eine neue Version zu erhalten

Wenn Sie tatsächlich eine Kiste aktualisieren möchten, bietet Cargo den Befehl update, der die Cargo.lock-Datei ignorieren und alle neuesten Versionen ermitteln wird, die Ihren Spezifikationen in Cargo.toml entsprechen. Cargo wird dann diese Versionen in die Cargo.lock-Datei schreiben. Andernfalls wird Cargo standardmäßig nur nach Versionen suchen, die größer als 0.8.5 und kleiner als 0.9.0 sind. Wenn die rand-Kiste die beiden neuen Versionen 0.8.6 und 0.9.0 veröffentlicht hat, würden Sie Folgendes sehen, wenn Sie cargo update ausführen:

$ cargo update
Updating crates.io index
Updating rand v0.8.5 - > v0.8.6

Cargo ignoriert die 0.9.0-Version. An diesem Punkt würden Sie auch eine Änderung in Ihrer Cargo.lock-Datei bemerken, die angibt, dass die Version der rand-Kiste, die Sie jetzt verwenden, 0.8.6 ist. Um die rand-Version 0.9.0 oder jede Version in der 0.9.x-Reihe zu verwenden, müssten Sie die Cargo.toml-Datei so aktualisieren, dass sie wie folgt aussieht:

[dependencies]
rand = "0.9.0"

Die nächste Mal, wenn Sie cargo build ausführen, wird Cargo das Registrierungsverzeichnis der verfügbaren Kisten aktualisieren und Ihre rand-Anforderungen gemäß der neuen Version, die Sie angegeben haben, neu bewerten.

Es gibt noch viel mehr zu sagen über Cargo und seine Ökosystem, über das wir im Kapitel 14 sprechen werden, aber für jetzt ist das alles, was Sie wissen müssen. Cargo macht es sehr einfach, Bibliotheken zu wiederverwenden, sodass Rustaceans kleinere Projekte schreiben können, die aus einer Anzahl von Paketen zusammengesetzt sind.

Generieren einer Zufallszahl

Lassen Sie uns beginnen, rand zu verwenden, um eine Zahl zu generieren, die geraten werden soll. Der nächste Schritt ist es, src/main.rs zu aktualisieren, wie in Listing 2-3 gezeigt.

Dateiname: src/main.rs

use std::io;
1 use rand::Rng;

fn main() {
    println!("Guess the number!");

  2 let secret_number = rand::thread_rng().gen_range(1..=100);

  3 println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
     .read_line(&mut guess)
     .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Listing 2-3: Hinzufügen von Code, um eine Zufallszahl zu generieren

Zuerst fügen wir die Zeile use rand::Rng; hinzu [1]. Das Rng-Trait definiert Methoden, die Zufallszahlengeneratoren implementieren, und dieses Trait muss im Gültigkeitsbereich sein, damit wir diese Methoden verwenden können. Kapitel 10 wird Traits im Detail behandeln.

Als nächstes fügen wir zwei Zeilen in die Mitte hinzu. In der ersten Zeile [2] rufen wir die rand::thread_rng-Funktion auf, die uns den speziellen Zufallszahlengenerator gibt, den wir verwenden werden: einen, der lokal zum aktuellen Ausführungsthread ist und von dem Betriebssystem initialisiert wird. Dann rufen wir die gen_range-Methode auf dem Zufallszahlengenerator auf. Diese Methode wird vom Rng-Trait definiert, das wir mit der use rand::Rng;-Anweisung in den Gültigkeitsbereich gebracht haben. Die gen_range-Methode nimmt einen Bereichsausdruck als Argument und generiert eine Zufallszahl im Bereich. Der Art von Bereichsausdruck, den wir hier verwenden, hat die Form start..=end und ist sowohl für die untere als auch für die obere Grenze eingeschlossen, daher müssen wir 1..=100 angeben, um eine Zahl zwischen 1 und 100 zu erhalten.

Hinweis: Sie werden nicht einfach wissen, welche Traits zu verwenden und welche Methoden und Funktionen aus einer Kiste aufzurufen sind, daher hat jede Kiste eine Dokumentation mit Anweisungen zur Verwendung. Ein weiterer praktischer Aspekt von Cargo ist, dass das Ausführen des Befehls cargo doc --open die lokal von allen Ihren Abhängigkeiten bereitgestellte Dokumentation erstellen und in Ihrem Browser öffnen wird. Wenn Sie beispielsweise an anderer Funktionalität in der rand-Kiste interessiert sind, führen Sie cargo doc --open aus und klicken Sie in der linken Seitenleiste auf rand.

Die zweite neue Zeile [3] druckt die Geheimzahl. Dies ist nützlich, wenn wir das Programm entwickeln und testen möchten, aber wir werden es in der endgültigen Version löschen. Es ist nicht viel von einem Spiel, wenn das Programm die Antwort sofort ausgibt, wenn es startet!

Versuchen Sie, das Programm ein paar Mal auszuführen:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

Sie sollten verschiedene Zufallszahlen erhalten, und alle sollten Zahlen zwischen 1 und 100 sein. Tolle Arbeit!

Vergleichen der Vermutung mit der Geheimzahl

Jetzt, wo wir Benutzereingaben und eine Zufallszahl haben, können wir sie vergleichen. Dieser Schritt wird in Listing 2-4 gezeigt. Beachten Sie, dass dieser Code noch nicht kompilieren wird, wie wir erklären werden.

Dateiname: src/main.rs

use rand::Rng;
1 use std::cmp::Ordering;
use std::io;

fn main() {
    --snip--

    println!("You guessed: {guess}");

  2 match guess.3 cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Listing 2-4: Behandlung der möglichen Rückgabewerte beim Vergleichen von zwei Zahlen

Zuerst fügen wir eine weitere use-Anweisung hinzu [1], um einen Typ namens std::cmp::Ordering aus der Standardbibliothek in den Gültigkeitsbereich zu bringen. Der Ordering-Typ ist eine weitere Enumeration und hat die Varianten Less, Greater und Equal. Dies sind die drei möglichen Ergebnisse, wenn Sie zwei Werte vergleichen.

Dann fügen wir fünf neue Zeilen am Ende hinzu, die den Ordering-Typ verwenden. Die cmp-Methode [3] vergleicht zwei Werte und kann auf jedem Objekt aufgerufen werden, das verglichen werden kann. Sie nimmt eine Referenz auf das Objekt, mit dem Sie vergleichen möchten: hier wird guess mit secret_number verglichen. Dann gibt sie eine Variante der Ordering-Enumeration zurück, die wir mit der use-Anweisung in den Gültigkeitsbereich gebracht haben. Wir verwenden einen match-Ausdruck [2], um zu bestimmen, was als nächstes zu tun ist, basierend auf der Variante von Ordering, die von der cmp-Methode mit den Werten in guess und secret_number zurückgegeben wurde.

Ein match-Ausdruck besteht aus Armen. Ein Arm besteht aus einem Muster, gegen das verglichen wird, und dem Code, der ausgeführt werden soll, wenn der Wert, der an match übergeben wird, dem Muster des Arms entspricht. Rust nimmt den Wert, der an match übergeben wird, und durchsucht nacheinander jedes Arm-Muster. Muster und die match-Konstruktion sind leistungsstarke Rust-Features: sie ermöglichen es Ihnen, eine Vielzahl von Situationen auszudrücken, die Ihr Code möglicherweise auftauchen kann, und stellen sicher, dass Sie alle behandeln. Diese Features werden in Kapitel 6 und Kapitel 18 detailliert behandelt.

Lassen Sie uns ein Beispiel mit dem match-Ausdruck durchgehen, den wir hier verwenden. Nehmen wir an, dass der Benutzer 50 geraten hat und die zufällig generierte Geheimzahl diesmal 38 ist.

Wenn der Code 50 mit 38 vergleicht, wird die cmp-Methode Ordering::Greater zurückgeben, da 50 größer als 38 ist. Der match-Ausdruck erhält den Wert Ordering::Greater und beginnt, jedes Arm-Muster zu überprüfen. Er betrachtet das Muster des ersten Arms, Ordering::Less, und erkennt, dass der Wert Ordering::Greater nicht mit Ordering::Less übereinstimmt, daher ignoriert er den Code in diesem Arm und geht zum nächsten Arm. Das Muster des nächsten Arms ist Ordering::Greater, was tatsächlich mit Ordering::Greater übereinstimmt! Der zugehörige Code in diesem Arm wird ausgeführt und druckt Too big! auf dem Bildschirm. Der match-Ausdruck endet nach der ersten erfolgreichen Übereinstimmung, daher wird er in diesem Szenario den letzten Arm nicht betrachten.

Der Code in Listing 2-4 wird jedoch noch nicht kompilieren. Probieren wir es aus:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `String`, found integer
   |
   = note: expected reference `&String`
              found reference `&{integer}`

Der Kern des Fehlers besagt, dass es ungleiche Typen gibt. Rust hat ein starkes, statisches Typsystem. Allerdings hat es auch Typinferenz. Als wir let mut guess = String::new() geschrieben haben, konnte Rust schließen, dass guess ein String sein sollte, und hat uns nicht dazu gezwungen, den Typ zu schreiben. Die secret_number ist dagegen ein Zahlentyp. Einige der Zahlentypen in Rust können einen Wert zwischen 1 und 100 haben: i32, eine 32-Bit-Zahl; u32, eine unsigned 32-Bit-Zahl; i64, eine 64-Bit-Zahl; sowie andere. Wenn nichts anderes angegeben wird, nimmt Rust standardmäßig einen i32, was der Typ von secret_number ist, es sei denn, Sie geben an anderer Stelle Typinformationen an, die dazu führen würden, dass Rust einen anderen numerischen Typ schließt. Der Grund für den Fehler ist, dass Rust einen String und einen Zahlentyp nicht vergleichen kann.

Letztendlich möchten wir die String, die das Programm als Eingabe liest, in einen echten Zahlentyp umwandeln, damit wir sie numerisch mit der Geheimzahl vergleichen können. Wir tun dies, indem wir diese Zeile in den main-Funktionskörper einfügen:

Dateiname: src/main.rs

use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
      .read_line(&mut guess)
      .expect("Failed to read line");

    let guess: u32 = guess
      .trim()
      .parse()
      .expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Wir erstellen eine Variable namens guess. Aber warte, hat das Programm nicht bereits eine Variable namens guess? Ja, aber es ist hilfreich, dass Rust uns erlaubt, den vorherigen Wert von guess mit einem neuen zu überschreiben. Überschreiben ermöglicht es uns, den Variablennamen guess zu wiederverwenden, anstatt uns zu zwingen, zwei eindeutige Variablen zu erstellen, wie z. B. guess_str und guess. Wir werden dies im Kapitel 3 genauer behandeln, aber für jetzt wissen Sie, dass diese Funktion oft verwendet wird, wenn Sie einen Wert von einem Typ in einen anderen Typ umwandeln möchten.

Wir binden diese neue Variable an den Ausdruck guess.trim().parse(). Die guess im Ausdruck bezieht sich auf die ursprüngliche guess-Variable, die die Eingabe als String enthielt. Die trim-Methode auf einer String-Instanz entfernt alle Leerzeichen am Anfang und Ende, was wir tun müssen, um den String mit dem u32 vergleichen zu können, der nur numerische Daten enthalten kann. Der Benutzer muss die Eingabetaste drücken, um read_line zu befriedigen und seine Vermutung einzugeben, was einem Zeilenumbruchzeichen am Ende der Zeichenfolge hinzufügt. Wenn der Benutzer beispielsweise 5 eingibt und die Eingabetaste drückt, sieht guess so aus: 5\n. Das \n repräsentiert "Zeilenumbruch". (Auf Windows führt das Drücken der Eingabetaste zu einem Wagenrücklauf und einem Zeilenumbruch, \r\n.) Die trim-Methode entfernt \n oder \r\n, was nur 5 zurücklässt.

Die parse-Methode auf Strings wandelt einen String in einen anderen Typ um. Hier verwenden wir es, um von einem String zu einer Zahl zu wechseln. Wir müssen Rust den genauen Zahlentyp sagen, den wir möchten, indem wir let guess: u32 verwenden. Das Doppelpunktzeichen (:) nach guess sagt Rust, dass wir den Typ der Variable annotieren werden. Rust hat einige integrierte Zahlentypen; der hier verwendete u32 ist eine unsigned 32-Bit-Ganzzahl. Es ist eine gute Standardauswahl für eine kleine positive Zahl. Sie werden in Kapitel 3 über andere Zahlentypen lernen.

Zusätzlich bedeutet die u32-Annotation in diesem Beispielprogramm und der Vergleich mit secret_number, dass Rust schließen wird, dass secret_number ebenfalls ein u32 sein sollte. Jetzt wird der Vergleich zwischen zwei Werten des gleichen Typs durchgeführt!

Die parse-Methode funktioniert nur auf Zeichen, die logisch in Zahlen umgewandelt werden können, und kann daher leicht zu Fehlern führen. Wenn beispielsweise die Zeichenfolge A👍% enthielt, gäbe es keine Möglichkeit, das in eine Zahl umzuwandeln. Da es fehlschlagen kann, gibt die parse-Methode einen Result-Typ zurück, ähnlich wie die read_line-Methode (siehe zuvor in "Behandlung von potenziellen Fehlern mit Result"). Wir werden dieses Result auf die gleiche Weise behandeln, indem wir die expect-Methode erneut verwenden. Wenn parse ein Err-Result-Variant zurückgibt, weil es keine Zahl aus der Zeichenfolge erstellen konnte, wird der expect-Aufruf das Spiel abbrechen und die Nachricht ausgeben, die wir ihm geben. Wenn parse die Zeichenfolge erfolgreich in eine Zahl umwandeln kann, wird es das Ok-Variant von Result zurückgeben, und expect wird die Zahl zurückgeben, die wir aus dem Ok-Wert wollen.

Lassen Sie uns jetzt das Programm ausführen:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Super! Auch wenn Leerzeichen vor der Vermutung hinzugefügt wurden, hat das Programm immer noch erkannt, dass der Benutzer 76 geraten hat. Führen Sie das Programm ein paar Mal aus, um das unterschiedliche Verhalten mit verschiedenen Arten von Eingaben zu überprüfen: erraten Sie die Zahl richtig, erraten Sie eine zu hohe Zahl und erraten Sie eine zu niedrige Zahl.

Wir haben jetzt den Großteil des Spiels funktional, aber der Benutzer kann nur eine Vermutung machen. Ändern wir das, indem wir eine Schleife hinzufügen!

Ermöglichen mehrerer Vermutungen mit Schleifen

Das loop-Schlüsselwort erstellt eine Endlosschleife. Wir werden eine Schleife hinzufügen, um den Benutzern mehr Chancen zu geben, die Zahl zu erraten:

Dateiname: src/main.rs

use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");
        let mut guess = String::new();

        io::stdin()
         .read_line(&mut guess)
         .expect("Failed to read line");

        let guess: u32 = guess
         .trim()
         .parse()
         .expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

Wie Sie sehen können, haben wir alles ab dem Eingabeaufforderung für die Vermutung in eine Schleife verschoben. Stellen Sie sicher, dass Sie die Zeilen innerhalb der Schleife jeweils um weitere vier Leerzeichen einrücken und das Programm erneut ausführen. Das Programm wird jetzt für immer nach einer weiteren Vermutung fragen, was tatsächlich ein neues Problem aufwirft. Es scheint, dass der Benutzer nicht beenden kann!

Der Benutzer könnte immer den Programmablauf mit der Tastenkombination ctrl-C unterbrechen. Aber es gibt auch eine andere Möglichkeit, diesem unersättlichen Monster zu entkommen, wie in der parse-Diskussion in "Vergleichen der Vermutung mit der Geheimzahl" erwähnt: Wenn der Benutzer eine nicht-numerische Antwort eingibt, wird das Programm abstürzen. Wir können uns darauf verlassen, um dem Benutzer die Möglichkeit zu geben, das Spiel zu beenden, wie hier gezeigt:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread'main' panicked at 'Please type a number!: ParseIntError
{ kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Beim Tippen von quit wird das Spiel beendet, aber wie Sie bemerken werden, wird dies auch bei der Eingabe jeder anderen nicht-numerischen Eingabe passieren. Dies ist zumindest suboptimal; wir möchten, dass das Spiel auch stoppt, wenn die richtige Zahl erraten wird.

Beenden nach einem richtigen Rat

Lassen Sie uns das Spiel programmieren, um es zu beenden, wenn der Benutzer gewinnt, indem wir eine break-Anweisung hinzufügen:

Dateiname: src/main.rs

--snip--

match guess.cmp(&secret_number) {
    Ordering::Less => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal => {
        println!("You win!");
        break;
    }
}

Das Hinzufügen der break-Zeile nach You win! lässt das Programm die Schleife verlassen, wenn der Benutzer die Geheimzahl richtig erraten hat. Das Verlassen der Schleife bedeutet auch, das das Programm beendet wird, da die Schleife der letzte Teil von main ist.

Behandlung ungültiger Eingaben

Um das Verhalten des Spiels weiter zu verbessern, statt das Programm abzustürzen, wenn der Benutzer eine nicht-numerische Eingabe macht, lassen wir das Spiel ungültige Eingaben ignorieren, sodass der Benutzer weiterhin raten kann. Wir können das erreichen, indem wir die Zeile ändern, in der guess von einem String in einen u32 umgewandelt wird, wie in Listing 2-5 gezeigt.

Dateiname: src/main.rs

--snip--

io::stdin()
 .read_line(&mut guess)
 .expect("Failed to read line");

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

println!("You guessed: {guess}");

--snip--

Listing 2-5: Ignorieren einer nicht-numerischen Vermutung und erneut nach einer Vermutung fragen, anstatt das Programm abzustürzen

Wir wechseln von einem expect-Aufruf zu einem match-Ausdruck, um von einem Absturz bei einem Fehler zu einem Umgang mit dem Fehler zu gelangen. Denken Sie daran, dass parse ein Result-Typ zurückgibt und Result eine Enumeration ist, die die Varianten Ok und Err hat. Wir verwenden hier einen match-Ausdruck, wie wir es auch bei dem Ordering-Ergebnis der cmp-Methode getan haben.

Wenn parse die Zeichenfolge erfolgreich in eine Zahl umwandeln kann, wird es einen Ok-Wert zurückgeben, der die resultierende Zahl enthält. Dieser Ok-Wert wird dem Muster des ersten Arms entsprechen, und der match-Ausdruck wird einfach den num-Wert zurückgeben, den parse erzeugt hat und in den Ok-Wert gesteckt hat. Diese Zahl wird genau dort landen, wo wir sie in der neuen guess-Variable haben wollen, die wir erstellen.

Wenn parse die Zeichenfolge nicht in eine Zahl umwandeln kann, wird es einen Err-Wert zurückgeben, der weitere Informationen über den Fehler enthält. Der Err-Wert stimmt nicht mit dem Ok(num)-Muster im ersten match-Arm überein, stimmt aber mit dem Err(_)-Muster im zweiten Arm überein. Der Unterstrich, _, ist ein Platzhalterwert; in diesem Beispiel sagen wir, dass wir alle Err-Werte abfangen möchten, unabhängig davon, welche Informationen sie enthalten. Der Programm wird daher den Code des zweiten Arms ausführen, continue, was dem Programm sagt, zum nächsten Iterationsschritt der loop zu gehen und erneut nach einer Vermutung zu fragen. Effectiv ignoriert das Programm alle Fehler, die parse möglicherweise auftauchen kann!

Jetzt sollte alles im Programm wie erwartet funktionieren. Probieren wir es aus:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 4.45s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

Super! Mit einem kleinen letzten Anpassung werden wir das Raten-Spiel abschließen. Denken Sie daran, dass das Programm immer noch die Geheimzahl ausgibt. Das war für das Testen gut, aber es ruiniert das Spiel. Lassen Sie uns die println!, die die Geheimzahl ausgibt, löschen. Listing 2-6 zeigt den endgültigen Code.

Dateiname: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
         .read_line(&mut guess)
         .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Listing 2-6: Vollständiger Code für das Raten-Spiel

An diesem Punkt haben Sie das Raten-Spiel erfolgreich gebaut. Herzlichen Glückwunsch!

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Lab "Programmieren eines Raten-Spiels" abgeschlossen. Sie können in LabEx weitere Labs ausprobieren, um Ihre Fähigkeiten zu verbessern.