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

Type ขั้นสูง

ระบบ type ของ Rust มีฟีเจอร์บางอย่างที่เราเคยกล่าวถึงแต่ยังไม่ได้พูดถึง เราจะเริ่มโดยพูดถึง newtype โดยทั่วไปเมื่อเราตรวจสอบว่าทำไมพวกมันมี ประโยชน์เป็น type แล้ว เราจะไปที่ type alias ฟีเจอร์ที่คล้ายกับ newtype แต่มี semantic แตกต่างเล็กน้อย เราจะพูดถึง type ! และ dynamically sized type ด้วย

Type Safety และ Abstraction ด้วย Newtype Pattern

ส่วนนี้สมมุติว่าคุณอ่านส่วนก่อนหน้า “Implement External Trait ด้วย Newtype Pattern” แล้ว newtype pattern มี ประโยชน์สำหรับงานเกินจากที่เราพูดถึงตอนนี้ด้วย รวมการบังคับ statically ว่าค่าไม่เคย confuse และบ่งบอกหน่วยของค่า คุณเห็นตัวอย่างของการใช้ newtype เพื่อบ่งบอกหน่วยใน Listing 20-16 — จำว่า struct Millimeters และ Meters ห่อค่า u32 ใน newtype ถ้าเราเขียนฟังก์ชันกับ parameter ของ type Millimeters เราจะไม่สามารถ compile โปรแกรมที่บังเอิญพยายาม เรียกฟังก์ชันนั้นกับค่าของ type Meters หรือ u32 ธรรมดา

เราใช้ newtype pattern เพื่อ abstract รายละเอียด implementation บาง อย่างของ type ได้ด้วย — type ใหม่สามารถเปิดเผย public API ที่ต่างจาก API ของ inner type private

Newtype สามารถซ่อน implementation ภายในได้ด้วย ตัวอย่างเช่น เราให้ type People เพื่อห่อ HashMap<i32, String> ที่เก็บ ID ของคนที่ associate กับชื่อของพวกเขา โค้ดที่ใช้ People จะ interact เพียงกับ public API ที่เราให้ เช่นเมธอดเพื่อเพิ่ม string ชื่อให้ collection People — โค้ดนั้นไม่ต้องรู้ว่าเรา assign ID i32 ให้ชื่อภายใน Newtype pattern คือวิธีเบาเพื่อบรรลุ encapsulation เพื่อซ่อนรายละเอียด implementation ซึ่งเราพูดถึงในส่วน “Encapsulation ที่ซ่อนรายละเอียด Implementation” ในบทที่ 18

Type Synonym และ Type Alias

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

fn main() {
    type Kilometers = i32;

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

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

ตอนนี้ alias Kilometers คือ synonym สำหรับ i32 ต่างจาก type Millimeters และ Meters ที่เราสร้างใน Listing 20-16, Kilometers ไม่ใช่ type แยกใหม่ ค่าที่มี type Kilometers จะถูก treat เหมือนค่า ของ type i32:

fn main() {
    type Kilometers = i32;

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

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

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

Use case หลักสำหรับ type synonym คือลดการซ้ำซ้อน ตัวอย่างเช่น เราอาจมี type ยาวแบบนี้:

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

เขียน type ยาวนี้ใน signature ฟังก์ชันและเป็น type annotation ทั่วโค้ด ทำให้เหนื่อยและเสี่ยง error จินตนาการมี project เต็มไปด้วยโค้ดแบบใน Listing 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(|| ())
    }
}
Listing 20-25: ใช้ type ยาวในหลายที่

Type alias ทำให้โค้ดนี้จัดการง่ายมากขึ้นโดยลดการซ้ำซ้อน ใน Listing 20-26 เราแนะนำ alias ชื่อ Thunk สำหรับ type verbose และแทนการใช้ type ทั้งหมดด้วย alias ที่สั้นกว่า 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(|| ())
    }
}
Listing 20-26: แนะนำ type alias, Thunk เพื่อลดการซ้ำซ้อน

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

Type alias ก็ถูกใช้ปกติกับ type Result<T, E> สำหรับการลดการซ้ำซ้อน พิจารณาโมดูล std::io ใน standard library Operation I/O มัก return Result<T, E> เพื่อจัดการสถานการณ์เมื่อ operation fail ในการทำงาน Library นี้มี struct std::io::Error ที่ represent error I/O ที่ เป็นไปได้ทั้งหมด ฟังก์ชันหลายตัวใน std::io จะ return Result<T, E> ที่ E คือ std::io::Error เช่นฟังก์ชันเหล่านี้ใน trait Write:

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 มีการประกาศ type alias นี้:

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 เราใช้ fully qualified alias std::io::Result<T> ได้ — นั่นคือ Result<T, E> ที่ E ถูกเติมเป็น std::io::Error Signature ฟังก์ชัน trait Write จบลงดูแบบนี้:

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

