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

ข้อผิดพลาดที่สามารถแก้ไขได้ด้วย Result (Recoverable Errors with Result)

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

ย้อนความจากหัวข้อ “การจัดการความล้มเหลวที่เป็นไปได้ด้วย Result ในบทที่ 2 ว่าตัว enum Result นั้นถูกกำหนดขึ้นมาให้มีค่าตัวแปรย่อยสองตัว ได้แก่ Ok และ Err ดังนี้:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

ในจุดนี้ T และ E คือพารามิเตอร์ประเภทข้อมูลแบบเจเนอริก (generic type parameters) ซึ่งเราจะอธิบายเรื่องเจเนอริกในรายละเอียดเพิ่มเติมในบทที่ 10 สิ่งที่คุณจำเป็นต้องทราบในตอนนี้คือ T จะเป็นตัวแทนของประเภทข้อมูลที่จะส่งกลับคืนมาในกรณีที่คำสั่งทำสำเร็จภายใต้ตัวแปรย่อย Ok และ E จะเป็นตัวแทนของประเภทข้อผิดพลาดที่จะส่งกลับคืนมาในกรณีที่ล้มเหลวภายใต้ตัวแปรย่อย Err เนื่องจาก Result มีพารามิเตอร์ชนิดเจเนอริกเหล่านี้ เราจึงสามารถใช้ประเภทข้อมูล Result และเมธอดต่าง ๆ ที่ถูกสร้างขึ้นมาคู่กับมันได้ในสถานการณ์ที่หลากหลาย ซึ่งค่าของผลสำเร็จและค่าของข้อผิดพลาดที่เราต้องการส่งกลับคืนอาจจะแตกต่างกันไป

เรามาลองเรียกฟังก์ชันที่ส่งคืนค่าเป็น Result กันครับ เนื่องจากฟังก์ชันนี้อาจมีความล้มเหลวเกิดขึ้นได้ ในตัวอย่างที่ 9-3 เราจะพยายามเปิดไฟล์ไฟล์หนึ่ง

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

ประเภทข้อมูลที่ส่งกลับคืนจาก File::open คือ Result<T, E> โดยพารามิเตอร์เจเนอริก T จะถูกกำหนดในกระบวนการทำงานของ File::open ให้ใช้ประเภทข้อมูลของผลลัพธ์ที่สำเร็จคือ std::fs::File ซึ่งเป็นข้อมูลอ้างอิงจัดการไฟล์ (file handle) และประเภทข้อมูล E ที่ใช้สำหรับข้อผิดพลาดคือ std::io::Error ประเภทข้อมูลส่งคืนนี้มีความหมายว่า การเรียกใช้งาน File::open อาจสำเร็จและส่งกลับ file handle ที่เราสามารถอ่านหรือเขียนข้อมูลได้ หรือฟังก์ชันนี้อาจทำงานล้มเหลวได้เช่นกัน ตัวอย่างเช่น ไม่มีไฟล์นั้นอยู่ หรือเราไม่มีสิทธิ์เข้าใช้งานไฟล์นั้น ฟังก์ชัน File::open จำเป็นต้องมีหนทางบอกให้เราทราบว่าทำงานสำเร็จหรือล้มเหลว และในขณะเดียวกันก็ต้องมอบ file handle หรือข้อมูลความล้มเหลวมาให้เราด้วย ข้อมูลเหล่านี้คือสิ่งที่ enum Result นำเสนออย่างตรงไปตรงมา

ในกรณีที่ File::open ทำงานสำเร็จ ค่าในตัวแปร greeting_file_result จะเป็นออบเจกต์ Ok ที่มีข้อมูล file handle อยู่ภายใน ในกรณีที่ทำงานล้มเหลว ค่าในตัวแปร greeting_file_result จะเป็นออบเจกต์ Err ที่มีข้อมูลรายละเอียดเพิ่มเติมเกี่ยวกับข้อผิดพลาดที่เกิดขึ้น

เราจำเป็นต้องต่อยอดโค้ดจากตัวอย่างที่ 9-3 เพื่อดำเนินการคำสั่งต่าง ๆ กันไปตามข้อมูลค่าที่ File::open ส่งคืนมา ตัวอย่างที่ 9-4 แสดงวิธีการหนึ่งในการจัดการกับ Result โดยใช้เครื่องมือพื้นฐาน นั่นคือนิพจน์ match ที่เราได้เรียนรู้กันไปในบทที่ 6

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

สังเกตว่า เช่นเดียวกับ enum Option ตัวแปร enum Result และตัวแปรย่อยต่าง ๆ ของมันจะถูกนำเข้าสู่ขอบเขตการใช้งานโดยอัตโนมัติ (prelude) แล้ว ดังนั้นเราจึงไม่จำเป็นต้องระบุคีย์เวิร์ด Result:: นำหน้า Ok และ Err ในแต่ละกิ่งคำสั่งของ match

