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

การรีแฟกเตอร์เพื่อปรับปรุงมอดุลาลิตี้และการจัดการข้อผิดพลาด (Refactoring to Improve Modularity and Error Handling)

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

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

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

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

ลองมาแก้ปัญหาทั้ง 4 ประการนี้โดยการรีแฟกเตอร์โปรเจกต์ของเรากัน

การแยกส่วนความรับผิดชอบในโปรเจกต์ไบนารี (Separating Concerns in Binary Projects)

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

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

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

  • การเรียกใช้ตรรกะการแกะบรรทัดคำสั่งด้วยค่าอาร์กิวเมนต์
  • การตั้งค่าการกำหนดค่าอื่นๆ
  • การเรียกใช้ฟังก์ชัน run ใน lib.rs
  • การจัดการกับข้อผิดพลาดหาก run คืนค่าข้อผิดพลาดกลับมา

รูปแบบนี้เป็นเรื่องของการแยกส่วนความรับผิดชอบ (Separation of Concerns): main.rs จัดการการรันโปรแกรม และ lib.rs จัดการตรรกะทั้งหมดของงานที่มีอยู่ เนื่องจากคุณไม่สามารถทดสอบฟังก์ชัน main ได้โดยตรง โครงสร้างนี้จึงช่วยให้คุณทดสอบตรรกะทั้งหมดของโปรแกรมได้โดยการย้ายมันออกจากฟังก์ชัน main โค้ดที่เหลืออยู่ในฟังก์ชัน main จะมีขนาดเล็กพอที่จะตรวจสอบความถูกต้องได้เพียงแค่การอ่าน ลองทำโปรแกรมของเราใหม่โดยทำตามกระบวนการนี้กัน

การสกัดตัวแกะอาร์กิวเมนต์ (Extracting the Argument Parser)

เราจะสกัดฟังก์ชันการทำงานสำหรับการแกะอาร์กิวเมนต์ออกเป็นฟังก์ชันที่ main จะเรียกใช้ Listing 12-5 แสดงจุดเริ่มต้นใหม่ของฟังก์ชัน main ที่เรียกใช้ฟังก์ชันใหม่ชื่อ parse_config ซึ่งเราจะนิยามใน 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)
}

เรายังคงรวบรวมอาร์กิวเมนต์บรรทัดคำสั่งลงในเวกเตอร์เหมือนเดิม แต่แทนที่จะกำหนดค่าอาร์กิวเมนต์ที่ดัชนี 1 ให้กับตัวแปร query และค่าอาร์กิวเมนต์ที่ดัชนี 2 ให้กับตัวแปร file_path ภายในฟังก์ชัน main เราจะส่งเวกเตอร์ทั้งหมดไปยังฟังก์ชัน parse_config จากนั้นฟังก์ชัน parse_config จะถือตรรกะที่กำหนดว่าอาร์กิวเมนต์ใดไปที่ตัวแปรใด แล้วส่งค่านั้นกลับไปยัง main เรายังคงสร้างตัวแปร query และ file_path ใน main แต่ main จะไม่มีหน้าที่ในการกำหนดว่าอาร์กิวเมนต์บรรทัดคำสั่งและตัวแปรสอดคล้องกันอย่างไรอีกต่อไป

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

การจัดกลุ่มค่าการกำหนดค่า (Grouping Configuration Values)

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

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

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

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

เราได้เพิ่มสตรักต์ชื่อ Config ที่นิยามให้มีฟิลด์ชื่อ query และ file_path ซิกเนเจอร์ของ parse_config ตอนนี้ระบุว่ามันคืนค่าเป็น Config ในเนื้อหาของ parse_config จากเดิมที่เราเคยคืนค่า string slice ที่อ้างอิงถึงค่า String ใน args ตอนนี้เราได้นิยาม Config ให้บรรจุค่า String ที่เป็นเจ้าของเอง ตัวแปร args ใน main เป็นเจ้าของค่าอาร์กิวเมนต์และยอมให้ฟังก์ชัน parse_config ยืมพวกมันไปเท่านั้น ซึ่งหมายความว่าเราจะละเมิดกฎการยืมของ Rust หาก Config พยายามที่จะรับเอาความเป็นเจ้าของของค่าใน args

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

ข้อแลกเปลี่ยนของการใช้ clone

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

เราได้อัปเดต main เพื่อให้วางอินสแตนซ์ของ Config ที่คืนค่าโดย parse_config ลงในตัวแปรชื่อ config และเราได้อัปเดตโค้ดที่เคยใช้ตัวแปร query และ file_path แยกกัน เพื่อให้ตอนนี้ใช้ฟิลด์บนสตรักต์ Config แทน

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

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

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

