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

Refactor เพื่อ Modularity และจัดการ Error ให้ดีขึ้น

เพื่อปรับปรุงโปรแกรมของเรา เราจะแก้สี่ปัญหาที่เกี่ยวกับโครงสร้างของ โปรแกรมและวิธีจัดการ error ที่อาจเกิดขึ้น ก่อนอื่น ฟังก์ชัน main ของเราตอนนี้ทำสองงาน — มัน parse อาร์กิวเมนต์และอ่านไฟล์ เมื่อโปรแกรม ของเราเติบโต จำนวนงานแยกที่ฟังก์ชัน main จัดการจะเพิ่มขึ้น เมื่อ ฟังก์ชันได้รับความรับผิดชอบ มันยากขึ้นที่จะคิดเกี่ยวกับ ยากขึ้นที่จะ ทดสอบ และยากขึ้นที่จะเปลี่ยนโดยไม่ทำลายส่วนใดส่วนหนึ่ง ดีที่สุดที่จะ แยก functionality เพื่อให้แต่ละฟังก์ชันรับผิดชอบหนึ่งงาน

ประเด็นนี้ยังเชื่อมโยงกับปัญหาที่สอง — แม้ query และ file_path เป็น ตัวแปร configuration ของโปรแกรมของเรา ตัวแปรอย่าง contents ถูกใช้ ทำ logic ของโปรแกรม ยิ่ง main ยาว ยิ่งต้องนำตัวแปรมากเข้า scope — ยิ่งมีตัวแปรใน scope มาก ยิ่งยากที่จะตามจุดประสงค์ของแต่ละตัวแปร ดี ที่สุดที่จะจัดกลุ่มตัวแปร configuration เข้าโครงสร้างเดียวเพื่อให้ จุดประสงค์ของพวกมันชัดเจน

ปัญหาที่สามคือเราใช้ expect เพื่อ print ข้อความ error เมื่ออ่านไฟล์ fail แต่ข้อความ error แค่ print Should have been able to read the file การอ่านไฟล์ fail ได้หลายวิธี — ตัวอย่างเช่น ไฟล์อาจหาย หรือเราอาจไม่ มี permission ที่จะเปิดมัน ตอนนี้ ไม่ว่าสถานการณ์เป็นยังไง เราจะ print ข้อความ error แบบเดียวกันสำหรับทุกอย่าง ซึ่งไม่ให้ข้อมูลใด ๆ แก่ user!

ที่สี่ เราใช้ expect ในการจัดการ error และถ้า user รันโปรแกรมของเรา โดยไม่ระบุอาร์กิวเมนต์เพียงพอ พวกเขาจะได้ error index out of bounds จาก Rust ที่ไม่อธิบายปัญหาชัดเจน ดีที่สุดถ้าโค้ดจัดการ error ทั้งหมด อยู่ที่เดียว เพื่อให้ผู้ดูแลในอนาคตมีเพียงที่เดียวที่จะปรึกษาโค้ดถ้า logic จัดการ error ต้องเปลี่ยน การมีโค้ดจัดการ error ทั้งหมดที่เดียว จะรับประกันด้วยว่าเรา print ข้อความที่มีความหมายต่อ end user ของเรา

มาแก้สี่ปัญหานี้โดย refactor โปรเจกต์ของเรา

แยก Concern ในโปรเจกต์ Binary

ปัญหาเชิงการจัดระเบียบของการแบ่งความรับผิดชอบสำหรับหลายงานให้ฟังก์ชัน main ทั่วไปในโปรเจกต์ binary หลายโปรเจกต์ ผลคือ programmer Rust หลายคนพบว่ามีประโยชน์ที่จะแยก concern แยกของโปรแกรม binary เมื่อ ฟังก์ชัน main เริ่มใหญ่ขึ้น กระบวนการนี้มีขั้นตอนต่อไปนี้:

  • แยกโปรแกรมของคุณเป็นไฟล์ main.rs และไฟล์ lib.rs และย้าย logic ของโปรแกรมของคุณไปยัง lib.rs
  • ตราบใดที่ logic parse command line ของคุณเล็ก มันอยู่ในฟังก์ชัน main ได้
  • เมื่อ logic parse command line เริ่มซับซ้อน ดึงมันจากฟังก์ชัน main ไปยังฟังก์ชันหรือ type อื่น