เมื่อผลลัพธ์เป็น Ok โค้ดนี้จะดึงค่า file ที่อยู่ข้างในตัวแปรย่อย Ok ออกมา และเราจะนำข้อมูลค่า file handle นั้นไปกำหนดลงในตัวแปร greeting_file หลังจากโครงสร้างคำสั่ง match นี้สิ้นสุดลง เราจะนำ file handle ดังกล่าวไปประมวลผลคำสั่งอ่านหรือเขียนข้อมูลต่อได้

ส่วนอีกกิ่งคำสั่งหนึ่งของ match จะจัดการในกรณีที่เราได้ค่าเป็น Err จากคำสั่ง File::open ในตัวอย่างนี้เราเลือกที่จะเรียกใช้งานมาโคร panic! หากไม่มีไฟล์ชื่อ hello.txt ในไดเรกทอรีปัจจุบันของเราและเราสั่งรันโค้ดนี้ เราจะพบผลรายงานจากมาโคร panic! ดังต่อไปนี้:

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

thread 'main' (6018048) panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

และเช่นเคย ผลลัพธ์ที่แสดงจะบอกรายละเอียดอย่างชัดเจนว่ามีอะไรผิดพลาดขึ้นมา

การตรวจสอบเงื่อนไขข้อผิดพลาดประเภทต่าง ๆ (Matching on Different Errors)

โค้ดในตัวอย่างที่ 9-4 จะทำคำสั่ง panic! เสมอ ไม่ว่าฟังก์ชัน File::open จะล้มเหลวด้วยสาเหตุใดก็ตาม ทว่าเราต้องการดำเนินการคำสั่งแตกต่างกันไปตามเหตุผลของความล้มเหลวนั้น หาก File::open ล้มเหลวเนื่องจากไม่มีไฟล์นั้นอยู่จริง เราต้องการสร้างไฟล์ใหม่ขึ้นมาแล้วส่งคืนค่า file handle ของไฟล์ใหม่นั้น แต่หาก File::open ล้มเหลวจากสาเหตุอื่น ๆ เช่น เราไม่มีสิทธิ์เข้าเปิดไฟล์นั้น เรายังคงต้องการให้โค้ดรันคำสั่ง panic! เช่นเดิมเหมือนในตัวอย่างที่ 9-4 สำหรับกรณีนี้เราจะเพิ่มนิพจน์ match ซ้อนอยู่ด้านใน ดังที่แสดงในตัวอย่างที่ 9-5

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

ประเภทข้อมูลที่ File::open ส่งคืนมาภายในตัวแปรย่อย Err คือ io::Error ซึ่งเป็นโครงสร้างข้อมูล (struct) ที่เตรียมไว้โดยไลบรารีมาตรฐาน struct นี้มีเมธอดชื่อว่า kind ซึ่งเราสามารถเรียกใช้เพื่อนำมาตรวจสอบค่า io::ErrorKind ได้ ตัวแปร enum io::ErrorKind นี้จะถูกเตรียมไว้โดยไลบรารีมาตรฐานและมีตัวแปรย่อย ๆ แสดงแทนลักษณะข้อผิดพลาดต่าง ๆ ที่อาจเกิดขึ้นจากกระบวนการทำงานระดับ io โดยตัวแปรย่อยที่เราต้องการนำมาใช้คือ ErrorKind::NotFound ซึ่งบ่งชี้ว่าไฟล์ที่เราพยายามเปิดยังไม่มีอยู่จริง ดังนั้นเราจึงทำการ match กับตัวแปร greeting_file_result แต่ก็ใส่เงื่อนไข match ซ้อนอยู่ข้างในกับค่าของ error.kind() ด้วย

เงื่อนไขที่เราต้องการตรวจสอบใน match ด้านในคือ ค่าที่ส่งกลับมาจาก error.kind() นั้นเป็นตัวแปรย่อย NotFound ของ enum ErrorKind หรือไม่ หากใช่ เราจะพยายามสร้างไฟล์ขึ้นมาโดยใช้คำสั่ง File::create แอยางไรก็ตาม เนื่องจากคำสั่ง File::create ก็อาจจะทำงานล้มเหลวได้เช่นกัน เราจึงจำเป็นต้องใส่กิ่งคำสั่งที่สองไว้ในโครงสร้าง match ด้านในนี้ด้วยเพื่อพิมพ์ข้อความแจ้งความล้มเหลวกรณีสร้างไฟล์ไม่ได้ ส่วนกิ่งคำสั่งที่สองของโครงสร้าง match ด้านนอกยังคงมีโครงสร้างเหมือนเดิม เพื่อให้โปรแกรมเกิด panic เมื่อมีข้อผิดพลาดประเภทอื่นนอกเหนือจากกรณีหาไฟล์ไม่เจอ

ทางเลือกอื่นนอกเหนือจากการใช้ match กับ Result<T, E> (Alternatives to Using match with Result<T, E>)

