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

Udoskonalanie naszego projektu I/O

Dzięki tej nowej wiedzy o iteratorach możemy ulepszyć projekt I/O z Rozdziału 12, wykorzystując iteratory, aby uczynić kod bardziej czytelnym i zwięzłym. Przyjrzyjmy się, jak iteratory mogą ulepszyć naszą implementację funkcji Config::build i funkcji search.

Usuwanie clone za pomocą iteratora

W Listing 12-6 dodaliśmy kod, który przyjmował wycinek wartości String i tworzył instancję struktury Config poprzez indeksowanie do wycinka i klonowanie wartości, co pozwalało strukturze Config na posiadanie tych wartości. W Listing 13-17 odtworzyliśmy implementację funkcji Config::build taką, jaka była w Listing 12-23.

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Wówczas powiedzieliśmy, żeby nie martwić się nieefektywnymi wywołaniami clone, ponieważ usuniemy je w przyszłości. Cóż, ten czas nadszedł!

Potrzebowaliśmy clone tutaj, ponieważ mamy wycinek z elementami String w parametrze args, ale funkcja build nie jest właścicielem args. Aby zwrócić własność instancji Config, musieliśmy sklonować wartości z pól query i file_path Config, tak aby instancja Config mogła posiadać swoje wartości.

Dzięki naszej nowej wiedzy o iteratorach możemy zmienić funkcję build, aby przyjmowała własność iteratora jako swój argument, zamiast pożyczać wycinek. Użyjemy funkcjonalności iteratora zamiast kodu, który sprawdza długość wycinka i indeksuje do określonych miejsc. To wyjaśni, co robi funkcja Config::build, ponieważ iterator będzie miał dostęp do wartości.

Gdy Config::build przejmie własność iteratora i przestanie używać operacji indeksowania, które pożyczają, możemy przenieść wartości String z iteratora do Config zamiast wywoływać clone i tworzyć nową alokację.

Bezpośrednie używanie zwróconego iteratora

Otwórz plik src/main.rs swojego projektu I/O, który powinien wyglądać następująco:

Nazwa pliku: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Najpierw zmienimy początek funkcji main z Listing 12-24 na kod z Listing 13-18, który tym razem używa iteratora. To nie skompiluje się, dopóki nie zaktualizujemy również Config::build.

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem z parsowaniem argumentów: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Błąd aplikacji: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("za mało argumentów");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Funkcja env::args zwraca iterator! Zamiast zbierać wartości iteratora do wektora, a następnie przekazywać wycinek do Config::build, teraz przekazujemy własność iteratora zwróconego przez env::args bezpośrednio do Config::build.

Następnie musimy zaktualizować definicję Config::build. Zmieńmy sygnaturę Config::build tak, aby wyglądała jak w Listing 13-19. To nadal się nie skompiluje, ponieważ musimy zaktualizować ciało funkcji.

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem z parsowaniem argumentów: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Błąd aplikacji: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("za mało argumentów");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Dokumentacja standardowej biblioteki dla funkcji env::args pokazuje, że typ iteratora, który zwraca, to std::env::Args, a ten typ implementuje cechę Iterator i zwraca wartości String.

Zaktualizowaliśmy sygnaturę funkcji Config::build tak, aby parametr args miał typ generyczny z ograniczeniami cech impl Iterator<Item = String> zamiast &[String]. To użycie składni impl Trait, którą omówiliśmy w sekcji „Używanie cech jako parametrów” w Rozdziale 10, oznacza, że args może być dowolnym typem, który implementuje cechę Iterator i zwraca elementy String.

Ponieważ przejmujemy własność args i będziemy modyfikować args poprzez iterację po nim, możemy dodać słowo kluczowe mut do specyfikacji parametru args, aby uczynić go zmiennym.

Używanie metod cech Iterator

