Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Nazwa pliku: 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}");
}
Listing 2-1: Kod, który pobiera strzał od użytkownika i go wyświetla

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ść”

w Rozdziale 3. Aby zmienna była mutowalna, dodajemy `mut` przed

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 ResultOk 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
Listing 2-2: Wynik uruchomienia cargo build po dodaniu rand jako zależności

Moż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.

Nazwa pliku: src/main.rs
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}");
}
Listing 2-3: Dodawanie kodu do generowania liczby losowej

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.

Nazwa pliku: src/main.rs
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!"),
    }
}
Listing 2-4: Obsługa możliwych wartości zwracanych przez porównywanie dwóch liczb

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.

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();

        // --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;
            }
        }
    }
}
Listing 2-5: Ignorowanie zgadywanej liczby nieliczbowej i prośba o kolejną zgadywaną liczbę zamiast zawieszenia programu

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.

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);

    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: Kompletny kod gry w zgadywanie

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).