นั่นมีโครงสร้างคำสั่ง match อยู่เยอะมาก! แม้นิพจน์ match จะมีประโยชน์อย่างยิ่ง แต่มันก็ยังถือเป็นเครื่องมือระดับพื้นฐาน (primitive) ในบทที่ 13 คุณจะได้เรียนรู้เกี่ยวกับโคลเชอร์ (closures) ซึ่งถูกนำไปใช้งานร่วมกับเมธอดจำนวนมากที่กำหนดไว้สำหรับ Result<T, E> เมธอดเหล่านี้สามารถเขียนได้กระชับมากขึ้นกว่าการใช้ match เมื่อต้องรับมือกับค่าแบบ Result<T, E> ในโค้ดของคุณ

ตัวอย่างเช่น นี่คืออีกวิธีหนึ่งในการเขียนตรรกะแบบเดียวกับตัวอย่างที่ 9-5 โดยครั้งนี้เปลี่ยนมาใช้งานโคลเชอร์ร่วมกับเมธอด unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

ถึงแม้ว่าโค้ดนี้จะมีผลลัพธ์การทำงานเหมือนกับตัวอย่างที่ 9-5 ทุกประการ แต่มันไม่มีการใช้นิพจน์ match เลย และยังดูสะอาดสะอ้านอ่านง่ายกว่า คุณสามารถย้อนกลับมาดูตัวอย่างนี้อีกครั้งหลังจากอ่านบทที่ 13 จบแล้ว และลองค้นหาข้อมูลเมธอด unwrap_or_else ในเอกสารไลบรารีมาตรฐานดูครับ มีเมธอดทำนองนี้อีกหลายตัวที่จะช่วยเคลียร์นิพจน์ match ที่ซ้อนกันหลาย ๆ ชั้นให้เรียบร้อยขึ้นเมื่อต้องจัดการกับข้อผิดพลาด

ทางลัดในการเรียก Panic เมื่อเกิดข้อผิดพลาด (Shortcuts for Panic on Error)

การใช้งานนิพจน์ match ทำงานได้ดีพอสมควร แต่อาจจะเวิ่นเว้อไปนิดและไม่ได้สื่อสารเจตนาในการเขียนโค้ดได้ดีเสมอไป ประเภทข้อมูล Result<T, E> มีเมธอดผู้ช่วย (helper methods) จำนวนมากที่กำหนดไว้สำหรับทำงานเฉพาะทางต่าง ๆ เมธอด unwrap เป็นเมธอดทางลัดที่ถูกเขียนขึ้นให้มีโครงสร้างเหมือนกับคำสั่งนิพจน์ match ในตัวอย่างที่ 9-4 หากค่าของ Result เป็นตัวแปรย่อย Ok เมธอด unwrap จะส่งคืนค่าที่อยู่ภายใน Ok นั้นออกไป แต่หากค่าของ Result เป็นตัวแปรย่อย Err เมธอด unwrap จะเรียกใช้งานมาโคร panic! แทนเราทันที ตัวอย่างการทำงานจริงของ unwrap เป็นดังนี้:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

หากเราสั่งรันโค้ดนี้โดยไม่มีไฟล์ hello.txt อยู่จริง เราจะเจอกับข้อความแสดงข้อผิดพลาดจากการสั่ง panic! ที่เมธอด unwrap ดำเนินการให้:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

ทำนองเดียวกัน เมธอด expect ช่วยให้เรากำหนดข้อความผิดพลาดในการเรียก panic! ได้เองด้วย การเลือกใช้ expect แทนที่ unwrap พร้อมทั้งใส่ข้อความบอกรายละเอียดข้อผิดพลาดที่ดีจะช่วยสื่อเจตนาของโค้ดและทำให้การสืบหาจุดกำเนิดของ panic ง่ายขึ้น ไวยากรณ์ของ expect มีลักษณะดังนี้:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

เราใช้งาน expect ในลักษณะเดียวกับ unwrap คือ: ใช้คืนค่า file handle หรือสั่งรันมาโคร panic! ข้อความผิดพลาดที่ expect จะใช้ป้อนให้กับ panic! คืออาร์กิวเมนต์ที่เราส่งเข้าไปในวงเล็บของ expect แทนที่จะใช้ข้อความแจ้งเตือนตามค่าเริ่มต้นของ unwrap หน้าตาของผลลัพธ์จะเป็นดังนี้:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

สำหรับโค้ดที่เตรียมไว้ใช้งานจริง (production-quality code) นักพัฒนา Rust ส่วนใหญ่จะเลือกใช้งาน expect มากกว่า unwrap และพยายามให้ข้อมูลแวดล้อมเพิ่มเติมว่าเหตุใดขั้นตอนการทำงานดังกล่าวจึงคาดหวังว่าจะทำงานสำเร็จอยู่เสมอ ด้วยวิธีนี้ หากสมมติฐานของคุณผิดพลาดขึ้นมาในวันใดวันหนึ่ง คุณก็จะมีข้อมูลแวดล้อมที่เป็นประโยชน์ในการสืบค้นหาสาเหตุและแก้ไขบั๊กเพิ่มขึ้น

การส่งต่อข้อผิดพลาด (Propagating Errors)

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

