Piszemy grę zgadywankę

Rozpocznijmy zabawę z Rustem tworząc razem praktyczny projekt. Ten rozdział zapozna cię z kilkoma podstawowymi konceptami Rusta, prezentując ich użycie w prawdziwym programie. Dowiesz się, co oznaczają let, match, metoda, funkcja powiązana (associated function), nauczysz się, jak używać skrzyń (crates), i wielu innych rzeczy! Dokładniejsze omówienie tych tematów znajduje się w dalszych rozdziałach. W tym rozdziale przećwiczysz jedynie podstawy.

Zaimplementujemy klasyczny problem programistyczny dla początkujących: grę zgadywankę. Oto zasady: program generuje losową liczbę całkowitą z przedziału od 1 do 100. Następnie prosi użytkownika o wprowadzenie liczby z tego przedziału. Gdy użytkownik wprowadzi swoją odpowiedź, program informuje, czy podana liczba jest niższa czy wyższa od wylosowanej. Gdy gracz odgadnie wylosowaną liczbę, program wyświetla gratulacje dla zwycięzcy i kończy działanie.

Tworzenie nowego projektu

Aby stworzyć nowy projekt, wejdź do folderu projects utworzonego w rozdziale 1 i za pomocą Cargo wygeneruj szkielet projektu, w ten sposób:

$ cargo new guessing_game
$ cd guessing_game

Pierwsza komenda, cargo new, jako argument przyjmuje nazwę projektu (guessing_game). W kolejnej linii komenda cd przenosi nas do nowo utworzonego folderu projektu.

Spójrz na wygenerowany plik Cargo.toml:

Plik: Cargo.toml

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

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Jak już widziałeś w rozdziale 1, cargo new tworzy dla ciebie program „Hello World!”. Otwórz plik src/main.rs:

Plik: src/main.rs

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

Teraz skompilujemy i uruchomimy ten program w jednym kroku za pomocą komendy cargo run:

$ 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!

Komenda run jest przydatna, kiedy chcesz w szybki sposób testować kolejne iteracje rozwoju projektu. Tak właśnie jest w przypadku naszej gry: chcemy testować każdy krok, zanim przejdziemy do kolejnego.

Otwórz jeszcze raz plik src/main.rs. W tym pliku będziesz pisał kod programu.

Przetwarzanie odpowiedzi

Pierwsza część programu będzie prosiła użytkownika o podanie liczby, przetwarzała jego odpowiedź i sprawdzała, czy wpisane przez niego znaki mają oczekiwaną postać. Zaczynamy od wczytania odpowiedzi gracza. Przepisz kod z listingu 2-1 do pliku src/main.rs.

Plik: src/main.rs

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Listing 2-1: Implementacja wczytująca odpowiedź użytkownika i wypisująca ją na ekran

Powyższy fragment kodu zawiera dużo informacji - przeanalizujmy go kawałek po kawałku. Aby wczytać odpowiedź gracza a następnie wyświetlić ją na ekranie, musimy dołączyć do programu bibliotekę io (input/output). Biblioteka io pochodzi z biblioteki standardowej (znanej jako std):

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Domyślnie Rust posiada zestaw elementów zdefiniowanych w bibliotece standardowej, które importuje do każdego programu. Ten zestaw nazywany jest prelude i można zobaczyć co zawiera w dokumentacji biblioteki standardowej.

Jeśli typu, którego chcesz użyć, nie ma w prelude, musisz go jawnie zaciągnąć używając słowa use. Skorzystanie z biblioteki std::io dostarcza wielu pożytecznych mechanizmów związanych z io, włącznie z funkcjonalnością do wczytywania danych wprowadzonych przez użytkownika.

Tak jak mówiliśmy już w rozdziale 1, każdy program rozpoczyna wykonanie w funkcji main.

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

