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

บทนำ

การมีส่วนร่วม

หากคุณสนใจอยากมีส่วนร่วมในการพัฒนาหนังสือเล่มนี้ สามารถอ่านรายละเอียดได้ที่แนวทางการมีส่วนร่วม

ข่าวสาร

ดีไซน์แพตเทิร์น

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

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

พึงระลึกไว้เสมอว่า: ทุกแพตเทิร์นต่างมีข้อแลกเปลี่ยน (trade-off) ในตัวเอง สิ่งสำคัญที่สุดจึงอยู่ที่การทำความเข้าใจว่า “ทำไม” คุณจึงเลือกใช้แพตเทิร์นหนึ่ง มากกว่าการมุ่งเน้นเพียงแค่ว่า “จะเขียนโค้ดอย่างไร”1

ดีไซน์แพตเทิร์นใน Rust

ภาษา Rust ไม่ใช่ภาษาโปรแกรมเชิงวัตถุ (Object-Oriented Programming) และด้วยการผสมผสานจุดเด่นเฉพาะตัวต่างๆ เข้าด้วยกัน เช่น แนวคิดเชิงฟังก์ชัน, ระบบชนิดข้อมูลที่เข้มงวด (strong type system) ตลอดจนระบบ borrow checker สิ่งเหล่านี้ทำให้ Rust มีเอกลักษณ์ที่ไม่เหมือนใคร ด้วยเหตุนี้ ดีไซน์แพตเทิร์นใน Rust จึงมีความแตกต่างไปจากภาษาเชิงวัตถุแบบดั้งเดิมอื่นๆ นั่นคือเหตุผลที่เราตั้งใจเขียนหนังสือเล่มนี้ขึ้นมา เราหวังว่าคุณจะเพลิดเพลินกับการอ่าน! โดยเนื้อหาในเล่มจะแบ่งออกเป็น 3 หมวดหลัก:

  • สำนวนการเขียนโค้ด (Idioms): แนวทางปฏิบัติที่ควรยึดถือขณะเขียนโค้ด เปรียบเสมือนบรรทัดฐานทางสังคมของชุมชนชาว Rust ซึ่งคุณควรหลีกเลี่ยงหรือฝ่าฝืนก็ต่อเมื่อมีเหตุผลที่หนักแน่นเพียงพอเท่านั้น
  • ดีไซน์แพตเทิร์น (Design Patterns): วิธีการและแบบแผนในการแก้ปัญหาทั่วไปที่พบบ่อยขณะเขียนโค้ด
  • แอนติแพตเทิร์น (Anti-patterns): วิธีการแก้ปัญหาที่มักพบเห็นบ่อยเช่นกัน แต่ในขณะที่ดีไซน์แพตเทิร์นนำมาซึ่งประโยชน์ แอนติแพตเทิร์นกลับสร้างปัญหาและผลเสียตามมามากกว่า

  1. https://www.infoq.com/podcasts/software-architecture-hard-parts/ (Archive) ↩

ฉบับแปล

เราใช้งานเครื่องมือ mdbook-i18n-helper สำหรับการจัดการหลายภาษา สามารถอ่านขั้นตอนวิธีเพิ่มและอัปเดตฉบับแปลภาษาต่างๆ ได้ที่ repository ของโครงการ

ฉบับแปลภายนอก

หากคุณต้องการเพิ่มฉบับแปลภาษาใหม่ สามารถเปิด issue ได้ที่ repository หลัก

สำนวนการเขียนโค้ด (Idioms)

สำนวนการเขียนโค้ด (Idioms) คือรูปแบบ สไตล์ แนวทางปฏิบัติ และแพตเทิร์นที่ชุมชนนักพัฒนาส่วนใหญ่ยอมรับและใช้ร่วมกัน การเขียนโค้ดตามแนวทางที่ถูกต้องเหมาะสมของภาษา (Idiomatic Code) จะช่วยให้นักพัฒนาคนอื่นสามารถอ่านและทำความเข้าใจเจตนาของโค้ดได้รวดเร็วและชัดเจนยิ่งขึ้น

เพราะในท้ายที่สุดแล้ว คอมพิวเตอร์สนใจเพียงแค่ภาษาเครื่อง (Machine Code) ที่คอมไพเลอร์สร้างขึ้นมาเท่านั้น แต่ซอร์สโค้ดถูกเขียนขึ้นเพื่อให้มนุษย์ด้วยกันอ่านและดูแลรักษา ในเมื่อภาษาโปรแกรมคือชั้นของแอบสแตรกชันที่สร้างขึ้นมาเพื่อเรา แล้วทำไมเราถึงไม่ทำให้มันอ่านง่ายที่สุดล่ะ?

ขอให้ระลึกถึงหลักการ KISS: “Keep It Simple, Stupid” ซึ่งระบุไว้ว่า “ระบบส่วนใหญ่จะทำงานได้ดีที่สุดเมื่อถูกรักษาให้เรียบง่าย ไม่ซับซ้อน ดังนั้น ความเรียบง่ายจึงควรเป็นเป้าหมายสำคัญในการออกแบบ และควรหลีกเลี่ยงความซับซ้อนที่ไม่จำเป็น”

โค้ดมีไว้ให้มนุษย์ทำความเข้าใจ ไม่ใช่คอมพิวเตอร์

ใช้ชนิดที่ถูกยืมสำหรับอาร์กิวเมนต์

คำอธิบาย

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

หลักการนี้ไม่ได้จำกัดอยู่เพียงแค่ชนิดข้อมูลที่แปลงเป็น slice ได้ หรือชนิดข้อมูลแบบ fat pointer (พอยน์เตอร์พร้อม metadata) เท่านั้น แต่อันที่จริงแล้ว คุณควรเลือกใช้ไทป์แบบยืม (borrowed type) แทนการยืมไทป์ที่เป็นเจ้าของข้อมูล (borrowing the owned type) อยู่เสมอ เช่น เลือกใช้ &str แทน &String, เลือกใช้ &[T] แทน &Vec<T> หรือเลือกใช้ &T แทน &Box<T>

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

ตัวอย่าง

ในตัวอย่างนี้ เราจะแสดงให้เห็นถึงความแตกต่างระหว่างการใช้ &String กับการใช้ &str เป็นอาร์กิวเมนต์ของฟังก์ชัน แต่แนวคิดเดียวกันนี้ก็สามารถนำไปปรับใช้กับการเลือก &[T] แทน &Vec<T> หรือการเลือก &T แทน &Box<T> ได้เช่นเดียวกัน

ลองพิจารณาตัวอย่างที่เราต้องการตรวจสอบว่าคำคำหนึ่งมีสระเรียงติดกันตั้งแต่สามตัวขึ้นไปหรือไม่ ในกรณีนี้ เราไม่จำเป็นต้องเป็นเจ้าของสตริงเพื่อตรวจสอบเงื่อนไขดังกล่าว เราจึงส่งข้อมูลเข้ามาในรูปของการอ้างอิง (Reference) แทน

โค้ดจะมีหน้าตาประมาณนี้:

fn three_vowels(word: &String) -> bool {
    let mut vowel_count = 0;
    for c in word.chars() {
        match c {
            'a' | 'e' | 'i' | 'o' | 'u' => {
                vowel_count += 1;
                if vowel_count >= 3 {
                    return true;
                }
            }
            _ => vowel_count = 0,
        }
    }
    false
}

fn main() {
    let ferris = "Ferris".to_string();
    let curious = "Curious".to_string();
    println!("{}: {}", ferris, three_vowels(&ferris));
    println!("{}: {}", curious, three_vowels(&curious));

    // This works fine, but the following two lines would fail:
    // println!("Ferris: {}", three_vowels("Ferris"));
    // println!("Curious: {}", three_vowels("Curious"));
}

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

ตัวอย่างเช่น หากเราเปลี่ยนการประกาศฟังก์ชันเป็น:

fn three_vowels(word: &str) -> bool {

ทั้งสองเวอร์ชันก็จะสามารถคอมไพล์ผ่านได้อย่างราบรื่นและให้ผลลัพธ์เหมือนกัน:

Ferris: false
Curious: true

แต่ช้าก่อน! ประโยชน์ของเรื่องนี้ยังไม่หมดเพียงเท่านี้ คุณอาจจะแย้งในใจว่า: นั่นไม่ใช่เรื่องใหญ่ เพราะอย่างไรเสียเราก็แทบไม่เคยส่งค่า &'static str เข้ามาตรงๆ อยู่แล้ว (เหมือนในตัวอย่างที่เราส่ง "Ferris") แต่ถึงแม้จะมองข้ามกรณีนี้ไป คุณก็จะพบว่าการใช้ &str ยังคงมอบความยืดหยุ่นให้มากกว่าการใช้ &String อยู่ดี

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

ตัวอย่างโค้ดจะมีลักษณะดังนี้:

fn three_vowels(word: &str) -> bool {
    let mut vowel_count = 0;
    for c in word.chars() {
        match c {
            'a' | 'e' | 'i' | 'o' | 'u' => {
                vowel_count += 1;
                if vowel_count >= 3 {
                    return true;
                }
            }
            _ => vowel_count = 0,
        }
    }
    false
}

fn main() {
    let sentence_string =
        "Once upon a time, there was a friendly curious crab named Ferris".to_string();
    for word in sentence_string.split(' ') {
        if three_vowels(word) {
            println!("{word} has three consecutive vowels!");
        }
    }
}

เมื่อรันตัวอย่างนี้โดยที่ฟังก์ชันประกาศรับอาร์กิวเมนต์ชนิด &str จะให้ผลลัพธ์ดังนี้:

curious has three consecutive vowels!

อย่างไรก็ตาม ตัวอย่างนี้จะไม่สามารถทำงานได้เลยหากฟังก์ชันของเราประกาศรับอาร์กิวเมนต์เป็นชนิด &String นั่นเป็นเพราะว่า String Slice ที่ได้จาก split มีชนิดข้อมูลเป็น &str ไม่ใช่ &String ซึ่งการจะแปลงเป็น &String จำเป็นต้องมีการจัดสรรหน่วยความจำบน heap (Allocation) ใหม่ และคอมไพเลอร์ไม่สามารถแปลงให้ได้โดยอัตโนมัติ ในขณะที่การแปลงจาก String ไปเป็น &str นั้นแทบไม่มี overhead และคอมไพเลอร์ทำการแปลงให้โดยอัตโนมัติ (implicit)

ดูเพิ่มเติม

การต่อสตริงด้วย format!

คำอธิบาย

เราสามารถสร้างและต่อสตริงขึ้นมาได้ด้วยการเรียกเมท็อด push และ push_str บนตัวแปร String แบบ mutable หรือจะใช้โอเปอเรเตอร์ + ก็ได้ อย่างไรก็ตาม ในหลายๆ กรณี การเลือกใช้มาโคร format! มักจะสะดวกสบายกว่ามาก โดยเฉพาะอย่างยิ่งเมื่อต้องผสมผสานระหว่างสตริงคงที่ (String Literals) กับตัวแปรต่างๆ

ตัวอย่าง

#![allow(unused)]
fn main() {
fn say_hello(name: &str) -> String {
    // We could construct the result string manually.
    // let mut result = "Hello ".to_owned();
    // result.push_str(name);
    // result.push('!');
    // result

    // But using format! is better.
    format!("Hello {name}!")
}
}

ข้อดี

การใช้ format! มักเป็นแนวทางที่กระชับ สละสลวย และอ่านเข้าใจง่ายที่สุดในการต่อสตริงเข้าด้วยกัน

ข้อเสีย

ในแง่ของความเร็ว มักไม่ใช่วิธีที่มีประสิทธิภาพสูงสุด เพราะการเรียกใช้งาน push หรือ push_str ต่อเนื่องกันบนสตริงแบบ mutable จะทำงานได้เร็วกว่า (โดยเฉพาะอย่างยิ่งหากมีการจัดสรรพื้นที่หน่วยความจำล่วงหน้า (Pre-allocation) ตามขนาดที่คาดหวังไว้แล้ว)

คอนสตรัคเตอร์

คำอธิบาย

ภาษา Rust ไม่มีฟีเจอร์ Constructor เป็นคำสั่งเฉพาะของตัวภาษาเหมือนในภาษาเชิงวัตถุอื่นๆ แต่ธรรมเนียมปฏิบัติของชาว Rust คือการสร้าง Associated Function ที่มีชื่อว่า new เพื่อทำหน้าที่สร้างอินสแตนซ์ของออบเจกต์ขึ้นมา:

#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::new(42);
/// assert_eq!(42, s.value());
/// ```
pub struct Second {
    value: u64,
}

impl Second {
    // Constructs a new instance of [`Second`].
    // Note this is an associated function - no self.
    pub fn new(value: u64) -> Self {
        Self { value }
    }

    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}
}

คอนสตรัคเตอร์เริ่มต้น (Default Constructors)

Rust รองรับคอนสตรัคเตอร์เริ่มต้นผ่าน trait Default:

#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
pub struct Second {
    value: u64,
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}

impl Default for Second {
    fn default() -> Self {
        Self { value: 0 }
    }
}
}

Default ยังสามารถ derive ได้โดยอัตโนมัติหากทุกชนิดข้อมูลของทุกฟิลด์อิมพลีเมนต์ Default ไว้อยู่แล้ว เช่นเดียวกับกรณีของ Second:

#![allow(unused)]
fn main() {
/// Time in seconds.
///
/// # Example
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
#[derive(Default)]
pub struct Second {
    value: u64,
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}
}

ข้อสังเกต: เป็นเรื่องปกติธรรมดาและถือเป็นสิ่งที่คาดหวังได้ ว่าชนิดข้อมูลควรจะอิมพลีเมนต์ทั้ง Default และคอนสตรัคเตอร์ new ที่ไม่รับพารามิเตอร์ควบคู่กัน เนื่องจาก new คือธรรมเนียมปฏิบัติมาตรฐานของการสร้างออบเจกต์ใน Rust และผู้ใช้คาดหวังว่าจะมีเมท็อดนี้ให้เรียกใช้เสมอ ดังนั้นหากมีความสมเหตุสมผลที่คอนสตรัคเตอร์พื้นฐานจะไม่รับอาร์กิวเมนต์ใดๆ ก็ควรเขียนเมท็อด new() เตรียมไว้ด้วย แม้ว่าการทำงานภายในจะเหมือนกับ default() ทุกประการก็ตาม

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

ดูเพิ่มเติม

trait Default

คำอธิบาย

ชนิดข้อมูลจำนวนมากใน Rust มีคอนสตรัคเตอร์ เป็นของตนเอง อย่างไรก็ตาม คอนสตรัคเตอร์นั้นถือเป็นเมท็อดที่เฉพาะเจาะจงสำหรับแต่ละชนิดข้อมูล ทำให้ Rust ไม่สามารถสร้างแอบสแตรกชันครอบคลุม “ทุกสิ่งที่มีเมท็อด new()” ได้โดยตรง เพื่อทลายข้อจำกัดนี้ trait Default จึงถูกคิดค้นขึ้นมา เพื่อเปิดให้ชนิดข้อมูลต่างๆ สามารถใช้งานร่วมกับ Container และชนิดข้อมูลแบบเจเนอริกอื่นๆ ได้ (เช่น เมท็อด Option::unwrap_or_default()) ซึ่งที่น่าสังเกตคือ Container พื้นฐานหลายตัวในไลบรารีมาตรฐานต่างก็ได้อิมพลีเมนต์ trait นี้ไว้ล่วงหน้าแล้ว

นอกจาก Container ที่บรรจุข้อมูลเดี่ยวอย่าง Cow, Box หรือ Arc จะอิมพลีเมนต์ Default ให้กับชนิดข้อมูลภายในที่รองรับ Default แล้ว เรายังสามารถสั่ง #[derive(Default)] อัตโนมัติให้กับ struct ใดๆ ที่ทุกฟิลด์ภายในอิมพลีเมนต์ Default ไว้อยู่แล้วได้อีกด้วย ดังนั้น ยิ่งมีชนิดข้อมูลที่รองรับ Default มากขึ้นเท่าใด ประโยชน์และความยืดหยุ่นในการใช้งานร่วมกันในระบบนิเวศก็จะยิ่งทวีคูณขึ้นเท่านั้น

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

ตัวอย่าง

use std::{path::PathBuf, time::Duration};

// note that we can simply auto-derive Default here.
#[derive(Default, Debug, PartialEq)]
struct MyConfiguration {
    // Option defaults to None
    output: Option<PathBuf>,
    // Vecs default to empty vector
    search_path: Vec<PathBuf>,
    // Duration defaults to zero time
    timeout: Duration,
    // bool defaults to false
    check: bool,
}

impl MyConfiguration {
    // add setters here
}

fn main() {
    // construct a new instance with default values
    let mut conf = MyConfiguration::default();
    // do something with conf here
    conf.check = true;
    println!("conf = {conf:#?}");

    // partial initialization with default values, creates the same instance
    let conf1 = MyConfiguration {
        check: true,
        ..Default::default()
    };
    assert_eq!(conf, conf1);
}

ดูเพิ่มเติม

คอลเลกชันคือสมาร์ตพอยน์เตอร์

คำอธิบาย

การนำ trait Deref มาใช้กับโครงสร้างข้อมูลประเภทคอลเลกชัน (Collections) เสมือนว่ามันเป็นสมาร์ตพอยน์เตอร์ เพื่อเปิดมุมมองข้อมูลทั้งในแบบที่เป็นเจ้าของข้อมูล (Owning view) และแบบยืมข้อมูล (Borrowed view)

ตัวอย่าง

use std::ops::Deref;

struct Vec<T> {
    data: RawVec<T>,
    //..
}

impl<T> Deref for Vec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        //..
    }
}

โครงสร้าง Vec<T> คือคอลเลกชันที่เป็นเจ้าของข้อมูลของ T ในขณะที่ slice (&[T]) คือคอลเลกชันในมุมมองแบบยืมข้อมูลของ T การอิมพลีเมนต์ Deref ให้แก่ Vec จะเปิดทางให้คอมไพเลอร์ทำ deref coercion จาก &Vec<T> ไปเป็น &[T] ได้โดยอัตโนมัติ และผนวกรวมความสัมพันธ์นี้เข้ากับกระบวนการค้นหาเมท็อดอัตโนมัติ (auto-dereferencing) ส่งผลให้เมท็อดส่วนใหญ่ที่คุณคุ้นเคยบน Vec แท้จริงแล้วถูกอิมพลีเมนต์ไว้บน slice แทบทั้งสิ้น

นอกจากนี้ String และ &str ก็มีความสัมพันธ์ในลักษณะเดียวกันนี้เช่นกัน

ที่มาและเหตุผล

ระบบความเป็นเจ้าของ (Ownership) และการยืมข้อมูล (Borrowing) คือหัวใจสำคัญของภาษา Rust โครงสร้างข้อมูลที่ออกแบบมาจึงต้องคำนึงถึงพฤติกรรมเหล่านี้อย่างรอบคอบเพื่อส่งมอบประสบการณ์การใช้งานที่ดี เมื่อใดก็ตามที่คุณสร้างโครงสร้างข้อมูลที่เป็นเจ้าของข้อมูล การเปิดมุมมองแบบยืมข้อมูล (Borrowed view) ออกมาจะช่วยให้ API มีความยืดหยุ่นและใช้งานง่ายขึ้นอย่างมหาศาล

ข้อดี

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

เปิดทางเลือกให้ผู้เรียกใช้งานว่าต้องการยืมข้อมูล (Borrowing) หรือต้องการครอบครองความเป็นเจ้าของ (Taking ownership)

ข้อเสีย

เมท็อดหรือ trait ที่เข้าถึงได้ผ่านการ dereference เท่านั้น จะไม่ถูกนำมาพิจารณาในระหว่างการตรวจสอบขอบเขตชนิดข้อมูล (Trait Bounds checking) ดังนั้นการเขียนโปรแกรมเชิงเจเนอริกกับโครงสร้างข้อมูลที่ใช้แพตเทิร์นนี้จึงอาจมีความซับซ้อนเพิ่มขึ้น (ดูเพิ่มเติมได้ที่ trait Borrow, AsRef ฯลฯ)

เจาะลึกรายละเอียด

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

สมาร์ตพอยน์เตอร์ส่วนใหญ่ (เช่น Foo<T>) จะอิมพลีเมนต์ Deref<Target=T> แต่สำหรับคอลเลกชัน มักจะ dereference ไปสู่ชนิดข้อมูลแบบกำหนดเอง แม้ว่า [T] และ str จะได้รับการสนับสนุนเป็นพิเศษในระดับภาษา แต่ในกรณีทั่วไปก็ไม่จำเป็นต้องยึดติดกับรูปแบบนี้เสมอไป โดย Foo<T> สามารถอิมพลีเมนต์ Deref<Target=Bar<T>> ได้ โดยที่ Bar เป็นชนิดข้อมูลแบบ Dynamically Sized Type (DST) และ &Bar<T> จะทำหน้าที่เป็นมุมมองแบบยืมของข้อมูลภายใน Foo<T>

โดยทั่วไป คอลเลกชันที่มีการเรียงลำดับจะอิมพลีเมนต์ trait Index ร่วมกับ Range เพื่อรองรับไวยากรณ์การทำ slicing โดยชนิดข้อมูลเป้าหมายที่ได้ออกมาก็คือมุมมองแบบยืมข้อมูลนั่นเอง

ดูเพิ่มเติม

การทำขั้นสุดท้ายในเดสทรัคเตอร์ (Finalisation in Destructors)

คำอธิบาย

ภาษา Rust ไม่มีโครงสร้างบล็อก finally โดยตรงเหมือนในบางภาษา (ซึ่งหมายถึงบล็อกโค้ดที่จะทำงานเสมอไม่ว่าฟังก์ชันจะจบการทำงานด้วยกรณีใดก็ตาม) แต่เราสามารถใช้ประโยชน์จากเดสทรัคเตอร์ (Destructor) ของออบเจกต์ผ่าน trait Drop มารับหน้าที่รันโค้ด Cleanup ที่จำเป็นต้องทำงานก่อนออกจากฟังก์ชันได้

ตัวอย่าง

fn baz() -> Result<(), ()> {
    // some code
}

fn bar() -> Result<(), ()> {
    // These don't need to be defined inside the function.
    struct Foo;

    // Implement a destructor for Foo.
    impl Drop for Foo {
        fn drop(&mut self) {
            println!("exit");
        }
    }

    // The destructor of _exit will run however the function `bar` is exited.
    let _exit = Foo;
    // Implicit return with `?` operator.
    baz()?;
    // Normal return.
    Ok(())
}

ที่มาและเหตุผล

หากฟังก์ชันใดมีจุดส่งคืนค่า (Return points) หลายจุด การเขียนโค้ดเพื่อคืนทรัพยากรหรือบันทึกสถานะก่อนออกจากฟังก์ชันจะกลายเป็นเรื่องยุ่งยาก ซ้ำซ้อน และเสี่ยงต่อการเกิดข้อผิดพลาด (Bugs) ได้ง่าย โดยเฉพาะอย่างยิ่งในกรณีที่มีการส่งค่ากลับโดยอัตโนมัติผ่านมาโครหรือโอเปอเรเตอร์ ? (ซึ่งจะ return ออกจากฟังก์ชันทันทีหากได้ผลลัพธ์เป็น Err แต่จะทำงานต่อหากเป็น Ok) แม้ว่า ? จะทำหน้าที่คล้ายกลไกการจัดการข้อยกเว้น แต่ต่างจาก Java (ที่มีบล็อก finally) ตรงที่ Rust ไม่มีคำสั่งสำเร็จรูปสำหรับสั่งให้โค้ดทำงานครอบคลุมทั้งในกรณีปกติและกรณีเกิดข้อผิดพลาด นอกจากนี้ การเกิด panic ก็ส่งผลให้ฟังก์ชันยุติการทำงานก่อนเวลาได้เช่นเดียวกัน

ข้อดี

โค้ดที่บรรจุอยู่ในเดสทรัคเตอร์จะได้รับการการันตีว่า (เกือบ) จะถูกเรียกใช้งานเสมอ ไม่ว่าจะเกิดเหตุการณ์ panic, มีการ return ออกก่อนเวลา หรือจบฟังก์ชันตามปกติ

ข้อเสีย

ไม่มีการรับประกันแบบ 100% ว่าเดสทรัคเตอร์จะถูกรันในทุกสถานการณ์ ตัวอย่างเช่น หากฟังก์ชันเกิดลูปไม่รู้จบ (Infinite loop) หรือโปรเซสของโปรแกรมเกิดแครชอย่างกะทันหันก่อนออกจากฟังก์ชัน นอกจากนี้ เดสทรัคเตอร์จะไม่ทำงานในกรณีที่เกิด panic ซ้ำซ้อนในเธรดที่กำลัง panic อยู่แล้ว ดังนั้น เราจึงไม่ควรพึ่งพาเดสทรัคเตอร์เป็น Finalizer สำหรับกรณีที่การ Cleanup มีความสำคัญยิ่งยวดระดับคอขาดบาดตาย

แพตเทิร์นนี้เพิ่มโค้ดที่ทำงานโดยอัตโนมัติ (Implicit) ซึ่งอาจสังเกตเห็นได้ยาก การอ่านโค้ดของฟังก์ชันอาจไม่ได้บอกอย่างชัดเจนในทันทีว่าจะมีเดสทรัคเตอร์ใดบ้างที่ทำงานเมื่อจบฟังก์ชัน ซึ่งอาจทำให้การดีบักมีความซับซ้อนขึ้น

การต้องสร้าง struct ใหม่และเขียนบล็อก impl Drop เพียงเพื่อทำหน้าที่ Cleanup อาจทำให้เกิดโค้ด boilerplate มากเกินความจำเป็น

เจาะลึกรายละเอียด

มีข้อควรระวังสำคัญเกี่ยวกับการจัดเก็บออบเจกต์ที่ทำหน้าที่เป็น Finalizer: ออบเจกต์นั้นจะต้องคงอยู่จนกระทั่งจบขอบเขตของฟังก์ชันแล้วจึงค่อยถูกทำลาย ดังนั้นออบเจกต์ดังกล่าวจะต้องถือครองค่าโดยตรง หรือเป็นสมาร์ตพอยน์เตอร์ที่มีความเป็นเจ้าของแต่เพียงผู้เดียว (Unique Ownership เช่น Box<Foo>) หากเรานำสมาร์ตพอยน์เตอร์แบบแบ่งปันความเป็นเจ้าของ (Shared Ownership เช่น Rc) มาใช้ ตัว Finalizer อาจมีอายุยืนยาวเกินกว่าขอบเขตของฟังก์ชันได้ ด้วยเหตุผลเดียวกันนี้ ตัว Finalizer จึงต้องไม่ถูกย้าย (Move) หรือถูก return ออกไปนอกฟังก์ชัน

Finalizer จะต้องถูกผูกเข้ากับตัวแปรเสมอ มิฉะนั้นมันจะถูกทำลายทิ้งทันทีในบรรทัดนั้นแทนที่จะรอจนกระทั่งหลุดออกจาก scope และชื่อตัวแปรควรขึ้นต้นด้วยเครื่องหมายขีดล่าง _ (เช่น _exit) หากตัวแปรนั้นไม่ได้ถูกนำไปเรียกใช้งานที่อื่น เพื่อป้องกันไม่ให้คอมไพเลอร์แจ้งเตือนคำเตือนตัวแปรไม่ได้ใช้งาน อย่างไรก็ดี ห้ามตั้งชื่อตัวแปรเป็น _ โดดๆ โดยไม่มีตัวอักษรอื่นต่อท้าย เพราะในภาษา Rust ตัวแปร _ จะถือเป็นการละทิ้งค่าและทำให้ออบเจกต์ถูกทำลาย (Drop) ทันที

ในภาษา Rust เดสทรัคเตอร์จะทำงานเมื่อออบเจกต์หลุดออกจากขอบเขต (Scope) ไม่ว่าจะเป็นการสิ้นสุดบล็อกโค้ด, มีการ return ก่อนเวลา หรือโปรแกรมเกิด panic โดยเมื่อเกิด panic ขึ้น Rust จะคลายสแตก (Stack Unwinding) เพื่อตามเรียกเดสทรัคเตอร์ของทุกออบเจกต์ในแต่ละ Stack Frame ดังนั้นเดสทรัคเตอร์จึงยังคงถูกเรียกใช้แม้ว่าการ panic จะเกิดขึ้นลึกลงไปในฟังก์ชันลูกที่ถูกเรียกก็ตาม

แต่หากเดสทรัคเตอร์ดันเกิด panic ซ้ำขึ้นมาอีกในระหว่างกระบวนการคลายสแตก Rust จะถือว่าเป็นสถานะที่ไม่ปลอดภัยและจะสั่งระงับการทำงานของเธรดนั้นทันที (Abort) โดยไม่เรียกเดสทรัคเตอร์ตัวอื่นๆ ต่อไปอีก ซึ่งย้ำเตือนว่าคุณต้องใช้ความระมัดระวังเป็นพิเศษไม่ให้โค้ดภายในบล็อก drop เกิด panic ขึ้นได้ เพราะอาจทำให้ทรัพยากรตกค้างอยู่ในสถานะที่ไม่พึงประสงค์

ดูเพิ่มเติม

RAII guards

mem::{take(_), replace(_)} เพื่อรักษาค่าที่เป็นเจ้าของไว้ใน enum ที่เปลี่ยนแปลง

คำอธิบาย

สมมติว่าเรามีตัวแปร &mut MyEnum ซึ่งมีอย่างน้อย 2 variants คือ A { name: String, x: u8 } และ B { name: String } และเราต้องการเปลี่ยนค่าจาก MyEnum::A ให้กลายเป็น B หากค่า x มีค่าเท่ากับศูนย์ โดยยังคงรักษาข้อมูล MyEnum::B เดิมไว้

เราสามารถทำสิ่งนี้ได้โดยไม่จำเป็นต้องทำการ .clone() ตัวแปร name เลยแม้แต่น้อย

