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

Dodawanie funkcjonalności z wykorzystaniem programowania sterowanego testami

Teraz, gdy logika wyszukiwania znajduje się w pliku src/lib.rs oddzielnie od funkcji main, znacznie łatwiej jest pisać testy dla podstawowej funkcjonalności naszego kodu. Możemy wywoływać funkcje bezpośrednio z różnymi argumentami i sprawdzać wartości zwracane bez konieczności wywoływania naszego pliku binarnego z wiersza poleceń.

W tej sekcji dodamy logikę wyszukiwania do programu minigrep przy użyciu procesu programowania sterowanego testami (TDD) zgodnie z następującymi krokami:

  1. Napisz test, który zawodzi, i uruchom go, aby upewnić się, że zawodzi z oczekiwanej przyczyny.
  2. Napisz lub zmodyfikuj wystarczająco dużo kodu, aby nowy test przeszedł.
  3. Zrefaktoruj kod, który właśnie dodałeś lub zmieniłeś, i upewnij się, że testy nadal przechodzą.
  4. Powtórz od kroku 1!

Chociaż jest to tylko jeden z wielu sposobów pisania oprogramowania, TDD może pomóc w kierowaniu projektem kodu. Pisanie testu przed napisaniem kodu, który sprawia, że test przechodzi, pomaga utrzymać wysokie pokrycie testami przez cały proces.

Będziemy testować implementację funkcjonalności, która faktycznie będzie wyszukiwać ciąg zapytania w zawartości pliku i generować listę pasujących wierszy. Tę funkcjonalność dodamy w funkcji nazwanej search.

Pisanie nieudanego testu

W pliku src/lib.rs dodamy moduł tests z funkcją testową, tak jak to zrobiliśmy w Rozdziale 11. Funkcja testowa określa zachowanie, jakie chcemy, aby funkcja search miała: Będzie przyjmować zapytanie i tekst do przeszukania, i będzie zwracać tylko te wiersze z tekstu, które zawierają zapytanie. Listing 12-15 pokazuje ten test.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Ten test wyszukuje ciąg "duct". Tekst, który przeszukujemy, ma trzy wiersze, z których tylko jeden zawiera "duct" (zauważ, że ukośnik wsteczny po otwierającym cudzysłowie podwójnym informuje Rusta, aby nie umieszczał znaku nowej linii na początku zawartości tego literału ciągu znaków). Twierdzimy, że wartość zwrócona przez funkcję search zawiera tylko oczekiwany wiersz.

Jeśli uruchomimy ten test, obecnie się nie powiedzie, ponieważ makro unimplemented! panikuje z komunikatem „not implemented”. Zgodnie z zasadami TDD, zrobimy mały krok, dodając wystarczająco dużo kodu, aby test nie panikował podczas wywoływania funkcji, definiując funkcję search tak, aby zawsze zwracała pusty wektor, jak pokazano w Listing 12-16. Następnie test powinien się skompilować i zakończyć niepowodzeniem, ponieważ pusty wektor nie pasuje do wektora zawierającego wiersz "safe, fast, productive.".

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Teraz omówmy, dlaczego musimy zdefiniować jawną żywotność 'a w sygnaturze search i użyć tej żywotności z argumentem contents i wartością zwracaną. Przypomnij sobie z Rozdziału 10, że parametry żywotności określają, która żywotność argumentu jest połączona z żywotnością wartości zwracanej. W tym przypadku wskazujemy, że zwrócony wektor powinien zawierać wycinki ciągów znaków, które odwołują się do wycinków argumentu contents (a nie argumentu query).

Innymi słowy, mówimy Rustowi, że dane zwrócone przez funkcję search będą żyły tak długo, jak dane przekazane do funkcji search w argumencie contents. To jest ważne! Dane, do których odwołuje się wycinek, muszą być ważne, aby referencja była ważna; jeśli kompilator założy, że tworzymy wycinki ciągów znaków z query zamiast z contents, to jego sprawdzanie bezpieczeństwa będzie nieprawidłowe.

Jeśli zapomnimy o adnotacjach żywotności i spróbujemy skompilować tę funkcję, otrzymamy następujący błąd:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust nie może wiedzieć, który z dwóch parametrów jest nam potrzebny do wyjścia, więc musimy mu to jawnie powiedzieć. Zauważ, że tekst pomocy sugeruje określenie tego samego parametru czasu życia dla wszystkich parametrów i typu wyjściowego, co jest nieprawidłowe! Ponieważ contents jest parametrem, który zawiera cały nasz tekst i chcemy zwrócić pasujące fragmenty tego tekstu, wiemy, że contents jest jedynym parametrem, który powinien być połączony z wartością zwracaną za pomocą składni czasu życia.

