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

Добавление функциональности с помощью разработки через тестирование

Теперь, когда логика поиска находится в src/lib.rs отдельно от функции main, писать тесты для основной функциональности нашего кода стало намного проще. Мы можем напрямую вызывать функции с разными аргументами и проверять возвращаемые значения, не запуская наш бинарный файл из командной строки.

В этом разделе мы добавим логику поиска в программу minigrep, используя процесс разработки через тестирование (test-driven development, TDD), который состоит из следующих шагов:

  1. Напишите тест, который завершается неудачей, и запустите его, чтобы убедиться, что он завершается неудачей именно по ожидаемой причине.
  2. Напишите или измените ровно столько кода, сколько нужно, чтобы новый тест прошел.
  3. Проведите рефакторинг только что добавленного или измененного кода и убедитесь, что тесты продолжают проходить.
  4. Повторите, начиная с шага 1!

Хотя TDD – всего лишь один из многих способов писать программы, он может помогать направлять проектирование кода. Если писать тест до кода, который должен заставить этот тест пройти, это помогает поддерживать высокое покрытие тестами на протяжении всего процесса.

Через тесты мы реализуем функциональность, которая будет фактически искать строку запроса в содержимом файла и создавать список строк, совпадающих с запросом. Мы добавим эту функциональность в функцию с именем search.

Написание падающего теста

В src/lib.rs мы добавим модуль tests с тестовой функцией, как делали в главе 11. Тестовая функция задает поведение, которое мы хотим получить от функции search: она будет принимать запрос и текст для поиска и возвращать только те строки из текста, которые содержат запрос. В листинге 12-15 показан этот тест.

Filename: src/lib.rs
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));
    }
}
Listing 12-15: Создание падающего теста для функции search, описывающего функциональность, которую мы хотим иметь

Этот тест ищет строку "duct". Текст, в котором мы ищем, состоит из трех строк, и только одна из них содержит "duct" (обратите внимание: обратная косая черта после открывающей двойной кавычки говорит Rust не вставлять символ новой строки в начало содержимого этого строкового литерала). Мы утверждаем, что значение, возвращаемое функцией search, содержит только ту строку, которую мы ожидаем.

Если мы запустим этот тест сейчас, он завершится неудачей, потому что макрос unimplemented! паникует с сообщением “not implemented”. Следуя принципам TDD, мы сделаем небольшой шаг: добавим ровно столько кода, чтобы вызов функции в тесте больше не приводил к панике. Для этого определим функцию search так, чтобы она всегда возвращала пустой вектор, как показано в листинге 12-16. После этого тест должен скомпилироваться и завершиться неудачей, потому что пустой вектор не совпадает с вектором, содержащим строку "safe, fast, productive.".

Filename: src/lib.rs
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));
    }
}
Listing 12-16: Определение минимальной части функции search, чтобы ее вызов не приводил к панике

Теперь обсудим, почему в сигнатуре search нужно явно определить время жизни 'a и использовать его с аргументом contents и возвращаемым значением. Вспомните из главы 10, что параметры времен жизни указывают, время жизни какого аргумента связано со временем жизни возвращаемого значения. В этом случае мы указываем, что возвращаемый вектор должен содержать строковые срезы, которые ссылаются на срезы аргумента contents (а не аргумента query).

Другими словами, мы говорим Rust, что данные, возвращаемые функцией search, будут жить так же долго, как данные, переданные в функцию search через аргумент contents. Это важно! Данные, на которые ссылается срез, должны быть действительными, чтобы ссылка была действительной; если компилятор решит, что мы создаем строковые срезы из query, а не из contents, он выполнит проверки безопасности неправильно.

Если мы забудем аннотации времен жизни и попробуем скомпилировать эту функцию, то получим такую ошибку:

$ 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 не может знать, какой из двух параметров нужен нам для выходного значения, поэтому мы должны сказать ему это явно. Обратите внимание, что текст справки предлагает указать один и тот же параметр времени жизни для всех параметров и выходного типа, но это неверно! Поскольку contents – это параметр, который содержит весь наш текст, а вернуть мы хотим части этого текста, совпадающие с запросом, мы знаем, что только contents должен быть связан с возвращаемым значением с помощью синтаксиса времен жизни.