fn deklaruje nową funkcję, () informuje, że funkcja ta nie przyjmuje żadnych parametrów, a { otwiera ciało funkcji.

W rozdziale 1 nauczyłeś się również, że println! jest makrem, które wyświetla zawartość stringa na ekranie:

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Powyższy fragment kodu wypisuje na ekranie informację, na czym polega gra, i prosi użytkownika o wprowadzenie odgadniętej przez niego liczby.

Zapisywanie wartości w zmiennych

Teraz stworzymy zmienną do zapisywania odpowiedzi użytkownika, w ten sposób:

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Program robi się coraz bardziej interesujący! W tej krótkiej linii wiele się dzieje. Instrukcji let używamy do utworzenia zmiennej. Tutaj kolejny przykład:

let apples = 5;

W tej linii tworzona jest nowa zmienna o nazwie apples, do której przypisana jest wartość 5. W Ruście wszystkie zmienne są domyślnie niemutowalne (stałe), co oznacza, że nadana im na początku wartość nie zmieni się. We’ll be discussing this concept in detail in the „Variables and Mutability” section in Chapter 3. Poniższy przykład pokazuje, jak stawiając słowo kluczowe mut przed nazwą zmiennej stworzyć zmienną mutowalną:

let apples = 5; // immutable
let mut bananas = 5; // mutable

Uwaga: Znaki // rozpoczynają komentarz, który ciągnie się do końca linii. Rust ignoruje zawartość komentarzy. Komentarze omówimy bardziej szczegółowo w rozdziale 3.

Powróćmy do naszej gry-zgadywanki. Teraz już wiesz, że let mut guess utworzy mutowalną zmienną o nazwie guess. Po prawej stronie znaku przypisania (=) jest wartość, która jest przypisywana do guess, i która jest wynikiem wywołania funkcji String::new, tworzącej nową instancję Stringa. String to dostarczany przez bibliotekę standardową typ tekstowy, gdzie tekst ma postać UTF-8 i może się swobodnie rozrastać.

Znaki :: w wyrażeniu ::new wskazują na to, że new jest funkcją powiązaną (associated function) z typem String. Funkcje powiązane są zaimplementowane na danym typie, w tym przypadku na Stringu, a nie na konkretnej instancji typu String (niektóre języki programowania nazywają to metodą statyczną). Funkcja new tworzy nowy, pusty String. W przyszłości spotkasz się z funkcjami new dla wielu różnych typów, ponieważ jest to standardowa nazwa dla funkcji tworzącej nową instancję danego typu.

Podsumowując, linia let mut guess = String::new(); stworzyła mutowalną zmienną, która jest obecnie przypisania do nowej, pustej instancji typu String. Uff!

Pobieranie danych od użytkownika

Przypominasz sobie, że załączyliśmy do programu obsługę wejścia/wyjścia z biblioteki standardowej przy pomocy linii use std::io;? Teraz wywołamy z stdin funkcję znajdującą się w module io, które pozwoli nam na pobranie danych od użytkownika:

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Gdybyśmy nie zaimportowali io za pomocą use std::io na początku programu, aby wywołać tę funkcję musielibyśmy napisać std::io::stdin. Funkcja stdin zwraca instancję std::io::Stdin, która jest typem reprezentującym uchwyt do standardowego wejścia dla twojego terminala.

Dalszy fragment kodu, .read_line(&mut guess), wywołuje metodę read_line na uchwycie wejścia standardowego, aby w ten sposób wczytać znaki wprowadzone przez gracza. Do metody read_line podajemy argument &mut guess, by wskazać gdzie zapisać wczytane znaki.

Zadaniem metody read_line jest wziąć to, co użytkownik wpisze na wejście standardowe i dodać to do podanego string (bez nadpisania jego zawartości), który przyjmuje ona jako argument. String ten musi być mutowalny, aby metoda była w stanie go zmodyfikować.

Znak & wskazuje na to, że argument guess jest referencją. Referencja oznacza, że wiele kawałków kodu może operować na jednej instancji danych, bez konieczności kopiowania tej danej kilkakrotnie. Referencje są skomplikowane, a jedną z głównych zalet Rusta jest to, jak bezpiecznie i łatwo można ich używać. Do dokończenia tego programu nie musisz znać wielu szczegółów na ten temat: rozdział 4 omówi referencje bardziej wnikliwie. Póki co wszystko co musisz wiedzieć o referencjach to to, że podobnie jak zmienne, domyślnie są niemutowalne. Dlatego musimy napisać &mut guess, a nie &guess, aby dało się tę referencję modyfikować.

Obsługa potencjalnych błędów z użyciem Result

Nie skończyliśmy jeszcze analizy tej linii kodu. Pomimo tego że doszliśmy już do trzeciej linii tekstu, wciąż jest to część pojedynczej, logicznej linii kodu. Kolejną częścią jest ta metoda:

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Moglibyśmy napisać ten kod tak:

io::stdin().read_line(&mut guess).expect("Błąd wczytania linii");

Jednakże taka długa linia jest trudna do czytania, więc lepiej ją podzielić. Często warto złamać linię i wprowadzić dodatkowe wcięcie, by poprawić czytelność długich wywołań ze składnią typu .nazwa_metody(). Teraz omówimy, co ta linia robi.

Jak już wspomnieliśmy wcześniej, read_line zapisuje tekst wpisany przez użytkownika do stringa przekazanego jako argument. Ale również zwraca wartość typu Result. Result jest enumeracją, często nazywaną enumem lub typamem wyliczeniowym. Typ wyliczeniowy to typ, który może mieć stały zestaw wartości, nazywanych wariantami (variants).

Chapter 6 will cover enums in more detail. The purpose of these Result types is to encode error-handling information.

Możliwe wartości Result to Ok i Err. Ok oznacza, że operacja powiodła się sukcesem i wewnątrz obiektu Ok znajduje się poprawnie wygenerowana wartość. Err oznacza, że operacja nie powiodła się, i obiekt Err zawiera informację o przyczynach niepowodzenia.

Obiekty typu Result, tak jak obiekty innych typów, mają zdefiniowane dla siebie metody. Instancja Result ma metodę expect, którą możesz wywołać. Jeśli dana instancja Result będzie miała wartość Err, wywołanie metody expect spowoduje zakończenie się programu i wyświetlenie na ekranie wiadomości, którą podałeś jako argument do expect. Sytuacje, gdy metoda read_line zwraca Err, najprawdopodobniej są wynikiem błędu pochodzącego z systemu operacyjnego. Gdy zaś zwrócony Result ma wartość Ok, expect odczyta wartość właściwą, przechowywaną przez Ok, i zwróci tę wartość, gotową do użycia w programie. W tym przypadku wartość ta odpowiada liczbie bajtów, które użytkownik wprowadził na wejście.

Gdybyśmy pominęli wywołanie expect, program skompilowałby się z ostrzeżeniem:

$ 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 ostrzega, że nie zrobiliśmy nic z wartością Result zwróconą z read_line, a co za tym idzie, program nie obsłużył potencjalnego błędu. Sposobem na pozbycie się tego ostrzeżenia jest dopisanie obsługi błędów. Tutaj jednak chcemy, by program zakończył się, gdy nie uda się odczytać odpowiedzi użytkownika, więc możemy użyć expect. O wychodzeniu ze stanu błędu przeczytasz w rozdziale 9.

Wypisywanie wartości z pomocą println! i placeholderów

Poza klamrą zamykającą program, w kodzie który dotychczas napisaliśmy została już tylko jedna linia do omówienia:

use std::io;

fn main() {
    println!("Zgadnij numer!");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Ta linia wyświetla na ekranie łańcuch, w którym zapisaliśmy odpowiedź użytkownika. Zestaw {} nawiasów nawiasów klamrowych to placeholder: pomyśl o {} jak o małych szczypcach kraba, które trzymają wartość w miejscu. Podczas wypisywania wartości zmiennej, nazwa zmiennej może znajdować się wewnątrz nawiasów klamrowych. By wypisać wynik wyrażenia, umieść puste nawiasy klamrowe w łańcuchu formatującym, a za łańcuchem same wyrażenia, oddzielone przecinkami, po jednym dla kolejnych pustych nawiasów klamrowych. 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} i y + 2 = {}", y + 2);
}