ตัวอย่าง

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

enum MyEnum {
    A { name: String, x: u8 },
    B { name: String },
}

fn a_to_b(e: &mut MyEnum) {
    if let MyEnum::A { name, x: 0 } = e {
        // This takes out our `name` and puts in an empty String instead
        // (note that empty strings don't allocate).
        // Then, construct the new enum variant (which will
        // be assigned to `*e`).
        *e = MyEnum::B {
            name: mem::take(name),
        }
    }
}
}

วิธีนี้ยังใช้ได้กับกรณีที่มีหลาย variant:

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

enum MultiVariateEnum {
    A { name: String },
    B { name: String },
    C,
    D,
}

fn swizzle(e: &mut MultiVariateEnum) {
    use MultiVariateEnum::*;
    *e = match e {
        // Ownership rules do not allow taking `name` by value, but we cannot
        // take the value out of a mutable reference, unless we replace it:
        A { name } => B {
            name: mem::take(name),
        },
        B { name } => A {
            name: mem::take(name),
        },
        C => D,
        D => C,
    }
}
}

ที่มาและเหตุผล

เมื่อทำงานกับ enum เราอาจต้องการเปลี่ยนค่าภายในของ enum ณ ตำแหน่งหน่วยความจำเดิม (In-place) ไปสู่ variant อื่น ซึ่งในทางปฏิบัติต้องแบ่งออกเป็น 2 จังหวะเพื่อให้ borrow checker ยอมผ่าน: ในจังหวะแรก เราจะอ่านตรวจสอบค่าที่มีอยู่เดิมและส่วนประกอบย่อยของมันเพื่อตัดสินใจว่าจะทำอะไรต่อ และในจังหวะที่สอง เราจึงค่อยเปลี่ยนค่าตามเงื่อนไขที่ต้องการ (ดังตัวอย่างข้างต้น)

กฎของ borrow checker จะไม่อนุญาตให้เราดึง name ออกมาจาก enum ดื้อๆ (เพราะข้อมูลใน enum จะเกิดภาวะว่างเปล่าไม่ได้ ต้องมีบางสิ่งอยู่ตรงนั้นเสมอ) แน่นอนว่าเราอาจแก้ปัญหาง่ายๆ ด้วยการเรียก .clone() ข้อมูล name แล้วนำตัวโคลนนั้นไปใส่ใน MyEnum::B แต่นั่นจะกลายเป็นการตกหลุมพรางของแอนติแพตเทิร์น โคลนเพื่อเอาใจ borrow checker ทันที แต่เราสามารถหลีกเลี่ยงการจัดสรรหน่วยความจำ (Allocation) ส่วนเกินได้ โดยการสลับค่าใน e ผ่าน mutable reference

ฟังก์ชัน mem::take ช่วยให้เราสลับดึงเอาค่าเดิมออกมา โดยแทนที่ตำแหน่งนั้นด้วยค่าเริ่มต้น (Default value) ของมัน และคืนค่าเดิมก่อนหน้ากลับมา สำหรับ String ค่า default คือสตริงว่าง ซึ่งไม่ต้องมีการจัดสรรหน่วยความจำบน heap เพิ่มเติม ผลลัพธ์คือเราจะได้ name ตัวเดิมในฐานะค่าที่เราเป็นเจ้าของ (Owned Value) และสามารถนำไปบรรจุลงใน enum ตัวใหม่ได้ทันที

ข้อสังเกต: mem::replace มีการทำงานที่คล้ายคลึงกันอย่างยิ่ง แต่เปิดให้เราระบุค่าที่จะนำมาสลับแทนที่ได้ตามใจชอบ โดยบรรทัดที่เทียบเท่ากับ mem::take ของเราคือ mem::replace(name, String::new())

นอกจากนี้ หากเรากำลังทำงานกับ Option และต้องการดึงค่าเดิมออกมาแล้วแทนที่ตำแหน่งนั้นด้วย None เมท็อด take() ของตัว Option เองถือเป็นทางเลือกที่สั้น กระชับ และเป็นสำนวนที่ถูกต้องตรงจุดยิ่งกว่า

ข้อดี

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

ข้อเสีย

โค้ดจะดูยาวขึ้นเล็กน้อย และหากเขียนผิดซ้ำๆ ก็อาจทำให้คุณหงุดหงิดกับ borrow checker ได้ นอกจากนี้ คอมไพเลอร์อาจไม่สามารถปรับลดทอนขั้นตอนการเขียนทับสองรอบ (Double store) ออกไปได้ ส่งผลให้ประสิทธิภาพลดลงเล็กน้อยเมื่อเทียบกับการเขียนโค้ดในภาษาตระกูล Unsafe

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

เจาะลึกรายละเอียด

แพตเทิร์นนี้เป็นเรื่องที่น่าสนใจและจำเป็นเฉพาะในภาษา Rust เท่านั้น เพราะในภาษาที่มี Garbage Collection (GC) คุณจะส่งผ่านการอ้างอิงถึงค่าเป็นปกติอยู่แล้ว (โดยมี GC คอยนับการอ้างอิงให้) ส่วนในภาษาระดับต่ำอย่าง C คุณก็แค่คัดลอกพอยน์เตอร์แล้วค่อยตามไปจัดการทีหลัง

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

ดูเพิ่มเติม

แพตเทิร์นนี้ช่วยกำจัดแอนติแพตเทิร์น โคลนเพื่อเอาใจ borrow checker ในกรณีเฉพาะทางนี้ได้อย่างหมดจด

ไดนามิกดิสแพตช์บนสแตก

คำอธิบาย

เราสามารถทำ Dynamic Dispatch ข้ามชนิดข้อมูลที่แตกต่างกันได้โดยตรงบน Stack โดยไม่จำเป็นต้องใช้ Box เพื่อจัดสรรหน่วยความจำบน heap เสมอไป ดังตัวอย่างด้านล่างนี้:

ตัวอย่าง

use std::io;
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let arg = "-";

// We need to describe the type to get dynamic dispatch.
let readable: &mut dyn io::Read = if arg == "-" {
    &mut io::stdin()
} else {
    &mut fs::File::open(arg)?
};

// Read from `readable` here.

Ok(())
}

ที่มาและเหตุผล

โดยค่าเริ่มต้นแล้ว ภาษา Rust จะใช้กระบวนการ Monomorphization กับโค้ดที่มีการใช้ Generics ซึ่งหมายความว่าคอมไพเลอร์จะคัดลอกและสร้างฟังก์ชันชุดใหม่ขึ้นมาแยกกันสำหรับแต่ละชนิดข้อมูลที่ถูกส่งเข้ามาใช้งาน และทำการปรับแต่ง (Optimize) แต่ละชุดอย่างเป็นอิสระ แม้ว่าวิธีนี้จะช่วยให้โค้ดส่วนที่ประมวลผลบ่อย (Hot path) ทำงานได้รวดเร็วอย่างยิ่ง แต่ก็แลกมาด้วยการทำให้ขนาดไฟล์ไบนารีบวมขึ้นในจุดที่ประสิทธิภาพอาจไม่ใช่เรื่องสำคัญที่สุด และทำให้คอมไพเลอร์ต้องใช้เวลาคอมไพล์นานขึ้น ตลอดจนสิ้นเปลืองพื้นที่ CPU Cache

โชคดีที่ Rust ยังเปิดทางให้เราสามารถเลือกใช้ Dynamic Dispatch (ผ่าน Trait Object เช่น dyn Trait) ได้เมื่อต้องการ เพียงแต่เราต้องระบุชนิดข้อมูลกำกับไว้อย่างชัดเจน

ข้อดี

เราไม่จำเป็นต้องจัดสรรหน่วยความจำบน heap (ไม่จำเป็นต้องใช้ Box) ไม่จำเป็นต้องเตรียมสร้างออบเจกต์ตัวที่เราไม่ได้ใช้งานจริงในภายหลัง และไม่ต้องทำให้คอมไพเลอร์ต้องทำ Monomorphization โค้ดทั้งหมดที่ตามมาเพื่อให้รองรับทั้ง File หรือ Stdin

ข้อเสีย

ก่อนยุคของ Rust 1.79.0 โค้ดจำเป็นต้องประกาศตัวแปรด้วยคำสั่ง let แยกกันถึงสองตัว พร้อมกับใช้เทคนิคการกำหนดค่าเริ่มต้นแบบมีเงื่อนไข (Deferred Initialization) ซึ่งทำให้มีชิ้นส่วนโค้ดที่ต้องเขียนมากกว่าการใช้ Box:

// We still need to ascribe the type for dynamic dispatch.
let readable: Box<dyn io::Read> = if arg == "-" {
    Box::new(io::stdin())
} else {
    Box::new(fs::File::open(arg)?)
};
// Read from `readable` here.

แต่โชคดีที่ตั้งแต่ Rust 1.79.0 เป็นต้นมา ข้อจำกัดที่น่ารำคาญนี้ได้หมดไปแล้ว!

เจาะลึกรายละเอียด

ตั้งแต่ Rust 1.79.0 เป็นต้นมา คอมไพเลอร์จะช่วยขยายอายุขัย (Lifetime Extension) ของค่าชั่วคราวที่อยู่ภายใต้การอ้างอิง & หรือ &mut ให้ยืนยาวที่สุดเท่าที่จะเป็นไปได้ภายในขอบเขตของฟังก์ชันโดยอัตโนมัติ

นั่นหมายความว่าเราสามารถส่งค่า &mut ออกมาจากนิพจน์ if-else ได้โดยตรงอย่างสบายใจ โดยไม่ต้องคอยกังวลว่าจะต้องสร้างตัวแปร let พักข้อมูลไว้ก่อนเหมือนเมื่อก่อน

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

ดูเพิ่มเติม

  • การทำขั้นสุดท้ายในเดสทรัคเตอร์ (Finalisation in Destructors) และ RAII guards ที่ได้ประโยชน์จากการควบคุม อายุขัย (Lifetime) ของตัวแปรอย่างรัดกุม
  • สำหรับการสร้างเรฟเฟอเรนซ์แบบมีเงื่อนไขในรูป Option<&T> หรือ Option<&mut T> เราสามารถเก็บค่าลงใน Option<T> โดยตรง แล้วใช้เมท็อด .as_ref() เพื่อดึงเรฟเฟอเรนซ์ออกมาได้เมื่อต้องการ

สำนวน FFI

การเขียนโค้ดติดต่อกับภาษาภายนอก (FFI) ถือเป็นศาสตร์ที่มีเนื้อหากว้างขวางและลึกซึ้งในตัวเอง อย่างไรก็ดี ในหัวข้อนี้มีสำนวนและแนวทางปฏิบัติหลายประการที่ช่วยเป็นเข็มทิศนำทาง และช่วยป้องกันกับดักอันตรายสำหรับผู้ที่ยังไม่คุ้นเคยกับการเขียน unsafe Rust

ส่วนนี้รวบรวมสำนวนที่ช่วยอำนวยความสะดวกและเพิ่มความปลอดภัยเมื่อต้องทำ FFI:

  1. การจัดการข้อผิดพลาดตามธรรมเนียมปฏิบัติ — การจัดการข้อผิดพลาดด้วยรหัสจำนวนเต็ม (integer codes) และค่าส่งกลับที่เป็นสัญญาณพิเศษ (เช่น พอยน์เตอร์ NULL)

  2. การรับสตริง โดยใช้โค้ด unsafe ให้น้อยที่สุด

  3. การส่งสตริง ไปยังฟังก์ชัน FFI

การจัดการข้อผิดพลาดใน FFI

คำอธิบาย

ในภาษาภายนอก เช่น ภาษา C ข้อผิดพลาดมักแสดงด้วยรหัสสถานะที่ส่งกลับคืนมา (return codes) อย่างไรก็ตาม ระบบชนิดข้อมูล (type system) ของ Rust ช่วยให้เราสามารถรวบรวมและส่งต่อข้อมูลข้อผิดพลาดที่ละเอียดและสมบูรณ์กว่ามากผ่านชนิดข้อมูลที่สร้างขึ้นอย่างเต็มรูปแบบ

แนวทางปฏิบัติที่ดีที่สุดนี้นำเสนอรูปแบบรหัสข้อผิดพลาดชนิดต่างๆ และวิธีเปิดเผยข้อมูลเหล่านั้นออกไปให้ภาษาภายนอกใช้งานได้อย่างสะดวก:

  1. Flat Enum (enum ค่าเดี่ยวแบบเรียบ) ควรแปลงเป็นเลขจำนวนเต็มเพื่อส่งคืนเป็นรหัสข้อผิดพลาด
  2. Structured Enum (enum ที่มีฟิลด์ข้อมูลโครงสร้างซ้อนอยู่) ควรแปลงเป็นรหัสจำนวนเต็ม ควบคู่กับเมท็อดส่งคืนข้อความสตริงเพื่ออธิบายรายละเอียด
  3. ชนิดข้อมูลข้อผิดพลาดแบบกำหนดเอง (Custom Error Types) ควรทำให้มีโครงสร้างที่ “โปร่งใส” โดยกำหนดการจัดเรียงในหน่วยความจำให้เข้ากันได้กับ C (#[repr(C)])

ตัวอย่างโค้ด

Enum แบบแบน

enum DatabaseError {
    IsReadOnly = 1,    // user attempted a write operation
    IOError = 2,       // user should read the C errno() for what it was
    FileCorrupted = 3, // user should run a repair tool to recover it
}

impl From<DatabaseError> for libc::c_int {
    fn from(e: DatabaseError) -> libc::c_int {
        (e as i8).into()
    }
}

Enum แบบมีโครงสร้าง

pub mod errors {
    enum DatabaseError {
        IsReadOnly,
        IOError(std::io::Error),
        FileCorrupted(String), // message describing the issue
    }

    impl From<DatabaseError> for libc::c_int {
        fn from(e: DatabaseError) -> libc::c_int {
            match e {
                DatabaseError::IsReadOnly => 1,
                DatabaseError::IOError(_) => 2,
                DatabaseError::FileCorrupted(_) => 3,
            }
        }
    }
}

pub mod c_api {
    use super::errors::DatabaseError;
    use core::ptr;

    #[no_mangle]
    pub extern "C" fn db_error_description(
        e: Option<ptr::NonNull<DatabaseError>>,
    ) -> Option<ptr::NonNull<libc::c_char>> {
        // SAFETY: we assume that the lifetime of `e` is greater than
        // the current stack frame.
        let error = unsafe { e?.as_ref() };

        let error_str: String = match error {
            DatabaseError::IsReadOnly => {
                format!("cannot write to read-only database")
            }
            DatabaseError::IOError(e) => {
                format!("I/O Error: {e}")
            }
            DatabaseError::FileCorrupted(s) => {
                format!("File corrupted, run repair: {}", &s)
            }
        };

        let error_bytes = error_str.as_bytes();

        let c_error = unsafe {
            // SAFETY: copying error_bytes to an allocated buffer with a '\0'
            // byte at the end.
            let buffer = ptr::NonNull::<u8>::new(libc::malloc(error_bytes.len() + 1).cast())?;

            buffer
                .as_ptr()
                .copy_from_nonoverlapping(error_bytes.as_ptr(), error_bytes.len());
            buffer.as_ptr().add(error_bytes.len()).write(0_u8);
            buffer
        };

        Some(c_error.cast())
    }
}

ชนิดข้อผิดพลาดที่กำหนดเอง

struct ParseError {
    expected: char,
    line: u32,
    ch: u16,
}

impl ParseError {
    /* ... */
}

/* Create a second version which is exposed as a C structure */
#[repr(C)]
pub struct parse_error {
    pub expected: libc::c_char,
    pub line: u32,
    pub ch: u16,
}

impl From<ParseError> for parse_error {
    fn from(e: ParseError) -> parse_error {
        let ParseError { expected, line, ch } = e;
        parse_error { expected, line, ch }
    }
}

ข้อดี

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

ข้อเสีย

ต้องเขียนโค้ดแปลงข้อมูล (boilerplate) เพิ่มขึ้นพอสมควร และชนิดข้อมูลบางชนิดอาจไม่สามารถแปลงไปเป็นโครงสร้างแบบภาษา C ได้อย่างตรงไปตรงมา

การรับสตริง

คำอธิบาย

เมื่อรับสตริงผ่าน FFI ด้วยพอยน์เตอร์ มีหลักการสำคัญสองข้อที่ควรยึดถือ:

  1. คงสถานะของสตริงจากภาษาภายนอกให้อยู่ในรูปแบบ “การยืม” (&CStr) แทนที่จะคัดลอกข้อมูลโดยไม่จำเป็น
  2. ลดความซับซ้อนและปริมาณโค้ด unsafe ที่เกี่ยวข้องกับการแปลงสตริงแบบภาษา C ไปเป็นสตริงของ Rust ให้เหลือน้อยที่สุด

แรงจูงใจ

สตริงที่ใช้ในภาษา C มีพฤติกรรมและการจัดเก็บที่แตกต่างจากสตริงใน Rust ดังนี้:

  • สตริงใน C สิ้นสุดด้วยไบต์ศูนย์ (null-terminated) ขณะที่สตริงใน Rust จัดเก็บขนาดความยาวไว้ในตัว
  • สตริงใน C อาจประกอบด้วยไบต์ใดๆ ก็ได้ที่ไม่ใช่ศูนย์ ขณะที่สตริงใน Rust บังคับว่าต้องเข้ารหัสเป็น UTF-8 ที่ถูกต้องเสมอ
  • สตริงใน C เข้าถึงและจัดการผ่านการดำเนินการกับ raw pointer แบบ unsafe ขณะที่การทำงานกับสตริงใน Rust กระทำผ่านเมท็อดที่ปลอดภัย (safe methods)

ไลบรารีมาตรฐานของ Rust มีชนิดข้อมูลคู่เทียบกับ String และ &str สำหรับภาษา C โดยเฉพาะ ได้แก่ CString และ &CStr ซึ่งช่วยให้เราหลีกเลี่ยงความซับซ้อนและลดการเขียนโค้ด unsafe ในการแปลงค่าระหว่างสตริงของทั้งสองภาษาลงได้อย่างมาก

ชนิดข้อมูล &CStr ยังช่วยให้เราทำงานกับข้อมูลที่ถูกยืมมาได้โดยตรง ส่งผลให้การส่งต่อสตริงระหว่าง Rust และ C แทบไม่มีต้นทุนทางประสิทธิภาพเพิ่มเติม (zero-cost)

ตัวอย่างโค้ด

pub mod unsafe_module {

    // other module content

    /// Log a message at the specified level.
    ///
    /// # Safety
    ///
    /// It is the caller's guarantee to ensure `msg`:
    ///
    /// - is not a null pointer
    /// - points to valid, initialized data
    /// - points to memory ending in a null byte
    /// - won't be mutated for the duration of this function call
    #[no_mangle]
    pub unsafe extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
        let level: crate::LogLevel = match level { /* ... */ };

        // SAFETY: The caller has already guaranteed this is okay (see the
        // `# Safety` section of the doc-comment).
        let msg_str: &str = match std::ffi::CStr::from_ptr(msg).to_str() {
            Ok(s) => s,
            Err(e) => {
                crate::log_error("FFI string conversion failed");
                return;
            }
        };

        crate::log(msg_str, level);
    }
}

ข้อดี

ตัวอย่างข้างต้นได้รับการออกแบบเพื่อให้มั่นใจว่า:

  1. บล็อกคำสั่ง unsafe มีขอบเขตเล็กที่สุดเท่าที่จะเป็นไปได้
  2. พอยน์เตอร์ที่มีช่วงชีวิตที่คอมไพเลอร์ไม่สามารถติดตามได้ (untracked lifetime) จะถูกเปลี่ยนให้อยู่ในการอ้างอิงแบบแชร์ที่ผ่านการตรวจสอบอย่างปลอดภัย (tracked shared reference)

ลองพิจารณาอีกแนวทางหนึ่งที่มีการคัดลอกสตริงจริงๆ:

pub mod unsafe_module {

    // other module content

    pub extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
        // DO NOT USE THIS CODE.
        // IT IS UGLY, VERBOSE, AND CONTAINS A SUBTLE BUG.

        let level: crate::LogLevel = match level { /* ... */ };

        let msg_len = unsafe { /* SAFETY: strlen is what it is, I guess? */
            libc::strlen(msg)
        };

        let mut msg_data = Vec::with_capacity(msg_len + 1);

        let msg_cstr: std::ffi::CString = unsafe {
            // SAFETY: copying from a foreign pointer expected to live
            // for the entire stack frame into owned memory
            std::ptr::copy_nonoverlapping(msg, msg_data.as_mut(), msg_len);

            msg_data.set_len(msg_len + 1);

            std::ffi::CString::from_vec_with_nul(msg_data).unwrap()
        }

        let msg_str: String = unsafe {
            match msg_cstr.into_string() {
                Ok(s) => s,
                Err(e) => {
                    crate::log_error("FFI string conversion failed");
                    return;
                }
            }
        };

        crate::log(&msg_str, level);
    }
}

โค้ดนี้ด้อยกว่าแนวทางแรกในสองประเด็นหลัก:

  1. มีโค้ด unsafe จำนวนมากเกินไป และที่สำคัญคือมีข้อกำหนดความถูกต้อง (invariants) ที่โปรแกรมเมอร์ต้องคอยระวังด้วยตนเองมากขึ้น
  2. มีบั๊กซ่อนอยู่จากการคำนวณตำแหน่งพอยน์เตอร์ ซึ่งนำไปสู่พฤติกรรมที่ไม่พึงประสงค์หรือพฤติกรรมที่ไม่อาจคาดเดาได้ (Undefined Behavior: UB) ใน Rust

บั๊กในตัวอย่างนี้เกิดจากความผิดพลาดในการคำนวณขนาดพอยน์เตอร์: สตริงถูกคัดลอกมาครบจำนวน msg_len ไบต์ แต่ตัวปิดท้ายสตริง NUL ไม่ได้ถูกคัดลอกตามมาด้วย

จากนั้น เวกเตอร์ถูก บังคับขนาด (set_len) ให้เท่ากับความยาวของสตริงที่มีการจัดสรรพื้นที่ไว้ แทนที่จะใช้การ ปรับขนาดจริง (resize) ซึ่งจะช่วยเติมศูนย์ปิดท้ายให้ ผลลัพธ์คือไบต์สุดท้ายในเวกเตอร์กลายเป็นหน่วยความจำที่ยังไม่ได้กำหนดค่าเริ่มต้น (uninitialized memory) เมื่อมีการสร้าง CString ที่ท้ายบล็อก การอ่านข้อมูลจากเวกเตอร์นั้นจึงก่อให้เกิด UB ทันที!

เช่นเดียวกับบั๊กหน่วยความจำทั่วไป ปัญหานี้สืบค้นต้นตอได้ยากมาก บางครั้งโปรแกรมอาจ panic เพราะสตริงไม่ใช่ UTF-8 ที่ถูกต้อง บางครั้งอาจมีอักขระแปลกปลอมโผล่มาท้ายข้อความ หรือในบางกรณีอาจทำให้โปรแกรมหยุดทำงาน (crash) ไปเลยอย่างสิ้นเชิง

ข้อเสีย

แทบไม่มีข้อเสีย

การส่งสตริง

คำอธิบาย

เมื่อต้องการส่งสตริงไปยังฟังก์ชัน FFI มีหลักการ 4 ประการที่ควรยึดถือ:

  1. ควบคุมให้ช่วงชีวิต (lifetime) ของสตริงที่เป็นเจ้าของข้อมูลคงอยู่ยาวนานเพียงพอตลอดระยะเวลาที่ฟังก์ชันภายนอกเรียกใช้งาน
  2. ลดการใช้โค้ด unsafe ระหว่างการแปลงชนิดข้อมูลให้เหลือน้อยที่สุด
  3. หากโค้ดฝั่ง C มีความจำเป็นต้องแก้ไขข้อมูลในสตริง ให้ใช้ Vec แทนที่จะเป็น CString
  4. หาก Foreign Function API ไม่ได้กำหนดไว้เป็นอย่างอื่นโดยเฉพาะ ความเป็นเจ้าของในสตริงนั้นไม่ควรถูกส่งมอบให้กับฟังก์ชันปลายทาง (callee)

แรงจูงใจ

Rust รองรับการทำงานกับสตริงแบบ C ในตัวผ่านชนิดข้อมูล CString และ CStr อย่างไรก็ดี มีแนวทางหลากหลายรูปแบบให้เลือกใช้เมื่อต้องการส่งสตริงจากฟังก์ชันของ Rust ไปยังฟังก์ชันภาษาภายนอก

แนวทางปฏิบัติที่ดีที่สุดนั้นเรียบง่าย: ใช้ CString ในรูปแบบที่ลดการเขียนโค้ด unsafe ให้น้อยที่สุด อย่างไรก็ตาม มีข้อควรระวังสำคัญคือ ตัวออบเจกต์ของสตริงต้องมีช่วงชีวิตยาวนานเพียงพอ นอกจากนี้ ในเอกสารทางการของ Rust ยังระบุว่าการส่ง CString ข้ามไปให้แก้ไขแล้วแปลงกลับมาใช้ใหม่ (round-tripping) อาจก่อให้เกิด Undefined Behavior (UB) ได้ ดังนั้นหากต้องแก้ไขข้อมูล จึงจำเป็นต้องมีขั้นตอนเพิ่มเติม

ตัวอย่างโค้ด

pub mod unsafe_module {

    // other module content

    extern "C" {
        fn seterr(message: *const libc::c_char);
        fn geterr(buffer: *mut libc::c_char, size: libc::c_int) -> libc::c_int;
    }

    fn report_error_to_ffi<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
        let c_err = std::ffi::CString::new(err.into())?;

        unsafe {
            // SAFETY: calling an FFI whose documentation says the pointer is
            // const, so no modification should occur
            seterr(c_err.as_ptr());
        }

        Ok(())
        // The lifetime of c_err continues until here
    }

    fn get_error_from_ffi() -> Result<String, std::ffi::IntoStringError> {
        let mut buffer = vec![0u8; 1024];
        unsafe {
            // SAFETY: calling an FFI whose documentation implies
            // that the input need only live as long as the call
            let written: usize = geterr(buffer.as_mut_ptr(), 1023).into();

            buffer.truncate(written + 1);
        }

        std::ffi::CString::new(buffer).unwrap().into_string()
    }
}

ข้อดี

ตัวอย่างข้างต้นได้รับการออกแบบเพื่อให้มั่นใจว่า:

  1. บล็อกคำสั่ง unsafe มีขอบเขตเล็กที่สุดเท่าที่จะเป็นไปได้
  2. ออบเจกต์ CString มีช่วงชีวิตคงอยู่ยาวนานเพียงพอตามที่ต้องการ
  3. ข้อผิดพลาดจากการแปลงชนิดข้อมูล (typecast) จะถูกส่งต่อกลับออกมาเสมอเมื่อเป็นไปได้

ความผิดพลาดที่พบบ่อย (บ่อยจนถูกยกมาเตือนไว้ในเอกสารของ Rust เอง) คือการไม่เก็บตัวแปรสตริงไว้ในขอบเขตการทำงานก่อนเรียกใช้งานพอยน์เตอร์:

pub mod unsafe_module {

    // other module content

    fn report_error<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
        unsafe {
            // SAFETY: whoops, this contains a dangling pointer!
            seterr(std::ffi::CString::new(err.into())?.as_ptr());
        }
        Ok(())
    }
}

โค้ดข้างต้นจะทำให้เกิด dangling pointer (พอยน์เตอร์ชี้ไปยังหน่วยความจำที่ถูกคืนไปแล้ว) เนื่องจากช่วงชีวิตของค่าชั่วคราว CString จะสิ้นสุดลงทันทีเมื่อจบบรรทัด ไม่ได้ถูกยืดออกไปโดยการสร้าง raw pointer ซึ่งต่างจากการยืมแบบ reference ปกติ

อีกประเด็นหนึ่งที่มักถูกหยิบยกขึ้นมาคือ การสร้างเวกเตอร์ขนาด 1 KB ที่เต็มไปด้วยศูนย์เริ่มต้นนั้นอาจจะ “ช้า” ทว่าใน Rust เวอร์ชันปัจจุบัน คอมไพเลอร์ได้ปรับปรุง (optimize) มาโครดังกล่าวให้เป็นการเรียกใช้ zmalloc ซึ่งมีความเร็วเทียบเท่ากับขีดความสามารถของระบบปฏิบัติการในการจัดสรรหน่วยความจำที่เติมศูนย์ (ซึ่งรวดเร็วมาก)

ข้อเสีย

แทบไม่มีข้อเสีย

การวนซ้ำบน Option

คำอธิบาย

เราสามารถมอง Option เสมือนเป็น Container ที่บรรจุข้อมูลได้ 0 หรือ 1 ตัว โดยเฉพาะอย่างยิ่ง Option ได้อิมพลีเมนต์ trait IntoIterator ไว้อยู่แล้ว จึงสามารถนำไปใช้งานร่วมกับโค้ดเชิงเจเนอริกที่ต้องการ Iterator ได้อย่างแนบเนียน

ตัวอย่าง

เนื่องจาก Option อิมพลีเมนต์ IntoIterator เราจึงสามารถส่งมันเป็นอาร์กิวเมนต์ให้กับเมท็อด .extend() ได้โดยตรง:

#![allow(unused)]
fn main() {
let turing = Some("Turing");
let mut logicians = vec!["Curry", "Kleene", "Markov"];

logicians.extend(turing);

// equivalent to
if let Some(turing_inner) = turing {
    logicians.push(turing_inner);
}
}

หากคุณต้องการต่อท้ายค่าจาก Option เข้ากับ Iterator ที่มีอยู่เดิม ก็สามารถส่งมันเข้าไปในเมท็อด .chain() ได้ทันที:

#![allow(unused)]
fn main() {
let turing = Some("Turing");
let logicians = vec!["Curry", "Kleene", "Markov"];

for logician in logicians.iter().chain(turing.iter()) {
    println!("{logician} is a logician");
}
}

ข้อสังเกต: หากแน่ใจว่าค่าใน Option นั้นมีค่าเป็น Some เสมอ การเลือกใช้ std::iter::once เพื่อสร้าง Iterator สมาชิกเดี่ยว จะถือเป็นสำนวนการเขียนโค้ดที่ชัดเจนและตรงไปตรงมากว่า

นอกจากนี้ การที่ Option อิมพลีเมนต์ IntoIterator ยังทำให้เราสามารถนำมันไปวนลูปด้วย for loop ได้ด้วย ซึ่งมีผลลัพธ์เทียบเท่ากับการเขียน if let Some(..) แต่ในบริบททั่วไปส่วนใหญ่ การเลือกใช้ if let จะอ่านง่ายและเป็นธรรมชาติกว่า

ดูเพิ่มเติม

  • std::iter::once ตัวสร้างอิเทอเรเตอร์ที่ให้สมาชิกเพียงตัวเดียวพอดี เป็นทางเลือกที่อ่านเข้าใจง่ายกว่าการเขียน Some(foo).into_iter()
  • Iterator::filter_map เมท็อดดัดแปลงของ Iterator::map ที่ออกแบบมาสำหรับฟังก์ชันที่ส่งคืนค่ากลับมาเป็น Option
  • crate ref_slice มีฟังก์ชันสำหรับ แปลง Option เป็น slice ที่มีสมาชิก 0 หรือ 1 ตัว
  • เอกสารประกอบของ Option<T>

ส่งตัวแปรเข้าโคลเชอร์

คำอธิบาย

โดยค่าเริ่มต้นแล้ว Closure ในภาษา Rust จะจับ (Capture) ตัวแปรจากสภาพแวดล้อมรอบข้างด้วยวิธีการยืมข้อมูล (Borrowing) หรือคุณอาจเลือกใช้คีย์เวิร์ด move หน้า Closure เพื่อโอนย้ายสิทธิ์ความเป็นเจ้าของ (Move) ตัวแปรทั้งหมดเข้ามาใน Closure ก็ได้ อย่างไรก็ดี บ่อยครั้งที่คุณต้องการย้ายตัวแปรเข้ามาเพียงแค่บางตัว, ต้องการส่งเข้ามาในรูปของสำเนาข้อมูล (Clone), ต้องการส่งผ่านการอ้างอิง (Reference) หรือต้องการแปลงรูปข้อมูลบางประการก่อนใช้งาน

แนวทางปฏิบัติที่แนะนำคือ การประกาศผูกตัวแปรซ้ำ (Variable Rebinding) ไว้ภายในบล็อกขอบเขต (Scope) แยกเฉพาะก่อนสร้าง Closure

ตัวอย่าง

เลือกใช้รูปแบบนี้:

#![allow(unused)]
fn main() {
use std::rc::Rc;

let num1 = Rc::new(1);
let num2 = Rc::new(2);
let num3 = Rc::new(3);
let closure = {
    // `num1` is moved
    let num2 = num2.clone();  // `num2` is cloned
    let num3 = num3.as_ref();  // `num3` is borrowed
    move || {
        *num1 + *num2 + *num3;
    }
};
}

แทนที่จะเขียนแบบนี้:

#![allow(unused)]
fn main() {
use std::rc::Rc;

let num1 = Rc::new(1);
let num2 = Rc::new(2);
let num3 = Rc::new(3);

let num2_cloned = num2.clone();
let num3_borrowed = num3.as_ref();
let closure = move || {
    *num1 + *num2_cloned + *num3_borrowed;
};
}

ข้อดี

ตัวแปรที่ถูกโคลนหรือเตรียมไว้จะถูกจัดกลุ่มอยู่ร่วมกับนิยามของ Closure อย่างเป็นระเบียบ ทำให้เจตนาในการใช้งานมีความชัดเจน และตัวแปรชั่วคราวเหล่านั้นจะถูกทำลาย (Drop) ทิ้งทันทีเมื่อพ้นบล็อก แม้จะไม่ได้ถูกนำไปบริโภคจนหมดภายใน Closure ก็ตาม

Closure สามารถใช้ชื่อตัวแปรเดียวกับโค้ดแวดล้อมภายนอกได้ทันที โดยไม่ต้องตั้งชื่อตัวแปรยาวๆ ให้รกรุงรัง (เช่น ไม่ต้องตั้งชื่อว่า num2_cloned หรือ num3_borrowed)

ข้อเสีย

บล็อกโค้ดของ Closure จะมีการเยื้องบรรทัด (Indentation) ลึกลงไปอีกหนึ่งระดับ

#[non_exhaustive] และฟิลด์แบบ private เพื่อการขยายต่อในอนาคต

คำอธิบาย

มีสถานการณ์จำนวนหนึ่งที่ผู้พัฒนาไลบรารีต้องการเพิ่มฟิลด์แบบ public ให้แก่ struct สาธารณะ หรือเพิ่ม variant ใหม่ๆ ให้แก่ enum ในอนาคต โดยไม่ทำให้โค้ดของผู้เรียกใช้งานเดิมเกิดข้อผิดพลาดขึ้น (รักษาสภาพความเข้ากันได้ย้อนหลัง หรือ Backwards Compatibility)

ภาษา Rust มีทางออกสำหรับโจทย์ข้อนี้ 2 แนวทาง:

  • กำหนดแอตทริบิวต์ #[non_exhaustive] บน struct, enum หรือบนแต่ละ variant ของ enum (สามารถอ่านเอกสารฉบับละเอียดเกี่ยวกับทุกจุดที่รองรับ #[non_exhaustive] ได้ที่ เอกสารประกอบ)

  • เพิ่มฟิลด์แบบ private (Private field) ไว้ใน struct เพื่อป้องกันไม่ให้ผู้เรียกใช้งานสร้างอินสแตนซ์ผ่าน struct literal โดยตรง หรือเขียน Pattern Matching ครอบคลุมทุกฟิลด์ (ดูรายละเอียดในหัวข้อทางเลือก)

ตัวอย่าง

#![allow(unused)]
fn main() {
mod a {
    // Public struct.
    #[non_exhaustive]
    pub struct S {
        pub foo: i32,
    }

    #[non_exhaustive]
    pub enum AdmitMoreVariants {
        VariantA,
        VariantB,
        #[non_exhaustive]
        VariantC {
            a: String,
        },
    }
}

fn print_matched_variants(s: a::S) {
    // Because S is `#[non_exhaustive]`, it cannot be named here and
    // we must use `..` in the pattern.
    let a::S { foo: _, .. } = s;

    let some_enum = a::AdmitMoreVariants::VariantA;
    match some_enum {
        a::AdmitMoreVariants::VariantA => println!("it's an A"),
        a::AdmitMoreVariants::VariantB => println!("it's a b"),

        // .. required because this variant is non-exhaustive as well
        a::AdmitMoreVariants::VariantC { a, .. } => println!("it's a c"),

        // The wildcard match is required because more variants may be
        // added in the future
        _ => println!("it's a new variant"),
    }
}
}

ทางเลือก: Private fields สำหรับ struct

#[non_exhaustive] จะทำงานเฉพาะการข้ามขอบเขตระหว่าง crate เท่านั้น ส่วนการทำงานภายใน crate เดียวกัน คุณสามารถใช้วิธีเพิ่มฟิลด์แบบ private เข้ามาช่วยแทนได้

ตามปกติแล้ว การเพิ่มฟิลด์ใหม่เข้าไปใน struct ถือเป็นการเปลี่ยนแปลงที่เข้ากันได้ย้อนหลังเป็นส่วนใหญ่ อย่างไรก็ดี หากผู้เรียกใช้งานเขียนโค้ด Pattern Matching แบบแยกส่วนประกอบ (Destructure) โดยระบุชื่อฟิลด์ไว้ครบทุกฟิลด์ การที่เราเพิ่มฟิลด์ใหม่เข้าไปจะส่งผลให้โค้ดของพวกเขาคอมไพล์ไม่ผ่านทันที แต่หากผู้เรียกใช้งานระบุชื่อเฉพาะฟิลด์ที่สนใจและใส่ .. ต่อท้าย การเพิ่มฟิลด์ใหม่ก็จะไม่กระทบต่อโค้ดเดิมเลย ดังนั้น การกำหนดให้มีฟิลด์แบบ private อย่างน้อยหนึ่งฟิลด์ใน struct จะบีบให้ผู้เรียกใช้งานต้องใช้รูปแบบ .. เสมอ ซึ่งช่วยรับประกันว่า struct ของเราจะรองรับการเปลี่ยนแปลงในอนาคตได้อย่างปลอดภัย

ข้อเสียของแนวทางนี้คือ คุณอาจจำเป็นต้องเพิ่มฟิลด์ที่ไม่ได้ใช้งานจริงเข้ามาใน struct แต่คุณสามารถเลือกใช้ Unit Type () เพื่อไม่ให้มีภาระด้านหน่วยความจำตอนรันไทม์ (Zero runtime overhead) และใส่เครื่องหมาย _ นำหน้าชื่อฟิลด์เพื่อปิดคำเตือนตัวแปรไม่ได้ใช้งานของคอมไพเลอร์ได้

#![allow(unused)]
fn main() {
pub struct S {
    pub a: i32,
    // Because `b` is private, you cannot match on `S` without using `..` and `S`
    //  cannot be directly instantiated or matched against
    _b: (),
}
}

เจาะลึกรายละเอียด

สำหรับ struct แล้ว การใส่ #[non_exhaustive] ช่วยให้เราสามารถเพิ่มฟิลด์ใหม่ๆ ในอนาคตได้อย่างปลอดภัยโดยไม่ทำลายความเข้ากันได้ย้อนหลัง และยังช่วยป้องกันไม่ให้ผู้เรียกใช้งานสร้างออบเจกต์ผ่าน struct literal ตรงๆ แม้ว่าทุกฟิลด์จะเป็น public ก็ตาม สิ่งนี้อาจมีประโยชน์ แต่ก็ควรพิจารณาให้รอบคอบว่าคุณต้องการให้ผู้ใช้พบข้อผิดพลาดจากคอมไพเลอร์ทันทีเมื่อมีการเพิ่มฟิลด์ใหม่ หรือต้องการให้โค้ดทำงานผ่านไปอย่างเงียบๆ

นอกจากนี้ เรายังสามารถใส่ #[non_exhaustive] ให้กับแต่ละ variant ของ enum ได้เช่นกัน ซึ่งพฤติกรรมของ variant ดังกล่าวจะเหมือนกับ struct ที่เป็น #[non_exhaustive] ทุกประการ

ขอแนะนำให้ใช้เครื่องมือนี้อย่างตั้งใจและระมัดระวัง: บ่อยครั้งที่การปรับเพิ่มเลขเวอร์ชันหลัก (Major Version) เมื่อมีการเพิ่มฟิลด์หรือ variant ใหม่ มักจะเป็นทางเลือกที่ชัดเจนและตรงไปตรงมากว่า #[non_exhaustive] อาจเหมาะสมอย่างยิ่งในสถานการณ์ที่คุณกำลังจำลองข้อมูลจากแหล่งภายนอกที่อาจมีการเปลี่ยนแปลงตลอดเวลาโดยไม่ขึ้นกับวงรอบการปล่อยไลบรารีของคุณ แต่มันไม่ได้ถูกออกแบบมาเพื่อเป็นเครื่องมืออเนกประสงค์สำหรับทุกกรณี

ข้อเสีย

การใช้ #[non_exhaustive] อาจทำให้โค้ดฝั่งผู้เรียกใช้งานมีความสะดวกสบาย (Ergonomics) ลดลงอย่างมาก โดยเฉพาะเมื่อถูกบีบให้ต้องเขียนโค้ดดักจับ enum variant ที่ตนเองไม่รู้จัก ดังนั้นจึงควรเลือกใช้เฉพาะเมื่อคุณมีความจำเป็นอย่างยิ่งที่จะต้องพัฒนาฟีเจอร์เพิ่มเติมโดยไม่ต้องการปรับเพิ่มเลขเวอร์ชันหลักเท่านั้น

เมื่อนำ #[non_exhaustive] ไปใช้กับ enum มันจะบังคับให้ผู้เรียกใช้งานทุกคนต้องเขียนกรณี Wildcard (_ => ...) เสมอ และหากในทางปฏิบัติไม่มีการกระทำใดที่สมเหตุสมผลสำหรับกรณีที่ไม่รู้จักนั้น ก็อาจนำไปสู่การเขียนโค้ดที่อึดอัด หรือโค้ดที่ไม่มีวันถูกเรียกใช้งานจริง และหากผู้ใช้เลือกที่จะใส่ panic!() ดักไว้ในกรณีนั้น การแจ้งเตือนเป็น Compile Error ตั้งแต่แรกก็น่าจะเป็นทางเลือกที่ดีกว่าอย่างแน่นอน

ดูเพิ่มเติม

การเริ่มต้นเอกสารประกอบอย่างง่าย

คำอธิบาย

หาก struct ของคุณมีขั้นตอนการกำหนดค่าเริ่มต้น (Initialization) ที่ซับซ้อนหรือต้องเขียนโค้ดยืดยาวเมื่อเขียนตัวอย่างในเอกสารประกอบ (Rustdoc) การห่อโค้ดตัวอย่างนั้นไว้ในฟังก์ชันตัวช่วยที่ประกาศรับ struct เป็นอาร์กิวเมนต์ จะช่วยประหยัดเวลาและลดโค้ดซ้ำซ้อนได้อย่างมาก

ที่มาและเหตุผล

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

ตัวอย่างเช่น:

struct Connection {
    name: String,
    stream: TcpStream,
}

impl Connection {
    /// Sends a request over the connection.
    ///
    /// # Example
    /// ```no_run
    /// # // Boilerplate are required to get an example working.
    /// # let stream = TcpStream::connect("127.0.0.1:34254");
    /// # let connection = Connection { name: "foo".to_owned(), stream };
    /// # let request = Request::new("RequestId", RequestType::Get, "payload");
    /// let response = connection.send_request(request);
    /// assert!(response.is_ok());
    /// ```
    fn send_request(&self, request: Request) -> Result<Status, SendErr> {
        // ...
    }

    /// Oh no, all that boilerplate needs to be repeated here!
    fn check_status(&self) -> Status {
        // ...
    }
}

ตัวอย่าง

แทนที่จะต้องพิมพ์โค้ด boilerplate ทั้งหมดซ้ำๆ เพื่อสร้าง Connection และ Request การสร้างฟังก์ชันตัวช่วยห่อหุ้มที่รับค่าเหล่านั้นเข้ามาเป็นพารามิเตอร์จะทำได้ง่ายและสะอาดกว่ามาก:

struct Connection {
    name: String,
    stream: TcpStream,
}

impl Connection {
    /// Sends a request over the connection.
    ///
    /// # Example
    /// ```
    /// # fn call_send(connection: Connection, request: Request) {
    /// let response = connection.send_request(request);
    /// assert!(response.is_ok());
    /// # }
    /// ```
    fn send_request(&self, request: Request) -> Result<Status, SendErr> {
        // ...
    }
}

ข้อสังเกต: ในตัวอย่างข้างต้น บรรทัด assert!(response.is_ok()); จะไม่ได้ถูกประมวลผลจริงในระหว่างการรันเทสต์ เพราะมันบรรจุอยู่ภายในฟังก์ชันที่ไม่มีการเรียกใช้งาน

ข้อดี

ช่วยให้เอกสารประกอบมีความกระชับ สะอาดตา และหลีกเลี่ยงการเขียนโค้ด boilerplate ซ้ำซากจำเจในทุกๆ เมท็อด

ข้อเสีย

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

เจาะลึกรายละเอียด

หากโค้ดตัวอย่างไม่จำเป็นต้องมีการรัน assertion จริงๆ แพตเทิร์นนี้ถือว่าตอบโจทย์ได้เป็นอย่างดี

แต่หากต้องการให้มีการทดสอบ assertion จริง ทางเลือกหนึ่งคือการสร้างเมท็อดแบบ public เพื่อทำหน้าที่สร้างอินสแตนซ์ทดสอบขึ้นมา แล้วกำกับด้วยแอตทริบิวต์ #[doc(hidden)] ไว้ (เพื่อซ่อนไม่ให้ผู้ใช้ทั่วไปมองเห็นในหน้าเอกสาร) เมื่อทำเช่นนี้ โค้ดตัวอย่างใน rustdoc ก็จะสามารถเรียกใช้เมท็อดดังกล่าวเพื่อสร้างออบเจกต์ขึ้นมาทดสอบได้อย่างสะดวก

ความสามารถในการแก้ไขชั่วคราว

คำอธิบาย

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

เราสามารถทำได้ 2 แนวทาง: คือการจัดการข้อมูลภายในบล็อกขอบเขตย่อย (Nested Block) หรือการประกาศตัวแปรซ้ำ (Variable Shadowing / Rebinding)

ตัวอย่าง

สมมติว่าเรามีเวกเตอร์ที่จำเป็นต้องเรียงลำดับข้อมูล (Sort) ให้เสร็จก่อนนำไปใช้งาน

วิธีใช้บล็อกขอบเขตย่อย (Nested Block):

let data = {
    let mut data = get_vec();
    data.sort();
    data
};

// Here `data` is immutable.

วิธีประกาศตัวแปรซ้ำ (Variable Rebinding / Shadowing):

let mut data = get_vec();
data.sort();
let data = data;

// Here `data` is immutable.

ข้อดี

คอมไพเลอร์จะช่วยรับประกันว่าคุณจะไม่เผลอแก้ไขค่าข้อมูลโดยไม่ได้ตั้งใจหลังจากจุดนั้นเป็นต้นไป

ข้อเสีย

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

คืนอาร์กิวเมนต์ที่ถูกย้ายความเป็นเจ้าของเมื่อเกิดข้อผิดพลาด

คำอธิบาย

หากฟังก์ชันที่อาจเกิดข้อผิดพลาดได้ (fallible function) มีการย้ายความเป็นเจ้าของ (move/consume) อาร์กิวเมนต์เข้าไปในฟังก์ชัน ให้ส่งคืนอาร์กิวเมนต์ตัวนั้นกลับออกมาภายในข้อมูลข้อผิดพลาด (error) ด้วยเสมอ

ตัวอย่าง

pub fn send(value: String) -> Result<(), SendError> {
    println!("using {value} in a meaningful way");
    // Simulate non-deterministic fallible action.
    use std::time::SystemTime;
    let period = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap();
    if period.subsec_nanos() % 2 == 1 {
        Ok(())
    } else {
        Err(SendError(value))
    }
}

pub struct SendError(String);

fn main() {
    let mut value = "imagine this is very long string".to_string();

    let success = 's: {
        // Try to send value two times.
        for _ in 0..2 {
            value = match send(value) {
                Ok(()) => break 's true,
                Err(SendError(value)) => value,
            }
        }
        false
    };

    println!("success: {success}");
}

แรงจูงใจ

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

ไลบรารีมาตรฐานของ Rust ใช้แนวทางนี้ในหลายจุด เช่น เมท็อด String::from_utf8 โดยเมื่อส่งเวกเตอร์ของไบต์ที่ไม่ได้ประกอบขึ้นเป็น UTF-8 ที่ถูกต้อง ตัวเมท็อดจะคืนค่าความผิดพลาดเป็น FromUtf8Error ซึ่งคุณสามารถเรียกใช้เมท็อด FromUtf8Error::into_bytes เพื่อนำเวกเตอร์เดิมที่ส่งเข้าไปกลับคืนมาได้

ข้อดี

เพิ่มประสิทธิภาพการทำงานอย่างชัดเจน เนื่องจากสามารถโอนย้ายความเป็นเจ้าของของอาร์กิวเมนต์ได้โดยไม่ต้องกังวลเรื่องการโคลนข้อมูลล่วงหน้า

ข้อเสีย

โครงสร้างของชนิดข้อมูลข้อผิดพลาด (error types) มีความซับซ้อนเพิ่มขึ้นเล็กน้อย

ดีไซน์แพตเทิร์น

ดีไซน์แพตเทิร์น (Design Patterns) คือ “แบบแผนการแก้ปัญหาทั่วไปที่สามารถนำกลับมาใช้ซ้ำได้ สำหรับโจทย์ที่พบบ่อยครั้งภายใต้บริบทหนึ่งๆ ในการออกแบบซอฟต์แวร์” ดีไซน์แพตเทิร์นถือเป็นวิธีที่ยอดเยี่ยมในการอธิบายวัฒนธรรมและแนวคิดของแต่ละภาษาโปรแกรม ทั้งนี้ ดีไซน์แพตเทิร์นมักจะมีความเฉพาะเจาะจงกับภาษานั้นๆ เป็นอย่างมาก สิ่งที่เป็นแพตเทิร์นอันซับซ้อนในภาษาหนึ่ง อาจไม่จำเป็นต้องมีในอีกภาษาหนึ่งเนื่องจากภาษานั้นมีฟีเจอร์รองรับในตัวอยู่แล้ว หรือในทางกลับกัน อาจไม่สามารถนำมาเขียนได้เลยเนื่องจากภาษานั้นขาดฟีเจอร์ที่จำเป็น

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

ดีไซน์แพตเทิร์นใน Rust

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

YAGNI

YAGNI เป็นตัวย่อมาจากวลี You Aren't Going to Need It (คุณจะยังไม่จำเป็นต้องใช้มันในตอนนี้) ซึ่งเป็นหนึ่งในหลักการออกแบบซอฟต์แวร์ที่สำคัญยิ่งที่ควรยึดถือขณะเขียนโค้ด

โค้ดที่ดีที่สุดที่ผมเคยเขียน คือโค้ดที่ผมไม่ต้องเขียนมันขึ้นมาเลย

หากเรานำหลักการ YAGNI มาปรับใช้กับดีไซน์แพตเทิร์น จะเห็นได้ว่าคุณลักษณะต่างๆ ของ Rust ช่วยให้เราสามารถตัดแพตเทิร์นแบบดั้งเดิมทิ้งไปได้หลายแบบ ตัวอย่างเช่น เราแทบไม่มีความจำเป็นต้องนำ Strategy pattern มาใช้ใน Rust เพราะเราสามารถใช้ความสามารถของ traits ทดแทนได้โดยตรง

แพตเทิร์นเชิงพฤติกรรม

จาก Wikipedia:

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

Command pattern (แพตเทิร์นคำสั่ง)

คำอธิบาย

แนวคิดพื้นฐานของ Command pattern คือการแยกการกระทำ (action) ออกมาเป็นออบเจกต์เฉพาะตัว แล้วส่งต่อไปยังส่วนอื่นๆ ในฐานะพารามิเตอร์

แรงจูงใจ

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

ตัวอย่าง

กำหนดการทำงานของฐานข้อมูลขึ้นมา 2 คำสั่ง ได้แก่ create table และ add field โดยคำสั่งแต่ละตัวจะรู้วิธียกเลิกการทำงานของตัวเอง (undo) เช่น drop table และ remove field ตามลำดับ เมื่อผู้ใช้เรียกใช้งาน migration ของฐานข้อมูล คำสั่งแต่ละตัวจะถูกประมวลผลตามลำดับที่วางไว้ และเมื่อผู้ใช้สั่ง rollback คำสั่งทั้งชุดจะถูกเรียกทำงานย้อนกลับในลำดับตรงกันข้าม

แนวทาง: ใช้ trait object

เรานิยาม trait ร่วมที่ห่อหุ้มคำสั่งของเราด้วยการดำเนินการ 2 ประการ ได้แก่ execute และ rollback โดยโครงสร้างข้อมูล (struct) ของคำสั่งทั้งหมดจะต้องนำ trait นี้ไปอิมพลีเมนต์

pub trait Migration {
    fn execute(&self) -> &str;
    fn rollback(&self) -> &str;
}

pub struct CreateTable;
impl Migration for CreateTable {
    fn execute(&self) -> &str {
        "create table"
    }
    fn rollback(&self) -> &str {
        "drop table"
    }
}

pub struct AddField;
impl Migration for AddField {
    fn execute(&self) -> &str {
        "add field"
    }
    fn rollback(&self) -> &str {
        "remove field"
    }
}

struct Schema {
    commands: Vec<Box<dyn Migration>>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }

    fn add_migration(&mut self, cmd: Box<dyn Migration>) {
        self.commands.push(cmd);
    }

    fn execute(&self) -> Vec<&str> {
        self.commands.iter().map(|cmd| cmd.execute()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.commands
            .iter()
            .rev() // reverse iterator's direction
            .map(|cmd| cmd.rollback())
            .collect()
    }
}

fn main() {
    let mut schema = Schema::new();

    let cmd = Box::new(CreateTable);
    schema.add_migration(cmd);
    let cmd = Box::new(AddField);
    schema.add_migration(cmd);

    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

แนวทาง: ใช้ function pointer

เราอาจเลือกใช้แนวทางอื่นโดยเขียนคำสั่งแต่ละตัวเป็นฟังก์ชันแยกต่างหาก แล้วจัดเก็บพอยน์เตอร์ของฟังก์ชัน (function pointer) ไว้สำหรับเรียกทำงานในภายหลัง และเนื่องจาก function pointer นำทั้ง 3 trait ได้แก่ Fn, FnMut และ FnOnce ไปใช้งานโดยอัตโนมัติ เราจึงสามารถส่งและจัดเก็บ closure แทน function pointer ได้เช่นเดียวกัน

type FnPtr = fn() -> String;
struct Command {
    execute: FnPtr,
    rollback: FnPtr,
}

struct Schema {
    commands: Vec<Command>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }
    fn add_migration(&mut self, execute: FnPtr, rollback: FnPtr) {
        self.commands.push(Command { execute, rollback });
    }
    fn execute(&self) -> Vec<String> {
        self.commands.iter().map(|cmd| (cmd.execute)()).collect()
    }
    fn rollback(&self) -> Vec<String> {
        self.commands
            .iter()
            .rev()
            .map(|cmd| (cmd.rollback)())
            .collect()
    }
}

fn add_field() -> String {
    "add field".to_string()
}

fn remove_field() -> String {
    "remove field".to_string()
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table".to_string(), || "drop table".to_string());
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

แนวทาง: ใช้ Fn trait object

และแนวทางสุดท้าย แทนที่จะต้องนิยาม trait กลางขึ้นมา เราสามารถจัดเก็บคำสั่งแต่ละตัวที่นำ trait Fn ไปใช้งานแยกกันไว้ในเวกเตอร์ได้โดยตรง

type Migration<'a> = Box<dyn Fn() -> &'a str>;

struct Schema<'a> {
    executes: Vec<Migration<'a>>,
    rollbacks: Vec<Migration<'a>>,
}

impl<'a> Schema<'a> {
    fn new() -> Self {
        Self {
            executes: vec![],
            rollbacks: vec![],
        }
    }
    fn add_migration<E, R>(&mut self, execute: E, rollback: R)
    where
        E: Fn() -> &'a str + 'static,
        R: Fn() -> &'a str + 'static,
    {
        self.executes.push(Box::new(execute));
        self.rollbacks.push(Box::new(rollback));
    }
    fn execute(&self) -> Vec<&str> {
        self.executes.iter().map(|cmd| cmd()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.rollbacks.iter().rev().map(|cmd| cmd()).collect()
    }
}

fn add_field() -> &'static str {
    "add field"
}

fn remove_field() -> &'static str {
    "remove field"
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table", || "drop table");
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

การอภิปราย

หากคำสั่งของเรามีขนาดเล็กและสามารถนิยามเป็นฟังก์ชันธรรมดาหรือส่งเข้ามาในรูปของ closure ได้ การเลือกใช้ function pointer มักเป็นทางเลือกที่เหมาะสมกว่า เนื่องจากไม่มี overhead จาก dynamic dispatch แต่หากคำสั่งของเรามีโครงสร้างที่ซับซ้อน เป็น struct เต็มรูปแบบที่มีฟังก์ชันและข้อมูลภายในหลายตัวแยกเป็นโมดูลชัดเจน การใช้ trait object ย่อมเหมาะสมและเข้ากับการจัดโครงสร้างมากกว่า กรณีการใช้งานจริงในลักษณะนี้สามารถพบได้ในเฟรมเวิร์ก actix ซึ่งใช้ประโยชน์จาก trait object ในการลงทะเบียน handler function สำหรับ routing ต่างๆ ส่วนกรณีที่ใช้ Fn trait object เราสามารถสร้างและใช้งานคำสั่งได้ในลักษณะเดียวกับแนวทางของ function pointer

ในด้านประสิทธิภาพ ย่อมมีข้อแลกเปลี่ยน (trade-off) ระหว่างความเร็วในการทำงานกับความเรียบง่ายและการจัดโครงสร้างโค้ดเสมอ โดย static dispatch จะให้ประสิทธิภาพการทำงานที่รวดเร็วกว่า ขณะที่ dynamic dispatch มอบความยืดหยุ่นสูงเมื่อเราต้องการออกแบบโครงสร้างของแอปพลิเคชัน

ดูเพิ่มเติม

Interpreter pattern (แพตเทิร์นอินเทอร์พรีเตอร์)

คำอธิบาย

หากโจทย์ปัญหาหนึ่งเกิดขึ้นซ้ำบ่อยครั้ง และต้องอาศัยขั้นตอนการแก้ปัญหาที่ยาวและซ้ำซาก เราสามารถแปลงรูปแบบของโจทย์ปัญหานั้นให้อยู่ในรูปของภาษาเชิงสัญลักษณ์ที่เรียบง่ายภาษาหนึ่ง แล้วสร้างออบเจกต์อินเทอร์พรีเตอร์ (interpreter) ขึ้นมาเพื่อแก้ปัญหาดังกล่าวด้วยการตีความประโยคที่เขียนขึ้นในภาษานั้น