ตัวอย่างเช่น ตัวอย่างที่ 9-6 แสดงฟังก์ชันที่ทำหน้าที่อ่านค่าชื่อผู้ใช้งาน (username) จากไฟล์ หากไฟล์นั้นไม่มีอยู่จริงหรืออ่านไม่ได้ ฟังก์ชันนี้จะส่งต่อข้อผิดพลาดเหล่านี้กลับไปยังโค้ดที่เป็นตัวเรียกใช้งานฟังก์ชัน

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

ฟังก์ชันนี้สามารถเขียนให้กระชับขึ้นได้มาก แต่เราจะขอเริ่มต้นจากการเขียนแบบทำมือเพื่อสำรวจขั้นตอนของระบบจัดการข้อผิดพลาดกันก่อน จากนั้นในช่วงท้ายจึงจะนำเสนอวิธีที่สั้นลง ลองมาดูประเภทข้อมูลส่งคืนของฟังก์ชันกันก่อน: Result<String, io::Error> บ่งบอกว่าฟังก์ชันนี้จะส่งคืนค่าประเภท Result<T, E> โดยมีพารามิเตอร์เจเนอริก T เป็นประเภทรูปธรรม String และพารามิเตอร์เจเนอริก E เป็นประเภทรูปธรรม io::Error

หากฟังก์ชันนี้ทำงานสำเร็จโดยไม่พบอุปสรรคใด ๆ โค้ดที่เป็นตัวเรียกใช้งานฟังก์ชันจะได้รับค่า Ok ที่มีข้อมูล String อยู่ข้างใน ซึ่งก็คือ username ที่ฟังก์ชันนี้ได้อ่านมาจากไฟล์ แต่หากเกิดปัญหาใด ๆ โค้ดผู้เรียกจะได้รับค่า Err ที่เก็บออบเจกต์ io::Error ซึ่งมีรายละเอียดความล้มเหลวไว้ เหตุผลที่เราเลือกประเภท io::Error เป็นชนิดข้อมูลส่งคืนของฟังก์ชันนี้ เนื่องจากบังเอิญว่าข้อผิดพลาดที่อาจเกิดขึ้นจากสองคำสั่งหลักในฟังก์ชันนี้ต่างก็ส่งคืนประเภทข้อมูลเดียวกัน นั่นคือฟังก์ชัน File::open และเมธอด read_to_string

โครงสร้างการทำงานเริ่มด้วยการเรียกฟังก์ชัน File::open จากนั้นเราจะรับมือค่า Result ด้วยคำสั่ง match คล้ายกับในตัวอย่างที่ 9-4 หากสำเร็จ ตัวแปรจัดการไฟล์ในรูปแบบตัวแปรแพทเทิร์น file จะถูกกำหนดให้กับตัวแปรแก้ไขได้ username_file และฟังก์ชันจะทำงานต่อ แต่หากเกิดกรณี Err แทนที่จะเรียกใช้งาน panic! เราจะใช้คีย์เวิร์ด return เพื่อจบฟังก์ชันเร็วกว่าปกติทันที และส่งคืนค่าข้อผิดพลาดจากคำสั่ง File::open ซึ่งจัดเก็บอยู่ในตัวแปรแพทเทิร์น e กลับไปยังโค้ดผู้เรียกใช้งานเป็นผลข้อผิดพลาดของฟังก์ชันนี้

ดังนั้น เมื่อเราได้ file handle มาเก็บไว้ใน username_file เรียบร้อยแล้ว ฟังก์ชันจะสร้างตัวแปร username ประเภท String ขึ้นมาใหม่ และเรียกใช้เมธอด read_to_string กับ username_file เพื่ออ่านเนื้อหาจากไฟล์มาใส่ใน username เนื่องจากเมธอด read_to_string ก็อาจทำงานล้มเหลวได้เช่นกัน (แม้ว่า File::open จะสำเร็จก็ตาม) เราจึงจำเป็นต้องใส่ match อีกตัวเพื่อรับมือกับ Result นั้น: หาก read_to_string ทำงานสำเร็จ ฟังก์ชันของเราก็จะถือว่าทำหน้าที่เสร็จสมบูรณ์ และส่งค่าชื่อผู้ใช้งานที่อ่านได้จากไฟล์ซึ่งตอนนี้ถูกเก็บอยู่ใน username โดยนำไปห่อหุ้มไว้ในออบเจกต์ Ok กลับไป แต่ถ้าล้มเหลวเราจะส่งคืนค่าข้อผิดพลาดในลักษณะเดียวกับเงื่อนไข match แรก อย่างไรก็ตามเราไม่จำเป็นต้องใส่คำนำหน้า return ตรงกิ่งสุดท้ายนี้ เนื่องจากเป็นนิพจน์คำสั่งสุดท้ายของฟังก์ชัน

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

รูปแบบของการส่งต่อข้อผิดพลาดนี้พบได้บ่อยมากในภาษา Rust จนทางผู้สร้างภาษาได้เตรียมตัวดำเนินการเครื่องหมายคำถาม ? เพื่ออำนวยความสะดวกในการเขียนโค้ด

