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

การปรับปรุงโปรเจกต์ I/O ของเรา

ด้วยความรู้ใหม่เกี่ยวกับตัวซ้ำนี้ เราสามารถปรับปรุงโปรเจกต์ I/O ในบทที่ 12 ได้ โดยการใช้ตัวซ้ำเพื่อทำให้จุดต่าง ๆ ในโค้ดมีความชัดเจนและกระชับยิ่งขึ้น มาดูกันว่าตัวซ้ำสามารถปรับปรุงการประยุกต์ใช้งานฟังก์ชัน Config::build และฟังก์ชัน search ของเราได้อย่างไร

การถอด clone ออกโดยใช้ตัวซ้ำ

ในโค้ดตัวอย่างที่ 12-6 เราได้เพิ่มโค้ดที่รับ slice ของค่า String และสร้างอินสแตนซ์ของ struct Config โดยการเข้าถึงดัชนีใน slice และทำสำเนา (clone) ค่านั้น ๆ เพื่อให้ struct Config เป็นเจ้าของค่าเหล่านั้น ในโค้ดตัวอย่างที่ 13-17 เราได้แสดงการประยุกต์ใช้ฟังก์ชัน Config::build ซ้ำอีกครั้งตามที่เคยอยู่ในโค้ดตัวอย่างที่ 12-23

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

use minigrep::{search, search_case_insensitive};

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ในเวลานั้น เราได้บอกว่าอย่าเพิ่งกังวลเกี่ยวกับการเรียก clone ที่ไม่มีประสิทธิภาพ เพราะเราจะถอดออกในอนาคต และตอนนี้เวลานั้นก็มาถึงแล้ว!

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

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

เมื่อ Config::build รับความเป็นเจ้าของของตัวซ้ำและเลิกใช้การดำเนินการเข้าถึงดัชนีที่ทำการยืมแล้ว เราจะสามารถย้ายค่า String จากตัวซ้ำเข้าไปใน Config ได้โดยตรง แทนที่จะต้องเรียก clone และสร้างการจัดสรรหน่วยความจำ (allocation) ใหม่

การใช้ตัวซ้ำที่ถูกคืนกลับมาโดยตรง

เปิดไฟล์ src/main.rs ของโปรเจกต์ I/O ของคุณ ซึ่งควรจะดูเป็นดังนี้:

Filename: src/main.rs

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

use minigrep::{search, search_case_insensitive};

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

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

    // --snip--

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

อันดับแรก เราจะเปลี่ยนส่วนเริ่มต้นของฟังก์ชัน main ที่เรามีในโค้ดตัวอย่างที่ 12-24 เป็นโค้ดในโค้ดตัวอย่างที่ 13-18 ซึ่งในครั้งนี้จะใช้ตัวซ้ำ โค้ดนี้ยังไม่สามารถคอมไพล์ได้จนกว่าเราจะอัปเดต Config::build ด้วยเช่นกัน

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

use minigrep::{search, search_case_insensitive};

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

    // --snip--

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ฟังก์ชัน env::args จะคืนค่าเป็นตัวซ้ำ! แทนที่จะรวบรวมค่าของตัวซ้ำลงในเวกเตอร์แล้วส่ง slice ให้กับ Config::build ตอนนี้เราส่งต่อความเป็นเจ้าของของตัวซ้ำที่ได้จาก env::args ให้กับ Config::build โดยตรง

ถัดไป เราจำเป็นต้องอัปเดตนิยามของ Config::build มาเปลี่ยนลายเซ็น (signature) ของ Config::build ให้ดูเหมือนโค้ดตัวอย่างที่ 13-19 โค้ดนี้ยังคงไม่สามารถคอมไพล์ได้เพราะเราต้องอัปเดตเนื้อหาของฟังก์ชันด้วย

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

use minigrep::{search, search_case_insensitive};

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

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

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