Другие языки программирования не требуют связывать аргументы с возвращаемыми значениями в сигнатуре, но со временем эта практика станет привычнее. Возможно, вы захотите сравнить этот пример с примерами из раздела «Проверка ссылок с помощью времен жизни» в главе 10.

Написание кода, чтобы тест прошел

Сейчас наш тест завершается неудачей, потому что мы всегда возвращаем пустой вектор. Чтобы исправить это и реализовать search, наша программа должна выполнить следующие шаги:

  1. Пройти по каждой строке содержимого.
  2. Проверить, содержит ли строка нашу строку запроса.
  3. Если содержит, добавить ее в список возвращаемых значений.
  4. Если не содержит, ничего не делать.
  5. Вернуть список совпавших результатов.

Разберем каждый шаг, начав с обхода строк.

Обход строк с помощью метода lines

В Rust есть удобный метод для построчного обхода строк, который так и называется – lines. Он работает так, как показано в листинге 12-17. Обратите внимание, что этот код пока не скомпилируется.

Filename: src/lib.rs
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));
    }
}
Listing 12-17: Обход каждой строки в contents

Метод lines возвращает итератор. Мы подробно поговорим об итераторах в главе 13. Но вспомните, что вы уже видели такой способ использования итератора в листинге 3-5, где мы использовали цикл for с итератором, чтобы выполнить код для каждого элемента коллекции.

Поиск запроса в каждой строке

Далее мы проверим, содержит ли текущая строка нашу строку запроса. К счастью, у строк есть удобный метод contains, который делает это за нас! Добавьте вызов метода contains в функцию search, как показано в листинге 12-18. Обратите внимание, что этот код пока все еще не скомпилируется.

Filename: src/lib.rs
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));
    }
}
Listing 12-18: Добавление функциональности, которая проверяет, содержит ли строка строку из query

Сейчас мы постепенно наращиваем функциональность. Чтобы код скомпилировался, нужно вернуть значение из тела функции, как мы и указали в ее сигнатуре.

Сохранение совпавших строк

Чтобы завершить эту функцию, нам нужен способ сохранить совпавшие строки, которые мы хотим вернуть. Для этого можно создать изменяемый вектор перед циклом for и вызвать метод push, чтобы сохранить line в векторе. После цикла for мы возвращаем вектор, как показано в листинге 12-19.

Filename: src/lib.rs
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));
    }
}
Listing 12-19: Сохранение совпавших строк, чтобы мы могли их вернуть

Теперь функция search должна возвращать только строки, содержащие query, и наш тест должен пройти. Запустим тест:

$ 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

Наш тест прошел, значит, мы знаем, что все работает!

На этом этапе мы могли бы рассмотреть возможности для рефакторинга реализации функции поиска, сохраняя прохождение тестов и ту же функциональность. Код в функции поиска не так уж плох, но он не использует некоторые полезные возможности итераторов. Мы вернемся к этому примеру в главе 13, где подробно изучим итераторы, и посмотрим, как его улучшить.

Теперь вся программа должна работать! Попробуем ее сначала со словом, которое должно вернуть ровно одну строку из стихотворения Эмили Дикинсон: 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

Отлично! Теперь попробуем слово, которое совпадет с несколькими строками, например 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!

И наконец, убедимся, что мы не получаем ни одной строки, когда ищем слово, которого вообще нет в стихотворении, например monomorphization:

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

Прекрасно! Мы создали собственную мини-версию классического инструмента и много узнали о том, как структурировать приложения. Также мы немного узнали о файловом вводе и выводе, временах жизни, тестировании и разборе командной строки.

Чтобы завершить этот проект, мы кратко покажем, как работать с переменными окружения и как печатать в стандартный поток ошибок: и то и другое полезно, когда вы пишете программы командной строки.