ทางลัดการส่งต่อข้อผิดพลาดด้วยตัวดำเนินการ ? (The ? Operator Shortcut)

ตัวอย่างที่ 9-7 แสดงการเขียนฟังก์ชัน read_username_from_file ซึ่งให้ผลลัพธ์การทำงานแบบเดียวกับในตัวอย่างที่ 9-6 ทุกประการ แต่เวอร์ชันนี้จะหันมาใช้งานตัวดำเนินการ ? แทน

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

การเติมเครื่องหมาย ? ต่อท้ายผลลัพธ์ที่เป็น Result ถูกกำหนดขึ้นมาให้ทำงานในลักษณะเกือบจะเทียบเท่ากับการเขียนประโยคเงื่อนไข match เพื่อรับมือค่า Result ในตัวอย่างที่ 9-6 กล่าวคือ หากค่าของ Result มีสถานะเป็น Ok ค่าจริงที่ห่ออยู่ใน Ok นั้นจะถูกสกัดส่งคืนมาจากนิพจน์คำสั่งนี้เพื่อให้โปรแกรมประมวลผลคำสั่งบรรทัดต่อไป แต่หากค่าเป็น Err ข้อผิดพลาด Err นั้นจะถูกส่งคืนออกจากขอบเขตของฟังก์ชันทั้งหมดในทันที ราวกับว่าเราได้พิมพ์สั่ง return เพื่อส่งต่อข้อผิดพลาดนั้นขึ้นไปให้โค้ดระดับบนเป็นผู้รับหน้าที่จัดการ

อย่างไรก็ตาม มีข้อแตกต่างประการหนึ่งระหว่างการใช้นิพจน์คำสั่ง match จากตัวอย่างที่ 9-6 กับพฤติกรรมของตัวดำเนินการ ? คือ: ค่าข้อผิดพลาดที่ถูกดำเนินการด้วยเครื่องหมาย ? จะถูกนำไปประมวลผลผ่านฟังก์ชัน from ซึ่งเป็นฟังก์ชันที่นิยามไว้ในทริต From ของไลบรารีมาตรฐาน ซึ่งมีหน้าที่แปลงชนิดข้อมูลประเภทหนึ่งไปเป็นอีกประเภทหนึ่ง เมื่อตัวดำเนินการ ? เรียกใช้งานฟังก์ชัน from ชนิดข้อผิดพลาดที่เกิดจะถูกแปลงไปเป็นชนิดข้อผิดพลาดที่สอดคล้องกับรายละเอียดประเภทข้อมูลส่งคืน (return type) ของฟังก์ชันปัจจุบันโดยอัตโนมัติ ซึ่งฟีเจอร์นี้จะมีประโยชน์มากหากตัวฟังก์ชันหลักต้องการคืนค่าข้อมูลชนิดข้อผิดพลาดแบบเดียวเพื่อเป็นตัวแทนของความล้มเหลวทุกรูปแบบ แม้ว่าคำสั่งย่อยภายในอาจจะพบเจอข้อผิดพลาดที่ต่างประเภทกันก็ตาม

ตัวอย่างเช่น เราสามารถดัดแปลงฟังก์ชัน read_username_from_file ในตัวอย่างที่ 9-7 เพื่อให้ส่งคืนข้อผิดพลาดประเภทเฉพาะเจาะจงที่เราเขียนขึ้นเองชื่อว่า OurError ได้ หากเราเขียนนิยามการสืบทอด impl From<io::Error> for OurError เพื่อสร้างข้อมูล OurError จากออบเจกต์ io::Error แล้ว คำสั่งเรียกใช้งานเครื่องหมาย ? ภายในฟังก์ชัน read_username_from_file จะเรียกใช้เมธอด from เพื่อแปลงประเภทข้อผิดพลาดเหล่านั้นให้เป็นชนิดที่ต้องการโดยอัตโนมัติโดยที่ไม่ต้องใส่โค้ดจัดการแปลงข้อมูลเพิ่มเติมลงในตัวฟังก์ชันหลักเลย

เมื่อพิจารณาในบริบทของตัวอย่างที่ 9-7 การใช้เครื่องหมาย ? ต่อท้ายคำสั่งเรียก File::open จะทำหน้าที่ส่งค่าที่อยู่ใน Ok ไปกำหนดให้กับตัวแปร username_file หากล้มเหลวตัวดำเนินการ ? จะหยุดการทำงานของฟังก์ชันทันทีและส่งออบเจกต์ Err ออกไปให้โค้ดส่วนที่เรียกใช้ฟังก์ชัน และหลักการแบบเดียวกันนี้จะถูกนำมาใช้กับเครื่องหมาย ? ที่ต่อท้ายคำสั่งเรียก read_to_string ด้วยเช่นกัน