โดยทั่วไป สำหรับปัญหาใดๆ ก็ตาม เราจะกำหนดองค์ประกอบดังนี้:

แรงจูงใจ

เป้าหมายของเราคือการแปลงนิพจน์ทางคณิตศาสตร์แบบเรียบง่ายให้อยู่ในรูปของ postfix (หรือที่เรียกว่า สัญกรณ์โพแลนด์แบบย้อนกลับ / Reverse Polish notation) เพื่อความเรียบง่าย นิพจน์ของเราจะประกอบด้วยตัวเลขโดด 10 ตัว คือ 0, …, 9 และตัวดำเนินการ 2 ชนิดคือ + กับ - ตัวอย่างเช่น นิพจน์ 2 + 4 จะถูกแปลงเป็น 2 4 +

ไวยากรณ์แบบไม่มีบริบท (Context Free Grammar) สำหรับปัญหาของเรา

โจทย์ของเราคือการแปลงนิพจน์แบบ infix ให้เป็นนิพจน์แบบ postfix เรามานิยามไวยากรณ์แบบไม่มีบริบท (Context-Free Grammar: CFG) สำหรับชุดนิพจน์ infix ที่ประกอบด้วย 0, …, 9, + และ - โดยมีข้อกำหนดดังนี้:

  • สัญลักษณ์ปลายทาง (Terminal symbols): 0, ..., 9, +, -
  • สัญลักษณ์ไม่ปลายทาง (Non-terminal symbols): exp, term
  • สัญลักษณ์เริ่มต้น (Start symbol): exp
  • และมีกฎการแปลง (Production rules) ดังนี้
exp -> exp + term
exp -> exp - term
exp -> term
term -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

ข้อสังเกต: ไวยากรณ์นี้อาจต้องนำไปแปลงรูปต่อยอดขึ้นอยู่กับรูปแบบการประมวลผล เช่น เราอาจจำเป็นต้องขจัด left recursion ออกไป สำหรับรายละเอียดเชิงลึกสามารถศึกษาเพิ่มเติมได้จากหนังสือ Compilers: Principles,Techniques, and Tools (หรือที่รู้จักกันอย่างแพร่หลายในชื่อ “Dragon Book”)

ทางออก

ในที่นี้ เราจะเลือกอิมพลีเมนต์ recursive descent parser แบบตรงไปตรงมา และเพื่อความกระชับ โค้ดจะหยุดทำงานทันที (panic) เมื่อพบข้อผิดพลาดทางไวยากรณ์ในนิพจน์ (ตัวอย่างเช่น 2-34 หรือ 2+5- จะถือว่าผิดตามโครงสร้างไวยากรณ์ที่นิยามไว้)

pub struct Interpreter<'a> {
    it: std::str::Chars<'a>,
}

impl<'a> Interpreter<'a> {
    pub fn new(infix: &'a str) -> Self {
        Self { it: infix.chars() }
    }

    fn next_char(&mut self) -> Option<char> {
        self.it.next()
    }

    pub fn interpret(&mut self, out: &mut String) {
        self.term(out);

        while let Some(op) = self.next_char() {
            if op == '+' || op == '-' {
                self.term(out);
                out.push(op);
            } else {
                panic!("Unexpected symbol '{op}'");
            }
        }
    }

    fn term(&mut self, out: &mut String) {
        match self.next_char() {
            Some(ch) if ch.is_ascii_digit() => out.push(ch),
            Some(ch) => panic!("Unexpected symbol '{ch}'"),
            None => panic!("Unexpected end of string"),
        }
    }
}

pub fn main() {
    let mut intr = Interpreter::new("2+3");
    let mut postfix = String::new();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "23+");

    intr = Interpreter::new("1-2+3-4");
    postfix.clear();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "12-3+4-");
}

การอภิปราย

หลายคนอาจมีความเข้าใจคลาดเคลื่อนว่า Interpreter pattern เป็นเรื่องของการออกแบบไวยากรณ์สำหรับภาษาเชิงรูปนัย (formal languages) และการสร้างตัววิเคราะห์ไวยากรณ์ (parser) เท่านั้น แต่แท้จริงแล้ว แพตเทิร์นนี้หมายรวมถึงการถ่ายทอดปัญหาให้อยู่ในรูปภาษาที่เฉพาะเจาะจง และสร้างฟังก์ชัน/คลาส/struct ขึ้นมารับผิดชอบการประมวลผลคำสั่งเหล่านั้น นอกจากนี้ ในภาษา Rust ยังมีฟีเจอร์อันทรงพลังอย่าง macro_rules! ซึ่งช่วยให้เราสามารถกำหนดไวยากรณ์พิเศษและกฎการขยายโค้ด (macro expansion) ลงในซอร์สโค้ดได้โดยตรง

ในตัวอย่างด้านล่างนี้ เราได้สร้าง macro_rules! แบบเรียบง่ายเพื่อคำนวณ ระยะทางแบบยูคลิด (Euclidean distance/length) ของเวกเตอร์ขนาด n มิติ ซึ่งการเขียนโค้ดในรูปแบบ norm!(x, 1, 2) นั้นทั้งสื่อความหมายได้ชัดเจนและมีประสิทธิภาพเหนือกว่าการนำ x, 1, 2 ไปแพ็กลงใน Vec เพื่อส่งให้ฟังก์ชันภายนอกคำนวณอย่างเห็นได้ชัด

macro_rules! norm {
    ($($element:expr),*) => {
        {
            let mut n = 0.0;
            $(
                n += ($element as f64)*($element as f64);
            )*
            n.sqrt()
        }
    };
}

fn main() {
    let x = -3f64;
    let y = 4f64;

    assert_eq!(3f64, norm!(x));
    assert_eq!(5f64, norm!(x, y));
    assert_eq!(0f64, norm!(0, 0, 0));
    assert_eq!(1f64, norm!(0.5, -0.5, 0.5, -0.5));
}

ดูเพิ่มเติม

นิวไทป์ (Newtype)

จะเป็นอย่างไรหากในบางสถานการณ์ เราต้องการให้ชนิดข้อมูลหนึ่งมีพฤติกรรมคล้ายคลึงกับอีกชนิดหนึ่ง หรือต้องการบังคับใช้กฎเกณฑ์บางอย่างตั้งแต่ขั้นตอนการคอมไพล์ โดยที่การประกาศเพียงนามแฝงของชนิดข้อมูล (type alias) นั้นไม่เพียงพอ?

ตัวอย่างเช่น เราอาจต้องการปรับแต่งการแสดงผลของ Display สำหรับ String เพื่อจุดประสงค์ด้านความปลอดภัย (เช่น การซ่อนรหัสผ่าน)

ในกรณีเช่นนี้ เราสามารถเลือกใช้แพตเทิร์น Newtype เพื่อสร้างความปลอดภัยของชนิดข้อมูล (type safety) ควบคู่ไปกับการห่อหุ้มข้อมูล (encapsulation) ได้อย่างสมบูรณ์แบบ

คำอธิบาย

ใช้ tuple struct ที่มีเพียงฟิลด์เดียวเพื่อสร้างตัวห่อหุ้มที่ซ่อนรายละเอียดภายใน (opaque wrapper) ให้กับชนิดข้อมูลเดิม ซึ่งจะทำให้เกิดชนิดข้อมูลตัวใหม่ขึ้นมาจริงๆ ไม่ใช่เพียงแค่ชื่อเรียกแทนหรือนามแฝงเหมือนกับการใช้ type

ตัวอย่าง

use std::fmt::Display;

// Create Newtype Password to override the Display trait for String
struct Password(String);

impl Display for Password {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "****************")
    }
}

fn main() {
    let unsecured_password: String = "ThisIsMyPassword".to_string();
    let secured_password: Password = Password(unsecured_password.clone());
    println!("unsecured_password: {unsecured_password}");
    println!("secured_password: {secured_password}");
}
unsecured_password: ThisIsMyPassword
secured_password: ****************

แรงจูงใจ

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

นอกจากนี้ Newtype ยังนิยมนำมาใช้แยกแยะหน่วยวัดทางกายภาพ เช่น การนำ f64 มาห่อหุ้มเพื่อแยกชนิดข้อมูลระหว่าง Miles (ไมล์) กับ Kilometres (กิโลเมตร) ออกจากกันอย่างเด็ดขาด

ข้อดี

ชนิดข้อมูลภายในที่ถูกห่อหุ้มกับชนิดข้อมูล Newtype ภายนอกจะถือเป็นคนละชนิดข้อมูลกันโดยสิ้นเชิงและไม่สามารถใช้สลับแทนกันได้โดยตรง (ต่างจากการใช้ type alias) ผู้ใช้งาน Newtype จึงไม่มีโอกาสเกิดความสับสนหรือส่งข้อมูลผิดชนิดอย่างแน่นอน

Newtype ถือเป็น zero-cost abstraction โดยสมบูรณ์ — ไม่ก่อให้เกิด overhead หรือภาระประสิทธิภาพเพิ่มเติมขณะรันไทม์เลย

ระบบควบคุมการเข้าถึง (privacy system) ของ Rust ช่วยรับประกันได้ว่าผู้ใช้งานจะไม่สามารถแอบเข้าถึงชนิดข้อมูลภายในที่ถูกห่อไว้ได้ (หากกำหนดให้ฟิลด์นั้นเป็น private ซึ่งเป็นค่าเริ่มต้นอยู่แล้ว)

ข้อเสีย

ข้อจำกัดสำคัญของ Newtype (โดยเฉพาะเมื่อเทียบกับ type alias) คือภาษา Rust ไม่ได้มีไวยากรณ์พิเศษมาช่วยส่งต่อเมท็อดให้อัตโนมัติ ซึ่งหมายความว่าเราอาจต้องเขียนโค้ด boilerplate ค่อนข้างมาก โดยเราต้องเขียนเมท็อดส่งต่อการทำงาน (pass-through method) สำหรับทุกฟังก์ชันที่ต้องการเปิดเผยออกมาจากชนิดข้อมูลภายใน และต้องเขียนบล็อก impl สำหรับทุก trait ที่ต้องการให้ Newtype ภายนอกใช้งานได้ด้วย

การอภิปราย

Newtype เป็นแพตเทิร์นที่พบเห็นได้ทั่วไปอย่างยิ่งในโค้ดภาษา Rust การสร้าง abstraction และการจำแนกหน่วยวัดถือเป็นกรณีการใช้งานหลัก แต่นอกจากนี้ยังสามารถนำไปประยุกต์ใช้ในลักษณะอื่นได้อีก:

  • การจำกัดฟังก์ชันการทำงาน (ลดจำนวนฟังก์ชันที่เปิดเผย หรือลด trait ที่นำไปใช้งาน)
  • การเปลี่ยนให้ชนิดข้อมูลที่มี copy semantics มีพฤติกรรมกลายเป็น move semantics แทน
  • การสร้าง abstraction เพื่อนำเสนอชนิดข้อมูลที่ชัดเจนยิ่งขึ้น และซ่อนชนิดข้อมูลภายในที่ซับซ้อน เช่น:
pub struct Foo(Bar<T1, T2>);

ในกรณีนี้ Bar อาจเป็นชนิดข้อมูล generic สาธารณะ ขณะที่ T1 และ T2 เป็นชนิดข้อมูลภายใน ผู้ใช้งานโมดูลไม่จำเป็นต้องทราบว่าเราอิมพลีเมนต์ Foo โดยอิงกับ Bar และสิ่งที่เราซ่อนไว้จากภายนอกอย่างแท้จริงก็คือชนิดข้อมูล T1, T2 ตลอดจนรูปแบบการประกอบเข้ากับ Bar

ดูเพิ่มเติม

RAII พร้อมการ์ด (RAII with guards)

คำอธิบาย

RAII ย่อมาจาก “Resource Acquisition Is Initialization” ซึ่งถือเป็นชื่อที่ชวนสับสนไม่น้อย แต่สาระสำคัญที่แท้จริงของแพตเทิร์นนี้คือ “การจัดสรรและเตรียมทรัพยากรให้พร้อมในขั้นตอนการสร้างออบเจกต์ (constructor) และทำการคืนหรือปิดทรัพยากร (finalisation / cleanup) ในขั้นตอนการทำลายออบเจกต์ (destructor)” สำหรับในภาษา Rust แพตเทิร์นนี้ได้รับการต่อยอดโดยนำออบเจกต์ RAII มาทำหน้าที่เป็น “การ์ด” (guard) คอยคุ้มครองทรัพยากร และอาศัยระบบชนิดข้อมูล (type system) เพื่อรับประกันว่าการเข้าถึงทรัพยากรจะต้องกระทำผ่านออบเจกต์การ์ดนี้เสมอ

ตัวอย่าง

Mutex Guard ในไลบรารีมาตรฐานถือเป็นตัวอย่างคลาสสิกของแพตเทิร์นนี้ (โค้ดด้านล่างนี้เป็นเวอร์ชันอย่างย่อของการอิมพลีเมนต์จริง):

use std::ops::Deref;

struct Foo {}

struct Mutex<T> {
    // We keep a reference to our data: T here.
    //..
}

struct MutexGuard<'a, T: 'a> {
    data: &'a T,
    //..
}

// Locking the mutex is explicit.
impl<T> Mutex<T> {
    fn lock(&self) -> MutexGuard<T> {
        // Lock the underlying OS mutex.
        //..

        // MutexGuard keeps a reference to self
        MutexGuard {
            data: self,
            //..
        }
    }
}

// Destructor for unlocking the mutex.
impl<'a, T> Drop for MutexGuard<'a, T> {
    fn drop(&mut self) {
        // Unlock the underlying OS mutex.
        //..
    }
}

// Implementing Deref means we can treat MutexGuard like a pointer to T.
impl<'a, T> Deref for MutexGuard<'a, T> {
    type Target = T;

    fn deref(&self) -> &T {
        self.data
    }
}

fn baz(x: Mutex<Foo>) {
    let xx = x.lock();
    xx.foo(); // foo is a method on Foo.
              // The borrow checker ensures we can't store a reference to the underlying
              // Foo which will outlive the guard xx.

    // x is unlocked when we exit this function and xx's destructor is executed.
}

แรงจูงใจ

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

ข้อดี

ช่วยป้องกันทั้งข้อผิดพลาดจากการลืมคืนทรัพยากร และข้อผิดพลาดจากการเผลอนำทรัพยากรที่ถูกคืนไปแล้วกลับมาใช้งานซ้ำ (use-after-free)

การอภิปราย

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

เป้าหมายหลักของ borrow checker คือการรับประกันว่าการอ้างอิง (reference) ไปยังข้อมูลจะต้องไม่คงอยู่ยาวนานกว่าตัวข้อมูลนั้น (do not outlive) แพตเทิร์น RAII Guard ทำงานได้อย่างมีประสิทธิภาพเนื่องจากตัวออบเจกต์การ์ดจะเก็บการอ้างอิงไปยังทรัพยากรเบื้องหลัง และเปิดให้เข้าถึงข้อมูลได้ผ่านการอ้างอิงนี้เท่านั้น โดย Rust จะรับประกันว่าตัวการ์ดจะไม่สามารถคงอยู่ยาวนานกว่าทรัพยากรเบื้องหลังได้ และการอ้างอิงไปยังทรัพยากรที่ส่งผ่านการ์ดก็จะไม่สามารถคงอยู่ยาวนานกว่าตัวการ์ดได้เช่นกัน เพื่อให้เห็นภาพการทำงานชัดเจนขึ้น ลองพิจารณาลายเซ็นของเมท็อด deref แบบระบุค่า lifetime ชัดเจน (โดยไม่ละเว้น lifetime elision):

fn deref<'a>(&'a self) -> &'a T {
    //..
}

