การเพิ่มฟังก์ชันการทำงานด้วย Test-Driven Development (Adding Functionality with Test-Driven Development)
เมื่อเรามีตรรกะการค้นหาใน src/lib.rs แยกต่างหากจากฟังก์ชัน main แล้ว มันจะง่ายขึ้นมากในการเขียนการทดสอบสำหรับฟังก์ชันหลักของโค้ดเรา เราสามารถเรียกใช้ฟังก์ชันได้โดยตรงด้วยอาร์กิวเมนต์ที่หลากหลายและตรวจสอบค่าคืนกลับได้ โดยไม่ต้องเรียกใช้ไฟล์ไบนารีจากบรรทัดคำสั่ง
ในหัวข้อนี้ เราจะเพิ่มตรรกะการค้นหาให้กับโปรแกรม minigrep โดยใช้กระบวนการ Test-Driven Development (TDD) ซึ่งมีขั้นตอนดังต่อไปนี้:
- เขียนการทดสอบที่ล้มเหลวและรันมัน เพื่อให้แน่ใจว่ามันล้มเหลวด้วยเหตุผลที่คุณคาดไว้
- เขียนหรือแก้ไขโค้ดเพียงเท่าที่จำเป็นเพื่อทำให้การทดสอบใหม่ผ่าน
- รีแฟกเตอร์โค้ดที่คุณเพิ่งเพิ่มหรือเปลี่ยนแปลง และตรวจสอบให้แน่ใจว่าการทดสอบยังคงผ่านต่อไป
- ทำซ้ำตั้งแต่ขั้นตอนที่ 1!
แม้ว่าจะเป็นเพียงหนึ่งในหลายๆ วิธีในการเขียนซอฟต์แวร์ แต่ TDD สามารถช่วยขับเคลื่อนการออกแบบโค้ดได้ การเขียนการทดสอบก่อนที่คุณจะเขียนโค้ดที่ทำให้การทดสอบผ่าน ช่วยรักษาความครอบคลุมของการทดสอบ (test coverage) ให้อยู่ในระดับสูงตลอดกระบวนการ
เราจะใช้ TDD ในการพัฒนาส่วนการปรับใช้ฟังก์ชันการทำงาน ที่จะทำการค้นหาสตริงข้อความค้นหาในเนื้อหาของไฟล์จริง และสร้างรายการของบรรทัดที่ตรงกับคำค้นหา เราจะเพิ่มฟังก์ชันการทำงานนี้ไว้ในฟังก์ชันที่ชื่อว่า search
การเขียนการทดสอบที่ล้มเหลว (Writing a Failing Test)
ใน src/lib.rs เราจะเพิ่มโมดูล tests พร้อมด้วยฟังก์ชันการทดสอบ เช่นเดียวกับที่เราทำใน บทที่ 11 ฟังก์ชันการทดสอบระบุพฤติกรรมที่เราต้องการให้ฟังก์ชัน search มี นั่นคือ: มันจะรับข้อความค้นหาและข้อความที่จะค้นหา แล้วคืนค่าเฉพาะบรรทัดจากข้อความที่มีข้อความค้นหาอยู่เท่านั้น 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 ไม่ให้ใส่ตัวอักษรขึ้นบรรทัดใหม่ที่จุดเริ่มต้นของเนื้อหาสตริง literal นี้) เรารายงานยืนยันว่าค่าที่คืนจากฟังก์ชัน search บรรจุเฉพาะบรรทัดที่เราคาดหวังไว้
หากเรารันการทดสอบนี้ มันจะล้มเหลวในปัจจุบัน เนื่องจากมาโคร unimplemented! จะเกิด panic พร้อมข้อความ “not implemented” เพื่อให้เป็นไปตามหลักการของ TDD เราจะทำขั้นตอนเล็กๆ โดยเพิ่มโค้ดเพียงเท่าที่จำเป็นเพื่อให้การทดสอบไม่เกิด panic เมื่อเรียกใช้ฟังก์ชัน โดยกำหนดให้ฟังก์ชัน 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));
}
}
ตอนนี้ เรามาพูดถึงสาเหตุที่เราจำเป็นต้องกำหนด lifetime 'a ที่ชัดเจนในซิกเนเจอร์ของ search และใช้ lifetime นั้นกับอาร์กิวเมนต์ contents และค่าคืนกลับ ย้อนกลับไปใน บทที่ 10 ที่พารามิเตอร์ lifetime ระบุว่า lifetime ของอาร์กิวเมนต์ตัวใดเชื่อมโยงกับ lifetime ของค่าคืนกลับ ในกรณีนี้ เราระบุว่าเวกเตอร์ที่ คืนกลับมาควรบรรจุ string slice ที่อ้างอิงถึง slice ของอาร์กิวเมนต์ contents (แทนที่จะเป็นอาร์กิวเมนต์ query)
กล่าวอีกนัยหนึ่ง เราบอก Rust ว่าข้อมูลที่คืนกลับโดยฟังก์ชัน search จะมีอายุอยู่ตราบเท่าที่ข้อมูลที่ส่งเข้าไปยังฟังก์ชัน search ในอาร์กิวเมนต์ contents มีอายุอยู่ นี่เป็นสิ่งสำคัญ! ข้อมูลที่ถูกอ้างอิง โดย slice จำเป็นต้องมีความถูกต้องเพื่อให้การอ้างอิงถูกต้อง หากคอมไพเลอร์ทายว่าเรากำลังทำ string slice ของ query แทนที่จะเป็น contents มันจะทำการตรวจสอบความปลอดภัยผิดพลาด
หากเราลืมสัญลักษณ์ lifetime และพยายามคอมไพล์ฟังก์ชันนี้ เราจะได้ข้อผิดพลาดนี้:
$ 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 ไม่สามารถรู้ได้ว่าพารามิเตอร์ใดในสองตัวที่เราต้องการสำหรับผลลัพธ์ ดังนั้นเราจึงต้องบอกมันอย่างชัดเจน สังเกตว่าข้อความช่วยเหลือแนะนำให้ระบุพารามิเตอร์ lifetime เดียวกันสำหรับพารามิเตอร์ทั้งหมดและประเภทผลลัพธ์ ซึ่งไม่ถูกต้อง! เนื่องจาก contents คือพารามิเตอร์ที่บรรจุข้อความทั้งหมดของเรา และเราต้องการคืนค่าชิ้นส่วนของข้อความนั้นที่ตรงกัน เรารู้ว่า contents เป็นพารามิเตอร์เดียวที่ควรเชื่อมโยงกับค่าคืนกลับโดยใช้ไวยากรณ์ lifetime
ภาษาโปรแกรมอื่นๆ ไม่ได้กำหนดให้คุณต้องเชื่อมโยงอาร์กิวเมนต์กับค่าคืนกลับในซิกเนเจอร์ แต่วิธีปฏิบัตินี้จะง่ายขึ้นตามกาลเวลา คุณอาจต้องการเปรียบเทียบตัวอย่างนี้กับตัวอย่างในหัวข้อ “การตรวจสอบการอ้างอิงด้วย Lifetimes” ในบทที่ 10
การเขียนโค้ดเพื่อให้การทดสอบผ่าน (Writing Code to Pass the Test)
ในปัจจุบัน การทดสอบของเราล้มเหลวเนื่องจากเราคืนค่าเวกเตอร์ว่างเสมอ เพื่อแก้ไขปัญหานั้นและปรับใช้ search โปรแกรมของเราจำเป็นต้องทำตามขั้นตอนเหล่านี้:
- วนลูปผ่านแต่ละบรรทัดของเนื้อหา (contents)
- ตรวจสอบว่าบรรทัดนั้นมีสตริงข้อความค้นหาของเราหรือไม่
- หากมี ให้เพิ่มบรรทัดนั้นลงในรายการของค่าที่เราจะคืนกลับ
- หากไม่มี ให้ไม่ต้องทำอะไร
- คืนค่ารายการผลลัพธ์ที่ตรงกัน
มาทำตามแต่ละขั้นตอนกัน โดยเริ่มจากการวนลูปผ่านบรรทัดต่างๆ
การวนลูปผ่านบรรทัดต่างๆ ด้วยเมธอด lines (Iterating Through Lines with the lines Method)
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 คืนค่าเป็น iterator เราจะพูดถึง iterator โดยละเอียดใน บทที่ 13 แต่ย้อนกลับไปว่าคุณได้เห็นวิธีการใช้ iterator แบบนี้แล้วใน Listing 3-5 ที่เราใช้ลูป for ร่วมกับ iterator เพื่อรันโค้ดกับสมาชิกแต่ละตัวใน collection
การค้นหาแต่ละบรรทัดด้วยข้อความค้นหา (Searching Each Line for the Query)
ถัดไป เราจะตรวจสอบว่าบรรทัดปัจจุบันมีสตริงข้อความค้นหาของเราหรือไม่ โชคดีที่สตริงมีเมธอดที่มีประโยชน์ชื่อ 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));
}
}
ในตอนนี้ เรากำลังสร้างฟังก์ชันการทำงานขึ้นมา เพื่อให้โค้ดคอมไพล์ได้ เราจำเป็นต้องคืนค่าจากเนื้อหาตามที่เราได้ระบุไว้ในซิกเนเจอร์ของฟังก์ชัน
การจัดเก็บบรรทัดที่ตรงกัน (Storing Matching Lines)
เพื่อให้ฟังก์ชันนี้เสร็จสมบูรณ์ เราต้องการวิธีจัดเก็บบรรทัดที่ตรงกันที่เราต้องการคืนกลับมา สำหรับสิ่งนั้น เราสามารถสร้างเวกเตอร์แบบเปลี่ยนแปลงค่าได้ (mutable vector) ก่อนลูป 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
การทดสอบของเราผ่านแล้ว ดังนั้นเรารู้ว่ามันทำงานได้!
ณ จุดนี้ เราสามารถพิจารณาโอกาสในการรีแฟกเตอร์การปรับใช้ฟังก์ชัน search ในขณะที่รักษาให้การทดสอบยังคงผ่านเพื่อคงฟังก์ชันการทำงานแบบเดิมไว้ โค้ดในฟังก์ชัน search ไม่ได้แย่เกินไป แต่ก็ยังไม่ได้ใช้ประโยชน์จากฟีเจอร์ที่มีประโยชน์บางประการของ iterators เราจะกลับมาดูตัวอย่างนี้ใน บทที่ 13 ที่เราจะได้สำรวจ iterators โดยละเอียด และดูวิธีปรับปรุงมันให้ดียิ่งขึ้น
ตอนนี้โปรแกรมทั้งหมดควรร่วมกันทำงานได้แล้ว! มาลองทดสอบกัน ขั้นแรกด้วยคำที่ควรคืนค่าตรงหนึ่งบรรทัดจากบทกวีของ 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
เยี่ยมเลย! คราวนี้มาลองคำที่ตรงกับหลายบรรทัด ดูบ้าง เช่น 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`
ยอดเยี่ยม! เราได้สร้างเครื่องมือคลาสสิกเวอร์ชันมินิของเราเองแล้ว และได้เรียนรู้หลายอย่างเกี่ยวกับวิธีจัดโครงสร้างแอปพลิเคชัน เรายังได้เรียนรู้นิดหน่อยเกี่ยวกับ input/output ของไฟล์, lifetimes, การทดสอบ และการแกะอาร์กิวเมนต์บรรทัดคำสั่ง
เพื่อสรุปโปรเจกต์นี้ให้สมบูรณ์ เราจะสาธิตสั้นๆ เกี่ยวกับวิธีการทำงานกับตัวแปรสภาพแวดล้อม (environment variables) และการพิมพ์ออกทาง standard error ซึ่งทั้งสองอย่างนี้เป็นประโยชน์อย่างมากเมื่อคุณเขียนโปรแกรมบรรทัดคำสั่ง