Następnie poprawimy ciało Config::build. Ponieważ args implementuje cechę Iterator, wiemy, że możemy na nim wywołać metodę next! Listing 13-20 aktualizuje kod z Listing 12-23, aby używał metody next.

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem z parsowaniem argumentów: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Błąd aplikacji: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Nie otrzymano ciągu zapytania"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Nie otrzymano ścieżki pliku"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Pamiętaj, że pierwsza wartość zwracana przez env::args to nazwa programu. Chcemy ją zignorować i przejść do następnej wartości, więc najpierw wywołujemy next i nic nie robimy z wartością zwracaną. Następnie wywołujemy next, aby uzyskać wartość, którą chcemy umieścić w polu query struktury Config. Jeśli next zwraca Some, używamy match, aby wyodrębnić wartość. Jeśli zwraca None, oznacza to, że nie podano wystarczającej liczby argumentów, i zwracamy Err. To samo robimy dla wartości file_path.

Upraszczanie kodu za pomocą adapterów iteratorów

Możemy również wykorzystać iteratory w funkcji search w naszym projekcie I/O, która jest tutaj odtworzona w Listing 13-21 w takiej postaci, w jakiej była 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));
    }
}

Możemy napisać ten kod w bardziej zwięzły sposób, używając metod adaptera iteratora. Pozwala to również uniknąć posiadania zmiennego, pośredniego wektora results. Styl programowania funkcyjnego preferuje minimalizowanie ilości zmiennego stanu, aby kod był bardziej czytelny. Usunięcie zmiennego stanu może umożliwić przyszłe ulepszenie, aby wyszukiwanie odbywało się równolegle, ponieważ nie musielibyśmy zarządzać współbieżnym dostępem do wektora results. Listing 13-22 pokazuje tę zmianę.

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

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

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

    results
}

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

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

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

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

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Pamiętaj, że celem funkcji search jest zwrócenie wszystkich wierszy w contents, które zawierają query. Podobnie jak w przykładzie filter w Listing 13-16, ten kod używa adaptera filter do zachowania tylko tych wierszy, dla których line.contains(query) zwraca true. Następnie zbieramy pasujące wiersze do innego wektora za pomocą collect. O wiele prościej! Zachęcamy do wprowadzenia tej samej zmiany, aby używać metod iteratora również w funkcji search_case_insensitive.

Dla dalszego ulepszenia, zwróć iterator z funkcji search poprzez usunięcie wywołania collect i zmianę typu zwracanego na impl Iterator<Item = &'a str>, tak aby funkcja stała się adapterem iteratora. Zauważ, że będziesz musiał również zaktualizować testy! Przeszukaj duży plik za pomocą narzędzia minigrep przed i po wprowadzeniu tej zmiany, aby zaobserwować różnicę w zachowaniu. Przed tą zmianą program nie wyświetli żadnych wyników, dopóki nie zbierze wszystkich wyników, ale po zmianie wyniki będą wyświetlane w miarę znajdowania każdej pasującej linii, ponieważ pętla for w funkcji run będzie mogła wykorzystać lenistwo iteratora.

Wybór między pętlami a iteratorami

Następne logiczne pytanie brzmi, jaki styl powinieneś wybrać w swoim kodzie i dlaczego: oryginalną implementację z Listing 13-21, czy wersję używającą iteratorów z Listing 13-22 (zakładając, że zbieramy wszystkie wyniki przed ich zwróceniem, a nie zwracamy iteratora). Większość programistów Rusta preferuje styl iteratorów. Na początku jest to trochę trudniejsze do opanowania, ale gdy już poczujesz się z różnymi adapterami iteratorów i tym, co robią, iteratory mogą być łatwiejsze do zrozumienia. Zamiast bawić się różnymi elementami pętli i budować nowe wektory, kod skupia się na ogólnym celu pętli. Abstrakcjonizuje to niektóre z typowych kodów, dzięki czemu łatwiej jest dostrzec koncepcje unikalne dla tego kodu, takie jak warunek filtrowania, który musi spełnić każdy element w iteratorze.

Ale czy te dwie implementacje są naprawdę równoważne? Intuicyjne założenie może być takie, że pętla niższego poziomu będzie szybsza. Porozmawiajmy o wydajności.