ความรับผิดชอบที่ยังอยู่ในฟังก์ชัน main หลังกระบวนการนี้ควรจำกัดอยู่ ที่ต่อไปนี้:

  • เรียก logic parse command line ด้วยค่าอาร์กิวเมนต์
  • ตั้งค่า configuration อื่นใด
  • เรียกฟังก์ชัน run ใน lib.rs
  • จัดการ error ถ้า run return error

pattern นี้เกี่ยวกับการแยก concern — main.rs จัดการการรันโปรแกรม และ lib.rs จัดการ logic ทั้งหมดของงานในมือ เพราะคุณทดสอบฟังก์ชัน main โดยตรงไม่ได้ โครงสร้างนี้ให้คุณทดสอบ logic ของโปรแกรมทั้งหมด โดยย้ายมันออกจากฟังก์ชัน main โค้ดที่ยังอยู่ในฟังก์ชัน main จะ เล็กพอที่จะตรวจสอบความถูกต้องโดยอ่าน มาทำใหม่โปรแกรมของเราโดยทำตาม กระบวนการนี้

ดึง Argument Parser

เราจะดึง functionality สำหรับ parse อาร์กิวเมนต์เข้าฟังก์ชันที่ main จะเรียก Listing 12-5 แสดงจุดเริ่มต้นใหม่ของฟังก์ชัน main ที่ เรียกฟังก์ชันใหม่ parse_config ซึ่งเราจะนิยามใน src/main.rs

Filename: src/main.rs
use std::env;
use std::fs;

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

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}
Listing 12-5: ดึงฟังก์ชัน parse_config จาก main

เรายัง collect อาร์กิวเมนต์ command line เข้า vector แต่แทนที่จะ assign ค่าอาร์กิวเมนต์ที่ index 1 ให้ตัวแปร query และค่าอาร์กิวเมนต์ ที่ index 2 ให้ตัวแปร file_path ภายในฟังก์ชัน main เราส่ง vector ทั้งหมดให้ฟังก์ชัน parse_config ฟังก์ชัน parse_config จากนั้นเก็บ logic ที่กำหนดว่าอาร์กิวเมนต์ไหนไปในตัวแปรไหน และส่งค่ากลับไปยัง main เรายังสร้างตัวแปร query และ file_path ใน main แต่ main ไม่มีความรับผิดชอบในการกำหนดว่าอาร์กิวเมนต์ command line และตัวแปร สอดคล้องกันอย่างไรอีกต่อไป

การทำใหม่นี้อาจดูเกินจำเป็นสำหรับโปรแกรมเล็กของเรา แต่เรากำลัง refactor ในขั้นตอนเล็ก ๆ ทีละนิด หลังจากทำการเปลี่ยนแปลงนี้ รันโปรแกรม อีกครั้งเพื่อตรวจสอบว่า parse อาร์กิวเมนต์ยังทำงาน ดีที่จะตรวจสอบ ความก้าวหน้าของคุณบ่อย ๆ เพื่อช่วยระบุสาเหตุของปัญหาเมื่อพวกมันเกิดขึ้น

จัดกลุ่มค่า Configuration

เราทำอีกขั้นตอนเล็กเพื่อปรับปรุงฟังก์ชัน parse_config เพิ่มเติมได้ ตอนนี้ เรากำลัง return tuple แต่เราแยก tuple นั้นเป็นชิ้นแต่ละชิ้น ทันที นี่เป็นสัญญาณว่าบางที เรายังไม่มี abstraction ที่ถูกต้อง