การอ้างอิงไปยังทรัพยากรที่ส่งคืนกลับมาจะมี lifetime เดียวกันกับ self ('a) ดังนั้น borrow checker จึงช่วยรับประกันว่าอายุของการอ้างอิงไปยัง T จะต้องสั้นกว่าอายุของ self เสมอ

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

ดูเพิ่มเติม

สำนวนการคืนทรัพยากรใน destructor

RAII เป็นแพตเทิร์นที่พบเห็นได้ทั่วไปในภาษา C++: cppreference.com, wikipedia

Style guide entry (ปัจจุบันเป็นเพียงหน้าว่าง)

Strategy pattern (แพตเทิร์นกลยุทธ์ หรือ Policy)

คำอธิบาย

Strategy pattern เป็นเทคนิคการออกแบบที่ช่วยในการแยกส่วนความรับผิดชอบ (Separation of Concerns: SoC) และช่วยลดการผูกมัดระหว่างโมดูลต่างๆ ในซอฟต์แวร์ด้วย หลักการผกผันการพึ่งพา (Dependency Inversion Principle: DIP)

แนวคิดพื้นฐานเบื้องหลัง Strategy pattern คือ เมื่อมีอัลกอริทึมสำหรับแก้ปัญหาใดปัญหาหนึ่ง เราจะกำหนดเพียงแค่โครงร่างหลักของอัลกอริทึมในระดับนามธรรม (abstract level) แล้วแยกตรรกะการทำงานเฉพาะเจาะจงของแต่ละอัลกอริทึมออกเป็นส่วนประกอบย่อยๆ ต่างหาก

ด้วยวิธีนี้ ฝั่งผู้เรียกใช้งาน (client) จะสามารถเลือกเปลี่ยนการอิมพลีเมนต์ที่ต้องการได้ตามสถานการณ์ ขณะที่ลำดับขั้นตอนการทำงานโดยรวมของอัลกอริทึมยังคงเดิม กล่าวอีกนัยหนึ่งคือ ข้อกำหนดเชิงนามธรรมจะไม่ขึ้นอยู่กับรายละเอียดการทำงานเฉพาะด้าน แต่การทำงานเฉพาะด้านเหล่านั้นจะต้องปฏิบัติตามข้อกำหนดเชิงนามธรรม นี่คือเหตุผลที่เราเรียกหลักการนี้ว่า “การผกผันการพึ่งพา (Dependency Inversion)”

แรงจูงใจ

ลองจินตนาการว่าเรากำลังพัฒนาโปรเจกต์ที่ต้องสร้างรายงานสรุปในทุกๆ สิ้นเดือน โดยมีความต้องการสร้างรายงานในหลากหลายรูปแบบ (ต่าง strategy กัน) เช่น รูปแบบ JSON หรือ Plain Text แต่เมื่อเวลาผ่านไป ความต้องการทางธุรกิจย่อมเปลี่ยนแปลง และเราไม่อาจทราบล่วงหน้าได้ว่าจะมีรูปแบบรายงานชนิดใดเพิ่มเข้ามาในอนาคต เช่น อาจต้องรองรับฟอร์แมตใหม่ทั้งหมด หรือต้องการปรับแต่งตรรกะของฟอร์แมตเดิมที่มีอยู่แล้ว

ตัวอย่าง

ในตัวอย่างนี้ สิ่งที่เป็นข้อกำหนดเชิงนามธรรม (abstractions) ของเราคือ Formatter และ Report ในขณะที่ Text และ Json คือ struct ที่ทำหน้าที่เป็น strategy โดยแต่ละ strategy จะต้องนำ trait Formatter ไปอิมพลีเมนต์

use std::collections::HashMap;

type Data = HashMap<String, u32>;

trait Formatter {
    fn format(&self, data: &Data, buf: &mut String);
}

struct Report;

impl Report {
    // Write should be used but we kept it as String to ignore error handling
    fn generate<T: Formatter>(g: T, s: &mut String) {
        // backend operations...
        let mut data = HashMap::new();
        data.insert("one".to_string(), 1);
        data.insert("two".to_string(), 2);
        // generate report
        g.format(&data, s);
    }
}

struct Text;
impl Formatter for Text {
    fn format(&self, data: &Data, buf: &mut String) {
        for (k, v) in data {
            let entry = format!("{k} {v}\n");
            buf.push_str(&entry);
        }
    }
}

struct Json;
impl Formatter for Json {
    fn format(&self, data: &Data, buf: &mut String) {
        buf.push('[');
        for (k, v) in data.into_iter() {
            let entry = format!(r#"{{"{}":"{}"}}"#, k, v);
            buf.push_str(&entry);
            buf.push(',');
        }
        if !data.is_empty() {
            buf.pop(); // remove extra , at the end
        }
        buf.push(']');
    }
}

fn main() {
    let mut s = String::from("");
    Report::generate(Text, &mut s);
    assert!(s.contains("one 1"));
    assert!(s.contains("two 2"));

    s.clear(); // reuse the same buffer
    Report::generate(Json, &mut s);
    assert!(s.contains(r#"{"one":"1"}"#));
    assert!(s.contains(r#"{"two":"2"}"#));
}

ข้อดี

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

ข้อเสีย

สำหรับแต่ละ strategy จะต้องมีโมดูลหรือชนิดข้อมูลมารองรับอย่างน้อยหนึ่งตัว ทำให้จำนวนโมดูลเพิ่มขึ้นตามจำนวนของ strategy และหากมี strategy ให้เลือกใช้เป็นจำนวนมาก ผู้ใช้งานจำเป็นต้องศึกษาความแตกต่างของแต่ละ strategy ด้วยตนเอง

การอภิปราย

ในตัวอย่างข้างต้น strategy ทั้งหมดถูกเขียนรวมไว้ในไฟล์เดียว ทว่าในทางปฏิบัติ มีหลากหลายวิธีในการจัดระเบียบ strategy ต่างๆ:

  • รวมไว้ในไฟล์เดียว (ดังที่แสดงในตัวอย่าง คล้ายกับการแยกเป็นโมดูลย่อยภายในไฟล์)
  • แยกออกเป็นโมดูล เช่น โมดูล formatter::json, formatter::text
  • ใช้ Feature Flags ของคอมไพเลอร์ เช่น ฟีเจอร์ json, ฟีเจอร์ text
  • แยกออกเป็น crate ต่างหาก เช่น crate json, crate text

Crate ยอดนิยมอย่าง Serde ถือเป็นตัวอย่างอันยอดเยี่ยมของการประยุกต์ใช้ Strategy pattern ในชีวิตจริง โดย Serde เปิดให้เราสามารถ ปรับแต่งพฤติกรรมการแปลงข้อมูล (custom serialization) ได้อย่างอิสระด้วยการเขียน implementation ของ Serialize และ Deserialize สำหรับชนิดข้อมูลของเราเอง ตัวอย่างเช่น เราสามารถสลับเปลี่ยนระหว่าง serde_json กับ serde_cbor ได้อย่างง่ายดายเพราะทั้งคู่เปิดเผยเมท็อดที่สอดคล้องกัน ซึ่งทำให้ crate ตัวช่วยอย่าง serde_transcode ยิ่งทรงพลังและใช้งานได้สะดวกสบายมากขึ้น

อย่างไรก็ดี ในภาษา Rust เราไม่จำเป็นต้องพึ่งพา trait เสมอไปในการสร้างแพตเทิร์นนี้

ตัวอย่างจำลองแบบเข้าใจง่าย (toy example) ต่อไปนี้แสดงการนำ Strategy pattern มาประยุกต์ใช้ผ่าน closure ใน Rust:

struct Adder;
impl Adder {
    pub fn add<F>(x: u8, y: u8, f: F) -> u8
    where
        F: Fn(u8, u8) -> u8,
    {
        f(x, y)
    }
}

fn main() {
    let arith_adder = |x, y| x + y;
    let bool_adder = |x, y| {
        if x == 1 || y == 1 {
            1
        } else {
            0
        }
    };
    let custom_adder = |x, y| 2 * x + y;

    assert_eq!(9, Adder::add(4, 5, arith_adder));
    assert_eq!(0, Adder::add(0, 0, bool_adder));
    assert_eq!(5, Adder::add(1, 3, custom_adder));
}

และในความเป็นจริง ภาษา Rust ได้นำแนวคิดนี้มาใช้งานอยู่แล้วในเมท็อด map ของ Option:

fn main() {
    let val = Some("Rust");

    let len_strategy = |s: &str| s.len();
    assert_eq!(4, val.map(len_strategy).unwrap());

    let first_byte_strategy = |s: &str| s.bytes().next().unwrap();
    assert_eq!(82, val.map(first_byte_strategy).unwrap());
}

ดูเพิ่มเติม

Visitor pattern (แพตเทิร์นผู้มาเยือน)

คำอธิบาย

Visitor pattern ทำหน้าที่ห่อหุ้มอัลกอริทึมที่ต้องเข้าไปดำเนินการกับชุดของออบเจกต์ที่ประกอบด้วยชนิดข้อมูลแตกต่างกัน (heterogeneous collection) ซึ่งเปิดโอกาสให้เราสามารถเขียนอัลกอริทึมที่หลากหลายเพื่อประมวลผลบนชุดข้อมูลเดิมได้ โดยไม่ต้องแก้ไขโครงสร้างข้อมูลหรือพฤติกรรมหลักของออบเจกต์เหล่านั้นเลย

ยิ่งไปกว่านั้น Visitor pattern ยังช่วยแยกตรรกะการท่องผ่านโครงสร้างข้อมูล (traversal) ออกจากการดำเนินการ (operations) ที่กระทำกับแต่ละโหนดข้อมูลอย่างชัดเจน

ตัวอย่าง

// The data we will visit
mod ast {
    pub enum Stmt {
        Expr(Expr),
        Let(Name, Expr),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract visitor
mod visit {
    use crate::ast::*;

    pub trait Visitor<T> {
        fn visit_name(&mut self, n: &Name) -> T;
        fn visit_stmt(&mut self, s: &Stmt) -> T;
        fn visit_expr(&mut self, e: &Expr) -> T;
    }
}

use ast::*;
use visit::*;

// An example concrete implementation - walks the AST interpreting it as code.
struct Interpreter;
impl Visitor<i64> for Interpreter {
    fn visit_name(&mut self, _n: &Name) -> i64 {
        panic!()
    }
    fn visit_stmt(&mut self, s: &Stmt) -> i64 {
        match *s {
            Stmt::Expr(ref e) => self.visit_expr(e),
            Stmt::Let(..) => unimplemented!(),
        }
    }

    fn visit_expr(&mut self, e: &Expr) -> i64 {
        match *e {
            Expr::IntLit(n) => n,
            Expr::Add(ref lhs, ref rhs) => self.visit_expr(lhs) + self.visit_expr(rhs),
            Expr::Sub(ref lhs, ref rhs) => self.visit_expr(lhs) - self.visit_expr(rhs),
        }
    }
}

เราสามารถเขียน Visitor ตัวอื่นๆ เพิ่มเติมได้ตามต้องการ เช่น ตัวตรวจสอบชนิดข้อมูล (type checker) โดยไม่ต้องแก้ไขโครงสร้างของข้อมูล AST เลยแม้แต่น้อย

แรงจูงใจ

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

การอภิปราย

ในทางปฏิบัติ เป็นเรื่องปกติที่เมท็อด visit_* จะคืนค่าว่าง (()) ต่างจากในตัวอย่างข้างต้น ซึ่งในกรณีดังกล่าว เราสามารถแยกโค้ดส่วนการท่องผ่านโครงสร้างข้อมูลออกมา เพื่อนำกลับมาใช้ซ้ำร่วมกันระหว่างอัลกอริทึมต่างๆ ได้ (พร้อมทั้งยังสามารถกำหนดเมท็อดเริ่มต้นที่ไม่ต้องทำอะไรเลย หรือ no-op default methods ได้ด้วย) สำหรับในภาษา Rust วิธีการที่นิยมใช้กันทั่วไปคือการสร้างฟังก์ชัน walk_* แยกไว้สำหรับแต่ละชนิดข้อมูล ตัวอย่างเช่น:

pub fn walk_expr(visitor: &mut Visitor, e: &Expr) {
    match *e {
        Expr::IntLit(_) => {}
        Expr::Add(ref lhs, ref rhs) => {
            visitor.visit_expr(lhs);
            visitor.visit_expr(rhs);
        }
        Expr::Sub(ref lhs, ref rhs) => {
            visitor.visit_expr(lhs);
            visitor.visit_expr(rhs);
        }
    }
}

ในภาษาอื่นๆ (เช่น Java) มักจะมีแบบแผนที่ข้อมูลแต่ละตัวจะมีเมท็อด accept เพื่อทำหน้าที่ส่งต่อ Visitor ในลักษณะเดียวกันนี้

ดูเพิ่มเติม

Visitor pattern เป็นแพตเทิร์นที่พบเห็นได้บ่อยในภาษาเชิงวัตถุ (OO) เกือบทุกภาษา

บทความบน Wikipedia

แพตเทิร์น Fold มีลักษณะคล้ายคลึงกับ Visitor แต่จะมุ่งเน้นการสร้างโครงสร้างข้อมูลเวอร์ชันใหม่ขึ้นมาแทนจากการท่องผ่านข้อมูลเดิม

แพตเทิร์นเชิงการสร้าง (Creational Patterns)

จาก Wikipedia:

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

Builder pattern (แพตเทิร์นผู้สร้าง หรือ บิลเดอร์)

คำอธิบาย

สร้างออบเจกต์ผ่านการเรียกใช้งานออบเจกต์ตัวช่วยประเภท Builder

ตัวอย่าง

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
pub struct Foo {
    // Lots of complicated fields.
    bar: String,
}

impl Foo {
    // This method will help users to discover the builder
    pub fn builder() -> FooBuilder {
        FooBuilder::default()
    }
}

#[derive(Default)]
pub struct FooBuilder {
    // Probably lots of optional fields.
    bar: String,
}

impl FooBuilder {
    pub fn new(/* ... */) -> FooBuilder {
        // Set the minimally required fields of Foo.
        FooBuilder {
            bar: String::from("X"),
        }
    }

    pub fn name(mut self, bar: String) -> FooBuilder {
        // Set the name on the builder itself, and return the builder by value.
        self.bar = bar;
        self
    }

    // If we can get away with not consuming the Builder here, that is an
    // advantage. It means we can use the FooBuilder as a template for constructing
    // many Foos.
    pub fn build(self) -> Foo {
        // Create a Foo from the FooBuilder, applying all settings in FooBuilder
        // to Foo.
        Foo { bar: self.bar }
    }
}

#[test]
fn builder_test() {
    let foo = Foo {
        bar: String::from("Y"),
    };
    let foo_from_builder: Foo = FooBuilder::new().name(String::from("Y")).build();
    assert_eq!(foo, foo_from_builder);
}
}

แรงจูงใจ

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

ข้อดี

แยกเมท็อดสำหรับการประกอบสร้างออบเจกต์ออกจากเมท็อดการทำงานทั่วไปอย่างชัดเจน

ป้องกันปัญหาการงอกของคอนสตรัคเตอร์จำนวนมากเกินความจำเป็น (constructor proliferation)

รองรับทั้งการสร้างและกำหนดค่าแบบบรรทัดเดียวจบ (one-liner initialization) ตลอดจนการประกอบสร้างที่มีขั้นตอนซับซ้อน

เมื่อมีการเพิ่มฟิลด์ใหม่เข้าไปใน struct ปลายทาง คุณสามารถอัปเดตตัว builder เพื่อให้โค้ดส่วนอื่นที่เรียกใช้อยู่เดิมยังคงเข้ากันได้ย้อนหลัง (backwards compatible) เสมอ

ข้อเสีย

มีความซับซ้อนมากกว่าการสร้าง struct โดยตรง หรือการเรียกฟังก์ชันคอนสตรัคเตอร์แบบธรรมดา

การอภิปราย

แพตเทิร์นนี้พบเห็นได้บ่อยครั้งในภาษา Rust (แม้กระทั่งกับออบเจกต์ที่มีโครงสร้างไม่ซับซ้อนมาก) เมื่อเทียบกับภาษาอื่นๆ เนื่องจากภาษา Rust ไม่มีฟีเจอร์ method overloading และไม่มีค่าเริ่มต้น (default arguments) สำหรับพารามิเตอร์ของฟังก์ชัน และเนื่องจากเราสามารถตั้งชื่อเมท็อดได้เพียงชื่อเดียว การสร้างคอนสตรัคเตอร์หลายๆ ตัวที่มีชื่อซ้ำกันจึงไม่สามารถทำได้เหมือนในภาษา C++, Java หรือภาษาอื่นๆ

นอกจากนี้ Builder pattern มักถูกนำมาประยุกต์ใช้ในกรณีที่ตัวออบเจกต์ Builder เองมีฟังก์ชันและประโยชน์ในการใช้งานเฉพาะด้าน ไม่ได้ทำหน้าที่เพียงแค่สร้างออบเจกต์เท่านั้น ตัวอย่างเช่น std::process::Command ซึ่งทำหน้าที่เป็น builder ในการตั้งค่าและสร้าง Child (กระบวนการหรือโปรเซสภายนอก) ซึ่งในกรณีเหล่านี้ จะไม่นิยมใช้รูปแบบการตั้งชื่อแบบ T และ TBuilder

ในตัวอย่างข้างต้น ตัว builder มีการรับและส่งคืนค่าด้วยการส่งผ่านค่า (by value / consuming self) ทว่าในความเป็นจริง บ่อยครั้งที่การรับและส่งคืน builder ในรูปของการอ้างอิงที่เปลี่ยนแปลงค่าได้ (&mut self) จะให้ความสะดวกสบาย (และมีประสิทธิภาพ) มากกว่า ซึ่ง borrow checker ของ Rust รองรับแนวทางนี้ได้อย่างเป็นธรรมชาติ โดยแนวทางดังกล่าวมีข้อดีคือทำให้เราสามารถเขียนโค้ดได้ทั้งแบบ:

let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.build();

ควบคู่ไปกับสไตล์แบบ method chaining เช่น FooBuilder::new().a().b().build() ได้เช่นเดียวกัน

ดูเพิ่มเติม

Fold pattern (แพตเทิร์นโฟลด์)

คำอธิบาย

ประมวลผลอัลกอริทึมลงบนสมาชิกแต่ละตัวในโครงสร้างข้อมูลเพื่อสร้างสมาชิกชุดใหม่ขึ้นมา ซึ่งส่งผลให้ได้โครงสร้างข้อมูลชุดใหม่ขึ้นมาทั้งชุด

ที่มาและรากศัพท์ของคำนี้ในบริบทนี้อาจดูแปลกตาไปบ้าง โดยคำว่า ‘fold’ และ ‘folder’ ถูกนำมาใช้อย่างแพร่หลายในตัวคอมไพเลอร์ของ Rust แม้ว่าในมุมมองทั่วไปมันจะดูคล้ายคลึงกับการ map มากกว่าการ fold ในความหมายทางคณิตศาสตร์หรือการยุบรวมข้อมูลตามปกติ (ดูรายละเอียดเพิ่มเติมได้ในหัวข้อการอภิปรายด้านล่าง)

ตัวอย่าง

// The data we will fold, a simple AST.
mod ast {
    pub enum Stmt {
        Expr(Box<Expr>),
        Let(Box<Name>, Box<Expr>),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract folder
mod fold {
    use ast::*;

    pub trait Folder {
        // A leaf node just returns the node itself. In some cases, we can do this
        // to inner nodes too.
        fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
        // Create a new inner node by folding its children.
        fn fold_stmt(&mut self, s: Box<Stmt>) -> Box<Stmt> {
            match *s {
                Stmt::Expr(e) => Box::new(Stmt::Expr(self.fold_expr(e))),
                Stmt::Let(n, e) => Box::new(Stmt::Let(self.fold_name(n), self.fold_expr(e))),
            }
        }
        fn fold_expr(&mut self, e: Box<Expr>) -> Box<Expr> { ... }
    }
}

use fold::*;
use ast::*;

// An example concrete implementation - renames every name to 'foo'.
struct Renamer;
impl Folder for Renamer {
    fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
        Box::new(Name { value: "foo".to_owned() })
    }
    // Use the default methods for the other nodes.
}

ผลลัพธ์จากการรัน Renamer บนต้นไม้ AST คือการได้ AST ตัวใหม่ที่มีโครงสร้างเหมือนกับตัวเดิมทุกประการ แต่ชื่อตัวแปรทั้งหมดจะถูกเปลี่ยนเป็น foo ในการใช้งานจริง ตัว Folder มักจะมีการเก็บสถานะ (state) บางอย่างไว้ภายใน struct เพื่อส่งต่อข้อมูลระหว่างแต่ละโหนดได้ด้วย

นอกจากนี้ Folder ยังสามารถถูกออกแบบให้แปลง (map) โครงสร้างข้อมูลชนิดหนึ่งไปเป็นอีกชนิดหนึ่ง (ซึ่งมักจะมีโครงสร้างที่ใกล้เคียงกัน) ได้ด้วย เช่น เราสามารถ fold ต้นไม้ AST ให้กลายเป็นต้นไม้ HIR ได้ (HIR ย่อมาจาก High-level Intermediate Representation)

แรงจูงใจ

เป็นเรื่องปกติที่เราต้องการแปลงโครงสร้างข้อมูลโดยประมวลผลตรรกะบางอย่างกับแต่ละโหนด สำหรับการทำงานที่ไม่ซับซ้อนบนโครงสร้างข้อมูลพื้นฐาน เราสามารถใช้ Iterator::map ได้อย่างง่ายดาย ทว่าสำหรับการประมวลผลที่มีความซับซ้อนสูง เช่น กรณีที่โหนดลำดับก่อนหน้าส่งผลต่อการประมวลผลโหนดลำดับถัดไป หรือในกรณีที่โครงสร้างข้อมูลมีความซับซ้อนจนไม่สามารถวนซ้ำแบบปกติได้ การเลือกใช้ Fold pattern ย่อมมีความเหมาะสมกว่ามาก

เช่นเดียวกับ Visitor pattern แพตเทิร์น Fold ช่วยให้เราสามารถแยกตรรกะการท่องผ่านโครงสร้างข้อมูล (traversal) ออกจากการดำเนินการ (operations) ที่กระทำกับแต่ละโหนดได้อย่างเป็นอิสระ

การอภิปราย

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

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

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

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

การเลือกใช้ Smart Pointer แบบนับจำนวนการอ้างอิง (Reference-Counted Pointer เช่น Rc หรือ Arc) จะช่วยรวมจุดเด่นของทั้งสองแนวทางเข้าด้วยกัน — ทำให้เราสามารถนำโครงสร้างข้อมูลเดิมกลับมาใช้ซ้ำได้โดยไม่ต้องคัดลอกโหนดที่ไม่มีการแก้ไข อย่างไรก็ดี วิธีนี้จะลดความสะดวกในการเขียนโค้ดลง และส่งผลให้โครงสร้างข้อมูลเหล่านั้นไม่สามารถแก้ไขค่าได้โดยตรง

ดูเพิ่มเติม

ตัว Iterator เองมีเมท็อดชื่อ fold เช่นกัน ทว่าเมท็อดดังกล่าวทำหน้าที่ยุบรวมโครงสร้างข้อมูลให้กลายเป็นค่าเดี่ยว (single value) ไม่ใช่การสร้างโครงสร้างข้อมูลชุดใหม่ขึ้นมา เมท็อด map ของ Iterator จึงมีความใกล้เคียงกับ Fold pattern ในบทนี้มากกว่า

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

แพตเทิร์น Visitor มีความสัมพันธ์อย่างใกล้ชิดกับ Fold โดยทั้งคู่มีแนวคิดร่วมกันในการท่องผ่านโครงสร้างข้อมูลเพื่อดำเนินการกับแต่ละโหนด ทว่า Visitor จะไม่ได้สร้างโครงสร้างข้อมูลชุดใหม่ขึ้นมา และไม่ได้ยึดครองหรือทำลาย (consume) ข้อมูลเดิม

แพตเทิร์นเชิงโครงสร้าง (Structural Patterns)

จาก Wikipedia:

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

การกระจายโครงสร้าง struct เพื่อการยืมที่เป็นอิสระต่อกัน (Struct decomposition for independent borrowing)

คำอธิบาย

ในบางครั้ง การสร้าง struct ขนาดใหญ่ที่มีฟิลด์ข้อมูลจำนวนมากอาจนำไปสู่ปัญหาติดขัดกับระบบ borrow checker — แม้ว่าในทางทฤษฎีแล้วแต่ละฟิลด์จะสามารถถูกยืมแยกจากกันได้อย่างเป็นอิสระ แต่บ่อยครั้งที่ทั้ง struct กลับถูกเรียกใช้งานพร้อมกัน จนส่งผลให้เกิดการล็อกและขัดขวางไม่ให้ฟิลด์อื่นถูกนำไปใช้งานได้ แนวทางแก้ไขปัญหานี้คือการแยก (decompose) struct ขนาดใหญ่ออกเป็น struct ย่อยๆ หลายตัว จากนั้นจึงนำ struct ย่อยเหล่านั้นมาประกอบ (compose) รวมกันกลับเข้ามาใน struct หลัก ซึ่งจะช่วยให้แต่ละ struct ย่อยสามารถถูกยืมใช้งานแยกจากกันได้อย่างเป็นอิสระและมีความยืดหยุ่นสูงขึ้น

นอกจากนี้ แนวทางดังกล่าวยังมักจะนำไปสู่การออกแบบที่ดีขึ้นในภาพรวมอีกด้วย เนื่องจากกระบวนการแยกส่วน struct มักจะช่วยให้เราค้นพบหน่วยการทำงานย่อย (smaller units of functionality) ที่มีความชัดเจนและเป็นโมดูลมากขึ้น

ตัวอย่าง

นี่คือตัวอย่างจำลองสถานการณ์ที่ borrow checker เข้ามาขัดขวางการเรียกใช้งาน struct ของเรา:

struct Database {
    connection_string: String,
    timeout: u32,
    pool_size: u32,
}

fn print_database(database: &Database) {
    println!("Connection string: {}", database.connection_string);
    println!("Timeout: {}", database.timeout);
    println!("Pool size: {}", database.pool_size);
}

fn main() {
    let mut db = Database {
        connection_string: "initial string".to_string(),
        timeout: 30,
        pool_size: 100,
    };

    let connection_string = &mut db.connection_string;
    print_database(&db);
    *connection_string = "new string".to_string();
}

คอมไพเลอร์จะรายงานข้อผิดพลาดดังต่อไปนี้:

let connection_string = &mut db.connection_string;
                        ------------------------- mutable borrow occurs here
print_database(&db);
               ^^^ immutable borrow occurs here
*connection_string = "new string".to_string();
------------------ mutable borrow later used here

เราสามารถนำแพตเทิร์นนี้มาประยุกต์ใช้เพื่อรีแฟกเตอร์ Database ให้กลายเป็น struct ย่อย 3 ตัว ซึ่งจะช่วยคลี่คลายปัญหาการตรวจสอบการยืมของ borrow checker ได้อย่างหมดจด:

// Database is now composed of three structs - ConnectionString, Timeout and PoolSize.
// Let's decompose it into smaller structs
#[derive(Debug, Clone)]
struct ConnectionString(String);

#[derive(Debug, Clone, Copy)]
struct Timeout(u32);

#[derive(Debug, Clone, Copy)]
struct PoolSize(u32);

// We then compose these smaller structs back into `Database`
struct Database {
    connection_string: ConnectionString,
    timeout: Timeout,
    pool_size: PoolSize,
}

// print_database can then take ConnectionString, Timeout and Poolsize struct instead
fn print_database(connection_str: ConnectionString, timeout: Timeout, pool_size: PoolSize) {
    println!("Connection string: {connection_str:?}");
    println!("Timeout: {timeout:?}");
    println!("Pool size: {pool_size:?}");
}

fn main() {
    // Initialize the Database with the three structs
    let mut db = Database {
        connection_string: ConnectionString("localhost".to_string()),
        timeout: Timeout(30),
        pool_size: PoolSize(100),
    };

    let connection_string = &mut db.connection_string;
    print_database(connection_string.clone(), db.timeout, db.pool_size);
    *connection_string = ConnectionString("new string".to_string());
}

แรงจูงใจ

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

ข้อดี

การแยก struct ช่วยให้เราสามารถก้าวข้ามข้อจำกัดบางประการของ borrow checker ได้อย่างปลอดภัย และบ่อยครั้งยังส่งผลให้โครงสร้างการออกแบบของระบบมีความสะอาดและชัดเจนขึ้น

ข้อเสีย

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

การอภิปราย

แพตเทิร์นนี้ไม่จำเป็นสำหรับภาษาโปรแกรมที่ไม่มีระบบ borrow checker จึงถือเป็นแพตเทิร์นที่มีความเฉพาะตัวอย่างยิ่งในภาษา Rust อย่างไรก็ดี การแบ่งย่อยระบบออกเป็นหน่วยการทำงานขนาดเล็กยังคงนำไปสู่โค้ดที่สะอาดและดูแลรักษาง่ายกว่าเสมอ ซึ่งสอดคล้องกับหลักวิศวกรรมซอฟต์แวร์สากลโดยไม่ขึ้นกับภาษาใดๆ

ความสำเร็จของแพตเทิร์นนี้พึ่งพาความสามารถของ borrow checker ในภาษา Rust ที่สามารถตรวจสอบการยืมฟิลด์แต่ละตัวแยกจากกันได้อย่างเป็นอิสระ ในตัวอย่างข้างต้น borrow checker รับรู้ได้อย่างถูกต้องว่า a.b กับ a.c เป็นคนละส่วนกันและสามารถถูกยืมแยกกันได้ โดยไม่ได้เหมาล็อกทั้งก้อนของ a ซึ่งหากทำเช่นนั้น แพตเทิร์นนี้จะใช้งานไม่ได้ผล

นิยมใช้ crate ขนาดเล็ก (Prefer small crates)

คำอธิบาย

นิยมเลือกใช้ crate ขนาดเล็กที่มีหน้าที่เฉพาะเจาะจงและทำงานสิ่งนั้นได้อย่างยอดเยี่ยม

ระบบ Cargo และ crates.io ช่วยให้การติดตั้งและจัดการไลบรารีภายนอกทำได้อย่างง่ายดายและสะดวกสบายกว่าภาษาอย่าง C หรือ C++ เป็นอย่างมาก ยิ่งไปกว่านั้น เนื่องจากแพ็กเกจที่เผยแพร่บน crates.io จะไม่สามารถถูกแก้ไขหรือลบออกได้ การบิลด์โปรเจกต์ใดๆ ที่ทำงานได้ในปัจจุบันจึงได้รับการรับประกันว่าจะยังคงคอมไพล์ผ่านได้ในอนาคตเสมอ เราจึงควรใช้ประโยชน์จากระบบนิเวศนี้ด้วยการแบ่งแยก dependency ให้มีขนาดเล็กและมีความละเอียดเฉพาะทางมากยิ่งขึ้น

ข้อดี

  • Crate ขนาดเล็กทำความเข้าใจได้ง่ายกว่า และช่วยส่งเสริมให้โค้ดมีความเป็นโมดูลสูง
  • Crate ช่วยอำนวยความสะดวกในการนำโค้ดกลับมาใช้ซ้ำข้ามโปรเจกต์ ตัวอย่างเช่น crate url ถูกพัฒนาขึ้นมาเพื่อเป็นส่วนหนึ่งของเว็บบราวเซอร์เอนจิน Servo แต่ในเวลาต่อมากลับได้รับการนำไปใช้งานอย่างกว้างขวางภายนอกโปรเจกต์ดังกล่าว
  • เนื่องจากหน่วยการคอมไพล์ของ Rust คือระดับ crate การแยกโปรเจกต์ออกเป็นหลายๆ crate ย่อยจึงช่วยให้คอมไพเลอร์สามารถบิลด์โค้ดแบบขนาน (parallel compilation) ได้รวดเร็วยิ่งขึ้น

ข้อเสีย

  • อาจนำไปสู่ภาวะ “dependency hell” ได้ เมื่อโปรเจกต์ต้องพึ่งพา crate เดียวกันแต่ต่างเวอร์ชันและขัดแย้งกันในเวลาเดียวกัน ตัวอย่างเช่น crate url มีทั้งเวอร์ชัน 1.0 และ 0.5 ซึ่งชนิดข้อมูล Url จาก url:1.0 และ url:0.5 ถือเป็นคนละชนิดกันโดยสิ้นเชิง ส่งผลให้ HTTP client ที่เรียกใช้ url:0.5 จะไม่สามารถรับค่า Url ที่สร้างขึ้นจาก Web Scraper ที่อิงกับ url:1.0 ได้
  • แพ็กเกจบน crates.io ไม่ได้ผ่านการคัดกรองหรือรับรองอย่างเป็นทางการ ดังนั้น crate บางตัวอาจเขียนขึ้นมาไม่ดี มีเอกสารประกอบไม่ครบถ้วน หรืออาจแฝงโค้ดที่ไม่ปลอดภัย
  • Crate ขนาดเล็ก 2 ตัวอาจได้รับการ optimize รวมกันได้น้อยกว่า crate ขนาดใหญ่เพียงตัวเดียว เนื่องจากคอมไพเลอร์ไม่ได้เปิดใช้งาน Link-Time Optimization (LTO) เป็นค่าเริ่มต้น

ตัวอย่าง

crate url ให้เครื่องมือและฟังก์ชันสำหรับการจัดการกับ URL

crate num_cpus ให้ฟังก์ชันสำหรับตรวจสอบจำนวน CPU บนเครื่อง

crate ref_slice ให้ฟังก์ชันสำหรับการแปลง &T ไปเป็น &[T] (ตัวอย่างเชิงประวัติศาสตร์)

ดูเพิ่มเติม

จำกัดขอบเขตความไม่ปลอดภัยไว้ในโมดูลขนาดเล็ก (Contain unsafety in small modules)

คำอธิบาย

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

ข้อดี

  • ช่วยจำกัดขอบเขตของโค้ด unsafe ที่จำเป็นต้องได้รับการตรวจสอบและทดสอบความปลอดภัย (audit) ให้เหลือน้อยที่สุด
  • การเขียนโมดูลชั้นนอกทำได้ง่ายและมั่นใจยิ่งขึ้น เนื่องจากสามารถวางใจในหลักประกันความถูกต้อง (guarantees) ที่โมดูลชั้นในดูแลให้อยู่แล้ว

ข้อเสีย

  • ในบางกรณี อาจเป็นเรื่องยากในการออกแบบอินเทอร์เฟซที่ลงตัวและครอบคลุม
  • การสร้าง abstraction ครอบทับอาจก่อให้เกิดภาระด้านประสิทธิภาพ (overhead) เล็กน้อย

ตัวอย่าง

  • crate toolshed ได้รวบรวมการทำงานที่เป็น unsafe ไว้ในโมดูลย่อยภายใน แล้วนำเสนอเฉพาะอินเทอร์เฟซที่ปลอดภัยให้แก่ผู้ใช้งานภายนอก
  • โครงสร้าง String ในไลบรารีมาตรฐาน (std) แท้จริงแล้วคือตัวห่อหุ้ม Vec<u8> ที่เพิ่ม invariant ควบคุมว่าข้อมูลภายในจะต้องเป็น UTF-8 ที่ถูกต้องเสมอ ซึ่งการทำงานปกติบน String จะช่วยรับประกันพฤติกรรมนี้ ทว่าผู้ใช้งานยังคงมีทางเลือกในการเรียกใช้เมท็อด unsafe เพื่อสร้าง String ได้โดยตรง ซึ่งในกรณีนั้น ผู้เรียกใช้จะต้องเป็นผู้รับผิดชอบในการรับประกันความถูกต้องของข้อมูลด้วยตนเอง

ดูเพิ่มเติม

ใช้ trait ที่กำหนดขึ้นเองเพื่อหลีกเลี่ยงข้อกำหนดชนิดที่ซับซ้อน (Use custom traits to avoid complex type bounds)

คำอธิบาย

ข้อกำหนดคุณสมบัติของชนิดข้อมูล (trait bounds) อาจมีความยาวและซับซ้อนจนอ่านยาก โดยเฉพาะอย่างยิ่งเมื่อต้องเกี่ยวข้องกับ trait ในตระกูล Fn1 ควบคู่ไปกับเงื่อนไขเฉพาะเจาะจงของชนิดข้อมูลผลลัพธ์ ในสถานการณ์เช่นนี้ การประกาศ trait ตัวใหม่ขึ้นมาโดยเฉพาะจะช่วยลดความเยิ่นเย้อของโค้ด ช่วยกำจัดพารามิเตอร์ของชนิดข้อมูลบางตัว และช่วยเพิ่มความชัดเจนในการสื่อความหมาย (expressiveness) ได้เป็นอย่างดี โดย trait ใหม่นี้สามารถมาพร้อมกับบล็อก impl แบบ generic สำหรับทุกๆ ชนิดข้อมูลที่สอดคล้องตามเงื่อนไขเดิม

ตัวอย่าง

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

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

ด้วยเหตุนี้ เราจึงอาจเริ่มต้นออกแบบโครงสร้างของชนิดข้อมูลและ trait สำหรับจัดการค่าต่างๆ ไว้ดังนี้:

#![allow(unused)]
fn main() {
use std::fmt::Display;

struct Value<G: FnMut() -> Result<T, Error>, S: Fn(&T) -> Status, T: Display> {
    value: Option<T>,
    getter: G,
    status: S,
}

impl<G: FnMut() -> Result<T, Error>, S: Fn(&T) -> Status, T: Display> Value<G, S, T> {
    pub fn update(&mut self) -> Result<(), Error> {
        (self.getter)().map(|v| self.value = Some(v))
    }

    pub fn value(&self) -> Option<&T> {
        self.value.as_ref()
    }

    pub fn status(&self) -> Option<Status> {
        self.value().map(&self.status)
    }
}

// ...

enum Status {
    // ...
}

struct Error {
    // ...
}
}

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

#![allow(unused)]
fn main() {
use std::fmt::Display;
trait Getter {
    type Output: Display;

    fn get_value(&mut self) -> Result<Self::Output, Error>;
}

impl<F: FnMut() -> Result<T, Error>, T: Display> Getter for F {
    type Output = T;

    fn get_value(&mut self) -> Result<Self::Output, Error> {
        self()
    }
}

struct Value<G: Getter, S: Fn(&G::Output) -> Status> {
    value: Option<G::Output>,
    getter: G,
    status: S,
}

// ...
enum Status {}
struct Error;
}

ข้อดี

การสร้าง trait ใหม่ขึ้นมาจะช่วยลดความซับซ้อนของ trait bounds ลงได้อย่างเห็นได้ชัด โดยเฉพาะการตัดพารามิเตอร์ของชนิดข้อมูลออกไป นอกจากนี้ การตั้งชื่อ trait ที่สื่อความหมายยังช่วยให้อ่านโค้ดเข้าใจได้ง่ายขึ้น และในฐานะที่เป็น abstraction ตัว trait ใหม่ยังเปิดโอกาสให้ต่อยอดประโยชน์อื่นๆ เพิ่มเติมได้อีก เช่น:

  • สามารถสร้างชนิดข้อมูลเฉพาะทางอื่นๆ ที่นำ trait นี้ไปใช้งาน (เช่น ชนิดข้อมูลที่แทนค่าคงที่เฉพาะบางอย่าง) ตลอดจนการนำ trait พื้นฐานที่มีประโยชน์ เช่น Default มาใช้งานร่วมด้วย
  • สามารถเพิ่มเมท็อดช่วยเหลืออื่นๆ เพิ่มเติมได้ ตราบใดที่เมท็อดเหล่านั้นสามารถใช้งานร่วมกับทุกชนิดข้อมูลที่เกี่ยวข้องได้

ข้อเสีย

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


  1. กล่าวคือ trait Fn, FnOnce และ FnMut ↩

  2. หมายความว่าผู้ใช้อาจจำเป็นต้องอ่านเอกสารประกอบเพิ่มเติม ↩

ดีไซน์แพตเทิร์นสำหรับ FFI (FFI Patterns)

การเขียนโค้ดติดต่อกับภาษาภายนอก (FFI) ถือเป็นศาสตร์ที่มีเนื้อหากว้างขวางและลึกซึ้งในตัวเอง อย่างไรก็ดี ในหัวข้อนี้มีแบบแผนการออกแบบที่ช่วยทำหน้าที่เป็นเข็มทิศนำทาง และช่วยหลีกเลี่ยงกับดักอันตรายสำหรับผู้ที่ยังไม่คุ้นเคยกับการเขียน unsafe Rust

ส่วนนี้รวบรวมดีไซน์แพตเทิร์นที่มีประโยชน์อย่างยิ่งเมื่อต้องทำ FFI:

  1. API แบบอิงออบเจกต์ (Object-Based API) — การออกแบบ API ที่มีคุณสมบัติด้านความปลอดภัยของหน่วยความจำที่ดีเยี่ยม และมีเส้นแบ่งที่ชัดเจนระหว่างส่วนที่ปลอดภัย (safe) กับส่วนที่ไม่ปลอดภัย (unsafe)

  2. การรวมชนิดข้อมูลเข้าสู่ตัวห่อหุ้ม (Type Consolidation into Wrappers) — การจัดกลุ่มชนิดข้อมูล Rust หลายตัวเข้าด้วยกันให้อยู่ในรูปของ “ออบเจกต์” ที่ซ่อนรายละเอียดภายใน (opaque object)

API แบบอิงออบเจกต์ (Object-Based API)

คำอธิบาย

เมื่อออกแบบ API ในภาษา Rust เพื่อเปิดเผยให้ภาษาภายนอกเรียกใช้งาน มีหลักการออกแบบสำคัญบางประการที่สวนทางกับการออกแบบ API สำหรับภาษา Rust โดยทั่วไป:

  1. ชนิดข้อมูลที่ห่อหุ้มสถานะภายใน (Encapsulated type) ทั้งหมดควรมี Rust เป็น เจ้าของ (owned by Rust) มีผู้ใช้งานภายนอกเป็นผู้ ควบคุมวงจรชีวิต (managed by user) และต้องเป็นชนิดข้อมูลที่ ซ่อนรายละเอียดภายใน (opaque type)
  2. ชนิดข้อมูลสำหรับแลกเปลี่ยนข้อมูล (Transactional type) ทั้งหมดควรมีผู้ใช้งานภายนอกเป็น เจ้าของ (owned by user) และต้องเป็นชนิดข้อมูลที่ เปิดเผยโครงสร้างชัดเจน (transparent type)
  3. พฤติกรรมทั้งหมดของไลบรารีควรถูกสร้างเป็นฟังก์ชันที่ทำงานอยู่บนชนิดข้อมูลประเภท Encapsulated
  4. พฤติกรรมทั้งหมดของไลบรารีควรถูกจัดกลุ่มและห่อหุ้มเข้าเป็นชนิดข้อมูลโดยพิจารณาจาก แหล่งกำเนิดและช่วงชีวิต (provenance / lifetime) เป็นหลัก ไม่ใช่พิจารณาตามโครงสร้างข้อมูล

แรงจูงใจ

ภาษา Rust รองรับการทำ FFI ร่วมกับภาษาภายนอกมาในตัว โดยเปิดโอกาสให้ผู้พัฒนา crate สามารถเปิดเผย API ที่เข้ากันได้กับภาษา C ผ่าน ABI มาตรฐานต่างๆ (แม้ว่ารายละเอียดส่วนนั้นจะไม่ใช่ประเด็นหลักของแนวปฏิบัตินี้ก็ตาม)

FFI ของ Rust ที่ได้รับการออกแบบมาอย่างยอดเยี่ยมจะต้องปฏิบัติตามหลักการออกแบบ API ของภาษา C ได้อย่างแนบเนียน ในขณะเดียวกันก็ต้องกระทบต่อแบบแผนความปลอดภัยฝั่ง Rust ให้น้อยที่สุด โดยเป้าหมายหลัก 3 ประการสำหรับ API ภาษาภายนอก ได้แก่:

  1. ใช้งานง่ายและเป็นธรรมชาติในภาษาปลายทาง
  2. ลดการบังคับใช้โค้ด unsafe ภายในฝั่ง Rust ให้เหลือน้อยที่สุดเท่าที่จะทำได้
  3. ควบคุมและลดความเสี่ยงต่อปัญหาความไม่ปลอดภัยในหน่วยความจำ ตลอดจนการเกิด Undefined Behavior (UB) ใน Rust ให้น้อยที่สุด

โค้ดฝั่ง Rust จำเป็นต้องไว้วางใจในความปลอดภัยด้านหน่วยความจำของภาษาภายนอกในระดับหนึ่ง อย่างไรก็ตาม โค้ด unsafe ทุกบรรทัดในฝั่ง Rust ล้วนเป็นช่องทางให้เกิดบั๊ก หรืออาจทวีความรุนแรงของ UB ให้ลุกลามได้

ตัวอย่างเช่น หากพอยน์เตอร์มีที่มาที่ไม่ถูกต้อง อาจก่อให้เกิดข้อผิดพลาด segmentation fault (segfault) จากการพยายามเข้าถึงตำแหน่งหน่วยความจำที่ไม่อนุญาต แต่หากพอยน์เตอร์นั้นถูกจัดการผ่านบล็อกคำสั่ง unsafe มันอาจลุกลามกลายเป็นการทำลายโครงสร้างฮีป (heap corruption) ได้อย่างสมบูรณ์

การออกแบบ API ในสไตล์แบบอิงออบเจกต์ (Object-Based API) ช่วยให้เราสามารถเขียนโค้ดตัวเชื่อม (shim) ที่มีคุณสมบัติด้านความปลอดภัยของหน่วยความจำที่ดีเยี่ยม และสร้างเส้นแบ่งขอบเขตที่ชัดเจนระหว่างส่วนที่ปลอดภัย (safe) กับส่วนที่เป็นอันตราย (unsafe)

ตัวอย่างโค้ด

มาตรฐาน POSIX ได้นิยาม API สำหรับการเข้าถึงฐานข้อมูลแบบไฟล์ที่รู้จักกันในชื่อ DBM ซึ่งถือเป็นตัวอย่างชั้นยอดของ API ในสไตล์ “อิงออบเจกต์”

โค้ดด้านล่างนี้คือนิยามในภาษา C ซึ่งอ่านเข้าใจได้ง่ายสำหรับผู้ที่คุ้นเคยกับ FFI โดยคำอธิบายด้านล่างจะช่วยชี้แจงประเด็นที่ละเอียดอ่อนให้เห็นภาพชัดเจนขึ้น:

struct DBM;
typedef struct { void *dptr, size_t dsize } datum;

int     dbm_clearerr(DBM *);
void    dbm_close(DBM *);
int     dbm_delete(DBM *, datum);
int     dbm_error(DBM *);
datum   dbm_fetch(DBM *, datum);
datum   dbm_firstkey(DBM *);
datum   dbm_nextkey(DBM *);
DBM    *dbm_open(const char *, int, mode_t);
int     dbm_store(DBM *, datum, datum, int);

API ชุดนี้นิยามชนิดข้อมูลไว้ 2 ชนิด ได้แก่ DBM และ datum

ชนิดข้อมูล DBM คือตัวแทนของชนิดข้อมูลแบบ “Encapsulated” ตามที่กล่าวไว้ข้างต้น โดยถูกออกแบบมาเพื่อเก็บรักษาสถานะภายใน และทำหน้าที่เป็นประตูทางเข้าหลักสำหรับเรียกใช้พฤติกรรมต่างๆ ของไลบรารี

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

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

ส่วนชนิดข้อมูล datum คือตัวแทนของชนิดข้อมูลแบบ “Transactional” ที่ออกแบบมาเพื่ออำนวยความสะดวกในการแลกเปลี่ยนข้อมูลระหว่างไลบรารีกับผู้ใช้ภายนอก

ตัวฐานข้อมูลถูกออกแบบมาเพื่อจัดเก็บ “ข้อมูลไร้โครงสร้างตายตัว” (unstructured data) โดยไม่มีการกำหนดความยาวหรือความหมายไว้ล่วงหน้า ผลลัพธ์คือ datum มีค่าเทียบเท่ากับสไลซ์ (&[u8]) ของ Rust ในโลกของภาษา C กล่าวคือ ประกอบด้วยกลุ่มของไบต์และตัวนับจำนวนไบต์ ความแตกต่างหลักคือไม่มีข้อมูลระบุชนิด ซึ่งแสดงผ่านการใช้ void

พึงระลึกไว้ว่าไฟล์เฮดเดอร์นี้ถูกเขียนขึ้นจากมุมมองของฝั่งไลบรารี ผู้ใช้อาจมีชนิดข้อมูลเฉพาะที่ตนเองใช้งานอยู่ซึ่งมีขนาดที่แน่นอน แต่ไลบรารีไม่จำเป็นต้องรับรู้ และตามกฎการแปลงชนิดข้อมูลของภาษา C พอยน์เตอร์ใดๆ ก็ตามสามารถถูกแปลงชนิดไปเป็น void ได้เสมอ

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

คำตอบที่มอบความปลอดภัยสูงสุดต่อหน่วยความจำคือ “ผู้ใช้ภายนอก” ทว่าในบางกรณี เช่น การดึงค่าข้อมูลออกมาจากฐานข้อมูล ผู้ใช้จะไม่ทราบขนาดล่วงหน้าจึงไม่สามารถจัดสรรหน่วยความจำล่วงหน้าได้อย่างถูกต้อง ในสถานการณ์เช่นนี้ โค้ดฝั่งไลบรารีจะเป็นผู้จัดสรรหน่วยความจำบนฮีปที่ผู้ใช้สามารถเข้าถึงได้ (เช่น การใช้ malloc และ free ของภาษา C) จากนั้นจึงทำการ ถ่ายโอนความเป็นเจ้าของ (transfer ownership) ในความหมายของ Rust ให้แก่ผู้ใช้

สิ่งเหล่านี้สะท้อนความหมายที่แท้จริงของพอยน์เตอร์ในภาษา C ซึ่งมีความหมายเช่นเดียวกับใน Rust นั่นคือ “ช่วงชีวิตที่ผู้ใช้ต้องควบคุมดูแลเอง (user-defined lifetime)” ผู้ใช้งานไลบรารีจำเป็นต้องอ่านเอกสารประกอบอย่างละเอียดเพื่อใช้งานได้อย่างถูกต้อง ถึงกระนั้นก็ตาม การตัดสินใจออกแบบ API ที่ดีจะสามารถช่วยบรรเทาความผิดพลาดของผู้ใช้ลงได้ และกุญแจสำคัญที่สุดคือ การถ่ายโอนความเป็นเจ้าของให้กับทุกสิ่งที่เป็นชนิดข้อมูลแบบ transparent

ข้อดี

แนวทางนี้ช่วยลดภาระและข้อกำหนดด้านความปลอดภัยของหน่วยความจำที่ผู้ใช้ภายนอกต้องคอยระวัง ให้เหลือเพียงข้อกำหนดจำนวนน้อย:

  1. ห้ามเรียกฟังก์ชันใดๆ ด้วยพอยน์เตอร์ที่ไม่ได้ส่งคืนมาจาก dbm_open (เพื่อป้องกันการเข้าถึงหน่วยความจำที่ไม่ถูกต้องหรือข้อมูลเสียหาย)
  2. ห้ามเรียกฟังก์ชันใดๆ บนพอยน์เตอร์หลังจากสั่งปิดด้วย close ไปแล้ว (use after free)
  3. ค่า dptr บน datum ใดๆ จะต้องเป็น NULL หรือชี้ไปยังช่วงหน่วยความจำที่ถูกต้องและมีความยาวตรงตามที่ระบุไว้เสมอ

นอกจากนี้ การออกแบบเช่นนี้ยังช่วยหลีกเลี่ยงปัญหาเรื่อง pointer provenance (ประวัติที่มาและความถูกต้องของพอยน์เตอร์) และ pointer aliasing ได้อย่างมาก เพื่อให้เห็นภาพชัดเจน ลองพิจารณาทางเลือกอื่นสำหรับการวนซ้ำคีย์ข้อมูล (key iteration):

ภาษา Rust มีชื่อเสียงโดดเด่นในเรื่อง Iterator เมื่อต้องพัฒนา iterator ขึ้นมาสักตัว โปรแกรมเมอร์จะสร้างชนิดข้อมูลแยกต่างหากที่มี lifetime ผูกติดกับเจ้าของข้อมูล และนำ Iterator trait ไปอิมพลีเมนต์

นี่คือรูปแบบการวนซ้ำในภาษา Rust สำหรับ DBM:

struct Dbm { ... }

impl Dbm {
    /* ... */
    pub fn keys<'it>(&'it self) -> DbmKeysIter<'it> { ... }
    /* ... */
}

struct DbmKeysIter<'it> {
    owner: &'it Dbm,
}

impl<'it> Iterator for DbmKeysIter<'it> { ... }

โค้ดชุดนี้มีความสะอาด เป็นสำนวนที่ถูกต้อง และปลอดภัยอย่างยิ่งด้วยการรับประกันของ Rust ทว่าลองพิจารณาดูว่าหากเราแปล API นี้ไปเป็น FFI ในภาษา C แบบตรงตัว มันจะมีหน้าตาอย่างไร:

#[no_mangle]
pub extern "C" fn dbm_iter_new(owner: *const Dbm) -> *mut DbmKeysIter {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
    iter: *mut DbmKeysIter,
    key_out: *const datum
) -> libc::c_int {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
    // THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}

API ชุดนี้ได้ทำลายข้อมูลสำคัญชิ้นหนึ่งไป นั่นคือ: ช่วงชีวิต (lifetime) ของ iterator จะต้องไม่คงอยู่ยาวนานกว่าช่วงชีวิตของออบเจกต์ Dbm ที่เป็นเจ้าของมัน ผู้ใช้งานไลบรารีในภาษา C อาจเขียนโค้ดในลักษณะที่ทำให้อิเทอเรเตอร์มีอายุยืนยาวกว่าข้อมูลที่มันกำลังวนซ้ำอยู่ ส่งผลให้เกิดการอ่านหน่วยความจำที่ไม่ได้จัดสรรหรือถูกคืนไปแล้ว

ตัวอย่างโค้ดในภาษา C ด้านล่างนี้มีบั๊กซ่อนอยู่:

int count_key_sizes(DBM *db) {
    // DO NOT USE THIS FUNCTION. IT HAS A SUBTLE BUT SERIOUS BUG!
    datum key;
    int len = 0;

    if (!dbm_iter_new(db)) {
        dbm_close(db);
        return -1;
    }

    int l;
    while ((l = dbm_iter_next(owner, &key)) >= 0) { // an error is indicated by -1
        free(key.dptr);
        len += key.dsize;
        if (l == 0) { // end of the iterator
            dbm_close(owner);
        }
    }
    if l >= 0 {
        return -1;
    } else {
        return len;
    }
}

นี่คือบั๊กสุดคลาสสิก สิ่งที่เกิดขึ้นเมื่อ iterator ส่งสัญญาณแจ้งสิ้นสุดการวนซ้ำคือ:

  1. เงื่อนไขของลูปกำหนดให้ l มีค่าเป็น 0 และโปรแกรมเข้าทำงานในลูปเนื่องจาก 0 >= 0
  2. มีการบวกค่าความยาวเพิ่มขึ้น (ในกรณีนี้คือบวกด้วย 0)
  3. เงื่อนไข if เป็นจริง ส่งผลให้ฐานข้อมูลถูกปิดลง ทั้งที่ความจริงควรมีคำสั่ง break ตรงจุดนี้
  4. เงื่อนไขของลูปวนกลับมาตรวจสอบอีกครั้ง ทำให้เกิดการเรียกใช้ next บนออบเจกต์ฐานข้อมูลที่ถูกปิดไปแล้ว!

ความร้ายกาจที่สุดของบั๊กประเภทนี้คือ หากโค้ดฝั่ง Rust เขียนขึ้นมาอย่างระมัดระวัง โค้ดชุดนี้จะทำงานผ่านได้ในเกือบทุกครั้ง! ตราบใดที่หน่วยความจำของ Dbm ยังไม่ถูกจัดสรรใหม่ทันที การตรวจสอบภายในจะตรวจพบข้อผิดพลาดและส่งค่า -1 กลับมา แต่ในบางจังหวะ มันอาจนำไปสู่ segmentation fault หรือร้ายแรงที่สุดคือการทำลายข้อมูลในหน่วยความจำโดยไม่รู้ตัว!

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

โปรแกรมเมอร์จึงจำเป็นต้องทำความเข้าใจเอกสาร API อย่างถ่องแท้ และการออกแบบ API ที่ชาญฉลาดจะช่วยตัดความเสี่ยงเหล่านี้ตั้งแต่ต้นลม API มาตรฐาน POSIX ของ DBM แก้ปัญหานี้ด้วยการ รวมความเป็นเจ้าของ (consolidating ownership) ของ iterator เข้าไว้กับตัวออบเจกต์หลักโดยตรง:

datum   dbm_firstkey(DBM *);
datum   dbm_nextkey(DBM *);

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

ข้อเสีย

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

ประการแรก ตัว API จะมีความยืดหยุ่นในการแสดงออก (expressiveness) ลดลง สำหรับ POSIX DBM จะมี iterator ได้เพียงตัวเดียวต่อหนึ่งออบเจกต์ และทุกการเรียกจะส่งผลเปลี่ยนแปลงสถานะภายในของออบเจกต์นั้น ซึ่งจำกัดกว่า iterator ทั่วไปในภาษาโปรแกรมส่วนใหญ่ สำหรับระบบที่มีความสัมพันธ์ของออบเจกต์ซับซ้อนและไม่ได้เรียงลำดับชั้นอย่างชัดเจน ข้อจำกัดนี้อาจสร้างภาระมากกว่าความปลอดภัยที่ได้รับ

ประการที่สอง การออกแบบระบบเช่นนี้อาจต้องใช้เวลาและความพยายามในการวางโครงสร้าง API สูงขึ้น ทั้งนี้ จุดออกแบบที่สำคัญสามารถศึกษาเพิ่มเติมได้จากแพตเทิร์นที่เกี่ยวข้อง:

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

การรวมชนิดข้อมูลเข้าสู่ตัวห่อหุ้ม (Type Consolidation into Wrappers)

คำอธิบาย

แพตเทิร์นนี้ได้รับการออกแบบมาเพื่อให้สามารถจัดการกับชนิดข้อมูลที่เกี่ยวข้องกันหลายชนิดได้อย่างราบรื่นและงดงาม ขณะเดียวกันก็ช่วยลดพื้นที่เสี่ยงของความไม่ปลอดภัยด้านหน่วยความจำ (memory unsafety surface area) ให้เหลือน้อยที่สุด

หนึ่งในเสาหลักสำคัญของกฎการตั้งชื่ออ้างอิงซ้ำ (aliasing rules) ในภาษา Rust คือระบบช่วงชีวิต (lifetimes) ซึ่งช่วยรับประกันว่ารูปแบบการเข้าถึงข้อมูลระหว่างชนิดข้อมูลต่างๆ จะมีความปลอดภัยต่อหน่วยความจำอย่างแท้จริง รวมถึงความปลอดภัยจากการแย่งใช้ข้อมูลพร้อมกัน (data race safety)

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

ดังนั้น เราจึงจำเป็นต้องวางใจในโค้ดของผู้ใช้ระดับหนึ่ง โดยเฉพาะประเด็นเรื่อง use-after-free (การเข้าถึงหน่วยความจำที่ถูกคืนไปแล้ว) ซึ่งคอมไพเลอร์ของ Rust ไม่สามารถเข้าไปช่วยตรวจสอบให้ได้ อย่างไรก็ดี การออกแบบ API ในบางลักษณะจะสร้างภาระในการระมัดระวังให้กับโค้ดฝั่งภาษาภายนอกสูงกว่าแบบอื่นๆ

รูปแบบ API ที่มีความเสี่ยงต่ำที่สุดคือ “ตัวห่อหุ้มแบบรวมศูนย์ (Consolidated Wrapper)” ซึ่งการดำเนินการและการมีปฏิสัมพันธ์ทั้งหมดที่อาจเกิดขึ้นกับออบเจกต์ จะถูกรวบเข้าไว้ใน “ชนิดข้อมูลตัวห่อหุ้ม” เพียงตัวเดียว โดยยังคงรักษาความสะอาดเรียบร้อยของ API ฝั่ง Rust เอาไว้ได้

ตัวอย่างโค้ด

เพื่อให้เข้าใจได้ชัดเจนยิ่งขึ้น ลองพิจารณาตัวอย่างคลาสสิกของ API ที่ต้องส่งออกไปภายนอก นั่นคือ: การวนซ้ำเพื่ออ่านข้อมูลในคอลเลกชัน

รูปแบบของ API มีลักษณะดังนี้:

  1. เริ่มต้น iterator ด้วยคำสั่ง first_key
  2. ทุกครั้งที่เรียก next_key ตัว iterator จะขยับเลื่อนไปยังตำแหน่งถัดไป
  3. หากเรียก next_key ในขณะที่ iterator อยู่ที่จุดสิ้นสุดแล้ว จะไม่มีอะไรเกิดขึ้น
  4. ตามที่กล่าวไว้ข้างต้น ตัว iterator จะถูก “ห่อหุ้มรวมเข้าไปใน” คอลเลกชัน (ซึ่งต่างจาก API ปกติของภาษา Rust)

หากตัว iterator มีการอิมพลีเมนต์เมท็อด nth() อย่างมีประสิทธิภาพ เราก็สามารถทำให้ iterator คงอยู่เพียงชั่วคราวตลอดช่วงเวลาของการเรียกใช้ฟังก์ชันแต่ละครั้งได้:

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
}

impl MySetWrapper {
    pub fn first_key(&mut self) -> Option<&Key> {
        self.iter_next = 0;
        self.next_key()
    }
    pub fn next_key(&mut self) -> Option<&Key> {
        if let Some(next) = self.myset.keys().nth(self.iter_next) {
            self.iter_next += 1;
            Some(next)
        } else {
            None
        }
    }
}

ผลลัพธ์ที่ได้คือ ตัวห่อหุ้มมีความเรียบง่าย ชัดเจน และปราศจากโค้ด unsafe โดยสิ้นเชิง

ข้อดี

แนวทางนี้ช่วยให้ API มีความปลอดภัยต่อการเรียกใช้งานสูงมาก และหลีกเลี่ยงปัญหาเรื่องความขัดแย้งของช่วงชีวิต (lifetimes) ข้ามชนิดข้อมูล สามารถดูรายละเอียดเพิ่มเติมเกี่ยวกับข้อดีและหลุมพรางที่แนวทางนี้ช่วยป้องกันได้ใน API แบบอิงออบเจกต์

ข้อเสีย

ในทางปฏิบัติ การห่อหุ้มชนิดข้อมูลมักเป็นเรื่องที่ซับซ้อนและท้าทาย และในบางครั้ง การยอมประนีประนอมในฝั่ง API ของ Rust อาจช่วยให้ทุกอย่างง่ายขึ้น

ตัวอย่างเช่น หาก iterator ไม่ได้อิมพลีเมนต์ nth() อย่างมีประสิทธิภาพ การเพิ่มตรรกะเฉพาะเพื่อให้ออบเจกต์จัดการการวนซ้ำภายในตนเอง หรือการปรับโครงสร้างเพื่อรองรับรูปแบบการเข้าถึงข้อมูลเฉพาะสำหรับ Foreign Function API ย่อมเป็นเรื่องที่คุ้มค่ากว่า

ความพยายามในการห่อหุ้ม Iterator (และล้มเหลว)

หากต้องการห่อหุ้ม iterator ทั่วไปเข้าไปใน API ให้ถูกต้องสมบูรณ์ ตัวห่อหุ้มจะต้องทำสิ่งที่โค้ดภาษา C ทำ นั่นคือ: ลบล้างข้อมูลช่วงชีวิต (lifetime) ของ iterator ออก แล้วจัดการควบคุมหน่วยความจำด้วยตนเอง

หากจะพูดอย่างตรงไปตรงมา การทำเช่นนี้ถือว่า ยากอย่างยิ่งยวด

นี่คือตัวอย่างภาพสะท้อนของหลุมพรางอันตรายเพียง หนึ่ง ประการ:

เวอร์ชันแรกของ MySetWrapper อาจมีหน้าตาดังนี้:

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
    // created from a transmuted Box<KeysIter + 'self>
    iterator: Option<NonNull<KeysIter<'static>>>,
}

การนำ transmute มาใช้เพื่อยืด lifetime และการใช้ raw pointer เพื่อซ่อนข้อมูลนั้น เป็นโค้ดที่ไม่น่าดูอยู่แล้ว แต่สิ่งที่เลวร้ายยิ่งกว่าคือ: การกระทำอื่นๆ ใดๆ ก็ตามบนโครงสร้างนี้สามารถก่อให้เกิด Undefined Behavior (UB) ใน Rust ได้ทันที

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

การเขียนฟังก์ชัน myset_store แบบตรงไปตรงมาอาจมีลักษณะดังนี้:

pub mod unsafe_module {

    // other module content

    pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
        // DO NOT USE THIS CODE. IT IS UNSAFE TO DEMONSTRATE A PROBLEM.

        let myset: &mut MySet = unsafe {
            // SAFETY: whoops, UB occurs in here!
            &mut (*myset).myset
        };

        /* ...check and cast key and value data... */

        match myset.store(casted_key, casted_value) {
            Ok(_) => 0,
            Err(e) => e.into(),
        }
    }
}

หากตัว iterator ยังคงมีตัวตนอยู่ในขณะที่ฟังก์ชันนี้ถูกเรียกใช้งาน เท่ากับว่าเราได้ละเมิดกฎ aliasing rules ของ Rust ทันที เพราะตามกฎของ Rust การอ้างอิงแบบเปลี่ยนแปลงค่าได้ (&mut) จะต้องมีสิทธิ์เข้าถึงออบเจกต์แต่เพียง เอกสิทธิ์ผู้เดียว (exclusive access) เท่านั้น ทว่าหาก iterator ยังคงอยู่ การเข้าถึงย่อมไม่ใช่เอกสิทธิ์อีกต่อไป และนำไปสู่ undefined behaviour ทันที! 1

เพื่อหลีกเลี่ยงปัญหานี้ เราจำเป็นต้องมีกลไกในการรับประกันว่าการอ้างอิงแบบ &mut นั้นมีเอกสิทธิ์อย่างแท้จริง ซึ่งหมายถึงการต้องลบล้าง shared reference ของ iterator ทิ้งไปก่อน แล้วค่อยสร้างขึ้นมาใหม่หลังจากเสร็จสิ้นการทำงาน ซึ่งในกรณีส่วนใหญ่ วิธีการนี้จะมีประสิทธิภาพด้อยกว่าโค้ดภาษา C อย่างเห็นได้ชัด

หลายคนอาจสงสัยว่า แล้วทำไมภาษา C ถึงทำเรื่องนี้ได้อย่างมีประสิทธิภาพมากกว่า? คำตอบแบบตรงไปตรงมาก็คือ ภาษา C ใช้วิธี “โกง” — กฎ aliasing ของ Rust คืออุปสรรคในเรื่องนี้ และภาษา C เพียงแค่เพิกเฉยต่อกฎเหล่านั้นสำหรับพอยน์เตอร์ของตน สิ่งที่ต้องแลกมาคือ เรามักจะเห็นโค้ดที่ระบุไว้ในคู่มือว่า “ไม่ปลอดภัยต่อการทำงานแบบหลายเธรด (not thread safe)” ภายใต้สถานการณ์บางอย่างหรือทั้งหมด ในความเป็นจริง ไลบรารี GNU C ถึงกับมีชุดคำศัพท์เฉพาะที่อุทิศให้กับพฤติกรรมการทำงานแบบพร้อมกัน (concurrent behavior) โดยเฉพาะ!

สำหรับภาษา Rust ยึดมั่นในการสร้างความปลอดภัยด้านหน่วยความจำตลอดเวลา ทั้งเพื่อความถูกต้องปลอดภัยและเพื่อเปิดทางให้คอมไพเลอร์สามารถทำการปรับแต่งโค้ด (optimization) ขั้นสูงที่ภาษา C ไม่สามารถทำได้ การถูกปิดกั้นไม่ให้ใช้ทางลัดอันตรายบางอย่างจึงเป็นราคาที่โปรแกรมเมอร์ Rust จำเป็นต้องจ่าย


  1. สำหรับโปรแกรมเมอร์สาย C ที่อาจกำลังสงสัย: ตัว iterator ไม่จำเป็นต้องถูกอ่านค่า ในระหว่าง ที่โค้ดส่วนนี้ทำงานเพื่อก่อให้เกิด UB เพราะกฎการเข้าถึงแบบเอกสิทธิ์จะเปิดทางให้คอมไพเลอร์ทำ optimization (เช่น การนำค่าไปพักไว้บนสแตก หรือการสลับลำดับคำสั่งเพื่อเพิ่มความเร็ว) ซึ่งการสังเกตข้อมูลผ่าน shared reference ของ iterator ที่ไม่สอดคล้องกันอาจเกิดขึ้นได้ ณ เวลาใดๆ ก็ตามหลังจาก การอ้างอิงแบบ &mut ถูกสร้างขึ้น ↩

แอนติแพตเทิร์น

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

โคลนเพื่อเอาใจ borrow checker

คำอธิบาย

ระบบ borrow checker ช่วยป้องกันไม่ให้นักพัฒนาเขียนโค้ดที่ไม่ปลอดภัยใน Rust โดยมีกฎเหล็กที่เข้มงวดว่าจะต้องตรงตามเงื่อนไขข้อใดข้อหนึ่ง: นั่นคือมี mutable reference ได้เพียงตัวเดียวเท่านั้น หรือมี immutable reference ได้หลายตัวพร้อมกัน หากโค้ดที่เขียนขึ้นละเมิดเงื่อนไขเหล่านี้ แอนติแพตเทิร์นนี้มักจะเกิดขึ้นเมื่อนักพัฒนาเลือกแก้ compile error ให้ผ่านไปทีด้วยการสั่ง clone ตัวแปรนั้นแทน

ตัวอย่าง

#![allow(unused)]
fn main() {
// define any variable
let mut x = 5;

// Borrow `x` -- but clone it first
let y = &mut (x.clone());

// without the x.clone() two lines prior, this line would fail on compile as
// x has been borrowed
// thanks to x.clone(), x was never borrowed, and this line will run.
println!("{x}");

// perform some action on the borrow to prevent rust from optimizing this
//out of existence
*y += 1;
}

ที่มาและเหตุผล

การเลือกใช้แพตเทิร์นนี้เพื่อแก้ปัญหาที่ชวนสับสนกับ borrow checker เป็นสิ่งที่ยั่วใจอย่างยิ่ง โดยเฉพาะอย่างยิ่งสำหรับผู้ที่เพิ่งเริ่มต้นเขียน Rust อย่างไรก็ดี วิธีนี้ส่งผลกระทบตามมาอย่างมีนัยสำคัญ เพราะการเรียก .clone() จะสร้างสำเนาข้อมูลชุดใหม่ขึ้นมาในหน่วยความจำ และการเปลี่ยนแปลงข้อมูลระหว่างตัวแปรทั้งสองตัวจะไม่ซิงค์ถึงกันเลย เสมือนว่าเป็นตัวแปรสองตัวที่แยกขาดจากกันโดยสิ้นเชิง

ทว่าก็มีกรณีพิเศษอยู่บ้าง เช่น Rc<T> ที่ถูกออกแบบมาให้รับมือกับการ .clone() อย่างชาญฉลาด โดยภายในตัวมันจะเก็บสำเนาข้อมูลไว้เพียงชุดเดียว การเรียก .clone() บน Rc จะได้อินสแตนซ์ของ Rc ตัวใหม่ที่ชี้ไปยังข้อมูลชุดเดิมของ Rc ต้นทาง พร้อมกับเพิ่มค่านับการอ้างอิง (reference count) ขึ้นหนึ่ง ซึ่งหลักการเดียวกันนี้ก็ถูกนำไปใช้ใน Arc ที่เป็นคู่แฝดสำหรับการทำงานข้ามเธรดได้อย่างปลอดภัย (thread-safe) เช่นกัน

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

แม้ว่าการใช้ .clone() พร่ำเพรื่อจะเป็นสัญญาณของแพตเทิร์นที่ไม่ดี แต่ในบางสถานการณ์ การเขียนโค้ดที่ประสิทธิภาพยังไม่เหมาะสมที่สุดก็เป็นสิ่งที่ยอมรับได้ เช่นในกรณีต่อไปนี้:

  • นักพัฒนายังเป็นมือใหม่และกำลังทำความเข้าใจระบบความเป็นเจ้าของ (ownership)
  • โค้ดไม่ได้มีข้อจำกัดเรื่องความเร็วหรือปริมาณการใช้หน่วยความจำที่เข้มงวด (เช่น โค้ดต้นแบบ หรือโปรเจกต์งาน Hackathon)
  • การจัดระเบียบโค้ดเพื่อให้ผ่านกฎของ borrow checker มีความซับซ้อนสูงมาก และคุณต้องการเน้นความเรียบง่ายในการอ่านโค้ดมากกว่าการเค้นประสิทธิภาพสูงสุด

หากสงสัยว่ามีการเรียก clone โดยไม่จำเป็น ควรศึกษาและทำความเข้าใจเนื้อหาใน บทว่าด้วย Ownership ในหนังสือ The Rust Book ให้กระจ่างก่อนตัดสินใจว่าการ clone นั้นจำเป็นจริงหรือไม่

นอกจากนี้ อย่าลืมรันคำสั่ง cargo clippy ในโปรเจกต์ของคุณอยู่เสมอ เพราะ clippy จะช่วยตรวจจับกรณีต่างๆ ที่ไม่จำเป็นต้องเรียกใช้ .clone() ให้โดยอัตโนมัติ

ดูเพิ่มเติม

#![deny(warnings)]

คำอธิบาย

ผู้พัฒนา crate ด้วยความหวังดีต้องการให้มั่นใจว่าโค้ดของตนจะคอมไพล์ผ่านโดยไม่มีคำเตือน (warning) ใดๆ จึงได้ใส่ attribute ไว้ที่ crate root ดังนี้:

ตัวอย่าง

#![allow(unused)]
#![deny(warnings)]

fn main() {
// All is well.
}

ข้อดี

สั้น กระชับ และจะหยุดการบิลด์ทันทีหากมีจุดใดผิดปกติหรือไม่เป็นไปตามมาตรฐาน

ข้อเสีย

การไม่อนุญาตให้คอมไพเลอร์บิลด์ผ่านเมื่อมีคำเตือน เท่ากับว่าผู้พัฒนา crate กำลังปฏิเสธหลักประกันความเสถียร (stability guarantee) อันเลื่องชื่อของภาษา Rust ไปโดยปริยาย เนื่องจากในบางครั้ง เมื่อมีการเพิ่มฟีเจอร์ใหม่หรือต้องการแก้ไขข้อบกพร่องในฟีเจอร์เก่าที่ออกแบบมาไม่รัดกุม ทีมพัฒนา Rust จึงจำเป็นต้องเปลี่ยนแนวทางการทำงาน โดยสร้าง lint ที่แสดงข้อความเตือน (warn) ในช่วงระยะเวลาผ่อนผันก่อน เพื่อให้เวลาผู้พัฒนาปรับตัว ก่อนที่จะยกระดับให้กลายเป็นข้อผิดพลาดที่ไม่อนุญาตให้คอมไพล์ผ่าน (deny) ในภายหลัง

ตัวอย่างเช่น เคยมีการค้นพบว่าชนิดข้อมูลหนึ่งสามารถมีบล็อก impl สองบล็อกที่มีชื่อเมท็อดซ้ำกันได้ ซึ่งถูกมองว่าเป็นการออกแบบที่ไม่ดี แต่เพื่อให้การเปลี่ยนผ่านเป็นไปอย่างราบรื่น ทีมงานจึงได้เพิ่ม lint overlapping-inherent-impls เข้ามาเพื่อแสดงคำเตือนสำหรับผู้ที่เผลอเขียนโค้ดลักษณะนี้ ก่อนที่มันจะกลายเป็น error ร้ายแรงที่คอมไพล์ไม่ผ่าน (hard compile error) ในรีลีสถัดๆ ไป

นอกจากนี้ ในบางครั้ง API เก่าอาจถูกประกาศเลิกใช้งาน (deprecated) ซึ่งจะส่งผลให้การเรียกใช้ API นั้นเกิดคำเตือนขึ้นมา ทั้งที่ก่อนหน้านี้ไม่เคยมีคำเตือนมาก่อน

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

ยิ่งไปกว่านั้น crate ที่มี lint เพิ่มเติมสำหรับตรวจสอบโค้ด (เช่น rust-clippy) ก็จะไม่สามารถนำมาใช้งานได้อีกต่อไป เว้นแต่ว่าจะลบ attribute ดังกล่าวออกก่อน อย่างไรก็ตาม ปัญหานี้สามารถบรรเทาได้ด้วยแฟล็ก –cap-lints โดยการส่งอาร์กิวเมนต์ --cap-lints=warn ในคำสั่งคอมไพล์ จะช่วยแปลง error ที่เกิดจาก lint ระดับ deny ทั้งหมดให้กลายเป็นเพียงคำเตือนธรรมดาได้

ทางเลือกอื่น

เราสามารถรับมือกับปัญหานี้ได้ 2 แนวทาง: แนวทางแรกคือแยกการตั้งค่าการบิลด์ออกจากตัวโค้ด และแนวทางที่สองคือระบุชื่อ lint ที่ต้องการสั่ง deny ไว้อย่างชัดเจนเป็นรายตัว

คำสั่งต่อไปนี้จะสั่งบิลด์โดยกำหนดให้คำเตือนทั้งหมดกลายเป็น deny ผ่านสภาพแวดล้อมภายนอก:

RUSTFLAGS="-D warnings" cargo build

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

หรืออีกทางเลือกหนึ่ง เราสามารถระบุ lint ที่ต้องการ deny ไว้ในโค้ดอย่างเจาะจง นี่คือรายการของ warning lint ที่ (น่าจะ) ปลอดภัยต่อการกำหนดเป็น deny (ข้อมูล ณ rustc 1.48.0):

#![deny(
    bad_style,
    const_err,
    dead_code,
    improper_ctypes,
    non_shorthand_field_patterns,
    no_mangle_generic_items,
    overflowing_literals,
    path_statements,
    patterns_in_fns_without_body,
    private_in_public,
    unconditional_recursion,
    unused,
    unused_allocation,
    unused_comparisons,
    unused_parens,
    while_true
)]

นอกจากนี้ lint ที่เดิมมีสถานะเป็น allow ต่อไปนี้ อาจเป็นความคิดที่ดีหากจะนำมาตั้งเป็น deny:

#![deny(
    missing_debug_implementations,
    missing_docs,
    trivial_casts,
    trivial_numeric_casts,
    unused_extern_crates,
    unused_import_braces,
    unused_qualifications,
    unused_results
)]