Ten kod wypisze na ekran x = 5 i y + 2 = 12.

Testowanie pierwszej część programu

Przetestujmy pierwszą część Zgadywanki. Uruchom grę poleceniem cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
     Running `target/debug/guessing_game`
Zgadnij numer!
Podaj swoją liczbę:
6
Wybrana przez ciebie liczba: 6

W tym miejscu pierwsza część gry jest gotowa: wczytujemy odpowiedź użytkownika z klawiatury i wypisujemy ją na ekranie.

Generowanie sekretnej liczby

Następnie musimy wygenerować sekretną liczbę, którą gracz będzie próbował odgadnąć. Sekretna liczba powinna zmieniać się przy każdym uruchomieniu programu, aby gra bawiła więcej niż raz. Użyjmy losowej liczby z przedziału od 1 do 100, żeby odgadnięcie jej nie było zbyt trudne. W bibliotece standardowej Rusta nie ma jeszcze obsługi liczb losowych, dlatego musimy sięgnąć do skrzyni rand.

Więcej funkcjonalności z użyciem skrzyń

Zapamiętaj: skrzynia (ang. crate) to kolejkcja plików źródłowych Rusta. Projekt, który budujemy, to skrzynia binarna (binary crate), czyli plik wykonywalny. Skrzynia rand to library crate, czyli biblioteka stworzona do używania w innych programach.

Z użyciem Cargo dodawanie zewnętrznych pakietów jest bajecznie proste. Aby móc używać rand w naszym kodzie, wystarczy zmodyfikować plik Cargo.toml tak, aby zaciągał skrzynię rand jako zależność do projektu. Otwórz Cargo.toml i dodaj na końcu, pod nagłówkiem sekcji [dependencies], poniższą linię. Upewnij się, że podałeś rand dokładnie tak jak poniżej, z z tym samym numerem wersji. Inaczej kody zawarte w tym tutorialu mogą nie zadziałać:

Plik: Cargo.toml

[dependencies]
rand = "0.8.5"

Plik Cargo.toml podzielony jest na sekcje, których ciało zaczyna się po nagłówku i kończy się w miejscu, gdzie zaczyna się kolejna sekcja. W sekcji [dependencies] informujesz Cargo, jakich zewnętrznych skrzyń i w której wersji wymaga twój projekt. Tutaj przy skrzyni rand znajduje się specyfikator wersji 0.8.5. Cargo rozumie Semantic Versioning (nazywane tez czasem SemVer), które to jest standardem zapisywania numeru wersji. Numer 0.8.5 jest właściwie skrótem do ^0.8.5, które oznacza wersję conajmniej 0.8.5, ale poniżej 0.9.0.

Cargo considers these versions to have public APIs compatible with version 0.8.5, and this specification ensures you’ll get the latest patch release that will still compile with the code in this chapter. Any version 0.9.0 or greater is not guaranteed to have the same API as what the following examples use.

Teraz bez zmieniania niczego w kodzie przekompilujmy projekt, tak jak przedstawia listing 2-2:

$ 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 libc v0.2.127
   Compiling getrandom v0.2.7
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.16
   Compiling rand_core v0.6.3
   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: Wynik po wywołaniu cargo build po dodaniu zależności do skrzyni rand

Być może u siebie zobaczysz inne numery wersji (jednak wszystkie będą kompatybilne z kodem, dzięki SemVer!), inne linie (zależnie od systemu operacyjnego), lub linie wydrukowane w innej kolejności.