ตัวบ่งชี้อีกตัวที่แสดงว่ามีพื้นที่สำหรับการปรับปรุงคือส่วน config ของ parse_config ซึ่งบ่งบอกว่าสองค่าที่เรา return เกี่ยวข้องกัน และทั้ง สองเป็นส่วนหนึ่งของค่า configuration เดียว เราไม่ได้สื่อความหมายนี้ใน โครงสร้างของข้อมูลในตอนนี้ นอกจากการจัดกลุ่มสองค่าเข้า tuple — เรา จะใส่สองค่าเข้า struct เดียวแทน และให้ field ของ struct แต่ละตัวมีชื่อ ที่มีความหมาย การทำเช่นนั้นจะทำให้ง่ายขึ้นสำหรับผู้ดูแลในอนาคตของโค้ด นี้ที่จะเข้าใจว่าค่าต่าง ๆ เกี่ยวข้องกันยังไงและจุดประสงค์ของพวกมันคือ อะไร

Listing 12-6 แสดงการปรับปรุงฟังก์ชัน parse_config

Filename: src/main.rs
use std::env;
use std::fs;

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

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}
Listing 12-6: Refactor parse_config เพื่อ return instance ของ struct Config

เราเพิ่ม struct ชื่อ Config ที่นิยามให้มี field ชื่อ query และ file_path signature ของ parse_config ตอนนี้ระบุว่ามัน return ค่า Config ใน body ของ parse_config ที่เราเคย return string slice ที่ อ้างถึงค่า String ใน args เราตอนนี้นิยาม Config ให้บรรจุค่า String ที่ own ตัวแปร args ใน main เป็น owner ของค่าอาร์กิวเมนต์ และให้ฟังก์ชัน parse_config borrow พวกมันเท่านั้น แปลว่าเราจะละเมิด กฎ borrowing ของ Rust ถ้า Config พยายามรับ ownership ของค่าใน args

มีวิธีหลายอย่างที่เราจัดการข้อมูล String ได้ — วิธีที่ง่ายที่สุด แม้จะไม่มีประสิทธิภาพอยู่บ้าง คือเรียกเมธอด clone บนค่า สิ่งนี้จะทำ copy เต็มของข้อมูลให้ instance Config own ซึ่งใช้เวลาและ memory มากกว่าการเก็บ reference ของข้อมูล string อย่างไรก็ตาม การ clone ข้อมูลยังทำให้โค้ดของเราตรงไปตรงมามาก เพราะเราไม่ต้องจัดการ lifetime ของ reference — ในสถานการณ์นี้ ยอมเสีย performance เล็กน้อยเพื่อได้ ความง่ายเป็น trade-off ที่คุ้มค่า

Trade-off ของการใช้ clone

มีแนวโน้มในหมู่ Rustacean หลายคนที่จะหลีกเลี่ยงการใช้ clone เพื่อ แก้ปัญหา ownership เพราะค่า runtime ของมัน ใน บทที่ 13 คุณจะเรียนวิธีใช้เมธอดที่มี ประสิทธิภาพมากกว่าในสถานการณ์ประเภทนี้ แต่ตอนนี้ ก็โอเคที่จะ copy string สักสองสามตัวเพื่อทำความก้าวหน้าต่อ เพราะคุณจะทำ copy เหล่านี้ เพียงครั้งเดียวและ file path และ query string ของคุณเล็กมาก ดีกว่า ที่จะมีโปรแกรมที่ทำงานได้แต่ไม่มีประสิทธิภาพหน่อย กว่าที่จะพยายาม hyperoptimize โค้ดในรอบแรก เมื่อคุณมีประสบการณ์กับ Rust มากขึ้น มัน จะง่ายขึ้นที่จะเริ่มด้วยคำตอบที่มีประสิทธิภาพที่สุด แต่ตอนนี้ ยอมรับได้สมบูรณ์แบบที่จะเรียก clone

เราอัพเดท main เพื่อให้มันวาง instance ของ Config ที่ return โดย parse_config ในตัวแปรชื่อ config และเราอัพเดทโค้ดที่ก่อนหน้าใช้ ตัวแปร query และ file_path แยก ให้ใช้ field บน struct Config แทน

