Przepływ sterowania

Wykonanie jakiegoś kodu w zależności od tego, czy warunek jest spełniony, albo wykonywania go wielokrotnie dopóki warunek jest spełniony, to podstawowe możliwości większości języków programowania. Najpopularniejsze konstrukcje, które pozwalają sterować przebiegiem wykonania kodu Rusta to wyrażenia if oraz pętle.

Wyrażenia if

Wyrażenie if pozwala na rozgałęzienie kodu w zależności od spełnienia warunków. Podajemy warunek i nakazujemy: „Jeśli ten warunek jest spełniony, uruchom ten blok kodu. Jeśli warunek nie jest spełniony, nie uruchamiaj tego bloku kodu.”

Stwórzmy nowy projekt o nazwie branches (z ang. rozgałęzienia) w katalogu projects by zgłębić wyrażanie if. W pliku src/main.rs wpiszmy:

Plik: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("warunek został spełniony");
    } else {
        println!("warunek nie został spełniony");
    }
}

Wszystkie wyrażenia if rozpoczynają się słowem kluczowym if, po którym następuje warunek. W tym przypadku, warunek sprawdza czy zmienna number ma wartość mniejszą od 5. Blok kodu umieszczony w nawiasach klamrowych bezpośrednio po warunku zostanie wykonany tylko wtedy, gdy warunek będzie spełniony, tj. będzie miał wartość logiczną true. Bloki kodu powiązane z warunkami w wyrażenie if nazywane są czasami odnogami, podobnie jak to było w przypadku wyrażenia match, o którym wspomnieliśmy w sekcji „Porównywanie Odpowiedzi z Sekretnym Numerem” rozdziału 2.

Opcjonalnie, możemy również dodać wyrażenie else, co zresztą uczyniliśmy, aby dodać alternatywny blok kodu, wykonywany gdy warunek nie zajdzie (da false). Jeśli nie ma wyrażenia else, a warunek da false, program po prostu pominie blok if i przejdzie do następnego fragmentu kodu.

Spróbujmy uruchomić ten kod. Powinniśmy zobaczyć:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
warunek został spełniony

Zmieńmy number tak, by jego wartość nie spełniała warunku false i zobaczmy co się stanie:

fn main() {
    let number = 7;

    if number < 5 {
        println!("warunek został spełniony");
    } else {
        println!("warunek nie został spełniony");
    }
}

Uruchommy program ponownie i zobaczmy co wypisze:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
warunek nie został spełniony

Warto też zauważyć, że warunek w tym kodzie musi dawać wartość typu bool, bo inaczej kod się nie skompiluje. Na przykład, spróbujmy uruchomić następujący kod:

Plik: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number wynosi trzy");
    }
}

Tutaj wartością warunku w if jest liczba 3, co prowadzi do błędu kompilacji:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

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

Błąd wskazuje, że Rust oczekiwał boola, a dostał liczbę. W przeciwieństwie do języków takich jak Ruby i JavaScript, Rust nie próbuje automatycznie konwertować typów nie-Boolean na Boolean. Jako warunek w if należy zawsze podać jawne wyrażenie dające wartość logiczną. Jeśli na przykład chcemy, aby blok kodu if uruchamiał się tylko wtedy, gdy liczba nie jest równa 0, to możemy zmienić wyrażenie if tak:

Plik: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number wynosi coś innego niż zero");
    }
}

Ten program wypisze number wynosi coś innego niż zero.

Obsługa wielu warunków za pomocą else if

Można użyć wielu warunków łącząc if i else w wyrażenie else if. Na przykład:

Plik: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number jest podzielny przez 4");
    } else if number % 3 == 0 {
        println!("number jest podzielny przez 3");
    } else if number % 2 == 0 {
        println!("number jest podzielny przez 2");
    } else {
        println!("number nie jest podzielny przez 4, 3, ani 2");
    }
}

Ten program może podążyć czterema różnymi ścieżkami. Po jego uruchomieniu powinniśmy zobaczyć:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number jest podzielny przez 3