ตัวดำเนินการ ? ช่วยตัดทอนโค้ดโครงสร้างที่เขียนซ้ำซาก (boilerplate) และทำให้ขั้นตอนการประมวลผลของฟังก์ชันสั้นลงอย่างเห็นได้ชัด เรายังสามารถย่อโค้ดนี้ให้กระชับลงได้อีกโดยการเชื่อมต่อคำสั่งเมธอด (method chaining) ต่อท้ายเครื่องหมาย ? ทันที ดังที่แสดงในตัวอย่างที่ 9-8

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

เราได้ย้ายคำสั่งสร้าง String ใหม่ของตัวแปร username ไปไว้ที่ตอนเริ่มต้นของฟังก์ชัน ส่วนนั้นยังเหมือนเดิม แต่แทนที่เราจะต้องเขียนคำสั่งเพื่อเก็บค่าตัวแปรพัก username_file เราจะเปลี่ยนมาเขียนเชื่อมโยงเมธอด read_to_string ต่อท้ายผลลัพธ์ของ File::open("hello.txt")? ตรง ๆ ไปเลย เรายังคงมีเครื่องหมาย ? ต่อท้ายคำสั่ง read_to_string และยังคงคืนค่า Ok(username) เมื่อทั้ง File::open และ read_to_string ทำงานสำเร็จ ซึ่งฟังก์ชันการทำงานทุกอย่างมีผลเทียบเท่ากับโค้ดในตัวอย่างที่ 9-6 และ 9-7 แต่อยู่ในรูปแบบการเขียนที่สะดวกกระชับขึ้น

ตัวอย่างที่ 9-9 แสดงวิธีหั่นโค้ดนี้ให้สั้นกระชับขึ้นไปอีกขั้นโดยเลือกใช้งานฟังก์ชัน fs::read_to_string

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

ขั้นตอนอ่านเนื้อหาจากไฟล์มาบันทึกในรูปของสตริงถือเป็นพฤติกรรมการทำงานที่พบได้เป็นปกติทั่วไป ทางไลบรารีมาตรฐานจึงจัดเตรียมฟังก์ชันที่ใช้งานง่ายอย่าง fs::read_to_string ซึ่งจะทำหน้าที่เปิดไฟล์, สร้าง String ใหม่, อ่านเนื้อหาไฟล์มาบันทึกเก็บใน String นั้น แล้วส่งข้อมูลกลับคืนมาให้เสร็จสรรพ แน่นอนว่าการเลือกใช้ fs::read_to_string ตรง ๆ จะทำให้เราไม่มีโอกาสได้อธิบายระบบจัดการข้อผิดพลาดทีละขั้น ดังนั้นเราจึงอธิบายแนวทางแบบยาวให้เห็นภาพกันก่อน

ฟังก์ชันที่สามารถใช้งานตัวดำเนินการ ? ได้ (Where to Use the ? Operator)

ตัวดำเนินการ ? สามารถนำไปใช้ได้เฉพาะกับฟังก์ชันที่มีชนิดของประเภทข้อมูลส่งคืนที่เข้ากันได้ (compatible) กับประเภทข้อมูลที่เครื่องหมาย ? ไปประมวลผล เนื่องจากตัวดำเนินการ ? ถูกกำหนดพฤติกรรมมาให้ทำงานในลักษณะจบคำสั่งฟังก์ชันก่อนกำหนดและส่งค่าผลลัพธ์ออกจากฟังก์ชันทันที เช่นเดียวกับประโยคนิพจน์คำสั่ง match ในตัวอย่างที่ 9-6 ซึ่งโครงสร้าง match ในจุดนั้นทำงานกับออบเจกต์ Result และส่วนของกิ่งคำสั่งจบฟังก์ชันก่อนกำหนดทำการคืนค่าเป็น Err(e) ออกไป ดังนั้นประเภทข้อมูลส่งคืนของตัวฟังก์ชันหลักจึงจำเป็นต้องเป็นชนิด Result เพื่อให้สอดรับเข้ากันกับค่า return นี้

ในตัวอย่างที่ 9-10 ลองมาศึกษาข้อความแสดงข้อผิดพลาดที่เราจะเจอกันหากเราฝืนใช้งานตัวดำเนินการ ? ภายในฟังก์ชัน main ที่มีชนิดประเภทข้อมูลส่งคืนที่ไม่สอดรับกับชนิดข้อมูลที่เรานำ ? ไปเติมต่อท้าย

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

โค้ดนี้จะเปิดไฟล์ซึ่งอาจเกิดความล้มเหลวได้ ตัวดำเนินการ ? จะประมวลผลต่อท้ายค่า Result ที่ส่งกลับมาจาก File::open แต่ทว่าฟังก์ชัน main นี้มีประเภทข้อมูลส่งคืนเป็น () ไม่ใช่ Result เมื่อเราทำการคอมไพล์โค้ดนี้ เราจะพบข้อความแจ้งเตือนข้อผิดพลาดดังนี้:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

ข้อผิดพลาดนี้ระบุชัดเจนว่า เราได้รับอนุญาตให้ใช้ตัวดำเนินการ ? ได้เฉพาะกับฟังก์ชันที่ส่งคืนค่าเป็นชนิด Result, Option หรือประเภทข้อมูลอื่นใดที่มีการนำเสนอ (implement) ทริต FromResidual เท่านั้น