บางคนอาจต้องการเพิ่ม missing-copy-implementations เข้าไปในรายการนี้ด้วยเช่นกัน

สังเกตว่าเราจงใจไม่ใส่ lint deprecated ไว้ในรายการนี้ เนื่องจากค่อนข้างแน่นอนว่าในอนาคตจะมี API อื่นๆ ถูกประกาศเลิกใช้งานเพิ่มเติมอีกเรื่อยๆ

ดูเพิ่มเติม

  • รวม lint ทั้งหมดของ clippy
  • เอกสารประกอบของ deprecate attribute
  • พิมพ์คำสั่ง rustc -W help เพื่อดูรายการ lint ทั้งหมดในเครื่องของคุณ และพิมพ์ rustc --help เพื่อดูตัวเลือกคำสั่งทั่วไป
  • rust-clippy ชุดเครื่องมือ lint เพื่อการเขียนโค้ด Rust ที่มีคุณภาพยิ่งขึ้น

พอลิมอร์ฟิซึมผ่าน Deref

คำอธิบาย

การนำ trait Deref ไปใช้อย่างผิดวัตถุประสงค์เพื่อเลียนแบบการสืบทอดคุณสมบัติ (inheritance) ระหว่าง struct ด้วยเป้าหมายที่จะนำเมท็อดกลับมาใช้ซ้ำ

