Programowanie gry w zgadywanie
Zanurzmy się w Rust, realizując wspólnie praktyczny projekt! Ten rozdział
wprowadza cię w kilka popularnych koncepcji Rusta, pokazując, jak używać ich
w prawdziwym programie. Dowiesz się o let, match, metodach, funkcjach
skojarzonych, zewnętrznych “crate’ach” i wielu innych! W kolejnych
rozdziałach będziemy badać te idee bardziej szczegółowo. W tym rozdziale
będziesz ćwiczyć tylko podstawy.
Zaimplementujemy klasyczny problem programistyczny dla początkujących: grę w zgadywanie. Działa to tak: program wygeneruje losową liczbę całkowitą od 1 do 100. Następnie poprosi gracza o podanie strzału. Po wprowadzeniu strzału, program wskaże, czy strzał jest za niski, czy za wysoki. Jeśli strzał jest prawidłowy, gra wyświetli komunikat gratulacyjny i zakończy się.
Konfiguracja nowego projektu
Aby skonfigurować nowy projekt, przejdź do katalogu projects, który utworzyłeś w Rozdziale 1, i utwórz nowy projekt za pomocą Cargo, w następujący sposób:
$ cargo new guessing_game
$ cd guessing_game
Pierwsze polecenie, cargo new, przyjmuje nazwę projektu (guessing_game) jako
pierwszy argument. Drugie polecenie zmienia katalog na katalog nowego projektu.
Spójrz na wygenerowany plik Cargo.toml:
Nazwa pliku: Cargo.toml
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
[dependencies]
Jak widziałeś w Rozdziale 1, cargo new generuje program “Hello, world!” dla
Ciebie. Sprawdź plik src/main.rs:
Nazwa pliku: src/main.rs
fn main() {
println!("Hello, world!");
}
Teraz skompilujmy ten program “Hello, world!” i uruchommy go w tym samym kroku,
używając polecenia cargo run:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/guessing_game`
Hello, world!
Polecenie run przydaje się, gdy trzeba szybko iterować nad projektem, tak
jak będziemy to robić w tej grze, szybko testując każdą iterację, zanim
przejdziemy do następnej.
Otwórz ponownie plik src/main.rs. Cały kod będziesz pisać w tym pliku.
Przetwarzanie strzału
Pierwsza część programu do zgadywania liczb poprosi o dane wejściowe od użytkownika, przetworzy je i sprawdzi, czy dane są w oczekiwanej formie. Na początek pozwolimy graczowi wprowadzić strzał. Wprowadź kod z Listingu 2-1 do src/main.rs.
use std::io;
fn main() {
println!("Guess the 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}");
}
Ten kod zawiera wiele informacji, więc przeanalizujmy go linia po linii. Aby
pobrać dane od użytkownika, a następnie wyświetlić wynik, musimy wprowadzić
bibliotekę wejścia/wyjścia io do zakresu. Biblioteka io pochodzi ze
standardowej biblioteki, znanej jako std:
use std::io;
fn main() {
println!("Guess the 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}");
}
Domyślnie Rust ma zestaw elementów zdefiniowanych w standardowej bibliotece, które są wprowadzane do zakresu każdego programu. Ten zestaw nazywa się preludium, a wszystko w nim możesz zobaczyć w dokumentacji biblioteki standardowej.
Jeśli typ, którego chcesz użyć, nie znajduje się w preludium, musisz
explicitnie wprowadzić ten typ do zakresu za pomocą instrukcji use. Użycie
biblioteki std::io zapewnia wiele przydatnych funkcji, w tym możliwość
przyjmowania danych wejściowych od użytkownika.
Jak widziałeś w Rozdziale 1, funkcja main jest punktem wejścia do programu:
use std::io;
fn main() {
println!("Guess the 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}");
}
Składnia fn deklaruje nową funkcję; nawiasy () wskazują, że nie ma
parametrów; a nawias klamrowy { rozpoczyna ciało funkcji.
Jak również dowiedziałeś się w Rozdziale 1, println! to makro, które
wyświetla ciąg znaków na ekranie:
use std::io;
fn main() {
println!("Guess the 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}");
}
Ten kod wyświetla komunikat informujący, czym jest gra, i prosi użytkownika o wprowadzenie danych.
Przechowywanie wartości w zmiennych
Następnie utworzymy zmienną do przechowywania danych wprowadzonych przez użytkownika, w ten sposób:
use std::io;
fn main() {
println!("Guess the 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}");
}
Teraz program staje się ciekawszy! W tej krótkiej linii dzieje się dużo. Używamy
instrukcji let do utworzenia zmiennej. Oto inny przykład:
let apples = 5;
Ta linia tworzy nową zmienną o nazwie apples i wiąże ją z wartością 5.
W Rust zmienne są domyślnie niezmienne, co oznacza, że gdy raz nadamy
zmiennej wartość, wartość ta nie ulegnie zmianie. Omówimy tę koncepcję
szczegółowo w sekcji „Zmienne i mutowalność”
nazwą zmiennej:
let apples = 5; // niezmienna
let mut bananas = 5; // mutowalna
Uwaga: Składnia // rozpoczyna komentarz, który trwa do końca linii. Rust
ignoruje wszystko, co znajduje się w komentarzach. Omówimy komentarze
szczegółowo w Rozdziale 3.
Wracając do programu gry w zgadywanie, wiesz już, że let mut guess wprowadzi
mutowalną zmienną o nazwie guess. Znak równości (=) mówi Rustowi, że
chcemy teraz powiązać coś ze zmienną. Po prawej stronie znaku równości
znajduje się wartość, z którą guess jest powiązane, czyli wynik wywołania
String::new, funkcji, która zwraca nową instancję typu String.
String to typ ciągu znaków dostarczony przez
standardową bibliotekę, który jest rozszerzalnym, kodowanym UTF-8 fragmentem
tekstu.
Składnia :: w linii ::new wskazuje, że new jest funkcją skojarzoną z
typem String. Funkcja skojarzona to funkcja zaimplementowana dla danego
typu, w tym przypadku String. Ta funkcja new tworzy nowy, pusty ciąg
znaków. Funkcję new znajdziesz na wielu typach, ponieważ jest to
powszechna nazwa dla funkcji, która tworzy nową wartość jakiegoś rodzaju.
Podsumowując, linia let mut guess = String::new(); utworzyła mutowalną
zmienną, która jest obecnie powiązana z nową, pustą instancją String. Uff!
Odbieranie danych od użytkownika
Przypomnijmy, że na początku programu dołączyliśmy funkcjonalność wejścia/
wyjścia ze standardowej biblioteki za pomocą use std::io;. Teraz wywołamy
funkcję stdin z modułu io, co pozwoli nam obsługiwać dane wejściowe od
użytkownika:
use std::io;
fn main() {
println!("Guess the 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}");
}
Gdybyśmy nie zaimportowali modułu io za pomocą use std::io; na początku
programu, nadal moglibyśmy użyć funkcji, pisząc wywołanie funkcji jako
std::io::stdin. Funkcja stdin zwraca instancję
std::io::Stdin, która jest typem
reprezentującym uchwyt do standardowego wejścia dla Twojego terminala.
Następnie, linia .read_line(&mut guess) wywołuje metodę
read_line na uchwycie standardowego wejścia
w celu pobrania danych od użytkownika. Przekazujemy również &mut guess jako
argument do read_line, aby powiedzieć jej, w którym ciągu znaków ma
przechowywać dane wejściowe od użytkownika. Pełnym zadaniem read_line jest
pobranie wszystkiego, co użytkownik wpisze do standardowego wejścia, i
dodanie tego do ciągu znaków (bez nadpisywania jego zawartości), dlatego
przekazujemy ten ciąg znaków jako argument. Argument ciągu znaków musi być
mutowalny, aby metoda mogła zmieniać zawartość ciągu znaków.
Znak & wskazuje, że ten argument jest referencją, co pozwala wielu
częściom Twojego kodu uzyskać dostęp do jednego fragmentu danych bez
konieczności wielokrotnego kopiowania tych danych do pamięci. Referencje są
złożoną funkcją, a jedną z głównych zalet Rusta jest to, jak bezpieczne i
łatwe jest używanie referencji. Nie musisz znać wielu z tych szczegółów, aby
zakończyć ten program. Na razie wystarczy wiedzieć, że podobnie jak zmienne,
referencje są domyślnie niezmienne. Dlatego musisz napisać &mut guess zamiast
&guess, aby uczynić ją mutowalną. (Rozdział 4 wyjaśni referencje bardziej
dokładnie).
Obsługa potencjalnych błędów za pomocą Result
Nadal pracujemy nad tą linią kodu. Teraz omawiamy trzecią linię tekstu, ale zwróć uwagę, że jest to nadal część jednej logicznej linii kodu. Następna część to ta metoda:
use std::io;
fn main() {
println!("Guess the 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}");
}
Ten kod mogliśmy napisać jako:
io::stdin().read_line(&mut guess).expect("Failed to read line");
Jednak jedna długa linia jest trudna do odczytania, więc najlepiej jest ją
podzielić. Często rozsądne jest wprowadzenie nowej linii i innych białych
znaków, aby ułatwić czytanie długich linii, gdy wywołujesz metodę za pomocą
składni .method_name(). Teraz omówmy, co ta linia robi.
Jak wspomniano wcześniej, read_line umieszcza wszystko, co użytkownik
wprowadzi, w przekazanym jej ciągu znaków, ale zwraca również wartość Result.
Result to wyliczenie enums,
często nazywane enum, które jest typem mogącym przyjmować jeden z wielu
możliwych stanów. Każdy możliwy stan nazywamy wariantem.
Rozdział 6 omówi szczegółowo enums. Celem tych
typów Result jest kodowanie informacji o obsłudze błędów.
Wariantami Result są Ok i Err. Wariant Ok wskazuje, że operacja
zakończyła się sukcesem i zawiera pomyślnie wygenerowaną wartość. Wariant Err
oznacza, że operacja zakończyła się niepowodzeniem i zawiera informacje o tym,
jak lub dlaczego operacja się nie powiodła.
Wartości typu Result, podobnie jak wartości każdego typu, mają zdefiniowane
na nich metody. Instancja Result ma metodę expect,
którą możesz wywołać. Jeśli ta instancja Result jest wartością Err,
expect spowoduje awarię programu i wyświetli komunikat, który przekazałeś
jako argument do expect. Jeśli metoda read_line zwróci Err, będzie to
prawdopodobnie wynikiem błędu pochodzącego z bazowego systemu operacyjnego.
Jeśli ta instancja Result jest wartością Ok, expect pobierze wartość
zwracaną, którą przechowuje Ok, i zwróci ci tylko tę wartość, abyś mógł jej
użyć. W tym przypadku ta wartość to liczba bajtów w danych wejściowych
użytkownika.
Jeśli nie wywołasz expect, program skompiluje się, ale otrzymasz ostrzeżenie:
$ 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: 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
|
10 | let _ = io::stdin().read_line(&mut guess);
| +++++++
warning: `guessing_game` (bin "guessing_game") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s
Rust ostrzega, że nie użyłeś wartości Result zwróconej z read_line,
wskazując, że program nie obsłużył możliwego błędu.
Właściwym sposobem na stłumienie ostrzeżenia jest faktyczne napisanie kodu
do obsługi błędów, ale w naszym przypadku chcemy po prostu, aby ten program
uległ awarii, gdy wystąpi problem, więc możemy użyć expect. Dowiesz się o
recoverowaniu z błędów w Rozdziale 9.
Wyświetlanie wartości za pomocą znaczników println!
Poza końcowym nawiasem klamrowym, pozostała tylko jedna linia do omówienia w dotychczasowym kodzie:
use std::io;
fn main() {
println!("Guess the 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}");
}
Ta linia wyświetla ciąg znaków, który teraz zawiera dane wejściowe
użytkownika. Zestaw nawiasów klamrowych {} to znacznik miejsca:
Wyobraź sobie {} jako małe szczypce kraba, które trzymają wartość na miejscu.
Podczas wyświetlania wartości zmiennej nazwa zmiennej może znajdować się w
środku nawiasów klamrowych. Podczas wyświetlania wyniku oceny wyrażenia,
umieść puste nawiasy klamrowe w ciągu formatującym, a następnie po ciągu
formatującym umieść listę wyrażeń oddzielonych przecinkami, które mają być
wyświetlone w każdym pustym znaczniku miejsca w tej samej kolejności.
Wyświetlanie zmiennej i wyniku wyrażenia w jednym wywołaniu println!
wyglądałoby tak:
#![allow(unused)]
fn main() {
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
}
Ten kod wydrukowałby x = 5 and y + 2 = 12.
Testowanie pierwszej części
Przetestujmy pierwszą część gry w zgadywanie. Uruchom ją za pomocą cargo run:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
W tym momencie pierwsza część gry jest gotowa: pobieramy dane z klawiatury, a następnie je wyświetlamy.
Generowanie tajnej liczby
Następnie musimy wygenerować tajną liczbę, którą użytkownik będzie próbował
zgadnąć. Tajna liczba powinna być inna za każdym razem, aby gra była zabawna
do wielokrotnego grania. Użyjemy losowej liczby od 1 do 100, aby gra nie była
zbyt trudna. Rust nie zawiera jeszcze funkcji do generowania liczb losowych w
standardowej bibliotece. Jednak zespół Rusta udostępnia pakiet rand
crate z tą funkcjonalnością.
Zwiększanie funkcjonalności za pomocą pakietu
Pamiętaj, że crate to zbiór plików źródłowych Rusta. Projekt, który budujemy,
jest binary crate, czyli plikiem wykonywalnym. Crate rand to library
crate, który zawiera kod przeznaczony do użycia w innych programach i nie może
być wykonywany samodzielnie.
Koordynacja zewnętrznych crate'ów przez Cargo to miejsce, w którym Cargo
naprawdę błyszczy. Zanim będziemy mogli napisać kod, który używa rand,
musimy zmodyfikować plik Cargo.toml, aby dodać rand jako zależność.
Otwórz ten plik teraz i dodaj następującą linię na dole, pod nagłówkiem
sekcji [dependencies], który Cargo dla Ciebie utworzył. Upewnij się, że
określasz rand dokładnie tak, jak tutaj, z tym numerem wersji, w przeciwnym
razie przykłady kodu w tym samouczku mogą nie działać:
Nazwa pliku: Cargo.toml
[dependencies]
rand = "0.8.5"
W pliku Cargo.toml wszystko, co następuje po nagłówku, jest częścią tej
sekcji, która trwa, dopóki nie rozpocznie się inna sekcja. W [dependencies]
informujesz Cargo, od których zewnętrznych pakietów zależy Twój projekt i
których wersji tych pakietów potrzebujesz. W tym przypadku określamy pakiet
rand ze specyfikatorem wersji semantycznej 0.8.5. Cargo rozumie Semantic
Versioning (czasami nazywane SemVer), który jest
standardem zapisu numerów wersji. Specyfikator 0.8.5 jest w rzeczywistości
skrótem od ^0.8.5, co oznacza dowolną wersję co najmniej 0.8.5, ale
padającą poniżej 0.9.0.
Cargo uważa, że te wersje mają publiczne API kompatybilne z wersją 0.8.5, a ta specyfikacja gwarantuje, że otrzymasz najnowszą wersję poprawki, która nadal będzie kompilować się z kodem w tym rozdziale. Żadna wersja 0.9.0 lub większa nie gwarantuje tego samego API, co w poniższych przykładach.
Teraz, bez zmiany kodu, zbudujmy projekt, jak pokazano w Listingu 2-2.
$ cargo build
Updating crates.io index
Locking 15 packages to latest Rust 1.85.0 compatible versions
Adding rand v0.8.5 (available: v0.9.0)
Compiling proc-macro2 v1.0.93
Compiling unicode-ident v1.0.17
Compiling libc v0.2.170
Compiling cfg-if v1.0.0
Compiling byteorder v1.5.0
Compiling getrandom v0.2.15
Compiling rand_core v0.6.4
Compiling quote v1.0.38
Compiling syn v2.0.98
Compiling zerocopy-derive v0.7.35
Compiling zerocopy v0.7.35
Compiling ppv-lite86 v0.2.20
Compiling rand_chacha v0.3.1
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
cargo build po dodaniu rand jako zależnościMożesz zobaczyć różne numery wersji (ale wszystkie będą kompatybilne z kodem, dzięki SemVer!) i różne linie (w zależności od systemu operacyjnego), a linie mogą być w innej kolejności.
Gdy dołączamy zewnętrzną zależność, Cargo pobiera najnowsze wersje wszystkiego, czego ta zależność potrzebuje, z rejestru, który jest kopią danych z Crates.io. Crates.io to miejsce, w którym ludzie w ekosystemie Rusta publikują swoje projekty Rust open source, aby inni mogli z nich korzystać.
Po zaktualizowaniu rejestru Cargo sprawdza sekcję [dependencies] i pobiera
wszystkie wymienione pakiety, które nie zostały jeszcze pobrane. W tym
przypadku, choć wymieniliśmy tylko rand jako zależność, Cargo pobrał również
inne pakiety, od których zależy rand, aby działał. Po pobraniu pakietów
Rust kompiluje je, a następnie kompiluje projekt z dostępnymi zależnościami.
Jeśli natychmiast ponownie uruchomisz cargo build bez wprowadzania żadnych
zmian, nie otrzymasz żadnych danych wyjściowych poza linią Finished.
Cargo wie, że już pobrał i skompilował zależności, a Ty nie zmieniłeś nic
w pliku Cargo.toml. Cargo wie również, że nie zmieniłeś nic w swoim kodzie,
więc nie kompiluje go ponownie. Nie mając nic do zrobienia, po prostu
kończy działanie.
Jeśli otworzysz plik src/main.rs, dokonasz trywialnej zmiany, a następnie zapiszesz go i ponownie zbudujesz, zobaczysz tylko dwie linie danych wyjściowych:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Te linie pokazują, że Cargo aktualizuje kompilację tylko o Twoją drobną zmianę w pliku src/main.rs. Twoje zależności nie uległy zmianie, więc Cargo wie, że może ponownie wykorzystać to, co już pobrał i skompilował dla nich.
Zapewnianie powtarzalnych kompilacji za pomocą pliku Cargo.lock
Cargo posiada mechanizm, który zapewnia, że możesz odbudować ten sam artefakt
za każdym razem, gdy Ty lub ktokolwiek inny buduje Twój kod: Cargo będzie używać
tylko tych wersji zależności, które określiłeś, chyba że wskażesz inaczej. Na
przykład, powiedzmy, że w przyszłym tygodniu zostanie wydana wersja 0.8.6 pakietu
rand, a ta wersja zawiera ważną poprawkę błędu, ale zawiera również regresję,
która zepsuje Twój kod. Aby temu zaradzić, Rust tworzy plik Cargo.lock za
pierwszym razem, gdy uruchamiasz cargo build, więc teraz mamy go w katalogu
guessing_game.
Kiedy budujesz projekt po raz pierwszy, Cargo ustala wszystkie wersje zależności, które spełniają kryteria, a następnie zapisuje je do pliku Cargo.lock. Kiedy budujesz swój projekt w przyszłości, Cargo zobaczy, że plik Cargo.lock istnieje i użyje tam określonych wersji, zamiast ponownie wykonywać całej pracy związanej z ustalaniem wersji. Pozwala to na automatyczne powtarzalne budowanie. Innymi słowy, Twój projekt pozostanie w wersji 0.8.5, dopóki wyraźnie go nie zaktualizujesz, dzięki plikowi Cargo.lock. Ponieważ plik Cargo.lock jest ważny dla powtarzalnych kompilacji, często jest dodawany do kontroli wersji wraz z resztą kodu w Twoim projekcie.
Aktualizacja “crate’a”, aby uzyskać nową wersję
Kiedy chcesz zaktualizować pakiet, Cargo udostępnia polecenie update, które
zignoruje plik Cargo.lock i ustali wszystkie najnowsze wersje, które pasują
do twoich specyfikacji w Cargo.toml. Cargo następnie zapisze te wersje do
pliku Cargo.lock. W przeciwnym razie, domyślnie, Cargo będzie szukać tylko
wersji większych niż 0.8.5 i mniejszych niż 0.9.0. Jeśli pakiet rand wydał
dwie nowe wersje 0.8.6 i 0.999.0, zobaczyłbyś następujące, gdybyś uruchomił
cargo update:
$ cargo update
Updating crates.io index
Locking 1 package to latest Rust 1.85.0 compatible version
Updating rand v0.8.5 -> v0.8.6 (available: v0.999.0)
Cargo ignoruje wydanie 0.999.0. W tym momencie zauważyłbyś również zmianę w
pliku Cargo.lock, wskazującą, że wersja pakietu rand, której teraz używasz,
to 0.8.6. Aby użyć wersji rand 0.999.0 lub dowolnej wersji z serii 0.999.x,
musiałbyś zamiast tego zaktualizować plik Cargo.toml, aby wyglądał tak (nie
wprowadzaj tej zmiany, ponieważ poniższe przykłady zakładają, że używasz rand
0.8):
[dependencies]
rand = "0.999.0"
Następnym razem, gdy uruchomisz cargo build, Cargo zaktualizuje rejestr
dostępnych pakietów i ponownie oceni Twoje wymagania dotyczące rand zgodnie z
nową wersją, którą określiłeś.
Wiele jest do powiedzenia na temat Cargo i jego ekosystemu, które omówimy w Rozdziale 14, ale na razie to wszystko, co musisz wiedzieć. Cargo bardzo ułatwia ponowne używanie bibliotek, więc Rustowcy mogą pisać mniejsze projekty, które są montowane z wielu pakietów.
Generowanie liczby losowej
Zacznijmy używać rand do generowania liczby do zgadnięcia. Następnym krokiem
jest aktualizacja src/main.rs, jak pokazano w Listingu 2-3.
use std::io;
use rand::Rng;
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");
println!("You guessed: {guess}");
}
Najpierw dodajemy linię use rand::Rng;. Cecha Rng definiuje metody,
które implementują generatory liczb losowych, i ta cecha musi być w zakresie,
abyśmy mogli używać tych metod. Rozdział 10 szczegółowo omówi cechy.
Następnie dodajemy dwie linie w środku. W pierwszej linii wywołujemy funkcję
rand::thread_rng, która daje nam konkretny generator liczb losowych, którego
będziemy używać: taki, który jest lokalny dla bieżącego wątku wykonawczego
i jest inicjowany przez system operacyjny. Następnie wywołujemy metodę
gen_range na generatorze liczb losowych. Ta metoda jest zdefiniowana przez
cechę Rng, którą wprowadziliśmy do zakresu za pomocą instrukcji
use rand::Rng;. Metoda gen_range przyjmuje wyrażenie zakresu jako argument
i generuje liczbę losową w tym zakresie. Rodzaj wyrażenia zakresu, którego tu
używamy, ma postać start..=end i jest inkluzywny na dolnej i górnej granicy,
więc musimy określić 1..=100, aby zażądać liczby od 1 do 100.
Uwaga: Nie będziesz po prostu wiedział, których cech używać i które metody
i funkcje wywoływać z “crate’a”, więc każdy “crate” ma dokumentację z
instrukcjami jego używania. Inną fajną funkcją Cargo jest to, że uruchomienie
polecenia cargo doc --open zbuduje dokumentację dostarczoną przez wszystkie
Twoje zależności lokalnie i otworzy ją w Twojej przeglądarce. Jeśli interesuje
Cię inna funkcjonalność w “crate’cie” rand, na przykład, uruchom
cargo doc --open i kliknij rand na pasku bocznym po lewej stronie.
Druga nowa linia wyświetla tajną liczbę. Jest to przydatne podczas opracowywania programu, aby móc go przetestować, ale usuniemy ją z ostatecznej wersji. Nie ma sensu grać, jeśli program wyświetla odpowiedź zaraz po uruchomieniu!
Spróbuj uruchomić program kilka razy:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
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` profile [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
Powinieneś otrzymać różne liczby losowe, i wszystkie powinny być liczbami pomiędzy 1 a 100. Świetna robota!
Porównywanie strzału z tajną liczbą
Teraz, gdy mamy dane wejściowe od użytkownika i liczbę losową, możemy je porównać. Ten krok jest pokazany w Listingu 2-4. Zauważ, że ten kod na razie się nie skompiluje, co wyjaśnimy.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
// --snip--
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");
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
Najpierw dodajemy kolejną instrukcję use, wprowadzając typ o nazwie
std::cmp::Ordering do zakresu ze standardowej biblioteki. Typ Ordering to
kolejne wyliczenie (enum) i ma warianty Less, Greater i Equal. Są to
trzy możliwe wyniki porównania dwóch wartości.
Następnie dodajemy pięć nowych linii na dole, które używają typu Ordering.
Metoda cmp porównuje dwie wartości i może być wywołana na wszystkim, co
można porównać. Przyjmuje referencję do tego, z czym chcesz porównać: tutaj
porównuje guess z secret_number. Następnie zwraca wariant wyliczenia
Ordering, które wprowadziliśmy do zakresu za pomocą instrukcji use.
Używamy wyrażenia match, aby zdecydować, co zrobić
dalej, w oparciu o to, który wariant Ordering został zwrócony z wywołania
cmp z wartościami w guess i secret_number.
Wyrażenie match składa się z ramion. Ramię składa się z wzorca, z którym
ma być dopasowywana wartość, oraz kodu, który powinien zostać uruchomiony, jeśli
wartość podana do match pasuje do wzorca tego ramienia. Rust pobiera wartość
podaną do match i kolejno przegląda wzorce każdego ramienia. Patrzy na
wzorzec pierwszego ramienia, Ordering::Less, i widzi, że wartość
Ordering::Greater nie pasuje do Ordering::Less, więc ignoruje kod w tym
ramieniu i przechodzi do następnego ramienia. Wzorzec następnego ramienia to
Ordering::Greater, który pasuje do Ordering::Greater! Powiązany kod w
tym ramieniu zostanie wykonany i wyświetli na ekranie Too big!. Wyrażenie
match kończy się po pierwszym udanym dopasowaniu, więc w tym scenariuszu
nie będzie patrzeć na ostatnie ramię.
Jednak kod w Listingu 2-4 jeszcze się nie skompiluje. Spróbujmy:
$ cargo build
Compiling libc v0.2.86
Compiling getrandom v0.2.2
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.10
Compiling rand_core v0.6.2
Compiling rand_chacha v0.3.0
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:23:21
|
23 | match guess.cmp(&secret_number) {
| --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
| |
| arguments to this method are incorrect
|
= note: expected reference `&String`
found reference `&{integer}`
note: method defined here
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979:8
For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
Rdzeń błędu stwierdza, że występują niezgodne typy. Rust ma silny, statyczny
system typów. Jednak ma również inferencję typów. Kiedy napisaliśmy
let mut guess = String::new(), Rust był w stanie wywnioskować, że guess
powinno być String i nie kazał nam pisać tego typu. secret_number natomiast
jest typem liczbowym. Kilka typów liczbowych Rusta może mieć wartość pomiędzy
1 a 100: i32, liczba 32-bitowa; u32, niepodpisana liczba 32-bitowa; i64,
liczba 64-bitowa; oraz inne. O ile nie określono inaczej, Rust domyślnie używa
i32, który jest typem secret_number, chyba że dodasz informacje o typie w
innym miejscu, co spowodowałoby, że Rust wywnioskowałby inny typ liczbowy.
Powodem błędu jest to, że Rust nie może porównywać ciągu znaków i typu
liczbowego.
Ostatecznie chcemy przekonwertować String, którą program odczytuje jako dane
wejściowe, na typ liczbowy, abyśmy mogli porównać ją numerycznie z tajną
liczbą. Robimy to, dodając tę linię do ciała funkcji main:
Nazwa pliku: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
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.");
// --snip--
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!"),
}
}
Linia to:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
Tworzymy zmienną o nazwie guess. Ale chwila, czy program nie ma już zmiennej
o nazwie guess? Tak, ale Rust pozwala nam w korzystny sposób zacienić
poprzednią wartość guess nową. Zacienianie pozwala nam ponownie użyć nazwy
zmiennej guess, zamiast zmuszać nas do tworzenia dwóch unikalnych zmiennych,
takich jak guess_str i guess, na przykład. Omówimy to szczegółowo w
Rozdziale 3, ale na razie wiedz, że ta funkcja jest
często używana, gdy chcesz przekonwertować wartość z jednego typu na inny.
Wiązamy tę nową zmienną z wyrażeniem guess.trim().parse(). guess w
wyrażeniu odnosi się do oryginalnej zmiennej guess, która zawierała dane
wejściowe jako ciąg znaków. Metoda trim na instancji String usunie
wszelkie białe znaki na początku i na końcu, co musimy zrobić, zanim
przekonwertujemy ciąg znaków na u32, który może zawierać tylko dane
liczbowe. Użytkownik musi nacisnąć enter, aby zadowolić read_line i
wprowadzić swój strzał, co dodaje znak nowej linii do ciągu znaków. Na
przykład, jeśli użytkownik wpisze 5 i naciśnie enter, guess
będzie wyglądało tak: 5\n. \n oznacza “nową linię”. (W systemie Windows
naciśnięcie enter powoduje powrót karetki i nową linię, \r\n.) Metoda
trim usuwa \n lub \r\n, pozostawiając tylko 5.
Metoda parse na ciągach znaków konwertuje ciąg
znaków na inny typ. Tutaj używamy jej do konwersji z ciągu znaków na liczbę.
Musimy powiedzieć Rustowi dokładny typ liczbowy, którego chcemy, używając
let guess: u32. Dwukropek (:) po guess mówi Rustowi, że będziemy
anotować typ zmiennej. Rust ma kilka wbudowanych typów liczbowych; u32
widziane tutaj to niepodpisana, 32-bitowa liczba całkowita. Jest to dobry
domyślny wybór dla małej liczby dodatniej. O innych typach liczbowych dowiesz
się w Rozdziale 3.
Dodatkowo, adnotacja u32 w tym przykładzie programu i porównanie z
secret_number oznacza, że Rust wywnioskuje, iż secret_number również
powinno być u32. Tak więc, teraz porównanie będzie dotyczyło dwóch wartości
tego samego typu!
Metoda parse będzie działać tylko na znakach, które można logicznie
przekształcić na liczby i dlatego może łatwo powodować błędy. Gdyby, na
przykład, ciąg znaków zawierał A👍%, nie byłoby sposobu na przekształcenie
tego na liczbę. Ponieważ może to zakończyć się niepowodzeniem, metoda parse
zwraca typ Result, podobnie jak metoda read_line (omówiona wcześniej w
sekcji „Obsługa potencjalnych błędów za pomocą Result”).
Będziemy traktować ten Result w ten sam sposób, ponownie używając metody
expect. Jeśli parse zwróci wariant Err Result, ponieważ nie udało się
utworzyć liczby z ciągu znaków, wywołanie expect spowoduje awarię gry i
wyświetli komunikat, który mu podamy. Jeśli parse z powodzeniem przekonwertuje
ciąg znaków na liczbę, zwróci wariant Ok Result, a expect zwróci liczbę,
której chcemy z wartości Ok.
Uruchommy teraz program:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!
Świetnie! Mimo że przed zgadywaną liczbą dodano spacje, program nadal rozpoznał, że użytkownik zgadł 76. Uruchom program kilka razy, aby sprawdzić różne zachowania z różnymi rodzajami danych wejściowych: zgadnij liczbę poprawnie, zgadnij liczbę, która jest za wysoka, i zgadnij liczbę, która jest za niska.
Mamy już większość gry działającą, ale użytkownik może wykonać tylko jeden strzał. Zmieńmy to, dodając pętlę!
Zezwalanie na wielokrotne zgadywanie za pomocą pętli
Słowo kluczowe loop tworzy nieskończoną pętlę. Dodamy pętlę, aby dać
użytkownikom więcej szans na odgadnięcie liczby:
Nazwa pliku: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
// --snip--
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
// --snip--
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!"),
}
}
}
Jak widać, przenieśliśmy wszystko od monitu o wprowadzenie zgadywanej liczby dalej do pętli. Upewnij się, że wciśniesz każdą linię wewnątrz pętli o kolejne cztery spacje i ponownie uruchom program. Program będzie teraz prosił o kolejny strzał w nieskończoność, co wprowadza nowy problem. Wygląda na to, że użytkownik nie może wyjść!
Użytkownik zawsze mógł przerwać program za pomocą skrótu klawiaturowego
ctrl-C. Ale jest inny sposób na ucieczkę przed tym
nienasyconym potworem, jak wspomniano w dyskusji o parse w sekcji
„Porównywanie strzału z tajną liczbą”: jeśli użytkownik wprowadzi
dane nieliczbowe, program ulegnie awarii. Możemy to wykorzystać, aby pozwolić
użytkownikowi wyjść, jak pokazano tutaj:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
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 src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Wpisanie quit spowoduje wyjście z gry, ale jak zauważysz, tak samo zadziała
wprowadzenie dowolnych innych danych nieliczbowych. Jest to, delikatnie mówiąc,
nieoptymalne; chcemy, aby gra zatrzymywała się również po odgadnięciu
prawidłowej liczby.
Zakończenie po prawidłowym strzale
Zaprogramujmy grę tak, aby kończyła się, gdy użytkownik wygra, dodając instrukcję
break:
Nazwa pliku: src/main.rs
use std::cmp::Ordering;
use std::io;
use rand::Rng;
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}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
Dodanie linii break po You win! powoduje, że program wychodzi z pętli, gdy
użytkownik poprawnie odgadnie tajną liczbę. Wyjście z pętli oznacza również
wyjście z programu, ponieważ pętla jest ostatnią częścią main.
Obsługa nieprawidłowych danych wejściowych
Aby jeszcze bardziej dopracować zachowanie gry, zamiast zawieszać program,
gdy użytkownik wprowadzi dane nieliczbowe, niech gra ignoruje takie dane,
umożliwiając użytkownikowi dalsze zgadywanie. Możemy to zrobić, zmieniając
linię, w której guess jest konwertowane z String na u32, jak pokazano
w Listingu 2-5.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
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();
// --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--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
Zmieniamy wywołanie expect na wyrażenie match, aby zamiast zawieszenia
w przypadku błędu, obsłużyć błąd. Pamiętaj, że parse zwraca typ Result,
a Result jest wyliczeniem, które ma warianty Ok i Err. Używamy tutaj
wyrażenia match, tak jak to robiliśmy z wynikiem Ordering metody cmp.
Jeśli parse jest w stanie pomyślnie przekształcić ciąg znaków w liczbę,
zwróci wartość Ok, która będzie zawierać wynikową liczbę. Ta wartość Ok
będzie pasować do wzorca pierwszego ramienia, a wyrażenie match po prostu
zwróci wartość num, którą parse wyprodukowało i umieściło w wartości Ok.
Ta liczba trafi dokładnie tam, gdzie chcemy, do nowej zmiennej guess, którą
tworzymy.
Jeśli parse nie jest w stanie przekształcić ciągu znaków w liczbę,
zwróci wartość Err, która zawiera więcej informacji o błędzie. Wartość Err
nie pasuje do wzorca Ok(num) w pierwszym ramieniu match, ale pasuje do
wzorca Err(_) w drugim ramieniu. Podkreślnik _ to wartość
„chwyć-wszystko”; w tym przykładzie mówimy, że chcemy dopasować wszystkie
wartości Err, niezależnie od tego, jakie informacje zawierają. Zatem program
wykona kod drugiego ramienia, continue, co nakazuje programowi przejść do
następnej iteracji loop i poprosić o kolejny strzał. Tak więc, efektywnie,
program ignoruje wszystkie błędy, jakie parse może napotkać!
Teraz wszystko w programie powinno działać zgodnie z oczekiwaniami. Spróbujmy:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
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!
Cudownie! Z drobną, ostatnią poprawką, zakończymy grę w zgadywanie. Przypomnij
sobie, że program nadal wyświetla tajną liczbę. To dobrze działało podczas
testowania, ale psuje grę. Usuńmy println!, które wyświetla tajną liczbę.
Listing 2-6 pokazuje ostateczny kod.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
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;
}
}
}
}
W tym momencie pomyślnie zbudowałeś grę w zgadywanie. Gratulacje!
Podsumowanie
Ten projekt był praktycznym sposobem na wprowadzenie cię w wiele nowych
koncepcji Rusta: let, match, funkcje, użycie zewnętrznych pakietów i
wiele innych. W kolejnych rozdziałach dowiesz się o tych koncepcjach bardziej
szczegółowo. Rozdział 3 obejmuje koncepcje, które posiadają większość języków
programowania, takie jak zmienne, typy danych i funkcje, i pokazuje, jak ich
używać w Ruście. Rozdział 4 bada własność, cechę, która odróżnia Rusta od
innych języków. Rozdział 5 omawia struktury i składnię metod, a Rozdział 6
wyjaśnia, jak działają wyliczenia (enums).