Teraz kiedy mamy już zdefiniowaną jakąś zewnętrzną zależność, Cargo ściąga najnowsze wersje wszystkich skrzyń z rejestru, który jest kopią danych z Crates.io. Crates.io to miejsce, gdzie ludzie związani z Rustem publikują dla innych swoje otwarto-źródłowe projekty.

Po zaktualizowaniu rejestru Cargo sprawdza sekcję [dependencies] i ściąga skrzynie, jeśli jakichś brakuje. W tym przypadku, pomimo że podaliśmy do zależności jedynie skrzynę rand, Cargo ściągnął jeszcze inne skrzynie, od których zależny jest rand. Po ich ściągnięciu Rust je kompiluje, a następnie, mając już dostępne niezbędne zależności, kompiluje projekt.

Gdybyś teraz bez wprowadzania jakichkolwiek zmian wywołał ponownie cargo build, nie zobaczyłbyś nic ponad linię Finished. Cargo wie, że zależności są już ściągnięte i skompilowane, i że nie zmieniałeś nic w ich kwestii w pliku Cargo.toml. Cargo również wie, że nie zmieniałeś nic w swoim kodzie, więc jego też nie rekompiluje. Nie ma nic do zrobienia, więc po prostu kończy swoje działanie.

Jeśli wprowadzisz jakąś trywialną zmianę w pliku src/main.rs, zapiszesz, a następnie ponownie zbudujesz projekt, zobaczysz jedynie dwie linijki na wyjściu:

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

Te dwie linie pokazują, że Cargo przebudował uwzględniając jedynie twoją maleńką zmianą z pliku src/main.rs. Zależności nie zmieniły się, więc Cargo wie, że może użyć ponownie te, które już raz ściągnął i skompilował.

Plik Cargo.lock zapewnia powtarzalność kompilacji

Cargo posiada mechanizm, który zapewnia że za każdym razem, gdy ty lub ktokolwiek inny będziecie przebudowywać projekt, kompilowane będą te same artefakty: Cargo użyje zależności w konkretnych wersjach, chyba że wskażesz inaczej. Na przykład, co by się stało, gdyby za tydzień wyszła nowa wersja skrzyni rand 0.8.6, która zawierałaby poprawkę istotnego błedu, ale jednocześnie wprowadza regresję, która zepsuje twój kod? Odpowiedzią na ten problem jest plik Cargo.lock, który został stworzony w momencie, gdy po raz pierwszy wywołałeś cargo build. Znajduje się on teraz w twoim folderze guessing_game.

Kiedy po raz pierwszy budujesz dany projekt, Cargo sprawdza wersje każdej z zależności, tak by kryteria były spełnione, i wynik zapisuje w pliku Cargo.lock. Od tego czasu przy każdym kolejnym budowaniu, Cargo widząc, że plik Cargo.lock istnieje, będzie odczytywać z niego wersje zależności do pobrania, zamiast na nowo próbować je określać. Dzięki temu twoje kompilacje są automatycznie reprodukowalne. Innymi słowy, twój projekt będzie wciąż używał wersji 0.8.5, do czasu aż sam jawnie nie wykonasz aktualizacji. Ponieważ plik Cargo.lock jest ważny dla powtarzalnych kompilacji, jest on często umieszczany w systemie kontroli wersji wraz z resztą kodu w projekcie.

Aktualizowanie skrzyni do nowszej wersji

Kiedy chcesz zmienić wersję skrzyni na nowszą, możesz skorzystać z komendy update dostarczanej przez Cargo, która zignoruje plik Cargo.lock i wydedukuje na nowo najświeższe wersje skrzyń, które pasują do twojej specyfikacji z Cargo.toml. Cargo zapisze te wersje do pliku Cargo.lock. Jednak domyślnie Cargo będzie szukało jedynie wersji większej od 0.8.5 i mniejszej od 0.9.0. Jeśli skrzynia rand została wypuszczona w dwóch nowych wersjach, 0.8.6 i 0.9.0, po uruchomieniu cargo update zobaczysz taki wynik:

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

Cargo ignoruje wydanie 0.9.0. Teraz zauważysz również zmianę w pliku Cargo.lock - wersja skrzyni rand będzie ustawiona na 0.8.6. Gdybyś chciał używać rand w wersji 0.9.0 lub jakiejkolwiek z serii 0.9.x, musiałbyś zaktualizować plik Cargo.toml do takiej postaci:

[dependencies]
rand = "0.9.0"

Następnym razem gdy wywołasz cargo build, Cargo zaktualizuje rejestr dostępnych skrzyń i zastosuje nowe wymagania co do wersji skrzyni rand, zgodnie z tym co zamieściłeś w pliku.

Można by jeszcze wiele mówić o Cargo i jego ekosystemie. Wrócimy do tego w rozdziale 14. Na razie wiesz wszystko, co w tej chwili potrzebujesz. Dzięki Cargo ponowne używanie bibliotek jest bardzo łatwe, więc Rustowcy mogą pisać mniejsze projekty, składające się z wielu skrzyń.

Generowanie losowej liczby

