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

Advanced Types

ระบบประเภท (type system) ของ Rust มีคุณสมบัติบางอย่างที่เราเคยเอ่ยถึงแต่ยังไม่ได้ลงรายละเอียด เราจะเริ่มต้นด้วยการพูดถึง newtypes โดยทั่วไปเมื่อพิจารณาว่าทำไมพวกมันจึงมีประโยชน์ในฐานะประเภทข้อมูล จากนั้นเราจะขยับไปพูดถึงนามแฝงประเภท (type aliases) ซึ่งเป็นคุณสมบัติที่คล้ายกับ newtypes แต่มีความหมายเชิงระบบ (semantics) ที่ต่างกันเล็กน้อย นอกจากนี้เราจะพูดถึงประเภท ! และประเภทที่มีขนาดแปรผันขณะรันไทม์ (dynamically sized types)

Type Safety and Abstraction with the Newtype Pattern

หัวข้อนี้สมมติว่าคุณได้อ่านหัวข้อก่อนหน้านี้เรื่อง “การอิมพลีเมนต์ External Traits ด้วยรูปแบบ Newtype” มาแล้ว รูปแบบ newtype (newtype pattern) ยังมีประโยชน์สำหรับงานอื่นๆ นอกเหนือจากที่เราได้พูดถึงไปแล้ว ซึ่งรวมถึงการบังคับใช้แบบสแตติก (statically enforcing) เพื่อไม่ให้ค่าสับสนกัน และใช้ในการระบุหน่วยของค่า คุณได้เห็นตัวอย่างของการใช้ newtype เพื่อระบุหน่วยในโค้ดตัวอย่างที่ 20-16 มาแล้ว: จำได้ไหมว่าสตรักต์ Millimeters และ Meters หุ้มค่า u32 ไว้ใน newtype หากเราเขียนฟังก์ชันที่มีพารามิเตอร์ประเภท Millimeters เราจะไม่สามารถคอมไพล์โปรแกรมที่พยายามเรียกใช้ฟังก์ชันนั้นด้วยค่าประเภท Meters หรือ u32 ธรรมดาโดยบังเอิญได้

เรายังสามารถใช้รูปแบบ newtype เพื่อการซ่อนและนามธรรม (abstract) รายละเอียดการอิมพลีเมนต์บางอย่างของประเภทข้อมูลได้: ประเภทใหม่สามารถเปิดเผย public API ที่แตกต่างจาก API ของประเภทภายในที่เป็น private

Newtype ยังสามารถซ่อนการอิมพลีเมนต์ภายในได้อีกด้วย ตัวอย่างเช่น เราสามารถให้ประเภท People เพื่อหุ้ม HashMap<i32, String> ที่เก็บ ID ของบุคคลคู่กับชื่อของพวกเขา โค้ดที่ใช้ People จะโต้ตอบเฉพาะกับ public API ที่เราจัดเตรียมไว้ เช่น เมธอดสำหรับเพิ่มสตริงชื่อลงในคอลเลกชัน People โค้ดนั้นไม่จำเป็นต้องรู้ว่าเรากำหนด ID แบบ i32 ให้กับชื่อเป็นการภายใน รูปแบบ newtype เป็นวิธีน้ำหนักเบาในการบรรลุการแคปซูล (encapsulation) เพื่อซ่อนรายละเอียดการอิมพลีเมนต์ ซึ่งเราได้พูดถึงไปในหัวข้อ “การแคปซูลที่ซ่อนรายละเอียดการอิมพลีเมนต์” ในบทที่ 18

Type Synonyms and Type Aliases

Rust มีความสามารถในการประกาศ นามแฝงประเภท (type alias) เพื่อตั้งชื่ออื่นให้กับประเภทที่มีอยู่แล้ว สำหรับสิ่งนี้ เราใช้คีย์เวิร์ด type ตัวอย่างเช่น เราสามารถสร้างนามแฝง Kilometers สำหรับ i32 ได้ดังนี้:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