Type alias ช่วยในสองวิธี — มันทำให้โค้ดง่ายในการเขียน และ มันให้เรา interface consistent ทั่ว std::io ทั้งหมด เพราะมันเป็น alias มัน เป็นเพียง Result<T, E> อื่น ซึ่งหมายความว่าเราใช้เมธอดใดที่ทำงานบน Result<T, E> กับมันได้ รวมทั้ง syntax พิเศษเช่น operator ?

Never Type ที่ไม่เคย Return

Rust มี type พิเศษชื่อ ! ที่รู้จักในศัพท์ theory type เป็น empty type เพราะมันไม่มีค่า เราชอบเรียกมัน never type เพราะมันยืนใน ที่ของ return type เมื่อฟังก์ชันจะไม่เคย return นี่คือตัวอย่าง:

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

โค้ดนี้อ่านเป็น “ฟังก์ชัน bar return never” ฟังก์ชันที่ return never ถูกเรียก diverging function เราไม่สามารถสร้างค่าของ type ! ดังนั้น bar ไม่เคย return ได้

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

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

use rand::Rng;

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

    let secret_number = rand::thread_rng().gen_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;
            }
        }
    }
}
Listing 20-27: match ที่มี arm ที่จบใน continue

ในเวลานั้น เราข้ามรายละเอียดบางอย่างในโค้ดนี้ ในส่วน “Control Flow Construct match ในบทที่ 6 เราพูดถึงว่า arm match ต้องทั้งหมด return type เดียวกัน ดังนั้น ตัวอย่างเช่น โค้ดต่อไปนี้ไม่ทำงาน:

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

Type ของ guess ในโค้ดนี้จะต้องเป็น integer และ string และ Rust ต้องการให้ guess มีเพียงหนึ่ง type แล้ว continue return อะไร? เราได้รับอนุญาตให้ return u32 จาก arm หนึ่งและมี arm อีกอันที่จบ ด้วย continue ใน Listing 20-27 ได้ยังไง?

อย่างที่คุณเดา continue มีค่า ! นั่นคือ เมื่อ Rust คำนวณ type ของ guess มันดูทั้งสอง match arm อันแรกกับค่า u32 และอันหลังกับค่า ! เพราะ ! ไม่เคยมีค่าได้ Rust ตัดสินว่า type ของ guess คือ u32

วิธีเป็นทางการของการอธิบายพฤติกรรมนี้คือ expression ของ type ! ถูก coerce เป็น type อื่นใดได้ เราได้รับอนุญาตให้จบ match arm นี้ด้วย continue เพราะ continue ไม่ return ค่า — แทน มันย้าย control กลับ ไปยังบนของ loop ดังนั้นในกรณี Err เราไม่เคย assign ค่าให้ guess

Never type มีประโยชน์กับ macro 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 ใน Listing 20-27 — Rust เห็นว่า val มี type T และ panic! มี type ! ดังนั้นผลของ expression match โดยรวมคือ T โค้ดนี้ทำงานเพราะ panic! ไม่ผลิตค่า — มันจบโปรแกรม ในกรณี None เราจะไม่ return ค่าจาก unwrap ดังนั้น โค้ดนี้ valid

Expression สุดท้ายอันหนึ่งที่มี type ! คือ loop:

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

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

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

Dynamically Sized Type และ Trait Sized

Rust ต้องการรู้รายละเอียดบางอย่างเกี่ยวกับ type ของมัน เช่นพื้นที่ เท่าไหร่ที่จะ allocate สำหรับค่าของ type เฉพาะ นี่ทิ้งหนึ่งมุมของระบบ type ของมัน confusing เล็กน้อยที่แรก — แนวคิดของ dynamically sized type บางครั้งเรียก DST หรือ unsized type type เหล่านี้ให้เราเขียน โค้ดโดยใช้ค่าที่ขนาดของเราสามารถรู้เพียงที่ runtime