Kiedy ten program wykonuje się, sprawdza każde wyrażenie if po kolei i wykonuje pierwszy blok kodu, dla którego zachodzi warunek. Proszę zauważyć, że mimo iż 6 jest podzielne przez 2, nie widzimy wyjścia liczba jest podzielna przez 2, ani nie widzimy tekstu liczba nie jest podzielna przez 4, 3, ani 2 z bloku else. Dzieje się tak dlatego, że Rust wykonuje blok tylko dla pierwszego warunku dającego true, a gdy już go znajdzie, to dalszych warunków nawet nie sprawdza.

Użycie zbyt wielu wyrażeń else if może zagmatwać kod. Więc jeśli jest ich więcej niż jedno, to warto rozważyć refaktoryzację kodu. Dla takich przypadków, w Ruście przewidziano potężną konstrukcję match, która jest opisana w rozdziale 6.

if w składni let

Ponieważ if jest wyrażeniem, to możemy go użyć po prawej stronie instrukcji let, aby przypisać jego wynik do zmiennej, co pokazano na listingu 3-2.

Plik: src/main.rs

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("Wartość zmiennej number wynosi: {number}");
}

Listing 3-2: Przypisanie wyniku wyrażenia if do zmiennej

Zmiennej number zostanie nadana wartość będąca wynikiem wyrażenia if. Uruchommy ten kod, aby zobaczyć co się stanie:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
Wartość zmiennej number wynosi: 5

Należy pamiętać, że wartościami bloków kodu są ostatnie wyrażenie w nich zawarte, a liczby same w sobie są również wyrażeniami. W tym przypadku, wartość całego wyrażenia if zależy od tego, który blok kodu zostanie wykonany. Oznacza to, że wartości, które potencjalnie mogą być wynikami poszczególnych odnóg if muszą być tego samego typu. Na listingu 3-2, wynikami zarówno ramienia if jak i ramienia else były liczby całkowite i32. Jeśli typy są niezgodne, jak w poniższym przykładzie, to otrzymamy błąd:

Plik: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("Wartość zmiennej number wynosi: {number}");
}

Próba skompilowania tego kodu skończy się błędem. Odnogi if i else mają niezgodne typy wartości, zaś Rust dokładnie wskazuje, gdzie w programie należy szukać problemu:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: if and else have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

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

Wyrażenie w bloku if daje liczbę całkowitą, a wyrażenie w bloku else łańcuch znaków. To nie zadziała, ponieważ zmienne muszą mieć jeden typ, a Rust musi już w czasie kompilacji definitywnie wiedzieć jakiego typu jest zmienna number. Znajomość tego typu pozwala kompilatorowi sprawdzić, czy jest on poprawny we wszystkich miejscach użycia number. Rust nie byłby w stanie tego zrobić, gdyby typ number był określany dopiero w czasie wykonywania; kompilator byłby bardziej skomplikowany i dawałby mniej gwarancji na kod, gdyby musiał śledzić wiele hipotetycznych typów dla każdej zmiennej.

Powtarzanie Za Pomocą Pętli

Często przydaje się wykonanie bloku kodu więcej niż raz. Do tego zadania Rust udostępnia kilka pętli, które wykonują kod wewnątrz ciała pętli do końca, po czym natychmiast wykonują go ponownie. Aby poeksperymentować z pętlami, stwórzmy nowy projekt o nazwie loops.

Rust ma trzy rodzaje pętli: loop, while, i for. Wypróbujmy każdy z nich.

Powtarzanie Wykonania Za Pomocą loop

Słowo kluczowe loop nakazuje Rustowi wykonywać blok kodu w koło, bez końca, lub do momentu, w którym wyraźnie nakażemy mu przestać.

By to zilustrować, zmieńmy zawartość pliku src/main.rs w katalogu loops na następującą:

Plik: src/main.rs

fn main() {
    loop {
        println!("znowu!");
    }
}