ตอนนี้นามแฝง Kilometers เป็น คำพ้อง (synonym) ของ i32; ไม่เหมือนกับประเภท Millimeters และ Meters ที่เราสร้างในโค้ดตัวอย่างที่ 20-16 Kilometers ไม่ใช่ประเภทใหม่แยกต่างหาก ค่าที่มีประเภท Kilometers จะถูกปฏิบัติเสมือนเป็นค่าประเภท i32 ทุกประการ:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

เนื่องจาก Kilometers และ i32 เป็นประเภทเดียวกัน เราจึงสามารถบวกค่าของทั้งสองประเภทเข้าด้วยกัน และสามารถส่งค่า Kilometers ไปยังฟังก์ชันที่รับพารามิเตอร์ i32 ได้ อย่างไรก็ตาม การใช้วิธีนี้จะทำให้เราไม่ได้ประโยชน์จากการตรวจประเภท (type-checking) ที่เราได้รับจากรูปแบบ newtype ที่กล่าวถึงก่อนหน้านี้ กล่าวอีกนัยหนึ่ง หากเราสับสนผสมค่า Kilometers และ i32 เข้าด้วยกันที่ไหนสักแห่ง คอมไพเลอร์จะไม่แจ้งข้อผิดพลาดให้เราทราบ

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

Box<dyn Fn() + Send + 'static>

การเขียนประเภทที่ยาวนี้ในลายเซ็นฟังก์ชัน (function signatures) และเป็นการระบุประเภททั่วทั้งโค้ดอาจเป็นเรื่องน่าเบื่อและเกิดข้อผิดพลาดได้ง่าย ลองจินตนาการถึงโปรเจกต์ที่เต็มไปด้วยโค้ดลักษณะดังโค้ดตัวอย่างที่ 20-25

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}

นามแฝงประเภททำให้โค้ดนี้จัดการได้ง่ายขึ้นโดยการลดการเขียนซ้ำ ในโค้ดตัวอย่างที่ 20-26 เราได้แนะนำนามแฝงชื่อ Thunk สำหรับประเภทที่ยืดยาวนั้น และสามารถแทนที่การใช้งานประเภทนั้นทั้งหมดด้วยนามแฝงสั้นๆ ว่า Thunk

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}

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

นามแฝงประเภทยังถูกใช้บ่อยกับประเภท Result<T, E> เพื่อลดการเขียนซ้ำอีกด้วย ลองพิจารณามอดูล std::io ในไลบรารีมาตรฐาน ปฏิบัติการ I/O มักจะคืนค่าเป็น Result<T, E> เพื่อจัดการกับสถานการณ์ที่ปฏิบัติการทำงานล้มเหลว ไลบรารีนี้มีสตรักต์ std::io::Error ที่เป็นตัวแทนของข้อผิดพลาด I/O ทั้งหมดที่เป็นไปได้ หลายฟังก์ชันใน std::io จะคืนค่า Result<T, E> โดยที่ E คือ std::io::Error เช่น ฟังก์ชันเหล่านี้ใน Write trait:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> ถูกเขียนซ้ำเยอะมาก ดังนั้น std::io จึงมีการประกาศนามแฝงประเภทนี้:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

เนื่องจากการประกาศนี้อยู่ในมอดูล std::io เราจึงสามารถใช้นามแฝงแบบระบุชื่อเต็มสมบูรณ์ std::io::Result<T> ได้ นั่นคือ Result<T, E> ที่มี E เติมด้วย std::io::Error ไว้แล้ว ลายเซ็นฟังก์ชันของ Write trait จึงออกมาดูดังนี้:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

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

The Never Type That Never Returns

Rust มีประเภทพิเศษชื่อ ! ซึ่งเป็นที่รู้จักในศัพท์ทฤษฎีประเภท (type theory) ว่าเป็น ประเภทว่างเปล่า (empty type) เนื่องจากมันไม่มีค่าใดๆ เลย เราชอบที่จะเรียกว่า never type เพราะมันอยู่แทนตำแหน่งประเภทที่ส่งคืนเมื่อฟังก์ชันจะไม่ส่งคืนค่ากลับมาเลย นี่คือตัวอย่าง:

fn bar() -> ! {
    // --snip--
    panic!();
}