ตอนนี้โค้ดของเราสื่อชัดเจนขึ้นว่า query และ file_path เกี่ยวข้องกัน และจุดประสงค์ของพวกมันคือ configure ว่าโปรแกรมจะทำงานยังไง โค้ดใดที่ ใช้ค่าเหล่านี้รู้ที่จะหาพวกมันใน instance config ใน field ที่ตั้ง ชื่อตามจุดประสงค์ของพวกมัน

สร้าง Constructor สำหรับ Config

จนถึงตอนนี้ เราดึง logic ที่รับผิดชอบในการ parse อาร์กิวเมนต์ command line จาก main และวางในฟังก์ชัน parse_config การทำเช่นนั้นช่วยให้ เราเห็นว่าค่า query และ file_path เกี่ยวข้องกัน และความสัมพันธ์ นั้นควรถูกสื่อในโค้ดของเรา จากนั้นเราเพิ่ม struct Config เพื่อตั้ง ชื่อจุดประสงค์ที่เกี่ยวข้องของ query และ file_path และเพื่อสามารถ return ชื่อค่าเป็นชื่อ field ของ struct จากฟังก์ชัน parse_config

ดังนั้น ตอนนี้จุดประสงค์ของฟังก์ชัน parse_config คือสร้าง instance Config เราเปลี่ยน parse_config จากฟังก์ชันธรรมดาเป็นฟังก์ชันชื่อ new ที่ associate กับ struct Config ได้ การเปลี่ยนแปลงนี้จะทำให้ โค้ด idiomatic มากขึ้น เราสร้าง instance ของ type ใน standard library เช่น String ได้โดยเรียก String::new ในทำนองเดียวกัน โดย เปลี่ยน parse_config เป็นฟังก์ชัน new ที่ associate กับ Config เราจะสร้าง instance ของ Config ได้โดยเรียก Config::new Listing 12-7 แสดงการเปลี่ยนแปลงที่เราต้องทำ

Filename: src/main.rs
use std::env;
use std::fs;

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

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-7: เปลี่ยน parse_config เป็น Config::new

เราอัพเดท main ที่เรากำลังเรียก parse_config ให้เรียก Config::new แทน เราเปลี่ยนชื่อ parse_config เป็น new และย้ายมันภายใน block impl ซึ่ง associate ฟังก์ชัน new กับ Config ลองคอมไพล์โค้ดนี้ อีกครั้งเพื่อให้แน่ใจว่ามันทำงาน

แก้การจัดการ Error

ตอนนี้เราจะทำงานแก้การจัดการ error ของเรา จำได้ว่าการพยายามเข้าถึงค่า ใน vector args ที่ index 1 หรือ index 2 จะทำให้โปรแกรม panic ถ้า vector มี item น้อยกว่าสาม ลองรันโปรแกรมโดยไม่มีอาร์กิวเมนต์ — มันจะ ดูแบบนี้:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

บรรทัด index out of bounds: the len is 1 but the index is 1 เป็น ข้อความ error ที่มีไว้สำหรับ programmer มันจะไม่ช่วย end user ของเรา เข้าใจว่าพวกเขาควรทำอะไรแทน มาแก้ตอนนี้

ปรับปรุงข้อความ Error

ใน Listing 12-8 เราเพิ่มการตรวจสอบในฟังก์ชัน new ที่จะตรวจสอบว่า slice ยาวพอก่อนเข้าถึง index 1 และ index 2 ถ้า slice ไม่ยาวพอ โปรแกรม panic และแสดงข้อความ error ที่ดีขึ้น

Filename: src/main.rs
use std::env;
use std::fs;

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

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

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

        Config { query, file_path }
    }
}
Listing 12-8: เพิ่มการตรวจสอบสำหรับจำนวนอาร์กิวเมนต์