ตัวอย่าง

ในบางครั้ง เราอาจต้องการเลียนแบบแพตเทิร์นยอดนิยมจากภาษาเชิงวัตถุ (Object-Oriented) อย่าง Java ดังนี้:

class Foo {
    void m() { ... }
}

class Bar extends Foo {}

public static void main(String[] args) {
    Bar b = new Bar();
    b.m();
}

เราสามารถใช้แอนติแพตเทิร์นการทำพอลิมอร์ฟิซึมผ่าน Deref เพื่อจำลองพฤติกรรมดังกล่าวได้ดังนี้:

use std::ops::Deref;

struct Foo {}

impl Foo {
    fn m(&self) {
        //..
    }
}

struct Bar {
    f: Foo,
}

impl Deref for Bar {
    type Target = Foo;
    fn deref(&self) -> &Foo {
        &self.f
    }
}

fn main() {
    let b = Bar { f: Foo {} };
    b.m();
}

ในภาษา Rust นั้นไม่มีระบบการสืบทอด (inheritance) สำหรับ struct เราจึงใช้วิธีประกอบออบเจกต์ (composition) โดยนำอินสแตนซ์ของ Foo มาบรรจุไว้ใน Bar แทน (เนื่องจากฟิลด์ดังกล่าวเก็บค่าโดยตรง จึงถูกจัดเก็บบนหน่วยความจำแบบอินไลน์ ดังนั้นหากมีฟิลด์ข้อมูล เลย์เอาต์ในหน่วยความจำก็จะเหมือนกับเวอร์ชันของ Java (โดยประมาณ ซึ่งหากต้องการความแน่นอน 100% ควรระบุ #[repr(C)] กำกับไว้))

และเพื่อให้การเรียกเมท็อดทำงานได้ เราจึงอิมพลีเมนต์ Deref ให้กับ Bar โดยกำหนดให้ Foo เป็น target (พร้อมคืนค่าฟิลด์ Foo ที่ฝังอยู่ภายใน) นั่นหมายความว่าเมื่อใดก็ตามที่เราทำ dereference กับตัวแปร Bar (เช่น การใช้โอเปอเรเตอร์ *) เราจะได้ค่า Foo กลับมา ซึ่งถือว่าแปลกประหลาดมาก เพราะโดยปกติแล้วการ dereference จะเป็นการดึงค่า T ออกมาจากเรฟเฟอเรนซ์ของ T แต่ในกรณีนี้เรากลับได้ชนิดข้อมูลสองชนิดที่ไม่เกี่ยวข้องกันเลย อย่างไรก็ดี เนื่องจากโอเปอเรเตอร์จุด (dot operator .) มีกลไกการทำ dereference โดยอัตโนมัติ (implicit dereferencing) จึงส่งผลให้การเรียกใช้เมท็อดผ่านจุดจะวิ่งไปค้นหาเมท็อดของ Foo นอกเหนือจากเมท็อดของ Bar ด้วย

ข้อดี

ช่วยลดโค้ด boilerplate ลงได้เล็กน้อย ตัวอย่างเช่น ไม่ต้องเขียนส่งต่อเมท็อดเองแบบนี้:

impl Bar {
    fn m(&self) {
        self.f.m()
    }
}

ข้อเสีย

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

นอกจากนี้ แพตเทิร์นนี้ไม่ได้สร้างความสัมพันธ์แบบชนิดข้อมูลย่อย (subtyping) ระหว่าง Foo กับ Bar เหมือนกับการสืบทอดใน Java หรือ C++ ยิ่งไปกว่านั้น trait ต่างๆ ที่ Foo อิมพลีเมนต์ไว้ ก็จะไม่ได้รับการอิมพลีเมนต์ให้กับ Bar โดยอัตโนมัติ ทำให้แพตเทิร์นนี้ทำงานร่วมกับการตรวจสอบขอบเขตชนิดข้อมูล (trait bounds) และการเขียนโปรแกรมเชิงเจเนอริก (generic programming) ได้แย่มาก

การใช้แพตเทิร์นนี้ยังให้ความหมายเชิงพฤติกรรมต่อ self ที่ผิดแผกไปจากภาษาเชิงวัตถุส่วนใหญ่อยู่เล็กน้อย เพราะตามปกติแล้ว self จะยังคงเป็นเรฟเฟอเรนซ์ที่ชี้ไปยังคลาสย่อย (sub-class) เสมอ แต่ในแพตเทิร์นนี้ self กลับกลายเป็น ‘คลาส’ ต้นทางที่เมท็อดนั้นถูกนิยามไว้แทน

ประการสุดท้าย แพตเทิร์นนี้รองรับเพียงการสืบทอดชั้นเดียว (single inheritance) เท่านั้น และไม่มีแนวคิดเรื่อง interface, การควบคุมระดับการเข้าถึงตามคลาส (class-based access control / privacy) หรือคุณสมบัติอื่นๆ ของการสืบทอดเลย จึงสร้างความสับสนและประหลาดใจให้กับโปรแกรมเมอร์ที่คุ้นเคยกับระบบ inheritance ใน Java เป็นอย่างมาก

เจาะลึกรายละเอียด

ในปัจจุบันยังไม่มีทางเลือกอื่นที่ดีเพียงทางเดียวอย่างสมบูรณ์ ทั้งนี้ขึ้นอยู่กับบริบทของงาน บางกรณีอาจเหมาะสมกว่าที่จะออกแบบใหม่โดยใช้ trait เข้ามาช่วย หรือเขียนเมท็อดครอบ (facade method) เพื่อส่งต่อการทำงานไปยัง Foo ด้วยตนเอง แม้ว่าทีมพัฒนา Rust จะมีความตั้งใจที่จะเพิ่มกลไกช่วยเรื่อง inheritance ในลักษณะนี้ แต่ก็คงต้องใช้เวลาอีกพอสมควรกว่าที่ฟีเจอร์ดังกล่าวจะเข้าสู่เวอร์ชัน stable สามารถอ่านรายละเอียดเพิ่มเติมได้จากบล็อก บทความเหล่านี้ และ RFC issue นี้

trait Deref ถูกออกแบบมาเพื่อการอิมพลีเมนต์สมาร์ตพอยน์เตอร์ที่กำหนดขึ้นเอง โดยมีเจตนารมณ์เพื่อแปลงจาก pointer-to-T ไปสู่ค่า T ไม่ใช่การแปลงข้ามชนิดข้อมูลที่ต่างกันอย่างสิ้นเชิง แต่น่าเสียดายที่ตัวนิยามของ trait ไม่สามารถบังคับกฎนี้ได้ (และอาจเป็นไปไม่ได้เลยที่จะบังคับในระดับภาษา)

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

ดูเพิ่มเติม

การใช้งาน Rust เชิงฟังก์ชัน

Rust เป็นภาษาที่มีรูปแบบการเขียนโค้ดแบบอิมเพอเรทีฟ (Imperative) แต่ในขณะเดียวกันก็รับเอาแนวคิดและกระบวนทัศน์ของการเขียนโปรแกรมเชิงฟังก์ชัน (Functional Programming) เข้ามาผสมผสานไว้อย่างเข้มข้น

ในทางวิทยาการคอมพิวเตอร์ การเขียนโปรแกรมเชิงฟังก์ชัน (Functional Programming) คือกระบวนทัศน์การเขียนโปรแกรมที่สร้างโปรแกรมขึ้นจากการประยุกต์และประกอบฟังก์ชันเข้าด้วยกัน เป็นรูปแบบการเขียนโปรแกรมเชิงประกาศ (Declarative) ซึ่งโครงสร้างของฟังก์ชันจะประกอบขึ้นจากสายธารของนิพจน์ (Expressions) ที่ประเมินและคืนค่าออกมา แทนที่จะเป็นลำดับคำสั่ง (Statements) แบบอิมเพอเรทีฟที่คอยเปลี่ยนแปลงสถานะ (State) ของโปรแกรมไปเรื่อยๆ

กระบวนทัศน์การเขียนโปรแกรม

หนึ่งในอุปสรรคที่ใหญ่ที่สุดในการทำความเข้าใจการเขียนโปรแกรมเชิงฟังก์ชัน สำหรับผู้ที่มีพื้นฐานมาจากการเขียนโปรแกรมเชิงอิมเพอเรทีฟ (Imperative) คือการเปลี่ยนกระบวนการคิด เพราะโปรแกรมเชิงอิมเพอเรทีฟจะบอกว่าต้องทำสิ่งนั้นอย่างไร (How) ทีละขั้นตอน ขณะที่โปรแกรมเชิงประกาศ (Declarative) จะบอกว่าต้องการผลลัพธ์อะไร (What) ลองมาดูตัวอย่างการหาผลรวมของตัวเลขตั้งแต่ 1 ถึง 10 เพื่อเปรียบเทียบความแตกต่างนี้กัน

เชิงอิมเพอเรทีฟ

#![allow(unused)]
fn main() {
let mut sum = 0;
for i in 1..11 {
    sum += i;
}
println!("{sum}");
}

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

isum
11
23
36
410
515
621
728
836
945
1055

นี่คือจุดเริ่มต้นของการเขียนโปรแกรมที่คนส่วนใหญ่คุ้นเคย ซึ่งเราถูกสอนมาว่าโปรแกรมคอมพิวเตอร์คือลำดับของขั้นตอนคำสั่ง (Step-by-step instructions)

แบบประกาศ (Declarative)

#![allow(unused)]
fn main() {
println!("{}", (1..11).fold(0, |a, b| a + b));
}

ว้าว! วิธีนี้แตกต่างไปอย่างสิ้นเชิง! เกิดอะไรขึ้นในโค้ดบรรทัดเดียวนี้? ขอให้จำไว้ว่าในแนวคิดแบบ Declarative เรากำลังระบุว่าเราต้องการผลลัพธ์อะไร (What) มากกว่าการสั่งว่าต้องทำอย่างไร (How) โดย fold คือฟังก์ชันที่ทำหน้าที่ประกอบฟังก์ชันเข้าด้วยกัน (Function Composition) ซึ่งชื่อนี้เป็นธรรมเนียมปฏิบัติที่สืบทอดมาจากภาษา Haskell

ในตัวอย่างนี้ เรากำลังประกอบฟังก์ชันการบวก (เขียนในรูปของ closure: |a, b| a + b) เข้ากับช่วงตัวเลขตั้งแต่ 1 ถึง 10 โดยกำหนดให้ 0 เป็นค่าเริ่มต้น ดังนั้นในรอบแรก a จึงมีค่าเป็น 0 ส่วน b คือสมาชิกตัวแรกของช่วง นั่นคือ 1 ผลลัพธ์จึงเป็น 0 + 1 = 1 จากนั้นกระบวนการ fold ก็จะทำงานต่อในรอบถัดไปโดยนำผลลัพธ์เดิมมาเป็นตัวตั้ง a = 1, b = 2 ได้ผลลัพธ์ใหม่เป็น 1 + 2 = 3 กระบวนการนี้จะดำเนินต่อไปเรื่อยๆ จนกระทั่งประมวลผลสมาชิกตัวสุดท้ายของช่วง นั่นคือ 10 ครบถ้วน

abresult
011
123
336
6410
10515
15621
21728
28836
36945
451055

เจเนอริกในฐานะ Type Classes

คำอธิบาย

ระบบชนิดข้อมูล (Type System) ของ Rust ได้รับการออกแบบให้มีความใกล้เคียงกับภาษาเชิงฟังก์ชัน (อย่างเช่น Haskell) มากกว่าภาษาเชิงอิมเพอเรทีฟ (อย่าง Java หรือ C++) ผลลัพธ์คือ Rust สามารถเปลี่ยนโจทย์ปัญหาการเขียนโปรแกรมหลายประเภทให้กลายเป็นปัญหาการตรวจสอบชนิดข้อมูลแบบคงที่ (Static Typing) ได้ตั้งแต่ตอนคอมไพล์ ซึ่งนี่คือหนึ่งในข้อได้เปรียบที่ทรงพลังที่สุดของการเลือกใช้แนวคิดเชิงฟังก์ชัน และเป็นหัวใจสำคัญของการรับประกันความถูกต้องในขั้นตอนคอมไพล์ (Compile-time Guarantees) ของ Rust

หัวใจสำคัญของแนวคิดนี้คือวิธีการทำงานของชนิดข้อมูลเจเนอริก (Generics) ในภาษาอย่าง C++ และ Java ชนิดข้อมูลเจเนอริกจะทำหน้าที่เสมือนโครงสร้าง Metaprogramming สำหรับคอมไพเลอร์ เช่น vector<int> และ vector<char> ใน C++ เป็นเพียงสำเนาโค้ด boilerplate สองชุดของคลาส vector เดียวกัน (หรือที่รู้จักกันในชื่อ template) โดยแค่แทนที่ชนิดข้อมูลที่แตกต่างกันลงไป

แต่ใน Rust พารามิเตอร์ของเจเนอริกจะสร้างสิ่งที่ในภาษาเชิงฟังก์ชันเรียกว่า “ข้อกำหนดขอบเขตของ Type Class (Type Class Constraint)” และพารามิเตอร์แต่ละตัวที่ผู้ใช้นำมากำหนด จะส่งผลให้เกิดชนิดข้อมูลใหม่ขึ้นมาจริงๆ กล่าวคือ Vec<isize> กับ Vec<char> ถือเป็นคนละชนิดข้อมูลกันโดยสิ้นเชิง ซึ่งทุกส่วนของระบบ Type System จะมองเห็นว่าเป็นสองชนิดที่แยกขาดจากกัน

กระบวนการนี้เรียกว่า Monomorphization ซึ่งเป็นการสร้างชนิดข้อมูลที่เป็นรูปธรรมขึ้นมาจากโค้ดแบบพอลิมอร์ฟิก (Polymorphic Code) พฤติกรรมพิเศษนี้กำหนดให้บล็อก impl ต้องระบุพารามิเตอร์เจเนอริกให้ชัดเจน ค่าของชนิดข้อมูลเจเนอริกที่ต่างกันจะทำให้ได้ชนิดข้อมูลที่ต่างกัน และชนิดข้อมูลที่ต่างกันก็สามารถมีบล็อก impl แยกเป็นของตัวเองได้

ในภาษาเชิงวัตถุ คลาสสามารถสืบทอดพฤติกรรมจากคลาสแม่ได้ แต่กลไกของ Rust นี้ไม่เพียงแต่อนุญาตให้เราเพิ่มพฤติกรรมเฉพาะให้กับสมาชิกบางตัวของ Type Class ได้เท่านั้น แต่ยังสามารถแนบพฤติกรรมพิเศษอื่นๆ เพิ่มเติมเข้าไปได้อีกด้วย

สิ่งที่ใกล้เคียงที่สุดคือ Runtime Polymorphism ใน JavaScript หรือ Python ที่คอนสตรัคเตอร์ใดๆ ก็สามารถเพิ่มสมาชิกหรือเมท็อดใหม่ๆ เข้าไปในออบเจกต์ได้อย่างอิสระตามใจชอบ อย่างไรก็ตาม สิ่งที่ทำให้ Rust เหนือกว่าภาษาเหล่านั้นคือ ทุกเมท็อดที่เพิ่มเข้ามาจะถูกตรวจสอบความถูกต้องของชนิดข้อมูล (Type Check) ได้อย่างแม่นยำตั้งแต่ตอนคอมไพล์ เพราะเจเนอริกทั้งหมดถูกนิยามไว้อย่างคงที่ (Static) ทำให้มีความสะดวกสบายในการใช้งานควบคู่ไปกับความปลอดภัยสูงสุด

ตัวอย่าง

สมมติว่าคุณกำลังออกแบบเซิร์ฟเวอร์จัดเก็บข้อมูลสำหรับเครื่องคอมพิวเตอร์ในห้องทดลอง เนื่องจากข้อกำหนดของระบบ คุณจึงจำเป็นต้องรองรับโปรโตคอลสองรูปแบบที่แตกต่างกัน: ได้แก่ BOOTP (สำหรับการบูตเครื่องผ่านเครือข่าย PXE) และ NFS (สำหรับพื้นที่จัดเก็บข้อมูลแบบ Remote Mount)

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

คำขอไฟล์จากเครื่องในห้องทดลองจะมีข้อมูลพื้นฐานที่เหมือนกัน ไม่ว่าจะส่งมาจากโปรโตคอลใดก็ตาม นั่นคือ: วิธีการยืนยันตัวตน (Authentication Method) และชื่อไฟล์ที่ต้องการดึง การเขียนโค้ดแบบตรงไปตรงมาอาจมีหน้าตาประมาณนี้:

enum AuthInfo {
    Nfs(crate::nfs::AuthInfo),
    Bootp(crate::bootp::AuthInfo),
}

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
}

การออกแบบลักษณะนี้อาจทำงานได้ดีในระดับหนึ่ง แต่สมมติว่าในเวลาต่อมา คุณจำเป็นต้องรองรับการเก็บ metadata ที่เจาะจงเฉพาะโปรโตคอลนั้นๆ ตัวอย่างเช่น ในกรณีของ NFS คุณต้องการทราบจุดเมานต์ (Mount Point) เพื่อบังคับใช้กฎความปลอดภัยเพิ่มเติม

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

นี่คือตัวอย่างโค้ดเมื่อต้องการดึงจุดเมานต์ของ NFS:

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
    mount_point: Option<PathBuf>,
}

impl FileDownloadRequest {
    // ... other methods ...

    /// Gets an NFS mount point if this is an NFS request. Otherwise,
    /// return None.
    pub fn mount_point(&self) -> Option<&Path> {
        self.mount_point.as_ref()
    }
}

ส่งผลให้โค้ดทุกจุดที่เรียกใช้ mount_point() จะต้องคอยเช็กค่า None และเขียนโค้ดดักไว้เสมอ แม้ว่าผู้เขียนโค้ดจะทราบดีอยู่แล้วว่าในเส้นทางการทำงานตรงนั้นจะมีเพียงคำขอแบบ NFS เท่านั้นก็ตาม!

จะดีกว่ามากหากเราสามารถทำให้คอมไพเลอร์แจ้งเตือนข้อผิดพลาดตั้งแต่ตอนคอมไพล์ (Compile-time Error) หากมีการเรียกใช้เมท็อดผิดประเภท เพราะตามความเป็นจริง โค้ดของผู้ใช้ย่อมทราบแน่ชัดอยู่แล้วว่าคำขอที่กำลังจัดการอยู่นั้นเป็น NFS หรือ BOOTP

ในภาษา Rust เราสามารถทำเช่นนั้นได้จริง! โดยทางออกคือการนำชนิดข้อมูลเจเนอริกเข้ามาใช้ เพื่อแยก API ออกจากกันตามชนิดข้อมูล

โครงสร้างโค้ดจะมีลักษณะดังนี้:

use std::path::{Path, PathBuf};

mod nfs {
    #[derive(Clone)]
    pub(crate) struct AuthInfo(String); // NFS session management omitted
}

mod bootp {
    pub(crate) struct AuthInfo(); // no authentication in bootp
}

// Keep the module private to prevent outside users from inventing their own protocols.
mod proto_trait {
    use super::{bootp, nfs};
    use std::path::{Path, PathBuf};

    pub(crate) trait ProtoKind {
        type AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo;
    }

    pub struct Nfs {
        auth: nfs::AuthInfo,
        mount_point: PathBuf,
    }

    impl Nfs {
        pub(crate) fn mount_point(&self) -> &Path {
            &self.mount_point
        }
    }

    impl ProtoKind for Nfs {
        type AuthInfo = nfs::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            self.auth.clone()
        }
    }

    pub struct Bootp(); // no additional metadata

    impl ProtoKind for Bootp {
        type AuthInfo = bootp::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            bootp::AuthInfo()
        }
    }
}

use proto_trait::ProtoKind; // keep internal to prevent impls
pub use proto_trait::{Bootp, Nfs}; // re-export so callers can see them

struct FileDownloadRequest<P: ProtoKind> {
    file_name: PathBuf,
    protocol: P,
}

// all common API parts go into a generic impl block
impl<P: ProtoKind> FileDownloadRequest<P> {
    fn file_path(&self) -> &Path {
        &self.file_name
    }

    fn auth_info(&self) -> P::AuthInfo {
        self.protocol.auth_info()
    }
}

// all protocol-specific impls go into their own block
impl FileDownloadRequest<Nfs> {
    fn mount_point(&self) -> &Path {
        self.protocol.mount_point()
    }
}

fn main() {
    // your code here
}

ด้วยแนวทางนี้ หากผู้ใช้เผลอทำผิดพลาดโดยส่งชนิดข้อมูลผิดประเภท:

fn main() {
    let mut socket = crate::bootp::listen()?;
    while let Some(request) = socket.next_request()? {
        match request.mount_point().as_ref() {
            "/secure" => socket.send("Access denied"),
            _ => {} // continue on...
        }
        // Rest of the code here
    }
}

คอมไพเลอร์จะฟ้อง compile error ทันที เนื่องจากชนิดข้อมูล FileDownloadRequest<Bootp> ไม่ได้มีการอิมพลีเมนต์เมท็อด mount_point() ไว้ มีเพียงชนิดข้อมูล FileDownloadRequest<Nfs> เท่านั้นที่มีเมท็อดนี้ และออบเจกต์ดังกล่าวก็ถูกสร้างขึ้นมาจากโมดูล NFS เท่านั้น ไม่ใช่โมดูล BOOTP!

ข้อดี

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

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

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

ข้อเสีย

ในปัจจุบัน แนวทางนี้จะทำให้ขนาดของไฟล์ไบนารีเพิ่มขึ้นเล็กน้อย เนื่องจากกลไก Monomorphization ของคอมไพเลอร์ ซึ่งหวังว่าจะได้รับการปรับปรุงให้ดียิ่งขึ้นในอนาคต

ทางเลือกอื่น

  • หากชนิดข้อมูลต้องการ “API แยกส่วน” อันเนื่องมาจากขั้นตอนการสร้างออบเจกต์หรือการกำหนดค่าเริ่มต้นเพียงบางส่วน ควรพิจารณาใช้แพตเทิร์นบิลเดอร์แทน

  • หาก API ระหว่างแต่ละชนิดข้อมูลเหมือนกันทุกประการและเปลี่ยนแปลงเฉพาะพฤติกรรมการทำงาน ควรพิจารณาใช้แพตเทิร์นกลยุทธ์แทน

ดูเพิ่มเติม

แพตเทิร์นนี้ถูกนำมาใช้อย่างแพร่หลายในไลบรารีมาตรฐานของ Rust:

  • Vec<u8> สามารถแปลงมาจาก String ได้ ซึ่งไม่เหมือนกับ Vec<T> ชนิดอื่นๆ1
  • อิเทอเรเตอร์สามารถแปลงเป็น binary heap ได้ แต่เฉพาะเมื่อสมาชิกภายในเป็นชนิดที่อิมพลีเมนต์ trait Ord เท่านั้น2
  • เมท็อด to_string ถูกกำหนดไว้เฉพาะสำหรับ Cow ที่มีชนิดข้อมูลเป็น str เท่านั้น3