ในการแก้ไขข้อผิดพลาดดังกล่าว คุณมีทางเลือก 2 ทางเลือก ทางเลือกแรกคือการปรับเปลี่ยนชนิดข้อมูลส่งคืนของฟังก์ชันหลักให้เข้ากันกับชนิดข้อมูลที่คุณนำตัวดำเนินการ ? ไปใช้งาน ตราบเท่าที่คุณไม่มีข้อจำกัดภายนอกใด ๆ ที่มาห้ามปรับโครงสร้างฟังก์ชัน ส่วนทางเลือกที่สองคือหันไปเขียนคำสั่ง match หรือเมธอดอื่นใดของ Result<T, E> เพื่อจัดการกับผลลัพธ์ของ Result<T, E> ด้วยวิธีกระบวนการที่เหมาะสมทดแทน

ข้อความแจ้งข้อผิดพลาดดังกล่าวยังได้ระบุถึงความจริงที่ว่า เครื่องหมาย ? สามารถใช้งานกับข้อมูลชนิด Option<T> ได้ด้วยเช่นกัน และเฉกเช่นเดียวกับการใช้เครื่องหมาย ? กับ Result คุณจะสามารถใช้ ? กับข้อมูลประเภท Option ได้ก็ต่อเมื่อฟังก์ชันดังกล่าวมีประเภทข้อมูลส่งคืนเป็น Option เท่านั้น พฤติกรรมของตัวดำเนินการ ? เมื่อถูกนำไปใช้กับ Option<T> จะคล้ายคลึงกับกรณีที่ใช้กับ Result<T, E> คือ: หากค่าของข้อมูลเป็น None ตัวแปร None จะถูกส่งคืนออกจากฟังก์ชันเร็วกว่าปกติทันที ณ จุดคำสั่งนั้น แต่ถ้าค่าเป็น Some ข้อมูลภายใน Some จะเป็นค่าที่ถูกส่งออกมาจากนิพจน์นั้นแล้วโปรแกรมจะประมวลผลคำสั่งบรรทัดต่อไป ตัวอย่างที่ 9-11 แสดงตัวอย่างของฟังก์ชันที่ทำหน้าที่สืบค้นหาตัวอักษรสุดท้ายของบรรทัดแรกในข้อความที่ป้อนเข้ามา

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

ฟังก์ชันนี้ส่งคืนข้อมูลเป็นประเภท Option<char> เนื่องจากเป็นไปได้ว่าอาจจะมีตัวอักษรอยู่ตรงนั้น หรืออาจจะไม่มีอยู่จริงก็ได้ โค้ดนี้จะรับพารามิเตอร์อาร์กิวเมนต์ string slice ของตัวแปร text และเรียกใช้เมธอด lines ซึ่งจะส่งคืนตัววนลูป (iterator) ของบรรทัดต่าง ๆ ในสตริง เนื่องจากฟังก์ชันนี้ต้องการตรวจสอบข้อมูลบรรทัดแรก มันจึงทำการเรียกใช้เมธอด next กับตัว iterator เพื่อดึงข้อมูลตัวแรกออกมา หากตัวแปร text มีค่าเป็นสตริงว่าง คำสั่งเรียกใช้ next จะคืนค่ากลับมาเป็น None ซึ่งในกรณีนี้การใช้เครื่องหมาย ? จะสั่งหยุดการทำงานและคืนค่า None ออกไปจากฟังก์ชัน last_char_of_first_line ทันที แต่หาก text ไม่ใช่สตริงว่าง เมธอด next จะส่งค่ากลับคืนมาเป็นออบเจกต์ Some ที่เก็บ string slice ของบรรทัดแรกใน text เอาไว้

เครื่องหมาย ? จะทำหน้าที่สกัดเอาตัว string slice นั้นออกมา จากนั้นเราสามารถเรียกใช้เมธอด chars กับ string slice ดังกล่าวเพื่อดึงเอา iterator ของอักษรแต่ละตัวออกมา เนื่องจากเราสนใจเฉพาะอักษรตัวสุดท้ายในบรรทัดแรกนี้ เราจึงเรียกใช้เมธอด last เพื่อคืนค่าอักษรตัวสุดท้ายของ iterator ซึ่งผลลัพธ์จะเป็นประเภท Option เนื่องจากมีโอกาสที่บรรทัดแรกนั้นอาจเป็นสตริงว่างได้ ตัวอย่างเช่น สตริงในตัวแปร text เริ่มต้นด้วยบรรทัดว่างแต่มีตัวอักษรอยู่ในบรรทัดอื่น ๆ เช่น "\nhi" แต่อย่างไรก็ดี หากมีตัวอักษรตัวสุดท้ายในบรรทัดแรกอยู่จริง ค่าอักษรดังกล่าวจะถูกคืนกลับมาในรูปของตัวแปรย่อย Some การเขียนคำสั่งตัวดำเนินการ ? ไว้ระหว่างกระบวนการทำงานช่วยให้เราสามารถเขียนอธิบายขั้นตอนเหล่านี้ได้อย่างกระชับจนจบได้ในบรรทัดเดียว หากเราไม่สามารถใช้งานเครื่องหมาย ? กับ Option ได้ เราก็คงต้องใช้วิธีเรียกเมธอดหลายรอบหรือเขียนนิพจน์ match ขึ้นมารับมือแทน

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