โค้ดนี้คล้ายกับ ฟังก์ชัน Guess::new ที่เราเขียนใน Listing 9-13 ที่เราเรียก panic! เมื่ออาร์กิวเมนต์ value อยู่นอกช่วงค่าที่ valid แทนที่จะตรวจสอบสำหรับช่วงของค่าที่นี่ เรากำลังตรวจสอบว่าความยาวของ args อย่างน้อย 3 และส่วนที่เหลือของฟังก์ชันทำงานภายใต้สมมุติฐาน ว่าเงื่อนไขนี้เป็นจริง ถ้า args มี item น้อยกว่าสาม เงื่อนไขนี้จะ เป็น true และเราเรียกมาโคร panic! เพื่อจบโปรแกรมทันที

ด้วยโค้ดเพิ่มสองสามบรรทัดใน new มารันโปรแกรมโดยไม่มีอาร์กิวเมนต์ อีกครั้ง เพื่อดูว่า error ดูเป็นยังไงตอนนี้:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

output นี้ดีขึ้น — ตอนนี้เรามีข้อความ error ที่สมเหตุสมผล อย่างไรก็ ตาม เรามีข้อมูลส่วนเกินที่เราไม่ต้องการให้ user ของเรา บางทีเทคนิคที่ เราใช้ใน Listing 9-13 ไม่ใช่ที่ดีที่สุดที่จะใช้ที่นี่ — การเรียก panic! เหมาะกับปัญหา programming มากกว่าปัญหา usage ดังที่พูดถึงในบทที่ 9 แทน เรา จะใช้เทคนิคอีกอย่างที่คุณเรียนในบทที่ 9 — return Result ที่ระบุความสำเร็จหรือ error

Return Result แทนการเรียก panic!

แทน เรา return ค่า Result ที่จะบรรจุ instance Config ในกรณี สำเร็จและจะอธิบายปัญหาในกรณี error ได้ เรายังจะเปลี่ยนชื่อฟังก์ชัน จาก new เป็น build เพราะ programmer หลายคนคาดหวังให้ฟังก์ชัน new ไม่ fail เมื่อ Config::build กำลังสื่อสารกับ main เราใช้ type Result เพื่อส่งสัญญาณว่ามีปัญหาได้ จากนั้น เราเปลี่ยน main เพื่อแปลง variant Err เป็น error ที่ practical มากขึ้นสำหรับ user ของเราโดยไม่มี text ล้อมรอบเกี่ยวกับ thread 'main' และ RUST_BACKTRACE ที่การเรียก panic! ก่อให้เกิด

Listing 12-9 แสดงการเปลี่ยนแปลงที่เราต้องทำกับค่า return ของฟังก์ชัน ที่ตอนนี้เรียก Config::build และ body ของฟังก์ชันที่จำเป็นเพื่อ return Result สังเกตว่านี่จะไม่คอมไพล์จนกว่าเราจะอัพเดท main ด้วย ซึ่งเราจะทำใน listing ถัดไป

Filename: src/main.rs
use std::env;
use std::fs;

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

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}
Listing 12-9: Return Result จาก Config::build

ฟังก์ชัน build ของเรา return Result พร้อม instance Config ใน กรณีสำเร็จและ string literal ในกรณี error ค่า error ของเราจะเป็น string literal ที่มี lifetime 'static เสมอ

เราทำสองการเปลี่ยนแปลงใน body ของฟังก์ชัน — แทนที่จะเรียก panic! เมื่อ user ไม่ส่งอาร์กิวเมนต์เพียงพอ ตอนนี้เรา return ค่า Err และ เรา wrap ค่า return Config ใน Ok การเปลี่ยนแปลงเหล่านี้ทำให้ ฟังก์ชันสอดคล้องกับ type signature ใหม่ของมัน

การ return ค่า Err จาก Config::build ให้ฟังก์ชัน main จัดการค่า Result ที่ return จากฟังก์ชัน build และออกจาก process สะอาดขึ้น ในกรณี error

เรียก Config::build และจัดการ Error