ดังนั้น ตอนนี้เมื่อวัตถุประสงค์ของฟังก์ชัน parse_config คือการสร้างอินสแตนซ์ Config เราสามารถเปลี่ยน parse_config จากฟังก์ชันธรรมดาให้กลายเป็นฟังก์ชันชื่อ new ที่เกี่ยวข้องกับสตรักต์ Config ได้ การทำการเปลี่ยนแปลงนี้จะทำให้โค้ดมีความเป็นธรรมชาติ (idiomatic) ยิ่งขึ้น เราสามารถสร้างอินสแตนซ์ของประเภทข้อมูลในไลบรารีมาตรฐาน เช่น String ได้โดยการเรียก String::new ในทำนองเดียวกัน โดยการเปลี่ยน parse_config ให้เป็นฟังก์ชัน new ที่เกี่ยวข้องกับ Config เราจะสามารถสร้างอินสแตนซ์ของ Config ได้โดยการเรียก Config::new Listing 12-7 แสดงการเปลี่ยนแปลงที่เราต้องทำ

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

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

การแก้ไขการจัดการข้อผิดพลาด (Fixing the Error Handling)

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

$ 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' (6023615) 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 เป็นข้อความข้อผิดพลาดที่ระบุไว้สำหรับโปรแกรมเมอร์ มันไม่ได้ช่วยให้ผู้ใช้ปลายทางเข้าใจสิ่งที่พวกเขาควรทำแทน ลองแก้ไขสิ่งนี้กัน

การปรับปรุงข้อความข้อผิดพลาด (Improving the Error Message)

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

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

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

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

$ 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' (6023776) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

การคืนค่า Result แทนการเรียกใช้ panic!

เราสามารถคืนค่า Result ซึ่งจะบรรจุอินสแตนซ์ Config ในกรณีที่สำเร็จ และจะอธิบายปัญหาในกรณีที่เกิดข้อผิดพลาด นอกจากนี้ เรายังจะเปลี่ยนชื่อฟังก์ชันจาก new เป็น build เนื่องจากโปรแกรมเมอร์หลายคนคาดหวังว่าฟังก์ชัน new จะไม่มีวันล้มเหลว เมื่อ Config::build สื่อสารกับ main เราสามารถใช้ประเภทข้อมูล Result เพื่อส่งสัญญาณว่ามีปัญหาเกิดขึ้น จากนั้นเราจะสามารถเปลี่ยน main เพื่อแปลงตัวแปร Err ให้เป็นข้อผิดพลาดที่เหมาะกับการใช้งานจริงมากขึ้นสำหรับผู้ใช้ โดยไม่มีข้อความแวดล้อมเกี่ยวกับ thread 'main' และ RUST_BACKTRACE ที่เกิดจากการเรียกใช้ panic!

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

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

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

เราได้ทำการเปลี่ยนแปลงสองอย่างในเนื้อหาของฟังก์ชัน: แทนที่จะเรียกใช้ panic! เมื่อผู้ใช้ไม่ได้ส่งอาร์กิวเมนต์ที่เพียงพอ ตอนนี้เราคืนค่า Err และเราได้ห่อค่าคืนกลับ Config ไว้ใน Ok การเปลี่ยนแปลงเหล่านี้ทำให้ฟังก์ชันสอดคล้องกับประเภทคืนค่าใหม่

การคืนค่า Err จาก Config::build ช่วยให้ฟังก์ชัน main สามารถจัดการกับค่า Result ที่คืนกลับมาจากฟังก์ชัน build และออกจากโปรเซสได้อย่างเป็นระเบียบมากขึ้นในกรณีเกิดข้อผิดพลาด

การเรียกใช้ Config::build และการจัดการข้อผิดพลาด

เพื่อจัดการกับกรณีข้อผิดพลาดและพิมพ์ข้อความที่เป็นมิตรกับผู้ใช้ เราจำเป็นต้องอัปเดต main เพื่อจัดการกับ Result ที่ถูกคืนกลับมาโดย Config::build ดังแสดงใน Listing 12-10 เรายังจะดึงความรับผิดชอบในการออกจากเครื่องมือบรรทัดคำสั่งด้วยรหัสข้อผิดพลาดที่ไม่ใช่ศูนย์ออกจาก panic! แล้วมาเขียนจัดการเอง รหัสสถานะการออกที่ไม่ใช่ศูนย์เป็นข้อตกลงในการส่งสัญญาณไปยังโปรเซสที่เรียกใช้โปรแกรมของเราว่าโปรแกรมจบการทำงานด้วยสถานะข้อผิดพลาด

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 นี้ เราได้ใช้เมธอดที่เรายังไม่ได้ครอบคลุมในรายละเอียดมาก่อน: unwrap_or_else ซึ่งถูกนิยามไว้บน Result<T, E> โดยไลบรารีมาตรฐาน การใช้ unwrap_or_else ช่วยให้เรากำหนดการจัดการข้อผิดพลาดแบบกำหนดเองที่ไม่ใช่ panic! ได้ หาก Result เป็นค่า Ok พฤติกรรมของเมธอดนี้จะคล้ายกับ unwrap: มันจะคืนค่าภายในที่ Ok ห่อหุ้มไว้ อย่างไรก็ตาม หากค่านั้นเป็น Err เมธอดนี้จะเรียกใช้โค้ดใน closure ซึ่งเป็นฟังก์ชันไม่มีชื่อที่เรานิยามและส่งเป็นอาร์กิวเมนต์ไปยัง unwrap_or_else เราจะครอบคลุมเรื่อง closure โดยละเอียดใน บทที่ 13 สำหรับตอนนี้ คุณแค่ต้องรู้ว่า unwrap_or_else จะส่งค่าภายในของ Err ซึ่งในกรณีนี้คือ static string "not enough arguments" ที่เราเพิ่มใน Listing 12-9 ไปยัง closure ในอาร์กิวเมนต์ err ที่ปรากฏระหว่างเครื่องหมายแนวตั้ง (|err|) โค้ดใน closure สามารถใช้ค่า err นั้นได้เมื่อรัน

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

