Додавання функціональності за допомогою розробки через тести (TDD)
Тепер, коли ми маємо пошукову логіку в src/lib.rs окремо від функції main,
нам набагато легше писати тести для основної функціональності нашого коду. Ми
можемо безпосередньо викликати функції з різними аргументами та перевіряти
повернені значення, не викликаючи наш бінарний файл із командного рядка.
У цьому розділі ми додамо логіку пошуку до програми minigrep, використовуючи
процес розробки через тести (TDD) за такими кроками:
- Написати тест, який не проходить, і запустити його, щоб переконатися, що він не проходить з тієї причини, яку ви очікуєте.
- Написати або змінити рівно стільки коду, щоб новий тест почав проходити.
- Рефакторити код, який ви щойно додали або змінили, і переконатися, що тести продовжують проходити.
- Повторити з кроку 1!
Хоча це лише один із багатьох способів писати програмне забезпечення, TDD може допомогти керувати дизайном коду. Написання тесту перед тим, як ви напишете код, що робить тест таким, що проходить, допомагає підтримувати високе покриття тестами протягом усього процесу.
Ми протестуємо реалізацію функціональності, яка фактично виконуватиме пошук
рядка запиту в вмісті файлу та формуватиме список рядків, що відповідають
запиту. Ми додамо цю функціональність у функцію під назвою search.
Написання тесту, що не проходить
У src/lib.rs ми додамо модуль tests із тестовою функцією, як ми зробили в
Розділі 11. Тестова функція задає поведінку, яку
ми хочемо, щоб мала функція search: вона прийматиме запит і текст для пошуку
та повертатиме лише ті рядки з тексту, які містять запит. У Cписку(Listing) 12-15
показано цей тест.
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));
}
}
Цей тест шукає рядок "duct". Текст, у якому ми шукаємо, складається з трьох
рядків, лише один з яких містить "duct" (зверніть увагу, що зворотна похила
риска після відкривної подвійної лапки повідомляє Rust не ставити символ нового
рядка на початку вмісту цього рядкового літерала). Ми стверджуємо, що значення,
повернене функцією search, містить лише той рядок, який ми очікуємо.
Якщо ми запустимо цей тест, він наразі завершиться невдало, тому що макрос
unimplemented! викликає паніку з повідомленням “not implemented”. Відповідно
до принципів TDD, ми зробимо маленький крок і додамо рівно стільки коду, щоб
тест не викликав паніку під час виклику функції, визначивши функцію search
так, щоб вона завжди повертала порожній вектор, як показано в списку(Listing) 12-16.
Тоді тест має скомпілюватися і завершитися невдало, тому що порожній вектор не
відповідає вектору, що містить рядок "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));
}
}
Тепер обговорімо, чому нам потрібно визначити явний час життя 'a у сигнатурі
search і використати цей час життя з аргументом 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 — це єдиний параметр, який слід пов’язати зі значенням повернення
за допомогою синтаксису часу життя.
Інші мови програмування не вимагають від вас пов’язувати аргументи зі значеннями повернення в сигнатурі, але з часом ця практика стане легшою. Ви можете захотіти порівняти цей приклад із прикладами в розділі “Validating References with Lifetimes” у Розділі 10.
Написання коду для проходження тесту
Наразі наш тест завершується невдало, тому що ми завжди повертаємо порожній
вектор. Щоб виправити це та реалізувати search, нашій програмі потрібно
виконати такі кроки:
- Пройти по кожному рядку вмісту.
- Перевірити, чи містить рядок наш рядок запиту.
- Якщо так, додати його до списку значень, які ми повертаємо.
- Якщо ні, нічого не робити.
- Повернути список результатів, що відповідають.
Давайте пройдемося по кожному кроку, починаючи з ітерації по рядках.
Ітерація по рядках за допомогою методу lines
Rust має корисний метод для обробки покрокової ітерації рядків, який зручно
називається lines, і який працює так, як показано в списку(Listing) 12-17. Зверніть
увагу, що це ще не скомпілюється.
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));
}
}
Метод lines повертає ітератор. Ми докладно поговоримо про ітератори в
Розділі 13. Але пригадайте, що ви бачили такий
спосіб використання ітератора в Listing 3-5, де ми
використовували цикл for з ітератором, щоб виконувати певний код для кожного
елемента в колекції.
Пошук у кожному рядку на відповідність запиту
Далі ми перевіримо, чи містить поточний рядок наш рядок запиту. На щастя,
рядки мають корисний метод під назвою contains, який робить це за нас!
Додайте виклик методу contains у функцію search, як показано в списку (Listing)
12-18. Зверніть увагу, що це все ще не скомпілюється.
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));
}
}
На даний момент ми нарощуємо функціональність. Щоб код скомпілювався, нам потрібно повернути значення з тіла, як ми й зазначили в сигнатурі функції.
Збереження рядків, що відповідають
Щоб завершити цю функцію, нам потрібен спосіб зберігати рядки, що відповідають,
які ми хочемо повернути. Для цього ми можемо створити змінний вектор перед
циклом for і викликати метод push, щоб зберегти line у векторі. Після
циклу for ми повертаємо вектор, як показано в списку(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));
}
}
Тепер функція 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`
Чудово! Ми створили власну мініверсію класичного інструмента і багато чого дізналися про те, як структурувати програми. Ми також трохи дізналися про ввід і вивід файлів, часи життя, тестування та розбір командного рядка.
Щоб завершити цей проєкт, ми коротко покажемо, як працювати зі змінними середовища та як друкувати в стандартний потік помилок; обидва ці прийоми корисні, коли ви пишете програми командного рядка.