ที่ผ่านมา ฟังก์ชัน main ทั้งหมดที่เรานำเสนอต่างก็คืนค่าเป็นประเภท () ฟังก์ชัน main มีลักษณะพิเศษเฉพาะเนื่องจากเป็นจุดเริ่มต้นและจุดสิ้นสุดของโปรแกรมประเภทรันไฟล์ไบนารี (executable program) และมีข้อกำหนดเฉพาะเจาะจงว่าประเภทข้อมูลส่งคืนควรเป็นชนิดใดเพื่อทำให้โปรแกรมทำงานได้ถูกต้องตามความคาดหมาย

โชคดีที่ฟังก์ชัน main สามารถคืนค่าเป็นประเภท Result<(), E> ได้ด้วยเช่นกัน ตัวอย่างที่ 9-12 ดัดแปลงโครงสร้างโค้ดจากตัวอย่างที่ 9-10 โดยปรับเปลี่ยนชนิดข้อมูลส่งคืนของ main ให้เป็น Result<(), Box<dyn Error>> และเพิ่มคำสั่งส่งคืนค่า Ok(()) ไว้ที่บรรทัดสุดท้าย ซึ่งโค้ดชุดนี้จะสามารถคอมไพล์ผ่านได้แล้วในตอนนี้

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

ชนิดข้อมูล Box<dyn Error> คือทริตอ็อบเจกต์ (trait object) ซึ่งเราจะมาพูดคุยรายละเอียดกันต่อในหัวข้อ “การใช้งานทริตอ็อบเจกต์เพื่อสร้างรูปแบบนามธรรมทับพฤติกรรมร่วม (Using Trait Objects to Abstract over Shared Behavior)” ในบทที่ 18 สำหรับตอนนี้ คุณสามารถมอง Box<dyn Error> ให้เข้าใจง่าย ๆ ในความหมายที่ว่า “ข้อผิดพลาดประเภทใดก็ได้” การใช้เครื่องหมาย ? กับค่า Result ในฟังก์ชัน main ที่ระบุข้อผิดพลาดเป็นประเภท Box<dyn Error> ได้รับการอนุญาตเพราะช่วยให้สามารถส่งคืนค่า Err ใด ๆ ออกไปก่อนกำหนดได้ ถึงแม้ว่าในโค้ดการทำงานจริงของฟังก์ชัน main นี้จะส่งคืนเฉพาะข้อผิดพลาดประเภท std::io::Error เท่านั้น แต่การระบุ Box<dyn Error> ในส่วนซิกเนเจอร์นี้จะช่วยรองรับโครงสร้างโปรแกรมให้ถูกต้องต่อไปแม้ในอนาคตจะมีโค้ดเพิ่มเติมที่อาจคืนข้อผิดพลาดรูปแบบอื่น ๆ เข้ามาก็ตาม

เมื่อฟังก์ชัน main มีประเภทข้อมูลส่งคืนเป็น Result<(), E> ตัวโปรแกรมจะส่งค่าสิ้นสุดกระบวนการทำงานกลับไปยังระบบ (exit code) เป็น 0 หากคำสั่ง main คืนค่ากลับมาเป็น Ok(()) และจะส่งค่าที่ไม่ใช่ศูนย์กลับไปหาก main ส่งคืนค่าที่เป็น Err โปรแกรมรันระบบที่เขียนด้วยภาษา C จะส่งตัวเลขจำนวนเต็มกลับมาเสมอเมื่อปิดตัวลง: โดยโปรแกรมที่ปิดตัวสำเร็จจะส่งค่า 0 และโปรแกรมที่จบการทำงานด้วยข้อผิดพลาดจะส่งเลขจำนวนเต็มอื่นที่ไม่ใช่ 0 ระบบภาษา Rust จึงเลือกคืนค่าจำนวนเต็มจากไฟล์โปรแกรมในลักษณะเดียวกันนี้เพื่อให้ทำงานเข้ากันได้ตามขนบข้อตกลงสากล

ฟังก์ชัน main อาจเลือกคืนค่าเป็นประเภทข้อมูลใด ๆ ก็ได้ตราบเท่าที่มีการสืบทอด ทริต std::process::Termination ซึ่งภายในจะมีเมธอด report ที่ทำหน้าที่ส่งคืนค่า ExitCode คุณสามารถค้นหาข้อมูลเพิ่มเติมจากเอกสารประกอบของไลบรารีมาตรฐานเพื่อศึกษาวิธีการสืบทอดทริต Termination สำหรับใช้กับประเภทข้อมูลของคุณเองได้

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