เอกสารไลบรารีมาตรฐานสำหรับฟังก์ชัน env::args แสดงให้เห็นว่าชนิดข้อมูลของตัวซ้ำที่มันคืนค่าคือ std::env::Args และชนิดข้อมูลนั้นประยุกต์ใช้เทรต Iterator และคืนค่าเป็น String

เราได้อัปเดตลายเซ็นของฟังก์ชัน Config::build เพื่อให้พารามิเตอร์ args มีชนิดข้อมูลทั่วไป (generic type) พร้อมขอบเขตของเทรต (trait bound) คือ impl Iterator<Item = String> แทนที่จะเป็น &[String] การใช้ไวยากรณ์ impl Trait นี้ที่เราได้พูดถึงในหัวข้อ “การใช้เทรตเป็นพารามิเตอร์” ของบทที่ 10 หมายความว่า args สามารถเป็นชนิดข้อมูลใดก็ได้ที่ประยุกต์ใช้เทรต Iterator และคืนค่าสมาชิกเป็น String

เนื่องจากเรารับความเป็นเจ้าของของ args และเราจะเปลี่ยนแปลงค่า args โดยการวนซ้ำเหนือมัน เราจึงสามารถเพิ่มคีย์เวิร์ด mut ไว้ในการระบุพารามิเตอร์ args เพื่อทำให้มันสามารถแก้ไขได้ (mutable)

การใช้เมธอดของเทรต Iterator

ถัดไป เราจะแก้ไขเนื้อหาของ Config::build เนื่องจาก args ประยุกต์ใช้เทรต Iterator เราจึงรู้ว่าเราสามารถเรียกใช้เมธอด next บนตัวมันได้! โค้ดตัวอย่างที่ 13-20 จะอัปเดตโค้ดจากโค้ดตัวอย่างที่ 12-23 ให้ใช้เมธอด next

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

use minigrep::{search, search_case_insensitive};

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

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

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

โปรดจำไว้ว่าค่าแรกในค่าคืนกลับของ env::args คือชื่อของโปรแกรม เราต้องการข้ามค่านั้นและไปยังค่าถัดไป ดังนั้นอันดับแรกเราจึงเรียก next โดยไม่ต้องทำอะไรกับค่าที่ได้ จากนั้น เราเรียก next อีกครั้งเพื่อดึงค่าที่เราต้องการใส่ลงในฟิลด์ query ของ Config หาก next คืนค่า Some เราจะใช้ match เพื่อดึงค่านั้นออกมา แต่ถ้าคืนค่า None หมายความว่าให้พารามิเตอร์มาไม่เพียงพอ และเราจะคืนค่ากลับล่วงหน้าด้วยค่า Err เราทำเช่นเดียวกันสำหรับค่า file_path

การทำให้โค้ดชัดเจนขึ้นด้วยตัวปรับแต่งตัวซ้ำ

เรายังสามารถใช้ประโยชน์จากตัวซ้ำในฟังก์ชัน search ของโปรเจกต์ I/O ของเราได้ด้วย ซึ่งฉายซ้ำไว้ที่นี่ในโค้ดตัวอย่างที่ 13-21 ตามที่เคยเป็นในโค้ดตัวอย่างที่ 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));
    }
}

เราสามารถเขียนโค้ดนี้ให้กระชับยิ่งขึ้นโดยใช้เมธอดตัวปรับแต่งตัวซ้ำ การทำเช่นนี้ยังช่วยให้เราหลีกเลี่ยงการมีเวกเตอร์ชั่วคราวแบบแก้ไขได้อย่าง results รูปแบบการเขียนโปรแกรมเชิงฟังก์ชัน (functional programming style) นิยมที่จะลดการมีสถานะที่แก้ไขได้ให้น้อยที่สุดเพื่อให้โค้ดชัดเจนขึ้น การถอดสถานะที่แก้ไขได้ออกอาจช่วยให้สามารถปรับปรุงในอนาคตเพื่อทำให้การค้นหาเกิดขึ้นแบบขนาน (in parallel) ได้ เพราะเราไม่ต้องจัดการการเข้าถึงเวกเตอร์ results พร้อมกันหลายเธรด โค้ดตัวอย่างที่ 13-22 แสดงการเปลี่ยนแปลงนี้

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