ในการจัดการกรณี error และ print ข้อความที่เป็นมิตรกับ user เราต้อง อัพเดท main เพื่อจัดการ Result ที่ return โดย Config::build ดังที่แสดงใน Listing 12-10 เรายังจะรับความรับผิดชอบของการออกจาก เครื่องมือ command line ด้วย error code ที่ไม่เป็นศูนย์จาก panic! และ implement มันด้วยมือแทน exit status ที่ไม่เป็นศูนย์เป็นธรรมเนียม ที่จะส่งสัญญาณไปยัง process ที่เรียกโปรแกรมของเราว่าโปรแกรมออกด้วย สถานะ error

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

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);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}
Listing 12-10: ออกด้วย error code ถ้า build Config fail

ใน listing นี้ เราใช้เมธอดที่เรายังไม่ครอบคลุมในรายละเอียด — unwrap_or_else ซึ่งนิยามบน Result<T, E> โดย standard library การ ใช้ unwrap_or_else ให้เรานิยามการจัดการ error แบบกำหนดเองที่ไม่ใช่ panic! ถ้า Result เป็นค่า Ok พฤติกรรมของเมธอดนี้คล้ายกับ unwrap — มัน return ค่าภายในที่ Ok กำลัง wrap อย่างไรก็ตาม ถ้าค่า เป็นค่า Err เมธอดนี้เรียกโค้ดใน closure ซึ่งเป็นฟังก์ชัน anonymous ที่เรานิยามและส่งเป็นอาร์กิวเมนต์ให้ unwrap_or_else เราจะครอบคลุม closure ในรายละเอียดมากขึ้นใน บทที่ 13 ตอนนี้ คุณแค่ต้องรู้ว่า unwrap_or_else จะส่งค่าภายในของ Err ซึ่งในกรณีนี้ คือ string static "not enough arguments" ที่เราเพิ่มใน Listing 12-9 ไปยัง closure ของเราในอาร์กิวเมนต์ err ที่ปรากฏระหว่าง vertical pipe โค้ดใน closure จากนั้นใช้ค่า err ได้เมื่อมันรัน

เราเพิ่มบรรทัด use ใหม่เพื่อนำ process จาก standard library เข้า scope โค้ดใน closure ที่จะถูกรันในกรณี error มีเพียงสองบรรทัด — เรา print ค่า err แล้วเรียก process::exit ฟังก์ชัน process::exit จะหยุดโปรแกรมทันทีและ return ตัวเลขที่ถูกส่งเป็น exit status code นี่คล้ายกับการจัดการแบบ panic! ที่เราใช้ใน Listing 12-8 แต่เราไม่ ได้รับ output ส่วนเกินทั้งหมดอีก ลอง:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

ดี! output นี้เป็นมิตรกับ user ของเรามากกว่ามาก

ดึง Logic จาก main

ตอนนี้เราเสร็จ refactor การ parse configuration แล้ว มาหันไปยัง logic ของโปรแกรม ดังที่เราระบุใน “แยก Concern ในโปรเจกต์ Binary” เราจะดึงฟังก์ชันชื่อ run ที่จะเก็บ logic ทั้งหมดที่ปัจจุบันอยู่ใน ฟังก์ชัน main ที่ไม่เกี่ยวข้องกับการตั้งค่า configuration หรือ จัดการ error เมื่อเราเสร็จ ฟังก์ชัน main จะกระชับและตรวจสอบความ ถูกต้องโดยการตรวจดูง่าย และเราจะเขียนเทสสำหรับ logic อื่นทั้งหมดได้

Listing 12-11 แสดงการปรับปรุงเล็ก ๆ ทีละนิดของการดึงฟังก์ชัน run

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

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

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

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}
Listing 12-11: ดึงฟังก์ชัน run ที่บรรจุ logic ที่เหลือของโปรแกรม

ฟังก์ชัน run ตอนนี้บรรจุ logic ที่เหลือทั้งหมดจาก main เริ่มจาก การอ่านไฟล์ ฟังก์ชัน run รับ instance Config เป็นอาร์กิวเมนต์

Return Error จาก run