$ 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

เยี่ยมเลย! ผลลัพธ์นี้เป็นมิตรกับผู้ใช้ของเรามากขึ้นมาก

การสกัดตรรกะออกจาก main

เมื่อเรารีแฟกเตอร์การแกะค่ากำหนดการทำงานเสร็จแล้ว คราวนี้เรามาดูตรรกะของโปรแกรมกัน ดังที่เราได้กล่าวไว้ใน “การแยกส่วนความรับผิดชอบในโปรเจกต์ไบนารี” เราจะสกัดฟังก์ชันชื่อ run ซึ่งจะเก็บตรรกะทั้งหมดที่อยู่ในฟังก์ชัน main ในปัจจุบันที่ไม่ได้เกี่ยวกับการตั้งค่ากำหนดการทำงานหรือการจัดการข้อผิดพลาด เมื่อเราทำเสร็จ ฟังก์ชัน main จะกระชับและง่ายต่อการตรวจสอบ และเราจะสามารถเขียนการทดสอบสำหรับตรรกะอื่นๆ ทั้งหมดได้

Listing 12-11 แสดงการปรับปรุงย่อยในการสกัดฟังก์ชัน run

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

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

การคืนค่าข้อผิดพลาดจาก run

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

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

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

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

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

ประการที่สาม ฟังก์ชัน run ตอนนี้คืนค่า Ok ในกรณีที่สำเร็จ เราได้ประกาศประเภทความสำเร็จของฟังก์ชัน run เป็น () ในซิกเนเจอร์ ซึ่งหมายความว่าเราจำเป็นต้องห่อค่า unit type ไว้ในค่า Ok ไวยากรณ์ Ok(()) นี้อาจดูแปลกนิดหน่อยในตอนแรก แต่การใช้ () แบบนี้เป็นวิธีที่เป็นธรรมชาติในการระบุว่าเราเรียกใช้ run เพื่อผลข้างเคียง (side effects) เท่านั้น มันไม่ได้คืนค่าที่เราจำเป็นต้องใช้

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

$ 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)]` (part of `#[warn(unused)]`) 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 บอกเราว่าโค้ดของเราเพิกเฉยต่อค่า Result และค่า Result อาจระบุว่าเกิดข้อผิดพลาดขึ้น แต่เราไม่ได้ตรวจสอบว่าเกิดข้อผิดพลาดหรือไม่ และคอมไพเลอร์เตือนเราว่าเราคงตั้งใจจะมีโค้ดจัดการข้อผิดพลาดอยู่ที่นี่! มาแก้ไขปัญหานั้นกัน

การจัดการข้อผิดพลาดที่คืนมาจาก run ใน main

เราจะตรวจสอบข้อผิดพลาดและจัดการโดยใช้เทคนิคที่คล้ายกับเทคนิคที่เราใช้กับ 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 คืนค่า Err หรือไม่ และเรียกใช้ process::exit(1) หากเป็นเช่นนั้น ฟังก์ชัน run ไม่ได้คืนค่าที่เราต้องการ unwrap ในลักษณะเดียวกับที่ Config::build คืนค่าอินสแตนซ์ Config เนื่องจาก run คืนค่า () ในกรณีสำเร็จ เราจึงใส่ใจเฉพาะการตรวจจับข้อผิดพลาดเท่านั้น ดังนั้นเราจึงไม่จำเป็นต้องใช้ unwrap_or_else เพื่อคืนค่าที่แกะออกมา ซึ่งจะเป็นเพียง () เท่านั้น

เนื้อหาของ if let และฟังก์ชัน unwrap_or_else จะเหมือนกันในทั้งสองกรณี: เราพิมพ์ข้อผิดพลาดออกมาและออกจากโปรแกรม

การแยกโค้ดออกเป็น Library Crate

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

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

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

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

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

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

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

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

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

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

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