Gdy uruchomimy ten program, zobaczymy znowu! wypisywane w koło, bez końca, dopóki ręcznie nie zatrzymamy programu. W większości terminali można to zrobić za pomocą skrótu klawiszowego ctrl-c. Spróbujmy:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/loops`
znowu!
znowu!
znowu!
znowu!
^Cznowu!

Symbol ^C pokazuje gdzie wcisnęliśmy ctrl-c. Słowo znowu! może zostać wypisane lub nie po ^C, zależnie od tego, która linia programu była wykonywana w momencie gdy odebrał on sygnał przerwania.

Szczęśliwie, Rust zapewnia również sposób na przerwanie pętli za pomocą kodu. Można umieścić słowo kluczowe break wewnątrz pętli, aby powiedzieć programowi, gdzie ma przerwać jej wykonywanie. Proszę sobie przypomnieć, że już to zrobiliśmy w grze-zgadywance w sekcji „Quitting After a Correct Guess” rozdziału 2, aby zakończyć program, gdy użytkownik wygrał grę, zgadując poprawną liczbę.

W grze-zgadywance użyliśmy również continue, które, użyte w pętli, nakazuje programowi pominąć kod pozostały do wykonania w bieżącej iteracji i rozpocząć następną iterację.

Zwracanie Wartości z Pętli

Jednym z zastosowań pętli loop jest ponawianie prób operacji, która może się nie udać, jak na przykład sprawdzenie czy wątek zakończył swoją pracę. Może zajść również potrzeba przekazania wyniku tej operacji poza pętlę, do reszty kodu. Aby to zrobić, można ten wynik podać bezpośrednio po wyrażeniu break zatrzymującym pętle. Zostanie on zwrócony na zewnątrz pętli i będzie można go tam użyć, na przykład tak:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("Zmienna result wynosi {result}");
}

Przed pętlą deklarujemy zmienną o nazwie counter i inicjujemy ją wartością 0. Następnie deklarujemy zmienną o nazwie result, która będzie przechowywać wartość zwracaną z pętli. W każdej iteracji pętli dodajemy 1 do zmiennej counter i, następnie, sprawdzamy czy counter jest równy 10. Jeśli jest, to używamy słowa kluczowego break z wartością counter * 2. Za pętlą umieściliśmy średnik kończący instrukcję przypisującą tą wartość do result. Na koniec wypisujemy wartość result, która w tym przypadku wynosi 20.

Etykiety Rozróżniające Pętle

Gdy zagnieżdżamy pętle w pętlach, break i continue odnoszą się do najbardziej wewnętrznej pętli, w której się znajdują. Można opcjonalnie określić etykietę dla pętli, której można następnie użyć z break lub continue, by wskazać, że odnoszą się one do wskazanej pętli zamiast najbardziej zagnieżdżonej. Etykiety pętli zaczynają się znakiem pojedynczego cytatu. Oto przykład:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Pętla zewnętrzna ma etykietę counting_up i liczy w górę od 0 do 2. Wewnętrzna pętla bez etykiety odlicza w dół od 10 do 9. Pierwszy break, bez etykiety, zakończy tylko wewnętrzną pętlę. Instrukcja break 'counting_up; wyjdzie z pętli zewnętrznej. Ten kod drukuje:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

Pętla Warunkowa while

Program często potrzebuje sprawdzić warunek wewnątrz pętli. Pętla działa tak długa jak warunek daje true. Gdy warunek przestaje dawać true, program wywołuje break, zatrzymując pętlę. Można zaimplementować takie zachowania za pomocą kombinacji loop, if, else i break; zachęcam do spróbowania. Jednak jest ono na tyle powszechne, że Rust ma dla niego wbudowaną konstrukcję językową, zwaną pętlą while. Program z listingu 3-3 wykonuje trzy iteracje za pomocą pętli while, odliczając za każdym razem, a następnie, po zakończeniu pętli, drukuje komunikat i wychodzi.

Plik: src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

Listing 3-3: Użycie pętli while by wykonywać kod dopóki zachodzi warunek

Taka konstrukcja eliminuje wiele zagnieżdżeń, które byłyby konieczne w przypadku użycia loop, if, else i break, równocześnie poprawiając czytelność. Pętla się wykonuje tak długo jak warunek daje true.

Przechodzenie Po Kolekcji Za Pomocą for

Do przejścia po kolekcji, takiej jak tablica, można użyć pętli while. Na przykład, pętla na listingu 3-4 wypisuje każdy element tablicy a.

Plik: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("wartość wynosi: {}", a[index]);

        index += 1;
    }
}

Listing 3-4: Przechodzenie po każdym elemencie kolekcji za pomocą pętli while

Ten kod liczy w górę po elementach tablicy. Rozpoczyna od indeksu 0 i wykonuje pętle aż do osiągnięcia ostatniego indeksu tablicy, tj. do momentu gdy index < 5 przestaje dawać true. Uruchomienie tego kodu spowoduje wydrukowanie każdego elementu tablicy:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
wartość wynosi: 10
wartość wynosi: 20
wartość wynosi: 30
wartość wynosi: 40
wartość wynosi: 50

Wszystkie pięć wartości zawartych w tablicy pojawia się w terminalu, zgodnie z oczekiwaniami. Nawet jeśli index osiągnie w pewnym momencie wartość 5, pętla zatrzymuje się przed próbą pobrania z tablicy szóstej wartości.

Jednakże to rozwiązanie jest podatne na błędy; program może spanikować, jeśli indeks lub warunek będzie nieprawidłowy. Na przykład, jeśli skócimy tablicę a do czterech elementów, zapominając przy tym zaktualizować warunek na while index < 4, kod spanikuje. To rozwiązanie może być również powolne, ponieważ kompilator może dodać kod sprawdzający, w każdej iteracji pętli, czy indeks znajduje się w granicach tablicy.

Zwięźlejszą alternatywą jest pętla for wykonująca jakiś kod dla każdego elementu w kolekcji. Listing 3-5 pokazuje przykład jej użycia.

Plik: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a.iter() {
        println!("wartość wynosi: {element}");
    }
}

Listing 3-5: Przechodzenie po każdym elemencie kolekcji za pomocą pętli for

Ten kod daje takie same wyjście jak ten na listingu 3-4. Co ważne, zwiększyliśmy bezpieczeństwo kodu i wyeliminowaliśmy zagrożenie wystąpienie błędów związanych z przekroczeniem końca tablicy albo niedojściem do niego i w konsekwencji nieosiągnięciem niektórych elementów.

W przeciwieństwie do metody użytej na listingu 3-4, korzystając z pętli for nie musimy martwić się poprawianiem innego kodu gdy zmieniamy liczbę elementów w tablicy.

Bezpieczeństwo i zwięzłość pętli for sprawiają, że jest ona najczęściej wykorzystywaną pętlą w Rust. Nawet gdy istnieje potrzeba wykonania jakiegoś kod określoną liczbę razy, jak w przykładzie odliczania, który na listingu 3-3 używał pętli while, większość rustowców użyłaby pętli for wraz z zawartym w bibliotece standardowej Range (z ang. zakres), który generuje wszystkie liczby w kolejności, zaczynając od jednej liczby i kończąc przed inną liczbą.

Oto jak wyglądałoby odliczanie przy użyciu pętli for i metody rev (o której jeszcze nie mówiliśmy) odwracającej zakres:

Plik: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

Ten kod jest nieco ładniejszy, nieprawdaż?

Podsumowanie

Udało się! To był długi rozdział: poznaliśmy zmienne, skalarne i złożone typy danych, funkcje, komentarze, wyrażenia if i pętle! Aby przećwiczyć pojęcia omawiane w tym rozdziale, spróbuj zbudować programy wykonujące następujące czynności:

  • Przeliczanie temperatur pomiędzy stopniami Celsjusza i Fahrenheita.
  • Generowanie n-tej liczby ciągu Fibonacciego.
  • Drukowanie tekstu kolędy "The Twelve Days of Christmas" z wykorzystaniem powtórzeń w piosence.

Kiedy będziesz gotowy aby przejść dalej, porozmawiamy o koncepcji Rusta, która nie jest powszechna w innych językach programowania, o własności.