เมื่อ logic โปรแกรมที่เหลือถูกแยกเข้าฟังก์ชัน run เราปรับปรุงการ จัดการ error ได้ เหมือนที่เราทำกับ Config::build ใน Listing 12-9 แทนที่จะให้โปรแกรม panic โดยเรียก expect ฟังก์ชัน run จะ return Result<T, E> เมื่อสิ่งใดผิดพลาด นี่จะให้เรารวม logic ในการจัดการ error เข้า main ในแบบที่เป็นมิตรกับ user เพิ่ม Listing 12-12 แสดง การเปลี่ยนแปลงที่เราต้องทำกับ signature และ body ของ run

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


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);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

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

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}
Listing 12-12: เปลี่ยนฟังก์ชัน run ให้ return Result

เราทำการเปลี่ยนแปลงสำคัญสามอย่างที่นี่ ก่อนอื่น เราเปลี่ยน return type ของฟังก์ชัน run เป็น Result<(), Box<dyn Error>> ฟังก์ชันนี้ ก่อนหน้านี้ return unit type () และเราเก็บอันนั้นเป็นค่าที่ return ในกรณี Ok

สำหรับ error type เราใช้ trait object Box<dyn Error> (และเรานำ std::error::Error เข้า scope ด้วย statement use ที่ด้านบน) เราจะ ครอบคลุม trait object ใน บทที่ 18 ตอนนี้ แค่ รู้ว่า Box<dyn Error> หมายความว่าฟังก์ชันจะ return type ที่ implement trait Error แต่เราไม่ต้องระบุว่าค่า return จะเป็น type เฉพาะเจาะจงอะไร นี่ให้เรามีความยืดหยุ่นที่จะ return ค่า error ที่อาจ เป็น type ต่างกันในกรณี error ต่างกัน keyword dyn ย่อมาจาก dynamic

ที่สอง เราลบการเรียก expect ในความนิยมของ operator ? ดังที่เรา พูดถึงใน บทที่ 9 แทนที่จะ panic! บน error ? จะ return ค่า error จากฟังก์ชันปัจจุบันให้ caller จัดการ

ที่สาม ฟังก์ชัน run ตอนนี้ return ค่า Ok ในกรณีสำเร็จ เราประกาศ success type ของฟังก์ชัน run เป็น () ใน signature แปลว่าเราต้อง wrap ค่า unit type ในค่า Ok syntax Ok(()) นี้อาจดูแปลก ๆ ในตอน แรก แต่การใช้ () แบบนี้เป็นวิธี idiomatic ที่จะระบุว่าเรากำลัง เรียก run เพื่อ side effect เท่านั้น — มันไม่ return ค่าที่เรา ต้องการ

เมื่อคุณรันโค้ดนี้ มันจะคอมไพล์แต่จะแสดง warning:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust บอกเราว่าโค้ดของเรา ignore ค่า Result และค่า Result อาจระบุ ว่ามี error เกิดขึ้น แต่เราไม่ได้ตรวจสอบดูว่ามี error หรือไม่ และ compiler เตือนเราว่าเราน่าจะตั้งใจให้มีโค้ดจัดการ error ที่นี่! มา แก้ปัญหานั้นตอนนี้

จัดการ Error ที่ Return จาก run ใน main

เราจะตรวจสอบ error และจัดการพวกมันโดยใช้เทคนิคคล้ายกับที่เราใช้กับ Config::build ใน Listing 12-10 แต่ต่างกันเล็กน้อย:

Filename: src/main.rs

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

fn main() {
    // --snip--

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

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

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

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

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

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}

เราใช้ if let แทน unwrap_or_else เพื่อตรวจสอบว่า run return ค่า Err ไหม และเรียก process::exit(1) ถ้าใช่ ฟังก์ชัน run ไม่ return ค่าที่เราต้องการ unwrap ในแบบเดียวกับที่ Config::build return instance Config เพราะ run return () ในกรณีสำเร็จ เราสนใจเฉพาะ การตรวจจับ error ดังนั้นเราไม่ต้องการ unwrap_or_else ในการ return ค่าที่ unwrap แล้ว ซึ่งจะเป็นเพียง ()