โค้ดนี้อ่านว่า “ฟังก์ชัน bar ส่งคืนค่าเป็น never” ฟังก์ชันที่ส่งคืนค่าเป็น never เรียกว่า diverging functions (ฟังก์ชันลู่ออก) เราไม่สามารถสร้างค่าของประเภท ! ได้ ดังนั้น bar จึงไม่มีทางส่งคืนค่ากลับมาได้เลย

แต่ประเภทที่คุณไม่สามารถสร้างค่าให้ได้จะมีประโยชน์อะไร? จำโค้ดในโค้ดตัวอย่างที่ 2-5 ที่เป็นส่วนหนึ่งของเกมทายตัวเลขได้ไหม? เราได้จำลองส่วนหนึ่งของมันมาไว้ที่นี่ในโค้ดตัวอย่างที่ 20-27

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

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

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

ประเภทของ guess ในโค้ดนี้จะต้องเป็นทั้งจำนวนเต็ม และ สตริง ซึ่ง Rust กำหนดว่า guess ต้องมีประเภทเดียวเท่านั้น แล้ว continue คืนค่าอะไร? ทำไมเราถึงได้รับอนุญาตให้ส่งคืน u32 จากแขนข้างหนึ่ง และมีแขนอีกข้างหนึ่งที่ลงท้ายด้วย continue ในโค้ดตัวอย่างที่ 20-27 ได้?

ตามที่คุณอาจเดาได้ continue มีค่าเป็น ! นั่นคือเมื่อ Rust คำนวณประเภทของ guess มันจะดูแขนของ match ทั้งสองข้าง ข้างแรกมีค่าเป็น u32 และข้างหลังมีค่าเป็น ! เนื่องจาก ! ไม่มีทางมีค่าได้ Rust จึงตัดสินใจว่าประเภทของ guess คือ u32

วิธีพูดอย่างเป็นทางการในการอธิบายพฤติกรรมนี้คือ นิพจน์ของประเภท ! สามารถถูกบีบบังคับแปลงประเภท (coerced) ไปเป็นประเภทอื่นใดก็ได้ เราได้รับอนุญาตให้จบแขน match นี้ด้วย continue เพราะ continue ไม่ได้ส่งคืนค่า แต่ย้ายการควบคุมกลับไปที่จุดเริ่มต้นของลูป ดังนั้นในกรณีที่เป็น Err เราจะไม่กำหนดค่าใดๆ ให้กับ guess

never type มีประโยชน์กับแมโคร panic! เช่นกัน จำฟังก์ชัน unwrap ที่เราเรียกใช้บนค่า Option<T> เพื่อสร้างค่าหรือเกิด panic ตามนิยามนี้ได้ไหม:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

ในโค้ดนี้ สิ่งเดียวกันนี้เกิดขึ้นเหมือนกับใน match ในโค้ดตัวอย่างที่ 20-27: Rust เห็นว่า val มีประเภท T และ panic! มีประเภท ! ดังนั้นผลลัพธ์ของนิพจน์ match โดยรวมจึงเป็น T โค้ดนี้ทำงานได้เพราะ panic! ไม่ได้สร้างค่า มันยุติการทำงานของโปรแกรม ในกรณี None เราจะไม่ส่งคืนค่าจาก unwrap โค้ดนี้จึงถูกต้องและใช้ได้

นิพจน์สุดท้ายที่มีประเภท ! คือลูป:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

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

Dynamically Sized Types and the Sized Trait

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

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

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust จำเป็นต้องรู้ว่าจะต้องจองหน่วยความจำเท่าใดสำหรับค่าใดๆ ของประเภทหนึ่งๆ และค่าทั้งหมดของประเภทนั้นจะต้องใช้ขนาดหน่วยความจำเท่ากัน หาก Rust อนุญาตให้เราเขียนโค้ดนี้ ค่า str ทั้งสองนี้จะต้องใช้ขนาดพื้นที่เท่ากัน แต่พวกมันมีความยาวต่างกัน: s1 ต้องการพื้นที่จัดเก็บ 12 ไบต์ และ s2 ต้องการ 15 ไบต์ นี่คือเหตุผลว่าทำไมจึงไม่สามารถสร้างตัวแปรที่ถือครองประเภทที่มีขนาดแปรผันขณะรันไทม์ได้