โปรดระลึกว่าจุดประสงค์ของฟังก์ชัน search คือการคืนค่าทุกบรรทัดใน contents ที่มี query บรรจุอยู่ คล้ายกับตัวอย่าง filter ในโค้ดตัวอย่างที่ 13-16 โค้ดนี้ใช้ตัวปรับแต่ง filter เพื่อเก็บเฉพาะบรรทัดที่ line.contains(query) คืนค่าเป็น true จากนั้นเรารวบรวมบรรทัดที่ตรงกันลงในอีกเวกเตอร์หนึ่งด้วย collect ง่ายขึ้นมาก! ลองเปลี่ยนไปใช้เมธอดตัวซ้ำในฟังก์ชัน search_case_insensitive ด้วยเช่นกันได้เลย

สำหรับการปรับปรุงเพิ่มเติมอีกขั้น ให้คืนค่าเป็นตัวซ้ำจากฟังก์ชัน search โดยการถอดการเรียก collect ออก และเปลี่ยนชนิดข้อมูลค่าคืนกลับเป็น impl Iterator<Item = &'a str> เพื่อให้ฟังก์ชันกลายเป็นตัวปรับแต่งตัวซ้ำ สังเกตว่าคุณจำเป็นต้องอัปเดตการทดสอบด้วย! ลองค้นหาในไฟล์ขนาดใหญ่โดยใช้เครื่องมือ minigrep ของคุณก่อนและหลังการเปลี่ยนแปลงนี้เพื่อสังเกตความแตกต่างของพฤติกรรม ก่อนการเปลี่ยนแปลงนี้ โปรแกรมจะไม่พิมพ์ผลลัพธ์ใด ๆ จนกว่าจะรวบรวมผลลัพธ์ทั้งหมดเสร็จสิ้น แต่หลังจากเปลี่ยนแปลง ผลลัพธ์จะถูกพิมพ์ทันทีที่พบบรรทัดที่ตรงกันแต่ละบรรทัด เนื่องจากลูป for ในฟังก์ชัน run สามารถใช้ประโยชน์จากความเกียจคร้าน (laziness) ของตัวซ้ำได้

การเลือกระหว่างลูปและตัวซ้ำ

คำถามถัดไปที่สมเหตุสมผลคือคุณควรเลือกรูปแบบใดในโค้ดของคุณเองและเพราะเหตุใด: ระหว่างการประยุกต์ใช้แบบดั้งเดิมในโค้ดตัวอย่างที่ 13-21 หรือเวอร์ชันที่ใช้ตัวซ้ำในโค้ดตัวอย่างที่ 13-22 (โดยสมมติว่าเรารวบรวมผลลัพธ์ทั้งหมดก่อนจะคืนค่ากลับแทนที่จะคืนค่าตัวซ้ำออกมา) โปรแกรมเมอร์ Rust ส่วนใหญ่นิยมใช้รูปแบบตัวซ้ำ มันอาจจะเข้าใจยากกว่าเล็กน้อยในตอนแรก แต่เมื่อคุณคุ้นเคยกับตัวปรับแต่งตัวซ้ำแบบต่าง ๆ และสิ่งที่พวกมันทำแล้ว ตัวซ้ำอาจจะเข้าใจได้ง่ายกว่า แทนที่จะต้องยุ่งกับรายละเอียดของการลูปและการสร้างเวกเตอร์ใหม่ โค้ดจะมุ่งเน้นไปที่เป้าหมายระดับสูงของลูป สิ่งนี้จะช่วยซ่อนโค้ดพื้นฐานทั่วไปเพื่อให้อ่านแนวคิดที่เป็นเอกลักษณ์ของโค้ดนี้ได้ง่ายขึ้น เช่น เงื่อนไขการกรองที่สมาชิกแต่ละตัวในตัวซ้ำต้องผ่าน

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