body ของ if let และฟังก์ชัน unwrap_or_else เหมือนกันในทั้งสอง กรณี — เรา print error และออก

แยกโค้ดเข้า Library Crate

โปรเจกต์ minigrep ของเราดูดีจนถึงตอนนี้! ตอนนี้เราจะแยกไฟล์ src/main.rs และใส่โค้ดบางส่วนใน src/lib.rs ด้วยวิธีนั้น เราทดสอบ โค้ดได้ และมีไฟล์ src/main.rs ที่มีความรับผิดชอบน้อยลง

มานิยามโค้ดที่รับผิดชอบในการค้นหา text ใน src/lib.rs แทนใน src/main.rs ซึ่งจะให้เรา (หรือใครก็ตามอื่นที่ใช้ library minigrep ของเรา) เรียกฟังก์ชันการค้นหาจากหลาย context มากกว่า binary minigrep ของเรา

ก่อนอื่น มานิยาม signature ของฟังก์ชัน search ใน src/lib.rs ดังที่ แสดงใน Listing 12-13 พร้อม body ที่เรียกมาโคร unimplemented! เรา จะอธิบาย signature ในรายละเอียดมากขึ้นเมื่อเราเติม implementation

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: นิยามฟังก์ชัน search ใน src/lib.rs

เราใช้ keyword pub บนนิยามฟังก์ชันเพื่อกำหนด search เป็นส่วนหนึ่ง ของ public API ของ library crate ของเรา ตอนนี้เรามี library crate ที่เราใช้จาก binary crate ของเราได้และทดสอบได้!

ตอนนี้เราต้องนำโค้ดที่นิยามใน src/lib.rs เข้า scope ของ binary crate ใน src/main.rs และเรียกมัน ดังที่แสดงใน Listing 12-14

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

fn main() {
    // --snip--
    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);
    }
}

// --snip--


struct Config {
    query: String,
    file_path: String,
}

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();

        Ok(Config { query, file_path })
    }
}

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

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}
Listing 12-14: ใช้ฟังก์ชัน search ของ library crate minigrep ใน src/main.rs

เราเพิ่มบรรทัด use minigrep::search เพื่อนำฟังก์ชัน search จาก library crate เข้า scope ของ binary crate จากนั้น ในฟังก์ชัน run แทนที่จะ print เนื้อหาของไฟล์ เราเรียกฟังก์ชัน search และส่งค่า config.query และ contents เป็นอาร์กิวเมนต์ จากนั้น run จะใช้ loop for เพื่อ print แต่ละบรรทัดที่ return จาก search ที่ตรงกับ query นี่ยังเป็นเวลาดีที่จะลบการเรียก println! ในฟังก์ชัน main ที่ แสดง query และ file path เพื่อให้โปรแกรมของเรา print เฉพาะผลค้นหา (ถ้าไม่มี error เกิดขึ้น)

สังเกตว่าฟังก์ชัน search จะ collect ผลทั้งหมดเข้า vector ที่มัน return ก่อนที่ print ใด ๆ จะเกิดขึ้น implementation นี้อาจช้าในการแสดงผลเมื่อ ค้นหาไฟล์ใหญ่ เพราะผลไม่ถูก print เมื่อพวกมันถูกพบ — เราจะพูดถึงวิธี ที่เป็นไปได้ในการแก้สิ่งนี้โดยใช้ iterator ในบทที่ 13

โอ้ว! นั่นเป็นงานเยอะ แต่เราตั้งตัวเองเพื่อความสำเร็จในอนาคตแล้ว ตอน นี้ง่ายขึ้นมากที่จะจัดการ error และเราทำให้โค้ด modular มากขึ้น เกือบ ทั้งหมดของงานเราจะทำใน src/lib.rs ตั้งแต่ตอนนี้ไป

มาใช้ประโยชน์จาก modularity ที่เพิ่งค้นพบนี้โดยทำสิ่งที่จะยากด้วยโค้ด เก่า แต่ง่ายด้วยโค้ดใหม่ — เราจะเขียนเทสบางตัว!