นอกจากนี้ ยังมี crate ยอดนิยมหลายตัวที่นำแพตเทิร์นนี้มาใช้เพื่อเพิ่มความยืดหยุ่นให้แก่ API:

  • ระบบนิเวศ embedded-hal สำหรับอุปกรณ์สมองกลฝังตัว นำแพตเทิร์นนี้มาใช้อย่างเข้มข้น ตัวอย่างเช่น ใช้ตรวจสอบการกำหนดค่าของรีจิสเตอร์ที่ควบคุมขาพินฮาร์ดแวร์ได้ตั้งแต่ตอนคอมไพล์ เมื่อพินถูกกำหนดเข้าสู่โหมดหนึ่ง จะคืนค่า struct Pin<MODE> ออกมา ซึ่งเจเนอริกจะคอยระบุว่าฟังก์ชันใดบ้างที่เรียกใช้งานได้ในโหมดนั้นๆ โดยที่ฟังก์ชันเหล่านั้นไม่ได้อยู่บนตัว Pin เปล่าๆ 4

  • ไลบรารี HTTP client hyper ใช้แนวทางนี้เพื่อเปิดเผย API ที่หลากหลายตามรูปแบบคำขอ โดย client ที่มีคอนเนกเตอร์ต่างกันจะมีเมท็อดและการอิมพลีเมนต์ trait ที่แตกต่างกันออกไป ในขณะที่เมท็อดแกนหลักยังคงใช้ร่วมกันได้กับทุกคอนเนกเตอร์ 5

  • แพตเทิร์น “Typestate” – ที่ออบเจกต์จะได้รับหรือสูญเสียความสามารถของ API ตามสถานะภายในตัวมัน (Invariant) – ก็ถูกนำมาอิมพลีเมนต์ใน Rust โดยใช้แนวคิดพื้นฐานเดียวกันนี้ แต่มีเทคนิคปลีกย่อยที่แตกต่างกันเล็กน้อย 6


  1. ดู: impl From<CString> for Vec<u8> ↩

  2. ดู: impl<T: Ord> FromIterator<T> for BinaryHeap<T> ↩

  3. ดู: impl<‘_> ToString for Cow<’_, str> ↩

  4. ตัวอย่าง: https://docs.rs/stm32f30x-hal/0.1.0/stm32f30x_hal/gpio/gpioa/struct.PA0.html ↩

  5. ดู: https://docs.rs/hyper/0.14.5/hyper/client/struct.Client.html ↩

  6. ดู: The Case for the Type State Pattern และ Rusty Typestate Series (วิทยานิพนธ์ฉบับละเอียด) ↩

ออปติกส์เชิงฟังก์ชัน (Functional Optics)

ออปติกส์ (Optics) เป็นรูปแบบหนึ่งของการออกแบบ API ที่พบได้ทั่วไปในภาษาเชิงฟังก์ชันบริสุทธิ์ แม้ว่าจะเป็นแนวคิดเชิงทฤษฎีที่ไม่ได้ถูกหยิบยกมาพูดถึงโดยตรงบ่อยนักในภาษา Rust

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

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

เพื่ออธิบายแนวคิดนี้ให้เห็นภาพชัดเจน เราจะใช้การออกแบบ API ของไลบรารี Serde มาเป็นกรณีศึกษา เนื่องจากเป็น API ยอดนิยมที่หลายคนมักรู้สึกว่าเข้าใจได้ยากหากอ่านเพียงแค่เอกสารอ้างอิงธรรมดา

ในบทนี้ เราจะครอบคลุมรูปแบบย่อยของออปติกส์ ได้แก่ The Iso, The Poly Iso และ The Prism

ตัวอย่าง API: Serde

การพยายามทำความเข้าใจหลักการทำงานของ Serde ด้วยการอ่านโค้ดของ API เพียงอย่างเดียวถือเป็นเรื่องที่ท้าทายไม่น้อย โดยเฉพาะสำหรับผู้ที่เพิ่งเริ่มศึกษา ลองพิจารณา trait Deserializer ที่ไลบรารีตัวแปลงไฟล์ (Parser) ต้องอิมพลีเมนต์:

pub trait Deserializer<'de>: Sized {
    type Error: Error;

    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;

    fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;

    // remainder omitted
}

และนี่คือนิยามของ trait Visitor ที่ถูกส่งเข้ามาเป็นพารามิเตอร์แบบเจเนอริก:

pub trait Visitor<'de>: Sized {
    type Value;

    fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
    where
        E: Error;

    fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
    where
        E: Error;

    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
    where
        E: Error;

    // remainder omitted
}

สังเกตว่ามีการทำ Type Erasure และมี Associated Type ซ้อนกันอยู่หลายชั้นจนชวนให้สับสน

แต่ภาพใหญ่เบื้องหลังโครงสร้างนี้คืออะไรกันแน่? ทำไม Visitor ไม่คืนค่าส่วนประกอบที่ผู้เรียกต้องการออกมาตรงๆ ในลักษณะของ Streaming API ให้จบเรื่องไป? ทำไมต้องสร้างส่วนประกอบที่ดูซับซ้อนเหล่านี้ขึ้นมามากมาย?

คำตอบสามารถอธิบายได้ด้วยแนวคิดในโลกของการเขียนโปรแกรมเชิงฟังก์ชันที่เรียกว่า Optics

Optics คือแนวทางในการประกอบพฤติกรรมและคุณสมบัติต่างๆ เข้าด้วยกัน ซึ่งถูกออกแบบมาเพื่ออำนวยความสะดวกให้แก่แพตเทิร์นที่พบบ่อยใน Rust เช่น การจัดการข้อผิดพลาด (Failure Handling), การแปลงชนิดข้อมูล (Type Conversion) และอื่นๆ1

แม้ว่าภาษา Rust จะไม่ได้มีฟีเจอร์รองรับเรื่องนี้ในระดับไวยากรณ์โดยตรง แต่แนวคิดของ Optics ก็แฝงอยู่เบื้องหลังการออกแบบสถาปัตยกรรมของภาษา และช่วยอธิบายเหตุผลในการออกแบบ API สำคัญๆ ของ Rust ได้เป็นอย่างดี

เป้าหมายสูงสุดที่ API เหล่านี้ต้องการบรรลุคือ: คุณสมบัติการประกอบเข้าด้วยกันได้อย่างไร้รอยต่อ (Composability)

ออปติกส์พื้นฐาน

The Iso

The Iso คือตัวแปลงค่ากลับไปมาระหว่างชนิดข้อมูลสองชนิด (Isomorphism) เป็นโครงสร้างพื้นฐานที่เรียบง่าย แต่ถือเป็นหน่วยสร้าง (Building Block) ที่สำคัญยิ่งในเชิงแนวคิด

ตัวอย่างเช่น สมมติว่าเรามีโครงสร้าง Hash Table ที่กำหนดขึ้นเองเพื่อใช้เป็นดัชนีคำค้น (Concordance) สำหรับเอกสาร2 โดยเก็บคำศัพท์เป็นคีย์ชนิด String และเก็บรายการตำแหน่งดัชนีเป็น Value (เช่น ตำแหน่งไบต์ในไฟล์)

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

หากเขียนในรูปแบบ Normal Form ตามที่ภาษาเชิงฟังก์ชันนิยมใช้:

case class ConcordanceSerDe {
  serialize: Concordance -> String
  deserialize: String -> Concordance
}

The Iso จึงเป็นเพียงคู่ของฟังก์ชันสองตัวที่แปลงค่ากลับไปมาระหว่างสองชนิดข้อมูล: นั่นคือ serialize และ deserialize

การอิมพลีเมนต์แบบตรงไปตรงมาใน Rust จะมีลักษณะดังนี้:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

struct Concordance {
    keys: HashMap<String, usize>,
    value_table: Vec<(usize, usize)>,
}

struct ConcordanceSerde {}

impl ConcordanceSerde {
    fn serialize(value: Concordance) -> String {
        todo!()
    }
    // invalid concordances are empty
    fn deserialize(value: String) -> Concordance {
        todo!()
    }
}
}

โครงสร้างแบบนี้อาจดูผิวเผินไปหน่อย เพราะใน Rust การทำงานลักษณะนี้มักจะอิมพลีเมนต์ผ่าน trait เช่น FromStr และ ToString ที่มีอยู่ในไลบรารีมาตรฐานอยู่แล้ว

และนั่นก็นำพาเราไปสู่หัวข้อถัดไป: Poly Isos

Poly Isos

ตัวอย่างก่อนหน้านี้เป็นการแปลงค่าระหว่างชนิดข้อมูลคู่เดิมที่ถูกกำหนดไว้ตายตัว แต่กลุ่มถัดไปจะยกระดับความสามารถขึ้นไปอีกขั้นด้วยการนำเจเนอริก (Generics) เข้ามาผสาน

Poly Isos ช่วยให้การแปลงข้อมูลทำงานแบบเจเนอริกกับชนิดข้อมูลใดๆ ก็ได้ โดยยังคงคืนค่าชนิดข้อมูลเป้าหมายออกมาได้อย่างถูกต้อง

สิ่งนี้นำเราเข้าใกล้การทำตัวแจงส่วน (Parser) มากขึ้น ลองพิจารณาว่า Parser พื้นฐานต้องทำอะไรบ้างหากเราตัดประเด็นเรื่อง Error ออกไปก่อน ในรูปแบบ Normal Form:

case class Serde[T] {
    deserialize(String) -> T
    serialize(T) -> String
}

ณ จุดนี้ เรามีพารามิเตอร์เจเนอริกตัวแรกเกิดขึ้นแล้ว นั่นคือชนิดข้อมูล T ที่ต้องการแปลง

ใน Rust เราสามารถอิมพลีเมนต์สิ่งนี้ได้ผ่านคู่ของ trait ในไลบรารีมาตรฐาน: ได้แก่ FromStr และ ToString ซึ่งใน Rust นั้นจะมีการจัดการข้อผิดพลาดควบคู่ไปด้วยเสมอ:

pub trait FromStr: Sized {
    type Err;

    fn from_str(s: &str) -> Result<Self, Self::Err>;
}

pub trait ToString {
    fn to_string(&self) -> String;
}

สิ่งที่ทำให้ Poly Iso แตกต่างจาก Iso ธรรมดา คือการเปิดให้เรานำไปประยุกต์ใช้กับชนิดข้อมูลที่หลากหลาย และคืนผลลัพธ์กลับมาในรูปของเจเนอริก ซึ่งเป็นคุณสมบัติพื้นฐานที่ String Parser ต้องการ

เมื่อมองเผินๆ วิธีนี้ดูเหมือนจะเพียงพอสำหรับการเขียน Parser แล้ว ลองดูตัวอย่างการใช้งานจริง:

use anyhow;

use std::str::FromStr;

struct TestStruct {
    a: usize,
    b: String,
}

impl FromStr for TestStruct {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<TestStruct, Self::Err> {
        todo!()
    }
}

impl ToString for TestStruct {
    fn to_string(&self) -> String {
        todo!()
    }
}

fn main() {
    let a = TestStruct {
        a: 5,
        b: "hello".to_string(),
    };
    println!("Our Test Struct as JSON: {}", a.to_string());
}

โค้ดนี้ดูสมเหตุสมผลดี แต่ในความเป็นจริงมีปัญหาสำคัญอยู่ 2 ประการ

ประการแรก เมท็อด to_string ไม่ได้บอกผู้ใช้เลยว่า “ผลลัพธ์นี้อยู่ในรูปแบบ JSON” ข้อมูลแต่ละประเภทอาจมีนิยามของสตริงที่แตกต่างกัน และหลายชนิดข้อมูลในไลบรารีมาตรฐานของ Rust ก็ไม่ได้คืนค่าออกมาในรูป JSON อยู่แล้ว การใช้วิธีนี้จึงอาจเกิดความสับสนได้ง่าย ซึ่งปัญหานี้แก้ได้ไม่ยากโดยการสร้าง trait ของเราเองขึ้นมา

แต่ปัญหาประการที่สองนั้นซับซ้อนและสำคัญกว่ามาก นั่นคือ: ปัญหาด้านความสามารถในการรองรับขนาดและการขยายระบบ (Scalability)

หากทุก struct ต้องมานั่งเขียนโค้ด to_string ด้วยมือทีละตัว โค้ดจะเริ่มกระจัดกระจายและซ้ำซ้อนอย่างมหาศาล ยิ่งถ้าแต่ละคนเลือกใช้ไลบรารี JSON ที่แตกต่างกัน ระบบทั้งหมดจะกลายเป็นความยุ่งเหยิงในทันที!

ทางออกสำหรับปัญหานี้คือนวัตกรรมชิ้นสำคัญของ Serde: นั่นคือการออกแบบ Data Model ที่เป็นอิสระจากฟอร์แมตภายนอก เพื่อทำหน้าที่เป็นตัวกลางในการจำลองโครงสร้างข้อมูลของ Rust และใช้ความสามารถของระบบโค้ดเจน (Code Generation / Macro) ในการสร้างชนิดข้อมูลตัวกลางสำหรับแปลงข้อมูล ซึ่งเรียกสิ่งนี้ว่า Visitor

ในรูปแบบ Normal Form (โดยละเว้นการจัดการข้อผิดพลาดเพื่อความกระชับ):

case class Serde[T] {
    deserialize: Visitor[T] -> T
    serialize: T -> Visitor[T]
}

case class Visitor[T] {
    toJson: Visitor[T] -> String
    fromJson: String -> Visitor[T]
}

ส่งผลให้เราได้ Poly Iso หนึ่งตัว และได้ Iso ธรรมดาอีกหนึ่งตัว โดยทั้งคู่สามารถแปลงมาเป็น trait ใน Rust ได้ดังนี้:

#![allow(unused)]
fn main() {
trait Serde {
    type V;
    fn deserialize(visitor: Self::V) -> Self;
    fn serialize(self) -> Self::V;
}

trait Visitor {
    fn to_json(self) -> String;
    fn from_json(json: String) -> Self;
}
}

ด้วยความที่กฎในการแปลงโครงสร้างข้อมูลของ Rust ไปสู่ Data Model ตัวกลางมีความสม่ำเสมอและคาดเดาได้ จึงเปิดทางให้ระบบ Code Generation สามารถสร้าง Visitor ที่สอดคล้องกับชนิดข้อมูล T แต่ละตัวขึ้นมาได้โดยอัตโนมัติ:

#[derive(Default, Serde)] // the "Serde" derive creates the trait impl block
struct TestStruct {
    a: usize,
    b: String,
}

// user writes this macro to generate an associated visitor type
generate_visitor!(TestStruct);

แต่เมื่อลองนำแนวทางนี้ไปใช้งานจริง:

fn main() {
    let a = TestStruct { a: 5, b: "hello".to_string() };
    let a_data = a.serialize().to_json();
    println!("Our Test Struct as JSON: {a_data}");
    let b = TestStruct::deserialize(
        generated_visitor_for!(TestStruct)::from_json(a_data));
}

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

วิธีนี้อาจพอถูไถไปได้… จนกระทั่งเรามาถึงปัญหาใหญ่ข้อสำคัญที่ไม่อาจมองข้ามได้ (The elephant in the room)

นั่นคือ ปัจจุบันวิธีนี้รองรับเพียงแค่ฟอร์แมต JSON เท่านั้น แล้วถ้าในอนาคตเราต้องการรองรับฟอร์แมตอื่นๆ เช่น YAML, Bincode หรือ TOML ล่ะ?

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

เพื่อแก้ปัญหานี้ เราจำเป็นต้องใช้แนวคิดที่ทรงพลังยิ่งกว่าเดิม

Prism

หากต้องการให้ระบบรองรับฟอร์แมตข้อมูลได้อย่างยืดหยุ่น โครงสร้างในรูปแบบ Normal Form จะต้องเป็นดังนี้:

case class Serde[T, F] {
    serialize: T, F -> String
    deserialize: String, F -> Result[T, Error]
}

โครงสร้างในลักษณะนี้เรียกว่า Prism ซึ่งมีระดับความเป็นเจเนอริกที่ “สูงขึ้นไปอีกขั้น” เมื่อเทียบกับ Poly Iso (โดยในที่นี้ ชนิดข้อมูลฟอร์แมต F คือกุญแจสำคัญ)

อย่างไรก็ดี เนื่องจาก Visitor จำเป็นต้องถูกกำหนดเป็น trait (เพราะชนิดข้อมูลแต่ละตัวจำเป็นต้องมีตรรกะเฉพาะในการแปลง) โครงสร้างนี้จึงต้องการระบบ Generic Type Boundary ที่ภาษา Rust ยังไม่รองรับในระดับภาษา

แต่โชคดีที่เรายังคงมีชนิดข้อมูล Visitor จากแนวทางก่อนหน้า แล้วหน้าที่แท้จริงของ Visitor คืออะไร? มันคือตัวแทนที่เปิดให้โครงสร้างข้อมูลแต่ละตัวสามารถกำหนดวิธีในการแจงส่วน (Parse) ตัวมันเองได้

แล้วจะเป็นอย่างไรถ้าเราเพิ่มอินเทอร์เฟซตัวกลางเข้ามาอีกชั้นหนึ่งสำหรับจัดการฟอร์แมตข้อมูลแบบเจเนอริก? เมื่อทำเช่นนั้น Visitor จะกลายเป็นเพียงรายละเอียดเบื้องหลังของการนำไปใช้งาน และทำหน้าที่เป็น “สะพานเชื่อม” ระหว่างสองฝั่งของ API

ในรูปแบบ Normal Form:

case class Serde[T] {
    serialize: F -> String
    deserialize F, String -> Result[T, Error]
}

case class VisitorForT {
    build: F, String -> Result[T, Error]
    decompose: F, T -> String
}

case class SerdeFormat[T, V] {
    toString: T, V -> String
    fromString: V, String -> Result[T, Error]
}

และผลลัพธ์ที่ได้คือ โครงสร้าง Poly Iso สองตัวที่อยู่ด้านล่าง ซึ่งสามารถนำมาอิมพลีเมนต์เป็น trait ใน Rust ได้อย่างลงตัว!

นี่จึงเป็นที่มาของสถาปัตยกรรม API ของ Serde:

  1. ชนิดข้อมูลแต่ละตัวที่ต้องการ Serialize จะต้องอิมพลีเมนต์ trait Deserialize หรือ Serialize (เทียบเท่ากับคลาส Serde)
  2. ชนิดข้อมูลเหล่านั้นจะมีชนิดตัวกลาง (2 ตัว สำหรับขาไปและขากลับ) ที่อิมพลีเมนต์ trait Visitor ซึ่งสร้างขึ้นโดยอัตโนมัติผ่าน Derive Macro โดยบรรจุตรรกะในการแปลงข้อมูลไปมาระหว่างโครงสร้างข้อมูลกับ Serde Data Model
  3. ชนิดข้อมูลที่อิมพลีเมนต์ trait Deserializer จะรับผิดชอบรายละเอียดทั้งหมดที่เจาะจงเฉพาะฟอร์แมตนั้นๆ โดยจะถูก “ขับเคลื่อนการทำงาน” ผ่าน Visitor

การแบ่งส่วน API และการทำ Type Erasure ของ Rust ในลักษณะนี้ แท้จริงแล้วคือการจำลอง Prism ผ่านการอ้างอิงทางอ้อม (indirection)

คุณสามารถสังเกตสิ่งนี้ได้จากนิยามของ trait Deserializer:

pub trait Deserializer<'de>: Sized {
    type Error: Error;

    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;

    fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;

    // remainder omitted
}

และ trait ของ Visitor:

pub trait Visitor<'de>: Sized {
    type Value;

    fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
    where
        E: Error;

    fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
    where
        E: Error;

    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
    where
        E: Error;

    // remainder omitted
}

รวมถึง trait Deserialize ที่มาโครช่วยอิมพลีเมนต์ให้:

pub trait Deserialize<'de>: Sized {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>;
}

เพื่อไม่ให้เนื้อหาดูเป็นนามธรรมจนเกินไป ลองมาดูตัวอย่างการทำงานจริงที่เป็นรูปธรรมกัน

Serde มีขั้นตอนอย่างไรในการแปลง JSON กลับมาเป็น struct Concordance ดังตัวอย่างข้างต้น?

  1. โค้ดของผู้ใช้จะเรียกฟังก์ชันของไลบรารีเพื่อแปลงข้อมูล ซึ่งจะสร้างอ็อบเจกต์ Deserializer ตามฟอร์แมต JSON ขึ้นมา
  2. เมื่อพิจารณาจากฟิลด์ต่างๆ ภายใน struct ตัว Visitor จะถูกสร้างขึ้นมาเพื่อรับหน้าที่แปลงชนิดข้อมูลพื้นฐานใน Data Model: ได้แก่ Vec (List), u64 และ String
  3. Deserializer จะส่งต่อการทำงานไปยัง Visitor ในขณะที่กำลังแจงส่วนแต่ละฟิลด์
  4. Visitor จะคอยตรวจสอบว่าข้อมูลที่พบตรงตามรูปแบบที่คาดหวังหรือไม่ หากไม่ตรง ก็จะแจ้ง error ออกมาเพื่อระบุว่าการ Deserialize ล้มเหลว

สำหรับโครงสร้างข้อมูลอย่างง่ายของเรา ลำดับขั้นตอนการทำงานจะเป็นดังนี้:

  1. เริ่มประมวลผลข้อมูลแบบ Map (เทียบเท่ากับ HashMap ของ Serde หรือ Dictionary/Object ของ JSON)
  2. ตรวจสอบคีย์ชนิด String ที่มีชื่อว่า “keys”
  3. เริ่มประมวลผลค่า Value ของ Map
  4. สำหรับแต่ละรายการ ให้ประมวลผลคีย์ String ตามด้วยค่าจำนวนเต็ม Integer
  5. สิ้นสุดการประมวลผล Map
  6. นำข้อมูล Map ที่ได้ไปกำหนดค่าลงในฟิลด์ keys ของ struct
  7. ตรวจสอบคีย์ชนิด String ที่มีชื่อว่า “value_table”
  8. เริ่มประมวลผลค่า Value ของ List
  9. สำหรับแต่ละรายการ ให้ประมวลผลค่าจำนวนเต็ม Integer
  10. สิ้นสุดการประมวลผล List
  11. นำข้อมูล List ที่ได้ไปกำหนดค่าลงในฟิลด์ value_table
  12. สิ้นสุดการประมวลผล Map ทั้งหมด

แต่อะไรคือตัวกำหนดว่าลำดับขั้นตอนใดที่ควรคาดหวัง?

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

Serde แก้ไขปัญหาความสะดวกในการใช้งานนี้ด้วย Derive Macro:

use serde::Deserialize;

#[derive(Deserialize)]
struct IdRecord {
    name: String,
    customer_id: String,
}

มาโครดังกล่าวจะสร้างบล็อก impl ที่อิมพลีเมนต์ trait Deserialize ให้แก่ struct โดยอัตโนมัติ

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

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

สำหรับตัวอย่างฉบับสมบูรณ์ สามารถศึกษาเพิ่มเติมได้จาก เอกสารประกอบของ Serde

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

กล่าวโดยสรุป ระบบชนิดข้อมูลที่ได้แรงบันดาลใจมาจากแนวคิดเชิงฟังก์ชันของ Rust ช่วยให้ภาษาเข้าใกล้แนวคิดขั้นสูงเหล่านี้และดึงเอาศักยภาพของมันออกมาใช้ได้อย่างเต็มที่ แต่อาจต้องอาศัย Procedural Macro เข้ามาช่วยสร้างสะพานเชื่อมในการสร้างโค้ดเจเนอริก

หากคุณสนใจศึกษาเพิ่มเติมเกี่ยวกับหัวข้อนี้ สามารถอ่านรายละเอียดต่อได้ในส่วนถัดไป

ดูเพิ่มเติม

  • crate lens-rs สำหรับการอิมพลีเมนต์ Lenses สำเร็จรูป ที่มีอินเทอร์เฟซสะอาดตาและใช้งานง่าย
  • Serde ไลบรารียอดนิยมที่นำแนวคิดเหล่านี้มาทำให้ผู้ใช้ปลายทางใช้งานได้อย่างสะดวกเป็นธรรมชาติ โดยไม่จำเป็นต้องเข้าใจทฤษฎีเชิงลึก
  • luminance crate สำหรับการเรนเดอร์คอมพิวเตอร์กราฟิกที่ใช้การออกแบบ API ในลักษณะเดียวกันนี้ รวมถึงใช้ Procedural Macro เพื่อสร้าง Prism แบบเต็มรูปแบบสำหรับบัฟเฟอร์ของพิกเซลชนิดต่างๆ
  • บทความเกี่ยวกับ Lenses ในภาษา Scala ซึ่งเขียนอธิบายไว้อย่างเข้าใจง่ายแม้จะไม่มีพื้นฐานภาษา Scala มาก่อน
  • เอกสารวิจัย: Profunctor Optics: Modular Data Accessors
  • Musli ไลบรารี Serialization ที่ใช้โครงสร้างคล้ายคลึงกันแต่ใช้แนวทางที่แตกต่าง เช่น การตัด visitor ออกไป

  1. School of Haskell: A Little Lens Starter Tutorial ↩

  2. Concordance บนวิกิพีเดีย ↩

แหล่งข้อมูลเพิ่มเติม

รวบรวมเนื้อหาและแหล่งเรียนรู้เพิ่มเติมที่มีประโยชน์

วิดีโอบรรยาย

หนังสือ (ออนไลน์)

หลักการออกแบบ

ภาพรวมสั้นๆ ของหลักการออกแบบที่พบบ่อย


SOLID

CRP (Composite Reuse Principle) หรือ การประกอบดีกว่าการสืบทอด

“หลักการที่ว่าคลาสควรส่งเสริมพฤติกรรมแบบพอลิมอร์ฟิกและการนำโค้ดกลับมาใช้ซ้ำผ่านการประกอบออบเจกต์ (Composition — โดยบรรจุอินสแตนซ์ของคลาสอื่นที่ทำงานตามต้องการ) มากกว่าการสืบทอดคุณสมบัติ (Inheritance) จากคลาสแม่” — Knoernschild, Kirk (2002). Java Design - Objects, UML, and Process

DRY (Don’t Repeat Yourself)

“องค์ความรู้หรือตรรกะทุกชิ้นภายในระบบ จะต้องมีตัวแทนที่ชัดเจน ไม่คลุมเครือ และเชื่อถือได้เพียงจุดเดียวเท่านั้น”

หลักการ KISS

ระบบส่วนใหญ่จะทำงานได้ดีและเสถียรที่สุดเมื่อถูกรักษาให้เรียบง่าย ไม่ซับซ้อน ดังนั้น ความเรียบง่ายจึงควรเป็นเป้าหมายสำคัญในการออกแบบ และควรหลีกเลี่ยงความซับซ้อนที่ไม่จำเป็นเสมอ

กฎของเดเมเทอร์ (Law of Demeter - LoD)

ออบเจกต์หนึ่งควรล่วงรู้โครงสร้างหรือคุณสมบัติภายในของออบเจกต์อื่น (รวมถึงคอมโพเนนต์ย่อยของมัน) ให้น้อยที่สุด ตามหลักการ “การซ่อนข้อมูล (Information Hiding)”

การออกแบบตามสัญญา (Design by contract - DbC)

นักออกแบบซอฟต์แวร์ควรกำหนดข้อกำหนดของอินเทอร์เฟซที่ชัดเจน แม่นยำ และตรวจสอบได้ สำหรับคอมโพเนนต์ของซอฟต์แวร์ ซึ่งขยายขอบเขตของชนิดข้อมูลนามธรรมด้วยเงื่อนไขก่อนเริ่มทำงาน (Preconditions), เงื่อนไขหลังทำงานสำเร็จ (Postconditions) และสัจพจน์ที่ไม่เปลี่ยนแปลง (Invariants)

การห่อหุ้ม (Encapsulation)

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

การแยกคำสั่ง-คำถาม (Command-Query-Separation - CQS)

“ฟังก์ชันไม่ควรสร้างผลข้างเคียง (Side effects)… มีเพียงคำสั่งที่ดำเนินการ (Procedures/Commands) เท่านั้นที่อนุญาตให้ก่อผลข้างเคียงได้” — Bertrand Meyer: Object-Oriented Software Construction

หลักการสร้างความประหลาดใจน้อยที่สุด (Principle of least astonishment - POLA)

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

Linguistic-Modular-Units

“โมดูลต้องสอดคล้องกับหน่วยทางวากยสัมพันธ์ (Syntactic Units) ในภาษาโปรแกรมที่ใช้งานจริง” — Bertrand Meyer: Object-Oriented Software Construction

Self-Documentation

“ผู้ออกแบบโมดูลควรพยายามทำให้ข้อมูลรายละเอียดทั้งหมดของโมดูล บรรจุอยู่ภายในตัวโมดูลนั้นเอง” — Bertrand Meyer: Object-Oriented Software Construction

Uniform-Access

“บริการทั้งหมดที่โมดูลนำเสนอควรเข้าถึงได้ผ่านรูปแบบสัญกรณ์ที่สม่ำเสมอ โดยไม่เผยให้เห็นว่าบริการนั้นทำงานผ่านการดึงข้อมูลที่จัดเก็บไว้หรือผ่านการคำนวณขึ้นใหม่” — Bertrand Meyer: Object-Oriented Software Construction

Single-Choice

“เมื่อใดก็ตามที่ระบบซอฟต์แวร์ต้องรองรับชุดทางเลือก ควรมีเพียงโมดูลเดียวในระบบที่ล่วงรู้รายการทางเลือกทั้งหมดอย่างครบถ้วน” — Bertrand Meyer: Object-Oriented Software Construction

Persistence-Closure

“เมื่อกลไกการจัดเก็บบันทึกออบเจกต์ใดลงระบบ มันจะต้องจัดเก็บออบเจกต์ทั้งหมดที่ออบเจกต์นั้นพึ่งพาไปด้วย และเมื่อกลไกการอ่านข้อมูลดึงออบเจกต์ที่บันทึกไว้ขึ้นมา มันจะต้องดึงออบเจกต์ที่เกี่ยวข้องซึ่งยังไม่ได้ถูกดึงมาขึ้นมาด้วยเช่นกัน” — Bertrand Meyer: Object-Oriented Software Construction