A teraz użyjmy w końcu skrzyni rand by wygerować liczbę do zgadnięcia. Zmodyfikujmy plik src/main.rs, tak jak pokazano na listingu 2-3:

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    println!("Sekretna liczba to: {secret_number}");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");
}

Listing 2-3: Zmiany potrzebne do wygenerowania losowej liczby

Najpierw dodajmy linię use rand::Rng;. Rng to cecha (ang. trait), która definiuje metody implementowane przez generator liczb losowych. Cecha ta musi być widoczna w zasięgu, w którym chcemy tych metod używać. Cechy szczegółowo omówimy w rozdziale 10.

Dodajemy również dwie linie w środku. W pierwszej linii wywołujemy funkcję rand::thread_rng, która daje nam gotowy do użycia konkretny generator liczb losowych: taki, który jest lokalny dla wątku wywołującego i seedowany z systemu operacyjnego. Następnie wywołujemy metodę gen_range tego generatora. Ta metoda zdefiniowana jest w cesze Rng, którą włączyliśmy wyrażeniem use rand::Rng;. Zakres typu start..=koniec jest inkluzywny, zawiera obie podane wartości granicznej, dolną i górną. Dlatego podaliśmy 1..=100, aby zażądać liczby pomiędzy 1 a 100.

Uwaga: Wiedza, której cechy użyć i które funkcje i metody ze skrzyni wywoływać, nie jest czymś co po prostu wiesz. Instrukcja jak używać danej skrzyni znajduje się zawsze w jej dokumentacji. Kolejną przydatną komendą Cargo jest polecenie cargo doc --open, które lokalnie zbuduje dokumentację dostarczaną przez wszystkie zależności, jakich używasz, i otworzy ją w przeglądarce. Gdyby, przykładowo, interesowały cię inne funkcjonalności ze skrzyni rand, wpisz cargo doc --open i wybierz rand z paska po lewej.

Druga dodana przez nas linia wypisuje na ekranie sekretną liczbę. Jest to przydatne podczas implementowania do testowania programu i zostanie usunięte w finalnej wersji. Gra nie byłaby zbyt ekscytująca, gdyby program podawał sekretną liczbę od razu na starcie!

Spróbuj uruchomić program kilka razy:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
     Running `target/debug/guessing_game`
Zgadnij liczbę!
Sekretna liczba to: 7
Podaj swoją liczbę:
4
Wybrana przez ciebie liczba: 4
$ cargo run
     Running `target/debug/guessing_game`
Zgadnij liczbę!
Sekretna liczba to: 83
Podaj swoją liczbę:
5
Wybrana przez ciebie liczba: 5

Za każdym razem powinieneś/powinnaś otrzymać inny sekretny numer, jednak zawsze z zakresu od 1 do 100. Dobra robota!

Porównywanie Odpowiedzi z Sekretnym Numerem

Teraz, kiedy już mamy odpowiedź gracza i wylosowaną sekretną liczbę, możemy je porównać. Ten krok przedstawiony jest na listingu 2-4. Ten kod nie będzie się jeszcze kompilował. Zaraz wyjaśnimy dlaczego.

Plik: src/main.rs

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

fn main() {
    // --snip--
    println!("Zgadnij liczbę!");

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

    println!("Sekretna liczba to: {secret_number}");

    println!("Podaj swoją liczbę:");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    println!("Wybrana przez ciebie liczba: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Za mała!"),
        Ordering::Greater => println!("Za duża!"),
        Ordering::Equal => println!("Jesteś zwycięzcą!"),
    }
}

Listing 2-4: Obsługa możliwych rezultatów operacji porównywania dwóch liczb

Po pierwsze dodaliśmy kolejne use, które wprowadza nam do zasięgu typ std::cmp::Ordering z biblioteki standardowej. Ordering jest enumem, takim jak Result, ale ma inne warianty: Less, Greater, i Equal. Są to trzy możliwe wyniki porównywania dwóch wartości.

Następnie dopisaliśmy na końcu pięć nowych linii wykorzystujących typ Ordering. Metoda cmp porównuje dwie wartości. Można wywołać ją na dowolnym obiekcie, który da się porównywać. Przyjmuje ona referencję do drugiego obiektu, z którym chcemy porównać pierwszy: tutaj porównujemy guess do secret_number. cmp zwraca wariant enuma Ordering (którego typ zaciągnęliśmy poprzez wyrażenie use). Za pomocą wyrażenia match, na podstawie wartości Ordering zwróconej przez wywołanie cmp z wartościami guess z secret_number, decydujemy, co zrobić dalej.

Wyrażenie match składa się z odnóg. Każda odnoga składa się ze wzorca dopasowania i kodu, który ma się wykonać, jeśli wartość podana na początku wyrażenia match będzie pasowała do danego wzorca. Rust bierze wartość podaną do match i przegląda kolejno wzorce ze wszystkich odnóg. Wzorce i konstrukcja match to potężne mechanizmy w Ruście, które pozwolą wyrazić w kodzie wiele różnych scenariuszy i pomogą zapewnić obsługę ich wszystkich. Zostaną one omówione szczegółowo, odpowiednio w rozdziale 6 i 18.

