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. Повторяем с первого шага!

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

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

Пишем проваливающийся тест

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

Файл: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --пропуск--

#[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));
    }
}
Листинг 12-15: Создаём для функции search проваливающийся тест — на ту функциональность, которую хотим получить

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

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

Файл: 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));
    }
}
Листинг 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. Учтите, что пока код не компилируется.

Файл: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // что-то делаем со строкой 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));
    }
}
Листинг 12-17: Перебираем все строки в contents

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

Ищем запрос в каждой строке

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

Файл: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // что-то делаем со строкой 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));
    }
}
Листинг 12-18: Добавляем проверку, есть ли в строке значение из query

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

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

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

Файл: 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));
    }
}
Листинг 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

Тест прошёл — значит, всё работает!

Сейчас можно подумать о рефакторинге реализации функции поиска, следя, чтобы тесты проходили и функциональность не менялась. Код функции search неплох, но не пользуется некоторыми полезными возможностями итераторов. Мы вернёмся к этому примеру в главе 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`

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

В завершение проекта коротко покажем, как работать с переменными окружения и как выводить в стандартный поток ошибок, — и то и другое пригодится при написании программ командной строки.