แล้วเราจะทำอย่างไร? ในกรณีนี้ คุณรู้อยู่แล้ว: เราทำให้ประเภทของ s1 และ s2 เป็น string slice (&str) แทนที่จะเป็น str ย้อนกลับไปดูหัวข้อ “String Slices” ในบทที่ 4 โครงสร้างข้อมูล slice จะจัดเก็บเฉพาะตำแหน่งเริ่มต้นและความยาวของ slice เท่านั้น ดังนั้น แม้ว่า &T จะเป็นค่าเดียวที่จัดเก็บตำแหน่งที่อยู่หน่วยความจำ (memory address) ที่ T ตั้งอยู่ แต่ string slice กลับเป็นค่า สอง ค่า: ตำแหน่งที่อยู่ของ str และความยาวของมัน ดังนั้น เราจึงสามารถทราบขนาดของค่า string slice ได้ตั้งแต่ตอนคอมไพล์: มันคือสองเท่าของความยาวของ usize นั่นคือ เราทราบขนาดของ string slice เสมอ ไม่ว่าสตริงที่มันอ้างอิงถึงจะยาวแค่ไหนก็ตาม โดยทั่วไป นี่คือวิธีที่ประเภทที่มีขนาดแปรผันขณะรันไทม์ถูกใช้ใน Rust: พวกมันมีข้อมูลกำกับ (metadata) พิเศษที่จัดเก็บขนาดของข้อมูลไดนามิกนั้น กฎเหล็กของประเภทที่มีขนาดแปรผันขณะรันไทม์คือ เราต้องวางค่าของประเภทที่มีขนาดแปรผันขณะรันไทม์ไว้หลังพอยน์เตอร์ (pointer) บางชนิดเสมอ

เราสามารถรวม str เข้ากับพอยน์เตอร์ได้ทุกประเภท: ตัวอย่างเช่น Box<str> หรือ Rc<str> ในความเป็นจริง คุณเคยเห็นสิ่งนี้มาก่อนแต่กับประเภทที่มีขนาดแปรผันขณะรันไทม์อีกชนิดหนึ่ง: traits ทุกๆ trait คือประเภทที่มีขนาดแปรผันขณะรันไทม์ที่เราสามารถอ้างถึงได้โดยใช้ชื่อของเทรต ในหัวข้อ “การใช้ Trait Objects เพื่อเป็นตัวแทนนามธรรมสำหรับพฤติกรรมร่วม” ในบทที่ 18 เราได้กล่าวไว้ว่าหากต้องการใช้ traits เป็น trait objects เราต้องวางพวกมันไว้หลังพอยน์เตอร์ เช่น &dyn Trait หรือ Box<dyn Trait> (Rc<dyn Trait> ก็ใช้ได้เช่นกัน)

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

fn generic<T>(t: T) {
    // --snip--
}

ในความเป็นจริงจะถูกปฏิบัติเสมือนว่าเราเขียนแบบนี้:

fn generic<T: Sized>(t: T) {
    // --snip--
}

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

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

ข้อจำกัดเทรตบน ?Sized หมายถึง “T อาจจะเป็น Sized หรือไม่เป็น Sized ก็ได้” และสัญลักษณ์นี้จะยกเลิกค่าเริ่มต้นที่ประเภทเจเนอริกต้องมีขนาดทราบแน่ชัดตอนคอมไพล์ ไวยากรณ์ ?Trait ที่มีความหมายนี้ใช้ได้เฉพาะกับ Sized เท่านั้น ไม่สามารถใช้กับเทรตอื่นได้

นอกจากนี้ให้สังเกตว่าเราได้เปลี่ยนประเภทของพารามิเตอร์ t จาก T เป็น &T เนื่องจากประเภทนั้นอาจจะไม่เป็น Sized เราจึงต้องใช้มันไว้หลังพอยน์เตอร์บางชนิด ในกรณีนี้ เราได้เลือกใช้การอ้างอิง (reference)

ถัดไป เราจะพูดถึงฟังก์ชันและโคลเชอร์กันครับ!