Przeanalizujmy na przykładzie, co dokładnie dzieje się z użytym tutaj wyrażeniem match. Powiedzmy, że użytkownik wybrał liczbę 50, a losowo wygenerowana sekretna liczba to 38.

Kiedy kod porówna 50 do 38, metoda cmp zwróci wartość Ordering::Greater, ponieważ 50 jest większe niż 38. Zatem match otrzymuje tutaj wartość Ordering::Greater. Match sprawdza wzorzec w pierwszej odnodze, Ordering::Less, ale wartość Ordering::Greater nie pasuje do wzorca Ordering::Less, więc kod z tej odnogi jest pomijany i sprawdzana jest następna odnoga. Wzorzec z następnej odnogi, Ordering::Greater, pasuje do Ordering::Greater! Powiązany kod w tej odnodze jest wykonywany i na ekranie pojawia się napis Za duża!. Wyrażenie match kończy wykonanie po pierwszym znalezionym dopasowaniu, więc ostatnia odnoga nie będzie już w tym przypadku sprawdzana.

Niemniej, kod z 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:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected struct `String`, found integer
   |                 |
   |                 arguments to this function are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: associated function defined here

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` due to previous error

Komunikat błędu wskazuje, że typy są niezgodne. Rust jest silnie statycznym typowanym językiem. Jednak również wspiera dedukcję typów. Kiedy napisaliśmy let guess = String::new(), Rust potrafił wywnioskować, że guess powinno być Stringiem, dzięki czemu nie musieliśmy pisać typu jawnie. Z drugiej strony, secret_number jest typem numerycznym. Wiele typów numerycznych może przyjmować wartość spomiędzy 1 a 100: i32, 32-bitowa liczba całkowita; u32, 32-bitowa liczba całkowita bez znaku; i64, 64-bitowa liczba całkowita; a także inne. Jeśli nie wskazano inaczej, to domyślnie Rust wybiera i32, co jest typem secret_number, jeśli nie wpisaliśmy gdzieś indziej w kodzie jakiejś informacji, która spowoduje że Rust wybierze inny typ. Przyczyną błędu jest to, że Rust nie potrafi porównywać stringa z typem numerycznym.

Ostatecznie musimy przekonwertować stringa, którego program wczytał jako wejście z klawiatury, do postaci typu numerycznego, który można porównać matematycznie do sekretnej liczby. Osiągamy to dodając kolejną linię do ciała funkcji main:

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    println!("Sekretna liczba to: {secret_number}");

    println!("Podaj swoją liczbę:");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Błąd wczytania linii");

    let guess: u32 = guess.trim().parse().expect("Podaj liczbę!");

    println!("Wybrana przez ciebie liczba: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Za mała!"),
        Ordering::Greater => println!("Za duża!"),
        Ordering::Equal => println!("Jesteś zwycięzcą!"),
    }
}

Dodana linia to:

let guess: u32 = guess.trim().parse().expect("Podaj liczbę!");

Tworzymy tu zmienną o nazwie guess. Ale czekaj, czy program przypadkiem nie ma już zmiennej o takiej nazwie? Owszem ma, ale szczęśliwie Rust pozwala przesłaniać poprzednią wartość zmiennej guess nową wartością. Przesłanianie (shadowing) pozwala użyć ponownie nazwy guess, zamiast zmuszać nas do tworzenia dwóch osobnych zmiennych, takich jak przykładowo guess_str i guess. Rozdział 3 opowiada więcej o przesłanianiu zmiennych. Teraz wspomnimy jedynie, że funkcjonalność ta jest często używana w sytuacjach, gdy konieczna jest konwersja wartości z jednego typu do drugiego.

Nowej zmiennej guess nadajemy wartość wyrażenia guess.trim().parse(). Zmienna guess w tym wyrażeniu odnosi się do pierwotnej zmiennej guess, która była stringiem zawierającym dane wczytane z klawiatury. Metoda trim z interfejsu Stringa spowoduje usunięcie wszelkich białych znaków znajdujących się na początku lub końcu stringa. Jest to niezbędne, bo aby sparsować String do typu u32, String ten powinien zawierać jedynie znaki numeryczne. Jednakże, aby zadowolić funkcję read_line, użytkownik musi wcisnąć enter. Po wciśnięciu enter znak nowej linii jest dopisywany do stringa. Przykładowo, jeśli użytkownik wpisał 5 i wcisnął enter, to guess przyjmie postać: 5\n. Znak \n reprezentuje nową linię, czyli wynik wciśnięcia enter. (Pod Windowsem w wyniku wciśnięcia enter otrzymujemy \r\n.) Metoda trim usunie niechciane \n, dzięki czemu w stringu pozostanie jedynie 5.

Metoda parse parsuje stringa do innego typu. Tu używamy jej by otrzymać typ liczbowy. Co więcej, musimy powiedzieć Rustowi, jakiego dokładnie typu oczekujemy, używając wyrażenia let guess: u32. Dwukropek (:) po guess informuje Rusta, że dalej podany będzie typ zmiennej. Rust ma kilka wbudowanych typów numerycznych; u32, którą tu podaliśmy, to 32-bitowa liczba całkowita bez znaku. Jest to dobry domyślny wybór dla małych liczb dodatnich. O innych typach numerycznych przeczytasz w rozdziale 3.

Dodatkowo, dzięki anotacji u32 w tym przykładowym programie i porównaniu tej liczby z secret_number, Rust wywnioskuje, że secret_number też powinien być typu u32. Zatem porównanie zachodzi pomiędzy dwiema wartościami tego samego typu!

Wywołanie parse często może zakończyć się niepowodzeniem. Jeśli, na przykład, string będzie zawierał A👍%, to jego konwersja do liczby nie może się udać. Z tego względu metoda parse zwraca typ Result, podobnie jak metoda read_line (wspominaliśmy o tym wcześniej w sekcji „Obsługa potencjalnych błędów z użyciem Result). Potraktujemy ten Result w ten sam sposób, używając ponownie metody expect. Jeśli parse zwróci wariant Err (ponieważ nie udało się stworzyć liczby ze stringa), wywołanie expect spowoduje zawieszenie się gry i wypisanie na ekran podanego przez nas tekstu. Gdy zaś parse powiedzie się i poprawnie skonwertuje stringa do liczby, zwrócony Result będzie wariantem Ok, a expect zwróci liczbę zaszytą w wartości Ok.

Teraz uruchomimy program!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43 secs
     Running `target/debug/guessing_game`
Zgadnij liczbę!
Sekretna liczba to: 58
Podaj swoją liczbę:
  76
Wybrana przez ciebie liczba: 76
Za duża!

Nieźle! Pomimo tego że dodaliśmy spacje przed liczbą, program wciąż poprawnie rozpoznał, że użytkownik wybrał liczbę 76. Uruchom program kilka razy, aby sprawdzić jak program reaguje na różne wejścia: podaj właściwą liczbę, za wysoką, następnie za niską.

Nasza gra już z grubsza działa, ale użytkownik może odgadywać liczbę tylko jeden raz. Zmieńmy to dodając pętlę!

Wielokrotne zgadywanie dzięki pętli

Słowo kluczowe loop (pętla) tworzy pętlę nieskończoną. Dodamy taką pętlę, żeby dać graczowi więcej szans na odgadnięcie liczby:

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    // --snip--

    println!("Sekretna liczba to: {secret_number}");

    loop {
        println!("Podaj swoją liczbę:");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Błąd wczytania linii");

        let guess: u32 = guess.trim().parse().expect("Podaj liczbę!");

        println!("Wybrana przez ciebie liczba: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Za mała!"),
            Ordering::Greater => println!("Za duża!"),
            Ordering::Equal => println!("Jesteś zwycięzcą!"),
        }
    }
}

Jak widzisz, przenieśliśmy do pętli cały kod następujący po zachęcie gracza do odgadnięcia liczby. Pamiętaj, żeby zwiększyć wcięcia linii wewnątrz pętli o kolejne cztery spacje, następnie uruchom program ponownie. Niestety teraz program pyta o wprowadzenie odgadniętej liczby w nieskończoność i użytkownik nie może z niego łatwo wyjść!

Użytkownik może zawsze zatrzymać program używając skrótu klawiszowego ctrl-c. Lecz jest jeszcze inny sposób, żeby uciec temu nienasyconemu potworowi, jak wspomnieliśmy w dyskusji o parse w „Porównywanie odpowiedzi gracza z sekretnym numerem”: wprowadzenie znaku, który nie jest liczbą, spowoduje zawieszenie się programu. Można z tego skorzystać, aby wyjść z programu, tak jak pokazujemy poniżej:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs
     Running `target/debug/guessing_game`
Zgadnij liczbę!
Sekretna liczba to: 59
Podaj swoją liczbę:
45
Wybrana przez ciebie liczba: 45
Za mała!
Podaj swoją liczbę:
60
Wybrana przez ciebie liczba: 60
Za duża!
Podaj swoją liczbę:
59
Wybrana przez ciebie liczba: 59
Jesteś zwycięzcą!
Podaj swoją liczbę:
quit
thread 'main' panicked at 'Podaj liczbę!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Wpisanie quit faktycznie powoduje wyjście z programu, ale taki sam skutek daje wprowadzenie dowolnego innego ciągu znaków nienumerycznych. Zamykanie programu w ten sposób nie jest zbyt optymalne. Dodatkowo, chcielibyśmy, aby gra zatrzymała się, kiedy gracz wprowadzi poprawny numer.

Wychodzenie z programu po poprawnym odgadnięciu

Dodanie wyrażenia break sprawi, że gra zakończy się, kiedy gracz wygra.

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    println!("Sekretna liczba to: {secret_number}");

    loop {
        println!("Podaj swoją liczbę:");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Błąd wczytania linii");

        let guess: u32 = guess.trim().parse().expect("Podaj liczbę!");

        println!("Wybrana przez ciebie liczba: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Za mała!"),
            Ordering::Greater => println!("Za duża!"),
            Ordering::Equal => {
                println!("Jesteś zwycięzcą!");
                break;
            }
        }
    }
}

Dodanie linii break po Jesteś zwycięzcą! powoduje, że program opuszcza pętlę, gdy gracz odgadnie poprawnie sekretny numer. Wyjście z pętli jest równoważne z zakończeniem pracy programu, ponieważ pętla jest ostatnią częścią funkcji main.

Obsługa niepoprawnych danych wejściowych

W celu dalszego ulepszenia gry zróbmy tak, żeby program, zamiast zawieszać się, ignorował wprowadzone dane nienumeryczne, a użytkownik mógł zgadywać dalej. Możemy to osiągnąć edytując linię, w której guess jest konwertowane ze Stringa do u32, w sposób przedstawiony na listingu 2-5.

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    println!("Sekretna liczba to: {secret_number}");

    loop {
        println!("Podaj swoją liczbę:");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Błąd wczytania linii");

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

        println!("Wybrana przez ciebie liczba: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Za mała!"),
            Ordering::Greater => println!("Za duża!"),
            Ordering::Equal => {
                println!("Jesteś zwycięzcą!");
                break;
            }
        }
    }
}

Listing 2-5: Ignorowanie wejścia nieliczbowego i pytanie o kolejne liczby, zamiast zawieszania programu

Zamieniliśmy expect na wyrażenie match by zamienić zakończenie się programu na obsługuję błędów. Pamiętaj, że typem zwracanym przez parse jest Result, a Result jest typem wyliczeniowym, który ma warianty Ok oraz Err. Używamy tutaj wyrażenia match, podobnie jak robiliśmy to z wynikiem Ordering zwracanym przez metodę cmp.

Jeśli parse jest w stanie pomyślnie zamienić stringa w liczbę, zwróci wariant Ok, zawierający w sobie liczbę otrzymaną w konwersji. Wartość Ok odpowiada wzorcowi z pierwszej gałęzi match, zatem match zwróci wartość num, która została obliczona i zapisana wewnątrz wartości Ok. Ta liczba zostanie przypisana do nowoutworzonej przez nas zmiennej guess.

Jeśli jednak parse nie jest w stanie przekonwertować stringa na liczbę, zwróci wartość Err, która zawiera dodatkowe informacje o błędzie. Wartość Err nie pasuje do wzorca Ok(num) z pierwszej odnogi match, ale pasuje do wzorca Err(_) z drugiej odnogi. Znak podkreślenia, _, pasuje do wszystkich wartości; w tym przypadku mówimy, że do wzorca mają pasować wszystkie wartości Err, bez znaczenia na to jakie dodatkowe informacje mają one w środku. Program zatem wykona instrukcje z drugiego ramienia, continue, co oznacza że program ma przejść do kolejnej iteracji pętli i poprosić o nową liczbę. Dzięki temu program ignoruje wszystkie problemy jakie może napotkać parse!

Teraz wszystko w naszym programie powinno działać zgodnie z oczekiwaniami. Wypróbujmy to:

$ 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`
Zgadnij liczbę!
Sekretna liczba to: 61
Podaj swoją liczbę:
10
Wybrana przez ciebie liczba: 10
Za mała!
Podaj swoją liczbę:
99
Wybrana przez ciebie liczba: 99
Za duża!
Podaj swoją liczbę:
foo
Podaj swoją liczbę:
61
Wybrana przez ciebie liczba: 61
Jesteś zwycięzcą!