Inne języki programowania nie wymagają łączenia argumentów z wartościami zwracanymi w sygnaturze, ale ta praktyka z czasem stanie się łatwiejsza. Możesz porównać ten przykład z przykładami w sekcji „Walidowanie referencji za pomocą żywotności” w Rozdziale 10.

Pisanie kodu, aby test przeszedł

Obecnie nasz test zawodzi, ponieważ zawsze zwracamy pusty wektor. Aby to naprawić i zaimplementować search, nasz program musi wykonać następujące kroki:

  1. Iteruj przez każdy wiersz zawartości.
  2. Sprawdź, czy wiersz zawiera nasz ciąg zapytania.
  3. Jeśli tak, dodaj go do listy wartości, które zwracamy.
  4. Jeśli nie, nie rób nic.
  5. Zwróć listę pasujących wyników.

Przejdźmy przez każdy krok, zaczynając od iteracji przez wiersze.

Iterowanie przez wiersze za pomocą metody lines

Rust ma przydatną metodę do obsługi iteracji po ciągach znaków wiersz po wierszu, wygodnie nazwaną lines, która działa tak, jak pokazano w Listing 12-17. Zauważ, że to jeszcze się nie skompiluje.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Metoda lines zwraca iterator. Dogłębnie omówimy iteratory w Rozdziale 13. Ale przypomnij sobie, że widziałeś ten sposób używania iteratora w Listing 3-5, gdzie używaliśmy pętli for z iteratorem, aby wykonać kod na każdym elemencie w kolekcji.

Wyszukiwanie zapytania w każdym wierszu

Następnie sprawdzimy, czy bieżący wiersz zawiera nasz ciąg zapytania. Na szczęście ciągi znaków mają przydatną metodę o nazwie contains, która to dla nas robi! Dodaj wywołanie metody contains w funkcji search, jak pokazano w Listing 12-18. Zauważ, że to nadal się nie skompiluje.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

W tej chwili budujemy funkcjonalność. Aby kod się skompilował, musimy zwrócić wartość z ciała, zgodnie z tym, co wskazaliśmy w sygnaturze funkcji.

Przechowywanie pasujących wierszy

Aby zakończyć tę funkcję, potrzebujemy sposobu na przechowywanie pasujących wierszy, które chcemy zwrócić. W tym celu możemy stworzyć modyfikowalny wektor przed pętlą for i wywołać metodę push, aby zapisać line w wektorze. Po pętli for zwracamy wektor, jak pokazano w Listing 12-19.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Teraz funkcja search powinna zwracać tylko wiersze zawierające query, a nasz test powinien przejść. Uruchommy test:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Nasz test przeszedł, więc wiemy, że działa!

W tym momencie moglibyśmy rozważyć możliwości refaktoryzacji implementacji funkcji wyszukiwania, jednocześnie utrzymując testy przechodzące, aby zachować tę samą funkcjonalność. Kod w funkcji wyszukiwania nie jest zbyt zły, ale nie wykorzystuje niektórych przydatnych funkcji iteratorów. Wrócimy do tego przykładu w Rozdziale 13, gdzie szczegółowo omówimy iteratory i przyjrzymy się, jak go ulepszyć.

Teraz cały program powinien działać! Wypróbujmy go najpierw ze słowem, które powinno zwrócić dokładnie jeden wiersz z wiersza Emily Dickinson: frog.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Świetnie! Teraz spróbujmy słowa, które będzie pasować do wielu wierszy, na przykład body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

I na koniec, upewnijmy się, że nie otrzymujemy żadnych wierszy, gdy szukamy słowa, którego nie ma w wierszu, na przykład monomorfization:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Doskonale! Zbudowaliśmy naszą mini wersję klasycznego narzędzia i wiele nauczyliśmy się o tym, jak strukturyzować aplikacje. Nauczyliśmy się również trochę o wejściu i wyjściu plikowym, żywotności, testowaniu i parsowaniu wiersza poleceń.

Aby zakończyć ten projekt, krótko zademonstrujemy, jak pracować ze zmiennymi środowiskowymi i jak drukować do standardowego strumienia błędów, co jest przydatne podczas pisania programów wiersza poleceń.