มาเจาะรายละเอียดของ dynamically sized type เรียก str ซึ่งเราใช้ผ่าน หนังสือ ใช่แล้ว ไม่ใช่ &str แต่ str เองคือ DST ในหลายกรณี เช่น เมื่อเก็บ text ที่ป้อนโดย user เราไม่สามารถรู้ว่า string ยาวเท่าไหร่ จนกระทั่ง runtime นั่นหมายความว่าเราไม่สามารถสร้างตัวแปรของ type str และเราไม่สามารถรับ argument ของ type str พิจารณาโค้ดต่อไปนี้ ซึ่งไม่ ทำงาน:

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

Rust ต้องการรู้ memory เท่าไหร่ที่จะ allocate สำหรับค่าใดของ type เฉพาะ และค่าทั้งหมดของ type ต้องใช้ memory ปริมาณเดียวกัน ถ้า Rust อนุญาตให้เราเขียนโค้ดนี้ สองค่า str เหล่านี้จะต้องใช้พื้นที่ปริมาณ เดียวกัน แต่พวกมันมีความยาวต่างกัน — s1 ต้องการ 12 byte ของ storage และ s2 ต้องการ 15 นี่คือเหตุผลที่ไม่เป็นไปได้ที่จะสร้างตัวแปรที่บรรจุ dynamically sized type

แล้วเราทำอะไร? ในกรณีนี้ คุณรู้คำตอบแล้ว — เราทำ type ของ s1 และ s2 เป็น string slice (&str) แทน str จำจากส่วน “String Slice” ในบทที่ 4 ว่าโครงสร้างข้อมูล slice เก็บเพียงตำแหน่งเริ่มต้นและความยาวของ slice ดังนั้น แม้ &T คือค่าเดียวที่เก็บ memory address ของที่ที่ T ตั้งอยู่ string slice คือ สอง ค่า — address ของ str และความยาวของมัน ดังนั้น เรารู้ขนาด ของค่า string slice ที่ compile time ได้ — มันคือสองเท่าของความยาวของ usize นั่นคือ เรารู้ขนาดของ string slice เสมอ ไม่ว่า string ที่มัน อ้างถึงยาวเท่าไหร่ โดยทั่วไป นี่คือวิธีที่ dynamically sized type ถูก ใช้ใน Rust — พวกมันมี metadata เพิ่มเล็กน้อยที่เก็บขนาดของข้อมูล dynamic กฎทองของ dynamically sized type คือเราต้องวางค่าของ dynamically sized type ไว้หลัง pointer ของบางชนิดเสมอ

เรารวม str กับ pointer ทุกชนิดได้ — ตัวอย่างเช่น Box<str> หรือ Rc<str> จริง ๆ คุณเห็นนี่ก่อนแต่กับ dynamically sized type ต่างกัน — trait ทุก trait คือ dynamically sized type ที่เราอ้างถึงได้โดยใช้ ชื่อของ trait ในส่วน “ใช้ Trait Object เพื่อ Abstract เหนือพฤติกรรมที่ แชร์” ในบทที่ 18 เรากล่าวว่าเพื่อใช้ trait เป็น trait object เราต้องวางพวกมัน ไว้หลัง pointer เช่น &dyn Trait หรือ Box<dyn Trait> (Rc<dyn Trait> จะทำงานด้วย)

เพื่อทำงานกับ DST, Rust ให้ trait Sized เพื่อตัดสินว่าขนาดของ type รู้ที่ compile time หรือไม่ trait นี้ถูก implement อัตโนมัติสำหรับ ทุกอย่างที่ขนาดรู้ที่ compile time นอกจากนี้ Rust เพิ่ม bound บน Sized ทุก generic function โดยปริยาย นั่นคือ นิยาม generic function แบบนี้:

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

จริง ๆ ถูก treat ราวกับเราเขียนนี้:

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

โดยค่าเริ่มต้น generic function จะทำงานเพียงบน type ที่มีขนาดที่รู้ที่ compile time อย่างไรก็ตาม คุณใช้ syntax พิเศษต่อไปนี้เพื่อ relax restriction นี้ได้:

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

Trait bound บน ?Sized หมายถึง “T อาจหรืออาจไม่เป็น Sized” และ notation นี้ override default ที่ generic type ต้องมีขนาดรู้ที่ compile time Syntax ?Trait กับความหมายนี้ใช้ได้เพียงสำหรับ Sized ไม่ใช่ trait อื่นใด

สังเกตด้วยว่าเรา switch type ของ parameter t จาก T เป็น &T เพราะ type อาจไม่เป็น Sized เราต้องใช้มันหลัง pointer ของบางชนิด ในกรณีนี้ เราเลือก reference

ถัดไป เราจะพูดเกี่ยวกับฟังก์ชันและ closure!