Wspaniale! Jeszcze jedna drobna poprawka i nasza gra w zgadywankę będzie już skończona. Program wciąż wyświetla sekretny numer. To było przydatne podczas testów, ale na dłuższą metę psułoby zabawę. Usuńmy println! odpowiedzialną za wyświetlanie sekretnego numeru. Listing 2-6 pokazuje końcową wersję programu.

Plik: src/main.rs

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

fn main() {
    println!("Zgadnij liczbę!");

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

    loop {
        println!("Podaj swoją liczbę:");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Błąd wczytania linii");

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

        println!("Wybrana przez ciebie liczba: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Za mała!"),
            Ordering::Greater => println!("Za duża!"),
            Ordering::Equal => {
                println!("Jesteś zwycięzcą!");
                break;
            }
        }
    }
}

Listing 2-6: Kompletna gra w zgadywankę

Podsumowanie

Właśnie udało ci się zbudować grę w zgadywankę. Gratulacje!

Ten projekt w praktyczny sposób zapoznał cię z wieloma konceptami Rusta: let, match, funkcjami, używaniem zewnętrznych skrzyń, i innymi. W najbliższych rozdziałach koncepty te będą omówione bardziej szczegółowo. Rozdział 3 omawia koncepty obecne w większości języków programowania, takie jak zmienne, typy danych czy funkcje, i prezentuje jak należy w nich korzystać w Ruście. Rozdział 4 odkrywa system własności, mechanizm który wyróżna Rusta spośród innych języków. Rozdział 5 omawia składnię struktur i metod, a rozdział 6 wyjaśnia, jak działają typy numeryczne.

This project was a hands-on way to introduce you to many new Rust concepts: let, match, functions, the use of external crates, and more. In the next few chapters, you’ll learn about these concepts in more detail. Chapter 3 covers concepts that most programming languages have, such as variables, data types, and functions, and shows how to use them in Rust. Chapter 4 explores ownership, a feature that makes Rust different from other languages. Chapter 5 discusses structs and method syntax, and Chapter 6 explains how enums work.